UploadDropzone accepts HTML/MD files; ViewerShell routes by representationType; PendingSelection uses SelectionCapture union.
316 lines
11 KiB
TypeScript
316 lines
11 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, SessionId } from "@shared/ids";
|
|
import type { Selector } from "@shared/selector";
|
|
import {
|
|
attachPersister,
|
|
createEngine,
|
|
engineSnapshotKey,
|
|
restoreFromStorage,
|
|
type Engine,
|
|
} from "@engine/index";
|
|
import type { SelectionCapture } from "@citation-evidence/evidence-anchor";
|
|
import { createPdfByteStore, type PdfByteStore } from "@source/index";
|
|
import { useContext as useReactContext } from "react";
|
|
import { SessionInternalContext } from "./SessionContextInternal";
|
|
|
|
/**
|
|
* Legacy single-bucket storage keys, kept for any user landing on a
|
|
* build without sessions. CE-WP-0005 switched persistence to per-session
|
|
* keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are
|
|
* only consulted when no `sessionId` is provided to the provider.
|
|
*/
|
|
const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
|
|
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
|
|
|
|
function storageKeyFor(sessionId: SessionId | null): string {
|
|
return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY;
|
|
}
|
|
|
|
function activeDocumentKeyFor(sessionId: SessionId | null): string {
|
|
return sessionId
|
|
? `citation-evidence:session:${sessionId}:active-document-id:v1`
|
|
: ACTIVE_KEY;
|
|
}
|
|
|
|
/**
|
|
* 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: SelectionCapture;
|
|
readonly selectors: readonly Selector[];
|
|
}
|
|
|
|
interface EngineContextValue {
|
|
readonly engine: Engine;
|
|
readonly byteStore: PdfByteStore;
|
|
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;
|
|
/**
|
|
* Bumps each time the engine's repos are mutated outside the normal
|
|
* event-emitting service path — currently only on `restoreFromStorage`.
|
|
* Consumers that cache `engine.documents.list()` via `useMemo` add this
|
|
* to their deps so the restored state is reflected on remount.
|
|
*/
|
|
readonly engineRevision: number;
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* Active session id. Drives the per-session storage key for the engine
|
|
* snapshot and the active-document pointer. `null`/omitted falls back
|
|
* to the legacy unscoped keys for back-compat with pre-CE-WP-0005
|
|
* builds.
|
|
*
|
|
* To switch sessions, parents should *re-key* this provider
|
|
* (`<EngineProvider key={sessionId} sessionId={sessionId}>`) so React
|
|
* unmounts the subtree and a fresh engine is created.
|
|
*/
|
|
readonly sessionId?: SessionId | null;
|
|
}
|
|
|
|
export function EngineProvider({
|
|
children,
|
|
engine: injected,
|
|
sessionId = null,
|
|
}: EngineProviderProps) {
|
|
const engine = useMemo(() => injected ?? createEngine(), [injected]);
|
|
// Prefer the SessionProvider's per-session byte store registry when
|
|
// available; fall back to a provider-local store for tests that mount
|
|
// EngineProvider on its own.
|
|
const sessionCtx = useReactContext(SessionInternalContext);
|
|
const [fallbackByteStore] = useState<PdfByteStore>(() => createPdfByteStore());
|
|
const byteStore =
|
|
sessionId && sessionCtx
|
|
? sessionCtx.getOrCreateByteStore(sessionId)
|
|
: fallbackByteStore;
|
|
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
|
|
// `restoreFromStorage` writes directly to the engine's repos without
|
|
// firing engine events (by design — see persistence.ts). That means
|
|
// consuming components (CollectionList etc.) wouldn't normally
|
|
// re-render to reflect the restored state. Bumping `engineRevision`
|
|
// after restore is what consumers add to their `useMemo` deps so
|
|
// the restored state shows up on (re-)mount.
|
|
const [engineRevision, setEngineRevision] = useState(0);
|
|
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
|
|
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
|
|
id: null,
|
|
version: 0,
|
|
});
|
|
|
|
const snapshotKey = storageKeyFor(sessionId);
|
|
const activeDocKey = activeDocumentKeyFor(sessionId);
|
|
|
|
// 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: snapshotKey });
|
|
if (result.restored) {
|
|
const saved = globalThis.localStorage.getItem(activeDocKey);
|
|
if (saved && engine.documents.get(saved as DocumentId)) {
|
|
setActiveDocumentIdState(saved as DocumentId);
|
|
}
|
|
// Force a re-render so consumers see the restored repos.
|
|
setEngineRevision((n) => n + 1);
|
|
}
|
|
return attachPersister(engine, { key: snapshotKey });
|
|
}, [engine, injected, snapshotKey, activeDocKey]);
|
|
|
|
// 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(activeDocKey, activeDocumentId);
|
|
} else {
|
|
globalThis.localStorage.removeItem(activeDocKey);
|
|
}
|
|
}, [activeDocumentId, injected, activeDocKey]);
|
|
|
|
// 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,
|
|
byteStore,
|
|
activeDocumentId,
|
|
setActiveDocumentId,
|
|
pendingSelection,
|
|
setPendingSelection,
|
|
scrollToAnnotationId: scrollState.id,
|
|
scrollVersion: scrollState.version,
|
|
scrollToAnnotation,
|
|
engineRevision,
|
|
}),
|
|
[
|
|
engine,
|
|
byteStore,
|
|
activeDocumentId,
|
|
setActiveDocumentId,
|
|
pendingSelection,
|
|
scrollState,
|
|
scrollToAnnotation,
|
|
engineRevision,
|
|
],
|
|
);
|
|
|
|
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 usePdfByteStore(): PdfByteStore {
|
|
const ctx = useContext(EngineContext);
|
|
if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider");
|
|
return ctx.byteStore;
|
|
}
|
|
|
|
export function useEngineRevision(): number {
|
|
const ctx = useContext(EngineContext);
|
|
if (!ctx) throw new Error("useEngineRevision: missing EngineProvider");
|
|
return ctx.engineRevision;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Track the most-recent `EvidenceItemActivated` event id from the engine
|
|
* bus. Returns `null` until something is activated. UI components that
|
|
* highlight "the active evidence" subscribe via this hook so they don't
|
|
* need to import the binder's active-state machine directly.
|
|
*/
|
|
export function useLastActivatedEvidence(): import("@shared/ids").EvidenceItemId | null {
|
|
const engine = useEngine();
|
|
const [id, setId] = useState<import("@shared/ids").EvidenceItemId | null>(null);
|
|
useEffect(() => {
|
|
return engine.bus.on("EvidenceItemActivated", (e) => setId(e.evidenceItemId));
|
|
}, [engine]);
|
|
return id;
|
|
}
|