import React, { useState, useEffect, useRef, useCallback } from 'react'; import type { NumericParamSpec } from '../utils/indicatorParamSpec'; interface NumericParamInputProps { value: number; spec: NumericParamSpec; disabled?: boolean; className?: string; onChange: (v: number) => void; } function formatOption(v: number, decimal: boolean): string { return decimal ? (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.?0+$/, '')) : String(Math.round(v)); } /** 입력 문자열을 숫자만(선택적 -, 소수점) 허용하도록 필터 */ function filterTyping(raw: string, allowNegative: boolean, decimal: boolean): string { let s = raw.replace(/[^\d.\-]/g, ''); if (!allowNegative) { s = s.replace(/-/g, ''); } else { const first = s.startsWith('-') ? '-' : ''; s = first + s.slice(first ? 1 : 0).replace(/-/g, ''); } if (!decimal) { s = s.replace(/\./g, ''); } else { const parts = s.replace('-', '').split('.'); if (parts.length > 2) { s = (s.startsWith('-') ? '-' : '') + parts[0] + '.' + parts.slice(1).join(''); } } return s; } function isPartialNumber(s: string, allowNegative: boolean, decimal: boolean): boolean { if (s === '' || s === '-') return true; if (decimal && (s === '.' || s === '-.')) return true; if (decimal) { return allowNegative ? /^-?\d*\.?\d*$/.test(s) : /^\d*\.?\d*$/.test(s); } return allowNegative ? /^-?\d*$/.test(s) : /^\d*$/.test(s); } const NumericParamInput: React.FC = ({ value, spec, disabled = false, className = 'ism-input', onChange, }) => { const wrapRef = useRef(null); const inputRef = useRef(null); const [str, setStr] = useState(() => formatOption(value, spec.decimal)); const [open, setOpen] = useState(false); const prevValueRef = useRef(value); const editingRef = useRef(false); useEffect(() => { if (editingRef.current) return; if (value !== prevValueRef.current) { prevValueRef.current = value; setStr(formatOption(value, spec.decimal)); } }, [value, spec.decimal]); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (wrapRef.current?.contains(e.target as Node)) return; setOpen(false); }; const tid = window.setTimeout(() => { document.addEventListener('mousedown', onDoc, true); }, 0); return () => { window.clearTimeout(tid); document.removeEventListener('mousedown', onDoc, true); }; }, [open]); const commit = useCallback((raw: string) => { const trimmed = raw.trim(); if (trimmed === '' || trimmed === '-' || trimmed === '-.') { setStr(formatOption(prevValueRef.current, spec.decimal)); return; } const v = parseFloat(trimmed); if (isNaN(v)) { setStr(formatOption(prevValueRef.current, spec.decimal)); return; } let clamped = Math.max(spec.min, Math.min(spec.max, v)); if (!spec.decimal) clamped = Math.round(clamped); else clamped = roundTo(clamped, spec.step < 1 ? 2 : 1); prevValueRef.current = clamped; onChange(clamped); setStr(formatOption(clamped, spec.decimal)); }, [spec, onChange]); const handleChange = (raw: string) => { const filtered = filterTyping(raw, spec.allowNegative, spec.decimal); if (!isPartialNumber(filtered, spec.allowNegative, spec.decimal)) return; setStr(filtered); }; const selectOption = (opt: number) => { prevValueRef.current = opt; onChange(opt); setStr(formatOption(opt, spec.decimal)); setOpen(false); inputRef.current?.focus(); }; const toggleList = () => { if (disabled) return; setOpen(prev => !prev); inputRef.current?.focus(); }; return (
e.stopPropagation()} onPointerDown={e => e.stopPropagation()} > handleChange(e.target.value)} onFocus={() => { editingRef.current = true; }} onBlur={e => { const related = e.relatedTarget as Node | null; window.setTimeout(() => { if (document.activeElement === inputRef.current) return; if (related && wrapRef.current?.contains(related)) return; editingRef.current = false; commit(e.target.value); setOpen(false); }, 0); }} onKeyDown={e => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); if (!disabled) setOpen(true); return; } if (e.key === 'Enter') { commit((e.target as HTMLInputElement).value); setOpen(false); } if (e.key === 'Escape') setOpen(false); }} /> {open && !disabled && (
    {spec.options.map(opt => (
  • ))}
)}
); }; function roundTo(v: number, decimals: number): number { const f = 10 ** decimals; return Math.round(v * f) / f; } export default NumericParamInput;