openpencil/src/components/shared/number-input.tsx
Kayshen-X 11375cfed8 feat(panels): enhance UI components with SectionHeader and improve layout
Introduce a new SectionHeader component for consistent section titles and action buttons across panels. Refactor existing sections (Appearance, Effects, Fill, Stroke, Text) to utilize SectionHeader, improving visual hierarchy and user experience. Update styles for better alignment and spacing in various input components, ensuring a cohesive design throughout the property panel.
2026-02-18 23:34:23 +08:00

126 lines
3.2 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react'
import { cn } from '@/lib/utils'
interface NumberInputProps {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
label?: string
icon?: React.ReactNode
suffix?: string
className?: string
}
export default function NumberInput({
value,
onChange,
min,
max,
step = 1,
label,
icon,
suffix,
className = '',
}: NumberInputProps) {
const [localValue, setLocalValue] = useState(String(value))
const [isDragging, setIsDragging] = useState(false)
const dragStartY = useRef(0)
const dragStartValue = useRef(0)
useEffect(() => {
if (!isDragging) {
setLocalValue(String(Math.round(value * 100) / 100))
}
}, [value, isDragging])
const clamp = useCallback(
(v: number) => {
let result = v
if (min !== undefined) result = Math.max(min, result)
if (max !== undefined) result = Math.min(max, result)
return result
},
[min, max],
)
const handleBlur = () => {
const parsed = parseFloat(localValue)
if (!isNaN(parsed)) {
onChange(clamp(parsed))
} else {
setLocalValue(String(value))
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleBlur()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
onChange(clamp(value + step))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
onChange(clamp(value - step))
}
}
const handleMouseDown = (e: React.MouseEvent) => {
if (e.target instanceof HTMLInputElement) return
setIsDragging(true)
dragStartY.current = e.clientY
dragStartValue.current = value
const handleMouseMove = (ev: MouseEvent) => {
const delta = dragStartY.current - ev.clientY
const newValue = clamp(dragStartValue.current + delta * step)
onChange(newValue)
}
const handleMouseUp = () => {
setIsDragging(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<div
className={cn(
'flex items-center h-6 bg-secondary rounded border border-transparent',
'hover:border-input focus-within:border-ring transition-colors',
className,
)}
onMouseDown={handleMouseDown}
>
{label && (
<span className="text-[10px] text-muted-foreground pl-1.5 pr-0.5 cursor-ew-resize select-none shrink-0">
{label}
</span>
)}
{icon && (
<span className="pl-1 pr-0.5 text-muted-foreground cursor-ew-resize select-none shrink-0 [&_svg]:w-3 [&_svg]:h-3">
{icon}
</span>
)}
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-foreground text-[11px] px-1 py-0.5 focus:outline-none tabular-nums"
/>
{suffix && (
<span className="text-[10px] text-muted-foreground pr-1.5 shrink-0">
{suffix}
</span>
)}
</div>
)
}