/** * Rect registry — the contract from `wiki/SharedContracts.md` §7. * * Three subsystems publish DOMRects into a single registry so the SVG * visual-guide overlay can draw curves between them: * * - "field" — a form field (registered by FormRenderer) * - "evidence-card" — a sidebar entry (registered by EvidenceSidebar / similar) * - "highlight" — a viewer highlight (registered by the PDF adapter) * * The overlay subscribes once and queries `getRect` on demand each frame. * **The contract MUST NOT change after CE-WP-0003-T03 lands** — that's the * whole point of pinning it now in §7. Future kinds (HTML-doc-section, * comment-anchor, …) are added by extending the `RectKind` union here AND * the doc together. */ export type RectKind = "field" | "evidence-card" | "highlight"; export interface RectRegistryRegistration { readonly kind: RectKind; readonly id: string; readonly getRect: () => DOMRect | null; } /** * A registry-change signal. Listeners typically don't care which * specific rect changed — they re-query everything they're tracking — * but the discriminator is kept so optimised consumers can filter. * * `rect-changed` with an undefined `kind`+`id` means "the registry is * advising you that *something* may have moved" (scroll, resize). That * is the dominant event in practice. */ export type RectRegistryEvent = | { readonly type: "registered"; readonly kind: RectKind; readonly id: string } | { readonly type: "unregistered"; readonly kind: RectKind; readonly id: string } | { readonly type: "rect-changed"; readonly kind?: RectKind; readonly id?: string; }; export interface RectRegistry { register( kind: RectKind, id: string, getRect: () => DOMRect | null, ): () => void; getRect(kind: RectKind, id: string): DOMRect | null; subscribe(listener: (event: RectRegistryEvent) => void): () => void; /** * Imperative invalidation. Useful for the overlay's own * active-evidence-change trigger (which isn't observable via DOM * observers). Emits a `rect-changed` with no kind/id. */ invalidate(): void; /** * Monotonically-increasing counter that bumps once per emit. Lets React * consumers use `useSyncExternalStore` without per-snapshot subscriber * leaks — the hook only adds one subscriber and reads `getVersion()` * for the snapshot. */ getVersion(): number; /** * Debug-only — enumerate everything currently registered. Used in tests * and not part of the §7 contract; safe to use from `app/`. */ list(): readonly { kind: RectKind; id: string }[]; } function keyOf(kind: RectKind, id: string): string { return `${kind}${id}`; } export function createRectRegistry(): RectRegistry { const entries = new Map(); const listeners = new Set<(event: RectRegistryEvent) => void>(); let version = 0; function emit(event: RectRegistryEvent) { version++; for (const l of listeners) { try { l(event); } catch { // Listener errors are swallowed: the overlay never wants a // single broken consumer to crash the others. } } } return { register(kind, id, getRect) { const key = keyOf(kind, id); entries.set(key, { kind, id, getRect }); emit({ type: "registered", kind, id }); return () => { const current = entries.get(key); // Only unregister if it's *this* registration. A re-register with // the same (kind,id) before our cleanup runs supersedes us. if (current && current.getRect === getRect) { entries.delete(key); emit({ type: "unregistered", kind, id }); } }; }, getRect(kind, id) { const entry = entries.get(keyOf(kind, id)); if (!entry) return null; try { return entry.getRect(); } catch { return null; } }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, invalidate() { emit({ type: "rect-changed" }); }, getVersion() { return version; }, list() { return [...entries.values()].map((e) => ({ kind: e.kind, id: e.id })); }, }; }