/** * 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 "./highlight-styles.css"; import "./debug-textlayer.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?" */ interface HighlightContainerProps { readonly activeAnnotationId: string | null | undefined; readonly onHighlightClicked: ((annotationId: string) => void) | undefined; } function makeSpikeHighlightContainer(props: HighlightContainerProps) { return function SpikeHighlightContainer(): ReactNode { const { highlight, isScrolledTo } = useHighlightContainerContext(); const isActive = props.activeAnnotationId === highlight.id; return (
{ // Click-capture so the click registers before the library's // built-in selection-clearing logic eats it. Stop propagation // so the highlight click doesn't also start a new selection. e.stopPropagation(); props.onHighlightClicked?.(highlight.id); }} >
); }; } /** * Resolve the rendered DOM rect for a highlight by data attribute, or * `null` if the highlight isn't currently rendered (e.g. its page hasn't * scrolled into view). Used by `app/forms/HighlightRectBridge` to feed * the rect registry as kind="highlight". * * `display: contents` on the wrapper means it has no box of its own; we * union the rects of its children. For TextHighlight that's typically * one rect per line. */ export function getHighlightClientRects(annotationId: string): DOMRect | null { if (typeof document === "undefined") return null; const wrapper = document.querySelector(`[data-highlight-id="${CSS.escape(annotationId)}"]`); if (!wrapper) return null; const rects = wrapper.getClientRects(); if (rects.length === 0) return null; let left = Infinity; let top = Infinity; let right = -Infinity; let bottom = -Infinity; for (const r of Array.from(rects)) { left = Math.min(left, r.left); top = Math.min(top, r.top); right = Math.max(right, r.right); bottom = Math.max(bottom, r.bottom); } if (!isFinite(left)) return null; return new DOMRect(left, top, right - left, bottom - top); } 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; /** * Annotation id currently focused. The matching highlight gets a * thicker border (see highlight-styles.css). `null`/undefined means * "no active highlight". */ readonly activeAnnotationId?: string | null; /** * Called when the user clicks an existing highlight in the page. * The receiver typically activates the matching evidence item. */ onHighlightClicked?(annotationId: string): void; /** * When true, paint the PDF text-layer spans in light grey so it's * obvious which glyphs have a selectable text overlay and which are * image-only. Also logs every onSelection event to the console. */ readonly debugTextLayer?: boolean; } 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, activeAnnotationId, onHighlightClicked, debugTextLayer, } = props; const HighlightContainer = useMemo( () => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }), [activeAnnotationId, onHighlightClicked], ); const utilsRef = useRef(null); const [didScroll, setDidScroll] = useState(null); const highlights = useMemo(() => { const out: Highlight[] = []; const skipped: { id: string; reason: string }[] = []; for (const a of storedAnnotations) { const h = highlightFromSelectors(a.id, a.text, a.selectors); if (h) out.push(h); else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" }); } if (debugTextLayer) { console.log("[ce] viewer highlights", { in: storedAnnotations.length, rendered: out.length, rendered_detail: out.map((h) => ({ id: h.id, page: h.position.boundingRect.pageNumber, bounding: h.position.boundingRect, rectCount: h.position.rects.length, })), skipped, }); } return out; }, [storedAnnotations, debugTextLayer]); useEffect(() => { if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return; const utils = utilsRef.current; const target = highlights.find((h) => h.id === scrollToAnnotationId); if (debugTextLayer) { console.log("[ce] scrollToAnnotation requested", { id: scrollToAnnotationId, utilsAvailable: !!utils, targetFound: !!target, knownIds: highlights.map((h) => h.id), }); } if (!utils || !target) return; utils.scrollToHighlight(target); setDidScroll(scrollToAnnotationId); }, [scrollToAnnotationId, highlights, didScroll, debugTextLayer]); return (
{(pdfDocument) => ( { utilsRef.current = u; }} onSelection={(selection) => { const capture = captureFromPdfSelection(selection); const selectors = selectorsFromPdfCapture(capture); if (debugTextLayer) { console.log("[ce] onSelection", { text: capture.text, page: capture.page, rects: capture.rects, selectorTypes: selectors.map((s) => s.type), raw: selection, }); } onSelectionCaptured(capture, selectors); }} > )}
); } // Re-export the §5 contract surface so callers see anchor as one entry point. export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };