import { useMemo, useRef, useState } from 'react'; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useVirtualizer } from '@tanstack/react-virtual'; import { GripVertical } from 'lucide-react'; import type { ElementModel, StagedDocument, StrategyKind } from '@/lib/engine'; import { STRATEGIES, STRATEGY_ORDER, eligibleStrategies } from '@/lib/engine'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/utils'; import { reorderElements, setElementStrategy, setInclude, renameElement, remapElementThread, remapSourceColor, canCreateMotifAlong, canCreateRailPair, createMotifAlong, createRailPair, } from './staging-actions'; interface Props { doc: StagedDocument; selectedIds: Set; focusedId: string | null; filter: string; onFilterChange: (v: string) => void; onSelect: (id: string, additive: boolean) => void; onSelectMany: (ids: string[]) => void; update: (fn: (doc: StagedDocument) => StagedDocument) => void; } function flagLabel(el: ElementModel): string | null { const f = el.flags; if (f.unsupported) return 'unsupported'; if (f.outsideHoop) return 'outside hoop'; if (f.degenerate) return 'degenerate'; if (f.densityHot) return 'dense'; if (f.selfIntersect) return 'self-intersect'; return null; } function ThreadDot({ doc, el, update, }: { doc: StagedDocument; el: ElementModel; update: Props['update']; }) { const gradientStops = el.sourceGradient?.stops ?? []; const background = gradientStops.length ? `linear-gradient(90deg, ${gradientStops.map((stop) => `${stop.color} ${stop.offset * 100}%`).join(', ')})` : (doc.palette[el.threadIndex] ?? '#000'); return ( } />
{(gradientStops.length ? gradientStops : [{ offset: 0, color: null }]).map( (stop, stopIndex) => (
{stop.color && ( )}
{doc.palette.map((hex, i) => { const selected = stop.color ? doc.threadMap[stop.color] === i : el.threadIndex === i; return (
), )}
); } function StrategyCell({ el, update }: { el: ElementModel; update: Props['update'] }) { const eligible = new Set(eligibleStrategies(el.geomType, el.role, el.sourceGradient !== null)); const order = el.role === 'relation' ? [el.strategy.kind] : STRATEGY_ORDER; return ( ); } function ElementRow({ el, doc, selected, focused, onSelect, update, }: { el: ElementModel; doc: StagedDocument; selected: boolean; focused: boolean; onSelect: Props['onSelect']; update: Props['update']; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: el.id, }); const [editing, setEditing] = useState(false); const flag = flagLabel(el); return (
onSelect(el.id, e.metaKey || e.ctrlKey || e.shiftKey)} > update((d) => setInclude(d, el.id, c))} onClick={(e) => e.stopPropagation()} aria-label="include" />
`${stop.color} ${stop.offset * 100}%`) .join(', ')})` : (doc.palette[el.threadIndex] ?? '#000'), }} /> {editing ? ( e.stopPropagation()} onBlur={(e) => { update((d) => renameElement(d, el.id, e.target.value || el.name)); setEditing(false); }} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); if (e.key === 'Escape') setEditing(false); }} /> ) : ( { e.stopPropagation(); setEditing(true); }} > {el.name} )} {flag ?? (el.role === 'relation' ? 'relationship' : el.geomType)} {el.role === 'relation' ? '—' : el.areaMm2.toFixed(0)}
e.stopPropagation()}>
e.stopPropagation()}>
); } export default function ElementList({ doc, selectedIds, focusedId, filter, onFilterChange, onSelect, onSelectMany, update, }: Props) { const ordered = useMemo( () => doc.operations.slice().sort((a, b) => a.order - b.order), [doc.operations], ); const visible = useMemo(() => { const q = filter.trim().toLowerCase(); if (!q) return ordered; return ordered.filter( (e) => e.name.toLowerCase().includes(q) || e.geomType.toLowerCase().includes(q), ); }, [ordered, filter]); const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: visible.length, getScrollElement: () => parentRef.current, estimateSize: () => 34, overscan: 12, }); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const onDragEnd = (e: DragEndEvent) => { const { active, over } = e; if (over && active.id !== over.id) { update((d) => reorderElements(d, String(active.id), String(over.id))); } }; const applyToSelection = (kind: StrategyKind) => update((d) => setElementStrategy(d, selectedIds, kind)); const selectedInOrder = Array.from(selectedIds); const operationById = new Map(doc.operations.map((operation) => [operation.id, operation])); const applicableStrategies = new Set(STRATEGY_ORDER); for (const id of selectedInOrder) { const operation = operationById.get(id); if (!operation) { applicableStrategies.clear(); break; } const eligible = new Set( eligibleStrategies(operation.geomType, operation.role, operation.sourceGradient !== null), ); for (const kind of applicableStrategies) { if (!eligible.has(kind)) applicableStrategies.delete(kind); } } const railPairReady = canCreateRailPair(doc, selectedInOrder); const motifAlongReady = canCreateMotifAlong(doc, selectedInOrder); return (
{/* sub-toolbar */}
onFilterChange(e.target.value)} placeholder="Filter by name / type…" className="h-7 text-[12px]" />
} > Select… onSelectMany(visible.map((e) => e.id))}> All onSelectMany(visible.filter((e) => e.sourceFill).map((e) => e.id))} > All fills onSelectMany( visible.filter((e) => !e.sourceFill && e.sourceStroke).map((e) => e.id), ) } > All strokes onSelectMany([])}>None } disabled={selectedIds.size === 0} > Apply strategy {STRATEGY_ORDER.map((k) => ( applyToSelection(k)} > {STRATEGIES[k].label} ))} } disabled={selectedIds.size !== 2} > Create relationship update((d) => createRailPair(d, selectedInOrder))} > Pair as satin rails update((d) => createMotifAlong(d, selectedInOrder))} > Repeat second as motif along first {selectedIds.size} selected
{/* rows */}
e.id)} strategy={verticalListSortingStrategy}>
{virtualizer.getVirtualItems().map((vi) => { const el = visible[vi.index]; return (
); })}
); }