220 lines
7.5 KiB
TypeScript
220 lines
7.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<EngineContextValue | null>(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<DocumentId | null>(null);
|
||
|
|
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(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<EngineContextValue>(
|
||
|
|
() => ({
|
||
|
|
engine,
|
||
|
|
activeDocumentId,
|
||
|
|
setActiveDocumentId,
|
||
|
|
pendingSelection,
|
||
|
|
setPendingSelection,
|
||
|
|
scrollToAnnotationId: scrollState.id,
|
||
|
|
scrollVersion: scrollState.version,
|
||
|
|
scrollToAnnotation,
|
||
|
|
}),
|
||
|
|
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
|
||
|
|
);
|
||
|
|
|
||
|
|
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
|
||
|
|
}
|
||
|
|
|
||
|
|
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<T extends Parameters<Engine["bus"]["on"]>[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,
|
||
|
|
};
|
||
|
|
}
|