276 lines
7.0 KiB
TypeScript
276 lines
7.0 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import './VerticalSlider.css';
|
|
|
|
type VerticalSliderProps = {
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
|
|
switchStepValue?: number;
|
|
switchStep?: number;
|
|
|
|
value: number;
|
|
onChange?: (value: number) => Promise<boolean>;
|
|
onInput?: (value: number) => void;
|
|
|
|
unit?: string;
|
|
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
export default function VerticalSlider({
|
|
min,
|
|
max,
|
|
step,
|
|
switchStepValue,
|
|
switchStep,
|
|
value,
|
|
onChange,
|
|
onInput,
|
|
unit = '',
|
|
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 [tickCount, setTickCount] = useState(MIN_TICKS);
|
|
|
|
useEffect(() => {
|
|
if (dragging.current || keyboardEditing.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();
|
|
}, []);
|
|
|
|
function getStep(value: number) {
|
|
if (switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue) {
|
|
return switchStep;
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
const decimals = useMemo(
|
|
() =>
|
|
Math.min(
|
|
3,
|
|
Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0)
|
|
),
|
|
[step, switchStep]
|
|
);
|
|
|
|
function normalizeValue(input: number) {
|
|
const clamped = Math.min(max, Math.max(min, input));
|
|
const currentStep = getStep(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();
|
|
|
|
const base = keyboardValue.current ?? value;
|
|
const currentStep = getStep(base);
|
|
|
|
let 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 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);
|
|
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 finalValue = dragValue.current;
|
|
const success = await onChange?.(finalValue);
|
|
|
|
dragging.current = false;
|
|
|
|
if (success === false) {
|
|
setDisplayValue(value);
|
|
dragValue.current = value;
|
|
onInput?.(value);
|
|
}
|
|
|
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
e.currentTarget.releasePointerCapture(e.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 = (displayValue - min) / (max - min);
|
|
|
|
return (
|
|
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
|
<div className="gauge-track" ref={trackRef}>
|
|
<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>
|
|
|
|
<div className="ticks right">
|
|
{Array.from({ length: tickCount }).map((_, i) => (
|
|
<span key={i} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="value">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={editingText ?? displayValue.toFixed(decimals)}
|
|
style={{ width: '100%' }}
|
|
onFocus={() => setEditingText(value.toFixed(decimals))}
|
|
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();
|
|
}
|
|
}}
|
|
/>
|
|
<span className="value-unit">{unit}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|