/** * @file use-annotation-selection.tsx — Phase 5.1 annotation selection store * @scope apps/studio/use-annotation-selection.tsx * @purpose Parallel to `use-selection-set`, but holds annotation stroke IDs * instead of DOM-element selections. Annotation IDs are local * identifiers (`s_*`) generated by `rid()` — they never live in * `_active.json` and don't post upstream to the dev-server shell. * * In-memory only. Scope is per canvas mount: when the iframe reloads, the * selection resets (the strokes themselves persist via `.annotations.svg`). */ import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState, } from 'react'; export interface AnnotationSelectionValue { selectedIds: string[]; replace: (id: string | string[]) => void; add: (id: string | string[]) => void; toggle: (id: string) => void; clear: () => void; contains: (id: string) => boolean; } const AnnotationSelectionContext = createContext(null); function dedupe(ids: string[]): string[] { const out: string[] = []; const seen = new Set(); for (const id of ids) { if (seen.has(id)) continue; seen.add(id); out.push(id); } return out; } export function AnnotationSelectionProvider({ children }: { children: ReactNode }) { const [selectedIds, setSelectedIds] = useState([]); const replace = useCallback((id: string | string[]) => { const next = dedupe(Array.isArray(id) ? id : [id]); setSelectedIds(next); }, []); const add = useCallback((id: string | string[]) => { const incoming = Array.isArray(id) ? id : [id]; setSelectedIds((prev) => dedupe([...prev, ...incoming])); }, []); const toggle = useCallback((id: string) => { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }, []); const clear = useCallback(() => { setSelectedIds([]); }, []); // Read live from state at call time so multi-step flows in the same tick // (e.g. a router callback that adds then asks `contains`) see the staged // result instead of a stale closure capture. MUST be a real useRef — the // pre-FigJam-v3 code created a fresh `{ current }` object literal every // render, so the stable useCallback closure kept reading the FIRST render's // (empty) selection forever: `contains()` always returned false, which // silently broke hull/multi-drag and click-keeps-selection (user-gate // finding, 2026-06-11). const containsRef = useRef(selectedIds); containsRef.current = selectedIds; const contains = useCallback((id: string) => containsRef.current.includes(id), []); const value = useMemo( () => ({ selectedIds, replace, add, toggle, clear, contains }), [selectedIds, replace, add, toggle, clear, contains] ); return ( {children} ); } export function useAnnotationSelection(): AnnotationSelectionValue { const ctx = useContext(AnnotationSelectionContext); if (!ctx) { throw new Error('useAnnotationSelection must be used inside '); } return ctx; } export function useAnnotationSelectionOptional(): AnnotationSelectionValue | null { return useContext(AnnotationSelectionContext); }