Files
editor/apps/editor/components/ui/primitives/number-input.tsx
T
2026-02-18 13:14:55 +09:00

167 lines
5.0 KiB
TypeScript

'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useRef, useState } from 'react'
interface NumberInputProps {
label: string
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
}
export function NumberInput({
label,
value,
onChange,
min,
max,
precision = 2,
step = 0.1,
className = '',
}: NumberInputProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const labelRef = useRef<HTMLLabelElement>(null)
const clamp = useCallback(
(val: number) => {
if (min !== undefined && val < min) return min
if (max !== undefined && val > max) return max
return val
},
[min, max],
)
const handleLabelMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isEditing) return
e.preventDefault()
setIsDragging(true)
startXRef.current = e.clientX
startValueRef.current = value
// Pause history tracking during drag
useScene.temporal.getState().pause()
let finalValue = value
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startXRef.current
// Determine step size based on modifier keys
let dragStep = step // Default from prop
if (moveEvent.shiftKey) {
dragStep = step * 10 // Coarse
} else if (moveEvent.altKey) {
dragStep = step * 0.1 // Fine
}
const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
// Only call onChange if value actually changed (avoid extra processing on tiny moves)
if (newFinalValue !== finalValue) {
finalValue = newFinalValue
onChange(finalValue)
}
}
const handleMouseUp = () => {
setIsDragging(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// Reset to initial value while still paused (no history entry)
// Then resume and apply final value (creates single history entry)
if (finalValue !== startValueRef.current) {
onChange(startValueRef.current)
useScene.temporal.getState().resume()
onChange(finalValue)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[isEditing, value, onChange, clamp, precision],
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const handleInputBlur = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
}
setIsEditing(false)
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
}
},
[inputValue, onChange, value, clamp, precision],
)
return (
<div className={`${className}`}>
<div className="flex items-center rounded border border-input bg-muted/30 overflow-hidden">
<label
ref={labelRef}
className={`px-2 py-1 text-muted-foreground text-xs select-none ${
isDragging ? 'cursor-ew-resize' : 'hover:cursor-ew-resize hover:text-foreground'
} transition-colors`}
onMouseDown={handleLabelMouseDown}
>
{label}
</label>
{isEditing ? (
<input
autoFocus
className="flex-1 bg-transparent px-2 py-1 text-foreground text-sm outline-none text-right"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
) : (
<div
className="flex-1 px-2 py-1 text-foreground text-sm cursor-text hover:bg-muted/50 transition-colors text-right"
onClick={handleValueClick}
>
{value.toFixed(precision)}
</div>
)}
</div>
</div>
)
}