generated from coulomb/repo-seed
Implement CE-WP-0002 T03-T09: ingest, anchor resolution, engine, UI, persistence, e2e
Completes the PDF review slice end-to-end. After this commit a user can
open a fixture, select text, save an evidence item with commentary, see
it in the sidebar, reload the page, click the item, and the viewer
scrolls to the passage.
- T03 src/source/pdf/{fingerprint,extract,ingest}.ts + 39 fixture tests
- SHA-256 fingerprint over a fresh ArrayBuffer (TS BufferSource-safe)
- PDF.js text extract; per-page normalize then join with "\n\n"
- PageMap + OffsetMap (gap-free coverage); pageLength = end - start
- Updated manifest's Betriebskosten quote to one PDF.js extracts cleanly
- T04 src/anchor/selectors/{create,resolve}.ts + 25 unit + 7 fixture tests
- createSelectors emits the maximal redundant set (TextQuote +
TextPosition + PdfRect + PdfPageText when available)
- resolveSelectors implements the SharedContracts §7 ladder; confidence
1.0 (pos+quote) → 0.7 (rect-only) → 0 (unresolved)
- Cross-module integration test moved to tests/integration/ to honor
the anchor↛source boundary lint rule
- T05 engine: sync event bus over the closed §4 vocabulary, Map-backed
repos, services, createEngine() composition root, 12 tests
- T06 work + app: three-pane shell (CollectionList | ViewerShell |
EvidenceSidebar) wired through EngineProvider; EngineContext lives in
src/work/ to respect the work↛app boundary; SpikeApp deleted
- T07 AnnotationToolbar: pendingSelection in context; Save runs
createSelectors → engine.annotations.create → engine.evidence.create
- T08 click-to-reopen + localStorage persistence
- scrollToAnnotation state in context with a version counter so a
second click on the same item re-fires the viewer scroll
- captureSnapshot/restoreSnapshot/attachPersister/restoreFromStorage;
restore bypasses services to avoid event-loops
- active-document id persisted alongside the snapshot so reload lands
on the same fixture; ADR-0005 written
- 9 persistence tests
- T09 tests/integration/app-prd-scenario.dom.test.tsx
- end-to-end happy-dom test of PRD scenario steps 1-8 through the real
React tree; viewer + ingest mocked per ADR-0004's headless-Chromium
limitation. Fixed memo-deps bug in EvidenceSidebar/ViewerShell where
useEngineEventTick values were not included in the useMemo deps,
leaving stale memoization across event-driven re-renders
- vitest.config.ts: happy-dom for *.dom.test.{ts,tsx} files
- noEmit added to tsconfig so tsc -b doesn't litter src/ with .js outputs
Gates: typecheck ✓ lint ✓ test 109/109 across 11 files ✓ build ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
219
src/work/EngineContext.tsx
Normal file
219
src/work/EngineContext.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user