/** * Engine + active-document React context. * * MVP composition root for the UI: one `Engine` instance for the lifetime of * the SPA, plus the "what's open in the viewer right now" pointer. * `useEngine()` returns the engine; `useActiveDocument()` returns the * currently-loaded `{document, representation}` pair, refreshed when the * engine emits `DocumentImported` / `DocumentRepresentationGenerated`. * * Replaces ad-hoc engine wiring inside each component. Per the workplan * (T07 note), state lives in a single React context; no Zustand or Redux. */ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from "react"; import type { Document, DocumentRepresentation } from "@shared/document"; import type { AnnotationId, DocumentId } from "@shared/ids"; import type { Selector } from "@shared/selector"; import { attachPersister, createEngine, restoreFromStorage, type Engine, } from "@engine/index"; import type { PdfSelectionCapture } from "@anchor/index"; /** * localStorage keys for the engine snapshot and the UI's "what was open" * pointer. ADR-0005 frames both as deliberately temporary — real * persistence later. */ const STORAGE_KEY = "citation-evidence:engine-snapshot:v1"; const ACTIVE_KEY = "citation-evidence:active-document-id:v1"; /** * The pending selection lives in context (not local component state) because * the toolbar that consumes it is rendered above the viewer, not inside it. * `null` means "no selection waiting for a comment". */ export interface PendingSelection { readonly capture: PdfSelectionCapture; readonly selectors: readonly Selector[]; } interface EngineContextValue { readonly engine: Engine; readonly activeDocumentId: DocumentId | null; setActiveDocumentId(id: DocumentId | null): void; readonly pendingSelection: PendingSelection | null; setPendingSelection(pending: PendingSelection | null): void; readonly scrollToAnnotationId: AnnotationId | null; /** The version counter bumps even when the same id is set twice in a row, * so a second click on the same evidence item still triggers a scroll. */ readonly scrollVersion: number; scrollToAnnotation(id: AnnotationId | null): void; } const EngineContext = createContext(null); interface EngineProviderProps { readonly children: ReactNode; /** Inject a pre-built engine for tests; production uses the default. */ readonly engine?: Engine; } export function EngineProvider({ children, engine: injected }: EngineProviderProps) { const engine = useMemo(() => injected ?? createEngine(), [injected]); const [activeDocumentId, setActiveDocumentIdState] = useState(null); const [pendingSelection, setPendingSelection] = useState(null); const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({ id: null, version: 0, }); // Restore from localStorage on first mount, then attach the persister. // The injected-engine path skips persistence (tests own their lifecycle). useEffect(() => { if (injected) return; if (typeof globalThis.localStorage === "undefined") return; const result = restoreFromStorage(engine, { key: STORAGE_KEY }); if (result.restored) { const saved = globalThis.localStorage.getItem(ACTIVE_KEY); if (saved && engine.documents.get(saved as DocumentId)) { setActiveDocumentIdState(saved as DocumentId); } } return attachPersister(engine, { key: STORAGE_KEY }); }, [engine, injected]); // Persist the active-document pointer alongside the engine snapshot so a // reload lands the user back where they were. useEffect(() => { if (injected) return; if (typeof globalThis.localStorage === "undefined") return; if (activeDocumentId) { globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId); } else { globalThis.localStorage.removeItem(ACTIVE_KEY); } }, [activeDocumentId, injected]); // Switching the active document discards any pending selection — it // belongs to the previous document's viewer state. const setActiveDocumentId = useCallback((id: DocumentId | null) => { setActiveDocumentIdState(id); setPendingSelection(null); setScrollState((prev) => ({ id: null, version: prev.version + 1 })); }, []); const scrollToAnnotation = useCallback((id: AnnotationId | null) => { setScrollState((prev) => ({ id, version: prev.version + 1 })); }, []); const value = useMemo( () => ({ engine, activeDocumentId, setActiveDocumentId, pendingSelection, setPendingSelection, scrollToAnnotationId: scrollState.id, scrollVersion: scrollState.version, scrollToAnnotation, }), [engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation], ); return {children}; } export function useEngine(): Engine { const ctx = useContext(EngineContext); if (!ctx) throw new Error("useEngine: missing EngineProvider"); return ctx.engine; } export function useActiveDocumentId(): { readonly id: DocumentId | null; setId(id: DocumentId | null): void; } { const ctx = useContext(EngineContext); if (!ctx) throw new Error("useActiveDocumentId: missing EngineProvider"); return { id: ctx.activeDocumentId, setId: ctx.setActiveDocumentId }; } export function useActiveDocument(): { readonly document: Document | null; readonly representation: DocumentRepresentation | null; } { const engine = useEngine(); const { id } = useActiveDocumentId(); const [tick, setTick] = useState(0); // Re-render when documents come and go so list views stay fresh. useEffect(() => { const off1 = engine.bus.on("DocumentImported", () => setTick((t) => t + 1)); const off2 = engine.bus.on("DocumentRepresentationGenerated", () => setTick((t) => t + 1)); return () => { off1(); off2(); }; }, [engine]); const document = id ? engine.documents.get(id) : null; const representation = id ? engine.documents.listRepresentations(id).at(-1) ?? null : null; // `tick` is intentionally read to silence unused-var warnings; the dep // chain is via useState so React handles the re-render. We don't actually // need to consume the value. void tick; return { document, representation }; } /** * Subscribe to a single engine event type and trigger a re-render each time * it fires. Returns the current monotonic counter — pure state-marker. */ export function useEngineEventTick[0]>( type: T, ): number { const engine = useEngine(); const [tick, setTick] = useState(0); const bump = useCallback(() => setTick((t) => t + 1), []); useEffect(() => engine.bus.on(type, bump), [engine, type, bump]); return tick; } export function usePendingSelection(): { readonly pending: PendingSelection | null; set(pending: PendingSelection | null): void; } { const ctx = useContext(EngineContext); if (!ctx) throw new Error("usePendingSelection: missing EngineProvider"); return { pending: ctx.pendingSelection, set: ctx.setPendingSelection }; } export function useScrollToAnnotation(): { readonly id: AnnotationId | null; readonly version: number; scrollTo(id: AnnotationId | null): void; } { const ctx = useContext(EngineContext); if (!ctx) throw new Error("useScrollToAnnotation: missing EngineProvider"); return { id: ctx.scrollToAnnotationId, version: ctx.scrollVersion, scrollTo: ctx.scrollToAnnotation, }; }