/** * 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((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 ( ); } 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(null); const [didScroll, setDidScroll] = useState(null); const highlights = useMemo(() => { 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 ( {(pdfDocument) => ( { utilsRef.current = u; }} onSelection={(selection) => { const capture = captureFromPdfSelection(selection); const selectors = selectorsFromPdfCapture(capture); onSelectionCaptured(capture, selectors); }} > )} ); } // Re-export the §5 contract surface so callers see anchor as one entry point. export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };