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; 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(null); const inputRef = useRef(null); const sliderRef = useRef(null); const trackRef = useRef(null); const keyboardValue = useRef(null); const [displayValue, setDisplayValue] = useState(value); const dragValue = useRef(value); const dragging = useRef(false); const thumbRef = useRef(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) { 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) { const target = e.currentTarget; dragging.current = true; target.setPointerCapture(e.pointerId); valueFromPointer(e.clientY); } function handlePointerMove(e: React.PointerEvent) { if (!dragging.current) return; valueFromPointer(e.clientY); } async function endDrag(e: React.PointerEvent) { 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 (
{Array.from({ length: tickCount }).map((_, i) => ( ))}
{Array.from({ length: tickCount }).map((_, i) => ( ))}
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(); } }} /> {unit}
); }