102 lines
2.8 KiB
TypeScript
102 lines
2.8 KiB
TypeScript
/**
|
||
* DualRange — one bounded, dual-thumb control for an output's routing range.
|
||
* The same control is used by the main-view output editor and the Outputs
|
||
* drawer so min/max edits have identical interaction and clamping semantics.
|
||
*/
|
||
import { useRef } from 'react';
|
||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||
|
||
export interface DualRangeProps {
|
||
min: number;
|
||
max: number;
|
||
onMin: (value: number) => void;
|
||
onMax: (value: number) => void;
|
||
}
|
||
|
||
export function DualRange({ min, max, onMin, onMax }: DualRangeProps) {
|
||
const track = useRef<HTMLDivElement>(null);
|
||
const drag = useRef<{ which: 'min' | 'max' | null }>({ which: null });
|
||
|
||
const valueAt = (clientX: number) => {
|
||
const el = track.current;
|
||
if (!el) return 0;
|
||
const rect = el.getBoundingClientRect();
|
||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||
};
|
||
|
||
const apply = (value: number) => {
|
||
if (drag.current.which === 'min') onMin(Math.min(value, max));
|
||
else if (drag.current.which === 'max') onMax(Math.max(value, min));
|
||
};
|
||
|
||
const down = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
const value = valueAt(event.clientX);
|
||
drag.current.which = Math.abs(value - min) <= Math.abs(value - max) ? 'min' : 'max';
|
||
apply(value);
|
||
};
|
||
|
||
const move = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||
if (drag.current.which) apply(valueAt(event.clientX));
|
||
};
|
||
|
||
const up = () => {
|
||
drag.current.which = null;
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={track}
|
||
onPointerDown={down}
|
||
onPointerMove={move}
|
||
onPointerUp={up}
|
||
onPointerCancel={up}
|
||
title={`range ${min.toFixed(2)}–${max.toFixed(2)}`}
|
||
style={{
|
||
position: 'relative',
|
||
height: 16,
|
||
flex: 1,
|
||
minWidth: 60,
|
||
background: 'var(--bg-1)',
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 'var(--r-pill)',
|
||
cursor: 'ew-resize',
|
||
touchAction: 'none',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
bottom: 0,
|
||
left: `${min * 100}%`,
|
||
right: `${(1 - max) * 100}%`,
|
||
background: 'linear-gradient(90deg, #4488ff, var(--accent))',
|
||
opacity: 0.4,
|
||
borderRadius: 'var(--r-pill)',
|
||
}}
|
||
/>
|
||
<Thumb pct={min} color="#4488ff" />
|
||
<Thumb pct={max} color="var(--accent)" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Thumb({ pct, color }: { pct: number; color: string }) {
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: '50%',
|
||
left: `${pct * 100}%`,
|
||
width: 10,
|
||
height: 10,
|
||
marginLeft: -5,
|
||
marginTop: -5,
|
||
borderRadius: '50%',
|
||
background: color,
|
||
boxShadow: `0 0 6px ${color}`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|