Implement CE-WP-0002 T01-T02: engine types + PDF viewer adapter spike

T01: shared engine types (Document, Selector union, Annotation, EvidenceItem,
branded IDs with newId factory) per wiki/SharedContracts.md §1-§3.

T02: react-pdf-highlighter-plus v1.1.4 spike behind the §5
DocumentViewerAdapter contract in src/anchor/. Pure round-trip math
extracted to pdf-selector-math.ts with 11 unit tests proving lossless
capture → selectors → JSON → restored-rects. ADR-0004 accepted; full
user-flow Playwright verification deferred to T09.

Adds Vite app shell (index.html, src/app/SpikeApp.tsx) so the spike is
exercisable via pnpm dev. tsconfig --noEmit prevents tsc -b from
littering src/ with stray .js outputs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 02:21:31 +02:00
parent 2f25f99cae
commit 2a7b05c190
22 changed files with 2538 additions and 13 deletions

View File

@@ -0,0 +1,192 @@
/**
* Throwaway PDF viewer adapter spike (CE-WP-0002-T02).
*
* Purpose: prove that `react-pdf-highlighter-plus` can implement the §5
* `DocumentViewerAdapter` contract end-to-end (select → save selectors →
* reload → resolve → scroll → render highlight) without leaking PDF.js
* types into `src/shared/` or `src/engine/`.
*
* This module is the only place in the codebase that imports
* `react-pdf-highlighter-plus`. The exported React component is consumed
* by `src/app/SpikeApp.tsx`.
*
* Replace before production. T03 (source ingest) + T04 (anchor resolution)
* will build the real PDFViewerAdapter on top of this lessons-learned.
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
PdfHighlighter,
PdfLoader,
TextHighlight,
MonitoredHighlightContainer,
useHighlightContainerContext,
type Highlight,
type PdfHighlighterUtils,
type PdfSelection,
type ScaledPosition,
} from "react-pdf-highlighter-plus";
import "react-pdf-highlighter-plus/style/style.css";
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
export { selectorsFromPdfCapture };
/**
* Inverse of `selectorsFromPdfCapture`: build a viewer-renderable
* `Highlight` from stored selectors. The spike's reload path leans on
* `PdfRectSelector` since it carries page + page-relative rects directly.
* T04 will own the production resolver and add the text-only paths.
*/
function highlightFromSelectors(
id: string,
text: string,
selectors: readonly Selector[],
): Highlight | null {
const rectSel = findPdfRectSelector(selectors);
if (!rectSel) return null;
const boundingRect = unionRect(rectSel.rects);
if (!boundingRect) return null;
const scaledRects = rectSel.rects.map((r) => toScaled(r, rectSel.page));
return {
id,
type: "text",
content: { text },
position: {
boundingRect: toScaled(boundingRect, rectSel.page),
rects: scaledRects,
} satisfies ScaledPosition,
};
}
/**
* Convert the adapter's `NormalizedRect` (page-relative 0..1) to the
* `Scaled` shape react-pdf-highlighter-plus expects (also normalized 0..1
* via width/height). We use a unit page-space of 1×1 — the library
* computes pixel coords from `pageNumber` and the renderer's actual page
* dimensions.
*/
function toScaled(r: NormalizedRect, page: number) {
return {
x1: r.x,
y1: r.y,
x2: r.x + r.width,
y2: r.y + r.height,
width: 1,
height: 1,
pageNumber: page,
};
}
/** PdfSelection → our domain-neutral `PdfSelectionCapture`. */
function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
const page = sel.position.boundingRect.pageNumber;
const rects = sel.position.rects.map<NormalizedRect>((r) => ({
x: r.x1 / r.width,
y: r.y1 / r.height,
width: (r.x2 - r.x1) / r.width,
height: (r.y2 - r.y1) / r.height,
}));
const br = sel.position.boundingRect;
const boundingRect: NormalizedRect = {
x: br.x1 / br.width,
y: br.y1 / br.height,
width: (br.x2 - br.x1) / br.width,
height: (br.y2 - br.y1) / br.height,
};
return {
kind: "pdf",
text: sel.content.text ?? "",
page,
rects,
boundingRect,
};
}
/**
* Trivial container that renders every stored highlight as a TextHighlight.
* For the spike, no editing tooling — just visual proof of "did the saved
* coordinates land on the right passage on the right page after reload?"
*/
function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
return (
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
);
}
export interface PdfSpikeViewerProps {
/** URL of the PDF to load (served by Vite dev server). */
readonly pdfUrl: string;
/** Previously-saved selector sets to restore on mount. */
readonly storedAnnotations: readonly StoredAnnotation[];
/** Called when the user produces a new selection. */
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
/** Annotation id to scroll to and highlight on mount, if any. */
readonly scrollToAnnotationId?: string;
}
export interface StoredAnnotation {
readonly id: string;
readonly text: string;
readonly selectors: readonly Selector[];
}
/**
* The spike's React component. Renders a PDF and:
* - emits `onSelectionCaptured(capture, selectors)` on every fresh selection
* - reconstructs and renders `storedAnnotations` immediately on load
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
*/
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId } = props;
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const [didScroll, setDidScroll] = useState<string | null>(null);
const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = [];
for (const a of storedAnnotations) {
const h = highlightFromSelectors(a.id, a.text, a.selectors);
if (h) out.push(h);
}
return out;
}, [storedAnnotations]);
useEffect(() => {
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
const utils = utilsRef.current;
const target = highlights.find((h) => h.id === scrollToAnnotationId);
if (!utils || !target) return;
utils.scrollToHighlight(target);
setDidScroll(scrollToAnnotationId);
}, [scrollToAnnotationId, highlights, didScroll]);
return (
<PdfLoader document={pdfUrl}>
{(pdfDocument) => (
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlights}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture);
onSelectionCaptured(capture, selectors);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
)}
</PdfLoader>
);
}
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };