This repository has been archived on 2026-07-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
citation-evidence/src/work/AnnotationToolbar.tsx
tegwick d54daf2e61 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>
2026-05-25 10:58:11 +02:00

101 lines
3.1 KiB
TypeScript

/**
* AnnotationToolbar — wires "I selected text" into "evidence appears in
* the sidebar".
*
* Visible only when a `pendingSelection` is set (the viewer publishes
* captures into context, then this toolbar lets the user attach commentary
* and commit). On Save it runs the full pipeline:
*
* 1. `createSelectors(capture, representation)` — anchor builds the
* maximal selector set against the active representation.
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
* emits AnnotationCreated.
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem with
* the user's commentary, emits EvidenceItemCreated.
*
* The sidebar re-renders via the engine event bus, so no other glue is
* needed.
*/
import { useEffect, useState } from "react";
import { createSelectors } from "@anchor/index";
import {
useActiveDocument,
useEngine,
usePendingSelection,
} from "./EngineContext";
export function AnnotationToolbar() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { pending, set } = usePendingSelection();
const [commentary, setCommentary] = useState("");
// Reset the commentary box whenever a fresh selection arrives.
useEffect(() => {
setCommentary("");
}, [pending]);
if (!pending || !document || !representation) return null;
const handleSave = () => {
const selectors = createSelectors(pending.capture, representation);
const annotation = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors,
quote: pending.capture.text,
});
engine.evidence.create({
annotationIds: [annotation.id],
...(commentary.trim().length > 0 ? { commentary: commentary.trim() } : {}),
});
set(null);
};
const handleDiscard = () => set(null);
const quote = pending.capture.text;
const shortQuote = quote.length > 200 ? `${quote.slice(0, 200)}` : quote;
return (
<div
style={{
borderBottom: "1px solid #f0c040",
background: "#fff8d6",
padding: 8,
fontFamily: "system-ui, sans-serif",
fontSize: 12,
}}
>
<div style={{ marginBottom: 6, fontWeight: 600 }}>
New annotation ({pending.selectors.length} selector{pending.selectors.length === 1 ? "" : "s"})
</div>
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
&ldquo;{shortQuote}&rdquo;
</div>
<textarea
value={commentary}
onChange={(e) => setCommentary(e.target.value)}
placeholder="Add a one-line comment (optional)…"
rows={2}
style={{
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={handleSave} style={{ fontSize: 12, padding: "4px 10px" }}>
Save evidence
</button>
<button onClick={handleDiscard} style={{ fontSize: 12, padding: "4px 10px" }}>
Discard
</button>
</div>
</div>
);
}