feat: start input section

This commit is contained in:
2026-08-12 22:19:09 +02:00
parent 00adc4df53
commit a28dd890dd
6 changed files with 395 additions and 183 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
.channel-group {
position: relative;
flex: 1 1 0;
/* flex: 1 1 0; */
min-height: 0;
display: flex;
@@ -2,6 +2,8 @@ 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;
@@ -9,6 +11,17 @@ type VerticalSliderProps = {
step?: StepFn;
/**
* 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().
*/
quantize?: QuantizeFn;
value: number;
onChange?: (value: number) => Promise<boolean>;
onInput?: (value: number) => void;
@@ -18,6 +31,9 @@ type VerticalSliderProps = {
showTicks?: boolean;
/** 'linear' (default) or 'log'. Log scale requires min > 0 and max > 0. */
scale?: Scale;
className?: string;
style?: React.CSSProperties;
};
@@ -37,16 +53,86 @@ function getDecimals(n: number) {
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;
}
/** Maps a real value to a 0..1 position, honoring the chosen scale. */
function valueToPercent(v: number, min: number, max: number, scale: Scale) {
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. */
function percentToValue(percent: number, min: number, max: number, scale: Scale) {
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,
value,
onChange,
onInput,
unit = '',
formatValue,
showTicks = true,
scale = 'linear',
className,
style,
}: VerticalSliderProps) {
@@ -60,10 +146,25 @@ export default function VerticalSlider({
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' && (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.'
);
}
useEffect(() => {
if (dragging.current || keyboardEditing.current || editingText !== null) {
if (
dragging.current ||
keyboardEditing.current ||
wheelEditing.current ||
editingText !== null
) {
return;
}
setDisplayValue(value);
@@ -99,6 +200,11 @@ export default function VerticalSlider({
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
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));
@@ -119,7 +225,11 @@ export default function VerticalSlider({
keyboardEditing.current = true;
e.preventDefault();
const base = keyboardValue.current ?? value;
// 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;
const currentStep = step(base);
let next = base;
@@ -147,11 +257,65 @@ export default function VerticalSlider({
}
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
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);
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 = min + percent * (max - min);
const raw = percentToValue(percent, min, max, scale);
updateValue(raw);
}
@@ -212,7 +376,7 @@ export default function VerticalSlider({
setEditingText(null);
}
const percent = (displayValue - min) / (max - min);
const percent = valueToPercent(displayValue, min, max, scale);
return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>