feat: add peq foor input

This commit is contained in:
2026-08-31 14:12:03 +02:00
parent a0c9fbb674
commit d6d0215e09
14 changed files with 1989 additions and 396 deletions
@@ -10,17 +10,19 @@ type VerticalSliderProps = {
max: number;
step?: StepFn;
quantize?: QuantizeFn;
/**
* Optional hard snap function applied after every value change (drag,
* keyboard, wheel, or typed input) — takes precedence over the built-in
* step-based rounding. Use this when the underlying hardware/DSP only
* accepts a fixed, non-uniformly-spaced set of values (e.g. a 0..N raw
* index mapped through a log curve) and the slider must always land on
* one of those exact values rather than an interpolated approximation.
* See makeLogQuantize().
* Optional discrete value set (must be sorted ascending). When provided,
* this takes full precedence over `step`/`quantize`/`scale`: the slider
* only ever lands on entries from this array. `min`/`max` are used to
* slice out the applicable sub-range of `values` (e.g. per-filter-type
* Q ranges that all share one master Q table) — both bounds should be
* exact members of `values`. Thumb position and drag/keyboard/wheel
* navigation are index-based rather than value-based, since spacing in
* the array need not be uniform or log-uniform.
*/
quantize?: QuantizeFn;
values?: readonly number[];
value: number;
onChange?: (value: number) => Promise<boolean>;
@@ -31,7 +33,7 @@ type VerticalSliderProps = {
showTicks?: boolean;
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
scale?: Scale;
className?: string;
@@ -60,8 +62,36 @@ function safeLogBound(n: number) {
return n > 0 ? n : Number.EPSILON;
}
/** Maps a real value to a 0..1 position, honoring the chosen scale. */
function valueToPercent(v: number, min: number, max: number, scale: Scale) {
/** Binary search for the index of the array entry closest to v. Assumes arr is sorted ascending and non-empty. */
function closestIndex(arr: readonly number[], v: number) {
let lo = 0;
let hi = arr.length - 1;
if (v <= arr[0]) return 0;
if (v >= arr[hi]) return hi;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] === v) return mid;
if (arr[mid] < v) lo = mid + 1;
else hi = mid;
}
const a = arr[lo - 1];
const b = arr[lo];
return v - a <= b - v ? lo - 1 : lo;
}
/** Maps a real value to a 0..1 position, honoring the chosen scale (or index position within `list`). */
function valueToPercent(
v: number,
min: number,
max: number,
scale: Scale,
list: readonly number[] | null
) {
if (list && list.length > 1) {
return closestIndex(list, v) / (list.length - 1);
}
if (scale === 'log') {
const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max));
@@ -73,8 +103,18 @@ function valueToPercent(v: number, min: number, max: number, scale: Scale) {
return (v - min) / (max - min);
}
/** Maps a 0..1 position back to a real value, honoring the chosen scale. */
function percentToValue(percent: number, min: number, max: number, scale: Scale) {
/** Maps a 0..1 position back to a real value, honoring the chosen scale (or nearest index within `list`). */
function percentToValue(
percent: number,
min: number,
max: number,
scale: Scale,
list: readonly number[] | null
) {
if (list && list.length > 1) {
const idx = Math.round(percent * (list.length - 1));
return list[Math.min(list.length - 1, Math.max(0, idx))];
}
if (scale === 'log') {
const logMin = Math.log(safeLogBound(min));
const logMax = Math.log(safeLogBound(max));
@@ -126,6 +166,7 @@ export default function VerticalSlider({
max,
step = defaultStep,
quantize,
values,
value,
onChange,
onInput,
@@ -151,13 +192,21 @@ export default function VerticalSlider({
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [tickCount, setTickCount] = useState(MIN_TICKS);
if (scale === 'log' && (min <= 0 || max <= 0)) {
if (scale === 'log' && !values && (min <= 0 || max <= 0)) {
// eslint-disable-next-line no-console
console.warn(
'VerticalSlider: scale="log" requires min > 0 and max > 0. Falling back to linear behavior for out-of-range bounds.'
);
}
// Slice the discrete value set down to the applicable [min, max] sub-range.
// Falls back to the full array if the slice is empty (e.g. misconfigured bounds).
const listValues = useMemo(() => {
if (!values || values.length === 0) return null;
const filtered = values.filter((v) => v >= min && v <= max);
return filtered.length > 0 ? filtered : values;
}, [values, min, max]);
useEffect(() => {
if (
dragging.current ||
@@ -191,10 +240,10 @@ export default function VerticalSlider({
return () => observer.disconnect();
}, []);
const decimals = useMemo(
() => Math.min(3, getDecimals(step(displayValue))),
[step, displayValue]
);
const decimals = useMemo(() => {
if (listValues) return Math.min(3, getDecimals(displayValue));
return Math.min(3, getDecimals(step(displayValue)));
}, [listValues, step, displayValue]);
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
@@ -203,6 +252,10 @@ export default function VerticalSlider({
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
if (listValues) {
return listValues[closestIndex(listValues, clamped)];
}
if (quantize) {
return Number(Math.min(max, Math.max(min, quantize(clamped))).toFixed(6));
}
@@ -232,13 +285,23 @@ export default function VerticalSlider({
// hasn't landed yet, `value` can still be stale, which made repeated
// key presses appear to snap back to the pre-press position.
const base = keyboardValue.current ?? displayValue;
const currentStep = step(base);
let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
let next: number;
if (listValues) {
const idx = closestIndex(listValues, base);
let nextIdx = idx;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight')
nextIdx = Math.min(listValues.length - 1, idx + 1);
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') nextIdx = Math.max(0, idx - 1);
next = listValues[nextIdx];
} else {
const currentStep = step(base);
next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next);
}
next = normalizeValue(next);
keyboardValue.current = next;
updateValue(next);
}
@@ -265,12 +328,20 @@ export default function VerticalSlider({
wheelEditing.current = true;
const base = wheelValue.current ?? displayValue;
const currentStep = step(base);
// deltaY < 0 means scrolling up/away from the user — treat that as
// increasing the value, matching the vertical slider's "up = more".
const direction = e.deltaY < 0 ? 1 : -1;
const next = normalizeValue(base + direction * currentStep);
let next: number;
if (listValues) {
const idx = closestIndex(listValues, base);
const nextIdx = Math.min(listValues.length - 1, Math.max(0, idx + direction));
next = listValues[nextIdx];
} else {
const currentStep = step(base);
next = normalizeValue(base + direction * currentStep);
}
wheelValue.current = next;
updateValue(next);
@@ -317,7 +388,7 @@ export default function VerticalSlider({
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = percentToValue(percent, min, max, scale);
const raw = percentToValue(percent, min, max, scale, listValues);
updateValue(raw);
}
@@ -378,7 +449,7 @@ export default function VerticalSlider({
setEditingText(null);
}
const percent = valueToPercent(displayValue, min, max, scale);
const percent = valueToPercent(displayValue, min, max, scale, listValues);
return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>