515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import './VerticalSlider.css';
|
|
|
|
type StepFn = (value: number) => number;
|
|
type QuantizeFn = (value: number) => number;
|
|
type Scale = 'linear' | 'log';
|
|
|
|
type VerticalSliderProps = {
|
|
min: number;
|
|
max: number;
|
|
|
|
step?: StepFn;
|
|
quantize?: QuantizeFn;
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
values?: readonly number[];
|
|
|
|
value: number;
|
|
onChange?: (value: number) => Promise<boolean>;
|
|
onInput?: (value: number) => void;
|
|
|
|
unit?: string | ((value: number) => string);
|
|
formatValue?: (value: number, decimals: number) => string;
|
|
|
|
showTicks?: boolean;
|
|
|
|
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. Ignored when `values` is set. */
|
|
scale?: Scale;
|
|
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
const defaultStep: StepFn = () => 0.1;
|
|
|
|
function formatValueDefault(v: number, decimals: number) {
|
|
return v.toFixed(decimals);
|
|
}
|
|
|
|
const MIN_TICKS = 3;
|
|
const MAX_TICK_SPACING_PX = 16;
|
|
|
|
function getDecimals(n: number) {
|
|
const s = n.toString();
|
|
const i = s.indexOf('.');
|
|
return i === -1 ? 0 : s.length - i - 1;
|
|
}
|
|
|
|
// --- scale helpers -----------------------------------------------------
|
|
|
|
/** Guards against log(0) / log(negative); falls back to a tiny positive epsilon. */
|
|
function safeLogBound(n: number) {
|
|
return n > 0 ? n : Number.EPSILON;
|
|
}
|
|
|
|
/** 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));
|
|
if (logMax === logMin) return 0;
|
|
const logV = Math.log(safeLogBound(v));
|
|
return (logV - logMin) / (logMax - logMin);
|
|
}
|
|
if (max === min) return 0;
|
|
return (v - min) / (max - min);
|
|
}
|
|
|
|
/** 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));
|
|
return Math.exp(logMin + percent * (logMax - logMin));
|
|
}
|
|
return min + percent * (max - min);
|
|
}
|
|
|
|
/**
|
|
* Builds a StepFn that yields roughly `points` evenly-spaced steps between
|
|
* `min` and `max` on a logarithmic scale — e.g. makeLogStep(19.7, 20160, 300)
|
|
* gives ~300 points across a 20 Hz - 20 kHz range, with step size growing
|
|
* proportionally to the current value (fine resolution at low frequencies,
|
|
* coarser at high ones — matches how the ear perceives frequency).
|
|
*
|
|
* This is an approximation used to size keyboard/wheel nudges — it does NOT
|
|
* guarantee landing on the same exact grid a real device would produce.
|
|
* Pair it with `quantize` (see makeLogQuantize) when you need the slider to
|
|
* always land exactly on the device's own value set.
|
|
*/
|
|
export function makeLogStep(min: number, max: number, points: number): StepFn {
|
|
const ratio = Math.pow(max / min, 1 / Math.max(1, points));
|
|
const stepSize = ratio - 1;
|
|
return (value: number) => Math.max(min, value) * stepSize;
|
|
}
|
|
|
|
/**
|
|
* Builds a QuantizeFn that snaps to the exact same log-spaced grid a device
|
|
* using a `raw` 0..points index would produce, e.g. for a frequency raw
|
|
* value stored as:
|
|
* raw = round(log(Hz / min) / log(max / min) * points)
|
|
* Hz = min * (max / min) ** (raw / points)
|
|
* makeLogQuantize(19.7, 20160, 300) reproduces exactly that 301-point set
|
|
* (raw 0..300), so the slider only ever settles on values the device itself
|
|
* would encode/decode to — no drift from an approximate step size.
|
|
*/
|
|
export function makeLogQuantize(min: number, max: number, points: number): QuantizeFn {
|
|
const logRatio = Math.log(max / min);
|
|
return (value: number) => {
|
|
const v = Math.max(min, Math.min(max, value));
|
|
const raw = Math.round((Math.log(v / min) / logRatio) * points);
|
|
const clampedRaw = Math.min(points, Math.max(0, raw));
|
|
return min * Math.pow(max / min, clampedRaw / points);
|
|
};
|
|
}
|
|
|
|
export default function VerticalSlider({
|
|
min,
|
|
max,
|
|
step = defaultStep,
|
|
quantize,
|
|
values,
|
|
value,
|
|
onChange,
|
|
onInput,
|
|
unit = '',
|
|
formatValue,
|
|
showTicks = true,
|
|
scale = 'linear',
|
|
className,
|
|
style,
|
|
}: VerticalSliderProps) {
|
|
const [editingText, setEditingText] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const sliderRef = useRef<HTMLDivElement>(null);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const keyboardValue = useRef<number | null>(null);
|
|
const [displayValue, setDisplayValue] = useState(value);
|
|
const dragValue = useRef(value);
|
|
const dragging = useRef(false);
|
|
const thumbRef = useRef<HTMLDivElement>(null);
|
|
const keyboardEditing = useRef(false);
|
|
const wheelValue = useRef<number | null>(null);
|
|
const wheelEditing = useRef(false);
|
|
const wheelCommitTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [tickCount, setTickCount] = useState(MIN_TICKS);
|
|
|
|
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 ||
|
|
keyboardEditing.current ||
|
|
wheelEditing.current ||
|
|
editingText !== null
|
|
) {
|
|
return;
|
|
}
|
|
setDisplayValue(value);
|
|
dragValue.current = value;
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
const el = trackRef.current;
|
|
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
|
|
const recompute = () => {
|
|
const h = el.clientHeight;
|
|
if (!h) return;
|
|
|
|
const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
|
|
|
|
setTickCount(n);
|
|
};
|
|
recompute();
|
|
|
|
const observer = new ResizeObserver(recompute);
|
|
observer.observe(el);
|
|
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
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);
|
|
|
|
const displayUnit = typeof unit === 'function' ? unit(displayValue) : unit;
|
|
|
|
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));
|
|
}
|
|
|
|
const currentStep = step(clamped);
|
|
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
|
|
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
|
|
}
|
|
|
|
function updateValue(input: number) {
|
|
const next = normalizeValue(input);
|
|
dragValue.current = next;
|
|
setDisplayValue(next);
|
|
onInput?.(next);
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
return;
|
|
}
|
|
|
|
keyboardEditing.current = true;
|
|
e.preventDefault();
|
|
|
|
// Base off displayValue (our own last-known-good local state), not the
|
|
// `value` prop: if onChange's round trip through the parent/audio engine
|
|
// 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;
|
|
|
|
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);
|
|
}
|
|
|
|
keyboardValue.current = next;
|
|
updateValue(next);
|
|
}
|
|
|
|
async function handleKeyUp() {
|
|
if (keyboardValue.current == null) return;
|
|
|
|
const next = keyboardValue.current;
|
|
const success = await onChange?.(next);
|
|
|
|
keyboardEditing.current = false;
|
|
keyboardValue.current = null;
|
|
|
|
if (success === false) {
|
|
setDisplayValue(value);
|
|
dragValue.current = value;
|
|
onInput?.(value);
|
|
}
|
|
}
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
|
|
wheelEditing.current = true;
|
|
|
|
const base = wheelValue.current ?? displayValue;
|
|
// 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;
|
|
|
|
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);
|
|
|
|
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
|
|
wheelCommitTimeout.current = setTimeout(() => {
|
|
void commitWheel();
|
|
}, 250);
|
|
}
|
|
|
|
async function commitWheel() {
|
|
if (wheelValue.current == null) return;
|
|
|
|
const next = wheelValue.current;
|
|
const success = await onChange?.(next);
|
|
|
|
wheelEditing.current = false;
|
|
wheelValue.current = null;
|
|
|
|
if (success === false) {
|
|
setDisplayValue(value);
|
|
dragValue.current = value;
|
|
onInput?.(value);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const el = sliderRef.current;
|
|
if (!el) return;
|
|
|
|
// React attaches onWheel passively by default, so preventDefault()
|
|
// wouldn't stop the page from scrolling underneath the slider — attach
|
|
// natively with { passive: false } instead.
|
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', handleWheel);
|
|
});
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (wheelCommitTimeout.current) clearTimeout(wheelCommitTimeout.current);
|
|
};
|
|
}, []);
|
|
|
|
function valueFromPointer(clientY: number) {
|
|
if (!sliderRef.current) return;
|
|
const rect = sliderRef.current.getBoundingClientRect();
|
|
const percent = 1 - (clientY - rect.top) / rect.height;
|
|
const raw = percentToValue(percent, min, max, scale, listValues);
|
|
updateValue(raw);
|
|
}
|
|
|
|
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
|
|
const target = e.currentTarget;
|
|
dragging.current = true;
|
|
target.setPointerCapture(e.pointerId);
|
|
valueFromPointer(e.clientY);
|
|
}
|
|
|
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
|
if (!dragging.current) return;
|
|
valueFromPointer(e.clientY);
|
|
}
|
|
|
|
async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
|
|
if (!dragging.current) return;
|
|
|
|
const target = e.currentTarget;
|
|
const pointerId = e.pointerId;
|
|
const finalValue = dragValue.current;
|
|
|
|
const success = await onChange?.(finalValue);
|
|
|
|
dragging.current = false;
|
|
|
|
if (success === false) {
|
|
setDisplayValue(value);
|
|
dragValue.current = value;
|
|
onInput?.(value);
|
|
}
|
|
|
|
if (target.hasPointerCapture(pointerId)) {
|
|
target.releasePointerCapture(pointerId);
|
|
}
|
|
}
|
|
|
|
async function commitEditing() {
|
|
if (editingText !== null && editingText !== '' && editingText !== '-') {
|
|
const parsed = Number(editingText);
|
|
|
|
if (!Number.isNaN(parsed)) {
|
|
const next = normalizeValue(parsed);
|
|
|
|
setDisplayValue(next);
|
|
onInput?.(next);
|
|
|
|
const success = await onChange?.(next);
|
|
|
|
if (success === false) {
|
|
setDisplayValue(value);
|
|
dragValue.current = value;
|
|
onInput?.(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
setEditingText(null);
|
|
}
|
|
|
|
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
|
|
|
return (
|
|
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
|
<div
|
|
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
|
|
ref={trackRef}
|
|
>
|
|
{showTicks && (
|
|
<div className="ticks left">
|
|
{Array.from({ length: tickCount }).map((_, i) => (
|
|
<span key={i} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="slider"
|
|
ref={sliderRef}
|
|
tabIndex={0}
|
|
onKeyDown={handleKeyDown}
|
|
onKeyUp={handleKeyUp}
|
|
onPointerDown={startDrag}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={endDrag}
|
|
onPointerCancel={endDrag}
|
|
>
|
|
<div className="track" />
|
|
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
|
|
</div>
|
|
|
|
{showTicks && (
|
|
<div className="ticks right">
|
|
{Array.from({ length: tickCount }).map((_, i) => (
|
|
<span key={i} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={['value', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={editingText ?? formattedValue}
|
|
style={{ width: '100%' }}
|
|
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
|
|
onChange={(e) => setEditingText(e.target.value)}
|
|
onBlur={commitEditing}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') inputRef.current?.blur();
|
|
if (e.key === 'Escape') {
|
|
setEditingText(null);
|
|
inputRef.current?.blur();
|
|
}
|
|
}}
|
|
/>
|
|
{displayUnit && <span className="value-unit">{' ' + displayUnit}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|