'use client' import { useScene } from '@pascal-app/core' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' interface SliderControlProps { label: React.ReactNode value: number onChange: (value: number) => void min?: number max?: number precision?: number step?: number className?: string unit?: string } export function SliderControl({ label, value, onChange, min = 0, max = 100, precision = 0, step = 1, className, unit = '', }: SliderControlProps) { const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) const [inputValue, setInputValue] = useState(value.toFixed(precision)) // Track the original value and bounds when dragging starts const [dragStartValue, setDragStartValue] = useState(null) const [dragMin, setDragMin] = useState(null) const [dragMax, setDragMax] = useState(null) const trackRef = useRef(null) const containerRef = useRef(null) const valueRef = useRef(value) valueRef.current = value const clamp = useCallback( (val: number) => { return Math.min(Math.max(val, min), max) }, [min, max], ) useEffect(() => { if (!isEditing) { setInputValue(value.toFixed(precision)) } }, [value, precision, isEditing]) useEffect(() => { const container = containerRef.current if (!container) return const handleWheel = (e: WheelEvent) => { if (isEditing) return e.preventDefault() const direction = e.deltaY < 0 ? 1 : -1 let scrollStep = step if (e.shiftKey) scrollStep = step * 10 else if (e.altKey) scrollStep = step * 0.1 const newValue = clamp(valueRef.current + direction * scrollStep) const finalValue = Number.parseFloat(newValue.toFixed(precision)) if (finalValue !== valueRef.current) { onChange(finalValue) } } container.addEventListener('wheel', handleWheel, { passive: false }) return () => container.removeEventListener('wheel', handleWheel) }, [isEditing, step, clamp, onChange, precision]) useEffect(() => { if (!isHovered || isEditing) return const handleKeyDown = (e: KeyboardEvent) => { let direction = 0 if (e.key === 'ArrowUp') direction = 1 else if (e.key === 'ArrowDown') direction = -1 if (direction !== 0) { e.preventDefault() let scrollStep = step if (e.shiftKey) scrollStep = step * 10 else if (e.altKey) scrollStep = step * 0.1 const newValue = clamp(valueRef.current + direction * scrollStep) const finalValue = Number.parseFloat(newValue.toFixed(precision)) if (finalValue !== valueRef.current) { onChange(finalValue) } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isHovered, isEditing, step, clamp, onChange, precision]) const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (isEditing) return e.preventDefault() const track = trackRef.current if (!track) return setIsDragging(true) setDragStartValue(value) setDragMin(min) setDragMax(max) useScene.temporal.getState().pause() const rect = track.getBoundingClientRect() const updateValueFromEvent = (clientX: number) => { const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) const rawValue = min + percent * (max - min) // snap to step const snapped = Math.round(rawValue / step) * step const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision)) onChange(finalValue) } updateValueFromEvent(e.clientX) const handlePointerMove = (moveEvent: PointerEvent) => { updateValueFromEvent(moveEvent.clientX) } const handlePointerUp = (e: PointerEvent) => { // Only stop dragging if we didn't release on the reset button // Let the reset button's onPointerDown handle its own cleanup if ((e.target as HTMLElement).closest('button')) { return } setIsDragging(false) const startVal = dragStartValue const finalVal = valueRef.current setDragStartValue(null) setDragMin(null) setDragMax(null) document.removeEventListener('pointermove', handlePointerMove) document.removeEventListener('pointerup', handlePointerUp) if (startVal !== null && startVal !== finalVal) { // Revert to start value while paused so the undo baseline is clean onChange(startVal) useScene.temporal.getState().resume() // Apply final value while recording onChange(finalVal) } else { useScene.temporal.getState().resume() } } document.addEventListener('pointermove', handlePointerMove) document.addEventListener('pointerup', handlePointerUp) }, [isEditing, min, max, step, precision, clamp, onChange] ) const handleValueClick = useCallback(() => { setIsEditing(true) setInputValue(value.toFixed(precision)) }, [value, precision]) const handleInputChange = useCallback((e: React.ChangeEvent) => { setInputValue(e.target.value) }, []) const submitValue = useCallback(() => { const numValue = Number.parseFloat(inputValue) if (!Number.isNaN(numValue)) { onChange(clamp(Number.parseFloat(numValue.toFixed(precision)))) } else { setInputValue(value.toFixed(precision)) } setIsEditing(false) }, [inputValue, onChange, clamp, precision, value]) const handleInputBlur = useCallback(() => { submitValue() }, [submitValue]) const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { setInputValue(value.toFixed(precision)) setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() const newV = clamp(value + step) onChange(newV) setInputValue(newV.toFixed(precision)) } else if (e.key === 'ArrowDown') { e.preventDefault() const newV = clamp(value - step) onChange(newV) setInputValue(newV.toFixed(precision)) } }, [submitValue, value, precision, step, clamp, onChange], ) const currentMin = isDragging && dragMin !== null ? dragMin : min const currentMax = isDragging && dragMax !== null ? dragMax : max const percent = Math.max(0, Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100)) const startPercent = dragStartValue !== null ? Math.max(0, Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100)) : null return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={cn("group flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors relative", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)} > {/* Reset button that appears when dragged away from start */} {isDragging && dragStartValue !== null && dragStartValue !== value && ( )}
{label}
{/* Track dots background */}
{[...Array(9)].map((_, i) => (
))}
{/* Original Thumb Ghost */} {isDragging && startPercent !== null && (
)} {/* Active Thumb */}
{isEditing ? (
{unit && {unit}}
) : (
{Number(value.toFixed(precision)).toFixed(precision)} {unit && {unit}}
)}
) }