fix: matrix choice selector
This commit is contained in:
@@ -28,17 +28,13 @@ function ChoiceSelect<T extends string>({
|
||||
|
||||
if (currentIndex === -1 || options.length === 0) return;
|
||||
|
||||
const nextIndex =
|
||||
(currentIndex + direction + options.length) % options.length;
|
||||
const nextIndex = (currentIndex + direction + options.length) % options.length;
|
||||
|
||||
onChange(options[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`choice-select ${className}`}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
<div className={`choice-select ${className}`} data-disabled={disabled || undefined}>
|
||||
<select
|
||||
id={id}
|
||||
className="choice-select__input"
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
.choice-stepper__arrows {
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { JSX, useEffect, useState } from 'react';
|
||||
import { JSX, useEffect, useRef, useState } from 'react';
|
||||
import Button from '../Button/Button';
|
||||
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||
import './ChoiceStepper.css';
|
||||
|
||||
const WHEEL_COMMIT_DELAY = 250;
|
||||
// Safety net: if the parent's `value` prop never comes back exactly equal
|
||||
// to what we committed (rounding, an intermediate transform, etc.), stop
|
||||
// waiting after this long so the stepper doesn't get stuck ignoring
|
||||
// external updates.
|
||||
const PENDING_FALLBACK_MS = 1000;
|
||||
|
||||
type CommitResult = Promise<boolean | void> | boolean | void;
|
||||
|
||||
function StepperArrows({
|
||||
onUp,
|
||||
onDown,
|
||||
@@ -44,7 +53,7 @@ type ChoiceStepperListProps<T> = {
|
||||
value: T;
|
||||
values: readonly T[];
|
||||
formatLabel: (value: T) => string;
|
||||
onChange: (value: T) => void;
|
||||
onChange: (value: T) => CommitResult;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
@@ -59,38 +68,117 @@ function ChoiceStepperList<T>({
|
||||
disabled,
|
||||
className,
|
||||
}: ChoiceStepperListProps<T>) {
|
||||
const index = values.indexOf(value);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [text, setText] = useState(formatLabel(value));
|
||||
|
||||
// True from the moment a commit starts until the prop `value` we get from
|
||||
// the parent actually reflects it (not just until onChange resolves —
|
||||
// those are two different moments and the gap between them is what was
|
||||
// causing the old-value blink).
|
||||
const interacting = useRef(false);
|
||||
// The value we're waiting to see echoed back via the `value` prop.
|
||||
const pendingValue = useRef<T | null>(null);
|
||||
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (interacting.current) {
|
||||
// Only release control once the prop actually catches up to what we
|
||||
// committed — otherwise keep showing localValue as-is (no flash back
|
||||
// to the stale old value in between).
|
||||
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
}, [value, formatLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const index = values.indexOf(localValue);
|
||||
const canUp = index === -1 || index < values.length - 1;
|
||||
const canDown = index > 0;
|
||||
|
||||
const [text, setText] = useState(formatLabel(value));
|
||||
useEffect(() => setText(formatLabel(value)), [value, formatLabel]);
|
||||
|
||||
const step = (direction: 1 | -1) => {
|
||||
const currentIndex = values.indexOf(value);
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
const stepLocal = (direction: 1 | -1): T | null => {
|
||||
const currentIndex = values.indexOf(localValue);
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (nextIndex < 0 || nextIndex >= values.length) return null;
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < values.length) {
|
||||
onChange(values[nextIndex]);
|
||||
const next = values[nextIndex];
|
||||
setLocalValue(next);
|
||||
setText(formatLabel(next));
|
||||
return next;
|
||||
};
|
||||
|
||||
async function commit(next: T) {
|
||||
interacting.current = true;
|
||||
pendingValue.current = next;
|
||||
|
||||
const result = await onChange(next);
|
||||
|
||||
if (result === false) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
return;
|
||||
}
|
||||
|
||||
// Accepted: don't clear `interacting` here. Wait for the prop-sync
|
||||
// effect above to see `value === next` and release it, so we never
|
||||
// hand control back to a still-stale prop. Guard with a timeout in
|
||||
// case it never matches exactly.
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
fallbackTimeout.current = setTimeout(() => {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
}, PENDING_FALLBACK_MS);
|
||||
}
|
||||
|
||||
const stepAndCommit = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next !== null) void commit(next);
|
||||
};
|
||||
|
||||
const stepWheel = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next === null) return;
|
||||
|
||||
interacting.current = true;
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
wheelTimeout.current = setTimeout(() => {
|
||||
wheelTimeout.current = null;
|
||||
void commit(next);
|
||||
}, WHEEL_COMMIT_DELAY);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
const match = values.find(
|
||||
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
||||
);
|
||||
if (match !== undefined) onChange(match);
|
||||
else setText(formatLabel(value));
|
||||
if (match !== undefined) {
|
||||
setLocalValue(match);
|
||||
void commit(match);
|
||||
} else {
|
||||
setText(formatLabel(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`choice-stepper ${className ?? ''}`}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
||||
<input
|
||||
id={id}
|
||||
className="choice-stepper__input"
|
||||
@@ -102,25 +190,32 @@ function ChoiceStepperList<T>({
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY < 0 && canUp) {
|
||||
step(1);
|
||||
stepWheel(1);
|
||||
} else if (e.deltaY > 0 && canDown) {
|
||||
step(-1);
|
||||
stepWheel(-1);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
else if (e.key === 'ArrowUp') {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
step(1);
|
||||
interacting.current = true;
|
||||
stepLocal(1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
step(-1);
|
||||
interacting.current = true;
|
||||
stepLocal(-1);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
void commit(localValue);
|
||||
}}
|
||||
/>
|
||||
<StepperArrows
|
||||
onUp={() => step(1)}
|
||||
onDown={() => step(-1)}
|
||||
onUp={() => stepAndCommit(1)}
|
||||
onDown={() => stepAndCommit(-1)}
|
||||
canUp={canUp}
|
||||
canDown={canDown}
|
||||
disabled={disabled}
|
||||
@@ -137,7 +232,7 @@ type ChoiceStepperRangeProps = {
|
||||
max: number;
|
||||
step?: number;
|
||||
formatLabel: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
onChange: (value: number) => CommitResult;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
@@ -154,30 +249,100 @@ function ChoiceStepperRange({
|
||||
disabled,
|
||||
className,
|
||||
}: ChoiceStepperRangeProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [text, setText] = useState(formatLabel(value));
|
||||
useEffect(() => setText(formatLabel(value)), [value, formatLabel]);
|
||||
|
||||
const interacting = useRef(false);
|
||||
const pendingValue = useRef<number | null>(null);
|
||||
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (interacting.current) {
|
||||
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
}, [value, formatLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clamp = (n: number) => Math.min(max, Math.max(min, n));
|
||||
|
||||
const canUp = value < max;
|
||||
const canDown = value > min;
|
||||
const canUp = localValue < max;
|
||||
const canDown = localValue > min;
|
||||
|
||||
const step = (direction: 1 | -1) => {
|
||||
const next = clamp(value + direction * stepSize);
|
||||
if (next !== value) onChange(next);
|
||||
const stepLocal = (direction: 1 | -1): number | null => {
|
||||
const next = clamp(localValue + direction * stepSize);
|
||||
if (next === localValue) return null;
|
||||
setLocalValue(next);
|
||||
setText(formatLabel(next));
|
||||
return next;
|
||||
};
|
||||
|
||||
async function commit(next: number) {
|
||||
interacting.current = true;
|
||||
pendingValue.current = next;
|
||||
|
||||
const result = await onChange(next);
|
||||
|
||||
if (result === false) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
fallbackTimeout.current = setTimeout(() => {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
}, PENDING_FALLBACK_MS);
|
||||
}
|
||||
|
||||
const stepAndCommit = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next !== null) void commit(next);
|
||||
};
|
||||
|
||||
const stepWheel = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next === null) return;
|
||||
|
||||
interacting.current = true;
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
wheelTimeout.current = setTimeout(() => {
|
||||
wheelTimeout.current = null;
|
||||
void commit(next);
|
||||
}, WHEEL_COMMIT_DELAY);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
const parsed = Number(text);
|
||||
if (Number.isFinite(parsed)) onChange(clamp(parsed));
|
||||
else setText(formatLabel(value));
|
||||
if (Number.isFinite(parsed)) {
|
||||
const next = clamp(parsed);
|
||||
setLocalValue(next);
|
||||
void commit(next);
|
||||
} else {
|
||||
setText(formatLabel(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`choice-stepper ${className ?? ''}`}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
||||
<input
|
||||
id={id}
|
||||
className="choice-stepper__input"
|
||||
@@ -189,9 +354,9 @@ function ChoiceStepperRange({
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY < 0 && canUp) {
|
||||
step(1);
|
||||
stepWheel(1);
|
||||
} else if (e.deltaY > 0 && canDown) {
|
||||
step(-1);
|
||||
stepWheel(-1);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -199,16 +364,26 @@ function ChoiceStepperRange({
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (canUp) step(1);
|
||||
if (canUp) {
|
||||
interacting.current = true;
|
||||
stepLocal(1);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (canDown) step(-1);
|
||||
if (canDown) {
|
||||
interacting.current = true;
|
||||
stepLocal(-1);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
void commit(localValue);
|
||||
}}
|
||||
/>
|
||||
<StepperArrows
|
||||
onUp={() => step(1)}
|
||||
onDown={() => step(-1)}
|
||||
onUp={() => stepAndCommit(1)}
|
||||
onDown={() => stepAndCommit(-1)}
|
||||
canUp={canUp}
|
||||
canDown={canDown}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -81,29 +81,29 @@ function CompressorPanel({
|
||||
<div className="compressor-panel__cell compressor-panel__cell--steppers">
|
||||
<div className="compressor-panel__stepper-item">
|
||||
<ChoiceStepper
|
||||
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||
value={comp.ratio}
|
||||
values={RATIO_ORDER}
|
||||
formatLabel={(v) => RATIO_LABEL[v]}
|
||||
onChange={(value) => {
|
||||
onLiveValue(channel, 'ratio', value);
|
||||
commit({ ratio: value });
|
||||
}}
|
||||
/>
|
||||
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||
value={comp.ratio}
|
||||
values={RATIO_ORDER}
|
||||
formatLabel={(v) => RATIO_LABEL[v]}
|
||||
onChange={(value) => {
|
||||
onLiveValue(channel, 'ratio', value);
|
||||
commit({ ratio: value });
|
||||
}}
|
||||
/>
|
||||
<div className="compressor-panel__cell-label">Ratio</div>
|
||||
</div>
|
||||
|
||||
<div className="compressor-panel__stepper-item">
|
||||
<ChoiceStepper
|
||||
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||
value={comp.knee}
|
||||
values={KNEE_VALUES}
|
||||
formatLabel={(v) => `${v} dB`}
|
||||
onChange={(value) => {
|
||||
onLiveValue(channel, 'knee', value);
|
||||
commit({ knee: value });
|
||||
}}
|
||||
/>
|
||||
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||
value={comp.knee}
|
||||
values={KNEE_VALUES}
|
||||
formatLabel={(v) => `${v} dB`}
|
||||
onChange={(value) => {
|
||||
onLiveValue(channel, 'knee', value);
|
||||
commit({ knee: value });
|
||||
}}
|
||||
/>
|
||||
<div className="compressor-panel__cell-label">Knee</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,10 +206,13 @@ function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimite
|
||||
<div className="limiter-graph__meters">
|
||||
{outputs.map((output) => {
|
||||
const outputState = dsp.state.current_config.output_states[output];
|
||||
const idx = OUTPUT_METER_INDEX[output];
|
||||
|
||||
return (
|
||||
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
|
||||
<MeterBar
|
||||
key={output}
|
||||
label={outputState.name}
|
||||
meter={dsp.output_meters.meters[output]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
.matrix-panel__row .choice-stepper {
|
||||
align-self: center;
|
||||
height: auto;
|
||||
aspect-ratio: 3 / 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -109,4 +109,11 @@
|
||||
|
||||
.matrix-gain-stepper .choice-stepper__input {
|
||||
font-size: 2.8cqh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.matrix-gain-stepper .choice-stepper__arrows {
|
||||
flex: 0 0 20%;
|
||||
width: 20%;
|
||||
padding-right: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user