import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, ChevronUp, Shuffle, Lock, LockOpen, Copy, Crosshair, Eye, EyeOff, } from 'lucide-react'; import type { ChalkDataVar, ReferenceDataVar } from '../lib/engine.ts'; import { parsePresets, snapValue, projectPoint, sampleRegion } from '../lib/editor/parameters.ts'; import type { ParamItem, ParamDef, PointParamDef, Preset, TextParamDef, ColorParamDef, PaletteParamDef, } from '../lib/editor/parameters.ts'; import { Slider } from '@/components/ui/slider.tsx'; import { Switch } from '@/components/ui/switch.tsx'; import { Input } from '@/components/ui/input.tsx'; import { cn } from '@/utils.ts'; import styles from './ParametersPanel.module.css'; import { oklab, parseColor, unoklab } from '../lib/core/colormath.ts'; // ── Types ──────────────────────────────────────────────────────────────────── export interface ParamChange { name: string; line: number; value: number | string | [number, number] | string[]; } interface Props { source: string; items: ParamItem[]; onParamChange: (name: string, line: number, value: number | string) => void; onAllParamsChange: (changes: ParamChange[]) => void; /** Lock state managed by parent (App.tsx) so stage can also show locked handles */ lockedParams: Set; onToggleLock: (name: string) => void; /** Cross-link: hovering a row or clicking Locate notifies the stage to highlight that handle */ onHighlightHandle?: (name: string | null) => void; /** Which handle the stage is currently highlighting (from stage hover → back to panel) */ highlightedHandle?: string | null; dataVars: ChalkDataVar[]; referenceVars: ReferenceDataVar[]; pinnedDataVars: Set; onTogglePinnedDataVar: (name: string) => void; onHoverDataVar: (name: string | null) => void; onRevealLine: (line: number) => void; } // ── Section separator ──────────────────────────────────────────────────────── function SectionHeader({ title }: { title: string }) { return ( ); } function dataSummary(value: ChalkDataVar): string { if (value.kind === 'point') { const [x, y] = value.strokes[0].vertices[0]; return `point · [${formatPointCoord(x)}, ${formatPointCoord(y)}]`; } if (value.kind === 'path') return `path · ${value.vertexCount.toLocaleString()} pts · ${(value.pathLength ?? 0).toFixed(1)} mm`; const noun = value.pathCount === 1 ? 'path' : 'paths'; return `${value.kind} · ${value.pathCount} ${noun} · ${value.vertexCount.toLocaleString()} pts`; } function DataRow({ value, pinned, onTogglePin, onHover, onLocate, onRevealLine, }: { value: ChalkDataVar; pinned: boolean; onTogglePin: () => void; onHover: (name: string | null) => void; onLocate: (name: string) => void; onRevealLine: (line: number) => void; }) { return (
onHover(value.name)} onMouseLeave={() => onHover(null)} >
); } function ReferenceRow({ value, onRevealLine, }: { value: ReferenceDataVar; onRevealLine: (line: number) => void; }) { const environment = value.environment.map((entry) => `${entry.name} = ${entry.value}`).join('\n'); return (
); } // ── Slider row ──────────────────────────────────────────────────────────────── interface SliderRowProps { def: ParamDef; isLocked: boolean; onChange: (name: string, line: number, value: number) => void; onToggleLock: () => void; } function SliderRow({ def, onChange, isLocked, onToggleLock }: SliderRowProps) { const { name, value, min, max, step, sliderKind, line } = def; // When focused, we keep a local draft string so the user can type freely. // While not focused we display the prop value directly (no sync needed). const [draft, setDraft] = useState(''); const [isEditing, setIsEditing] = useState(false); const display = isEditing ? draft : formatSliderValue(value, sliderKind); const handleFocus = useCallback(() => { setDraft(formatSliderValue(value, sliderKind)); setIsEditing(true); }, [value, sliderKind]); const commitInput = useCallback(() => { setIsEditing(false); const raw = parseFloat(draft); if (!Number.isFinite(raw)) return; const snapped = snapValue(raw, min, max, step); onChange(name, line, snapped); }, [draft, min, max, step, name, line, onChange]); const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); else if (e.key === 'ArrowUp') { e.preventDefault(); const next = snapValue(value + step, min, max, step); setDraft(formatSliderValue(next, sliderKind)); onChange(name, line, next); } else if (e.key === 'ArrowDown') { e.preventDefault(); const next = snapValue(value - step, min, max, step); setDraft(formatSliderValue(next, sliderKind)); onChange(name, line, next); } }, [value, step, min, max, sliderKind, name, line, onChange], ); const handleSliderChange = useCallback( (vals: number | readonly number[]) => { const raw = Array.isArray(vals) ? (vals as number[])[0] : (vals as number); const snapped = snapValue(raw, min, max, step); // Keep draft in sync when slider moves while editing if (isEditing) setDraft(formatSliderValue(snapped, sliderKind)); onChange(name, line, snapped); }, [min, max, step, sliderKind, name, line, onChange, isEditing], ); return (
{name}
{formatSliderBound(min, sliderKind)} {formatSliderBound(max, sliderKind)}
setDraft(e.target.value)} onFocus={handleFocus} onBlur={commitInput} onKeyDown={handleInputKeyDown} className={styles.valueInput} aria-label={`${name} value`} step={step} min={min} max={max} />
); } // ── Switch row ──────────────────────────────────────────────────────────────── interface SwitchRowProps { def: ParamDef; isLocked: boolean; onChange: (name: string, line: number, value: number) => void; onToggleLock: () => void; } function SwitchRow({ def, onChange, isLocked, onToggleLock }: SwitchRowProps) { const { name, value, labels, line } = def; const isOn = value !== 0; const offLabel = labels?.[0] ?? '0'; const onLabel = labels?.[1] ?? '1'; const handleChange = useCallback( (checked: boolean) => { onChange(name, line, checked ? 1 : 0); }, [name, line, onChange], ); return (
{name}
{offLabel} {onLabel}
); } // ── Text row ───────────────────────────────────────────────────────────────── interface TextRowProps { def: TextParamDef; onChange: (name: string, line: number, value: string) => void; } function TextRow({ def, onChange }: TextRowProps) { const { name, value, line } = def; return (