import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers"; function CommitField({ value, disabled, liveCommit, onCommit, }: { value: string; disabled?: boolean; liveCommit?: boolean; onCommit: (nextValue: string) => void; }) { const [draft, setDraft] = useState(value); const commitTimerRef = useRef | null>(null); const valueRef = useRef(value); const draftRef = useRef(draft); const inputRef = useRef(null); valueRef.current = value; draftRef.current = draft; useEffect(() => { setDraft(value); }, [value]); useEffect(() => { const el = inputRef.current; if (!el) return; const handler = (e: WheelEvent) => { if (disabled || document.activeElement !== el) return; const delta = e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta === 0) return; const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e); if (!nextDraft) return; e.preventDefault(); e.stopPropagation(); setDraft(nextDraft); scheduleCommitRef.current(nextDraft); }; el.addEventListener("wheel", handler, { passive: false }); return () => el.removeEventListener("wheel", handler); }, [disabled]); useEffect( () => () => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); }, [], ); const commitDraft = (nextDraft: string) => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); if (nextDraft !== valueRef.current) onCommit(nextDraft); }; const scheduleCommit = (nextDraft: string) => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); commitTimerRef.current = setTimeout(() => { if (nextDraft !== valueRef.current) onCommit(nextDraft); }, 120); }; const scheduleCommitRef = useRef(scheduleCommit); scheduleCommitRef.current = scheduleCommit; return ( { setDraft(e.target.value); if (liveCommit) scheduleCommit(e.target.value); }} onBlur={() => commitDraft(draft)} onKeyDown={(e) => { if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); return; } if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e); if (!nextDraft) return; e.preventDefault(); setDraft(nextDraft); scheduleCommit(nextDraft); }} title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined} className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600" /> ); } /* ------------------------------------------------------------------ */ /* MetricField */ /* ------------------------------------------------------------------ */ export function MetricField({ label, value, disabled, liveCommit, scrub, suffix, tooltip, onCommit, }: { label: string; value: string; disabled?: boolean; liveCommit?: boolean; scrub?: boolean; suffix?: string; tooltip?: string; onCommit: (nextValue: string) => void; }) { const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null); const handleScrubPointerDown = useCallback( (e: React.PointerEvent) => { if (disabled || !scrub) return; const parsed = parseFloat(value); if (!Number.isFinite(parsed)) return; (e.target as HTMLElement).setPointerCapture(e.pointerId); scrubRef.current = { startX: e.clientX, startValue: parsed, pointerId: e.pointerId }; }, [disabled, scrub, value], ); const handleScrubPointerMove = useCallback( (e: React.PointerEvent) => { const state = scrubRef.current; if (!state) return; const delta = e.clientX - state.startX; onCommit(String(Math.round(state.startValue + delta))); }, [onCommit], ); const handleScrubPointerUp = useCallback(() => { scrubRef.current = null; }, []); const scrubProps = scrub && !disabled ? ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500 cursor-ew-resize select-none", onPointerDown: handleScrubPointerDown, onPointerMove: handleScrubPointerMove, onPointerUp: handleScrubPointerUp, } as const) : ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const); return (
{label} {suffix && {suffix}}
); } /* ------------------------------------------------------------------ */ /* Simple field components */ /* ------------------------------------------------------------------ */ export function DetailField({ label, value, disabled, onCommit, }: { label: string; value: string; disabled?: boolean; onCommit: (nextValue: string) => void; }) { return ( ); } export function SliderControl({ value, min, max, step, displayValue, formatDisplayValue, disabled, onCommit, }: { value: number; min: number; max: number; step: number; displayValue: string; formatDisplayValue?: (nextValue: number) => string; disabled?: boolean; onCommit: (nextValue: number) => void; }) { const [draft, setDraft] = useState(value); const commitTimerRef = useRef | null>(null); const valueRef = useRef(value); valueRef.current = value; useEffect(() => { setDraft(value); }, [value]); useEffect( () => () => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); }, [], ); const commitDraft = (nextDraft: number) => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); if (nextDraft !== valueRef.current) onCommit(nextDraft); }; const scheduleCommit = (nextDraft: number) => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); commitTimerRef.current = setTimeout(() => { if (nextDraft !== valueRef.current) onCommit(nextDraft); }, 40); }; return (
{ const n = Number(e.target.value); setDraft(n); scheduleCommit(n); }} onMouseUp={() => commitDraft(draft)} onTouchEnd={() => commitDraft(draft)} onBlur={() => commitDraft(draft)} className="h-4 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing" />
{formatDisplayValue?.(draft) ?? displayValue}
); } export function SegmentedControl({ options, value, disabled, onChange, }: { options: Array<{ label: string; value: string }>; value: string; disabled?: boolean; onChange: (nextValue: string) => void; }) { return (
{options.map((option) => ( ))}
); } export function SelectField({ label, value, disabled, options, onChange, }: { label: string; value: string; disabled?: boolean; options: string[]; onChange: (nextValue: string) => void; }) { const renderedOptions = value && !options.includes(value) ? [value, ...options] : options; return ( ); } export function Section({ title, icon: _icon, children, accessory, defaultCollapsed = false, }: { title: string; icon: ReactNode; children: ReactNode; accessory?: ReactNode; defaultCollapsed?: boolean; }) { const [collapsed, setCollapsed] = useState(defaultCollapsed); const collapseIcon = collapsed ? ( ) : ( ); return (
{accessory &&
{accessory}
}
{!collapsed &&
{children}
}
); }