generated from coulomb/repo-seed
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>
101 lines
3.1 KiB
TypeScript
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" }}>
|
|
“{shortQuote}”
|
|
</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>
|
|
);
|
|
}
|