/** * 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 { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode, } from "react"; import { PdfHighlighter, PdfLoader, TextHighlight, MonitoredHighlightContainer, useHighlightContainerContext, type Highlight, type PdfHighlighterUtils, type PdfSelection, type ScaledPosition, } from "react-pdf-highlighter-plus"; // pdfjs-dist's own pdf_viewer.css is the authoritative source for // text-layer positioning. The version bundled with // react-pdf-highlighter-plus is a minimal *override* (missing // `position: absolute`, `inset: 0`, and PDF.js 4.x's // `--scale-factor` handling) — load the real one first, then the // library's overrides on top. import "pdfjs-dist/web/pdf_viewer.css"; 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 "@citation-evidence/engine/shared"; import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "../types"; import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math"; import { runScrollToHighlightJob } from "./scroll-job"; 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, }; } const ActiveAnnotationContext = createContext( undefined, ); const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>( undefined, ); /** * Stable highlight row — component type never changes so PdfHighlighter does * not remount highlight layers on activation changes (which disturbs scroll). * Active/focus styling reads from context instead. */ function SpikeHighlightContainer(): ReactNode { const activeAnnotationId = useContext(ActiveAnnotationContext); const onHighlightClicked = useContext(HighlightClickContext); const { highlight, isScrolledTo } = useHighlightContainerContext(); const isActive = activeAnnotationId === highlight.id; return (
{ e.stopPropagation(); 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; /** * Bumps when the same annotation should be re-scrolled (e.g. repeat click). * Format is opaque — typically `${annotationId}:${version}`. */ readonly scrollRequestKey?: 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; /** * Hide specific PDF.js layers so you can see what sits underneath. * Helps diagnose layer-stacking issues (e.g. "is the text layer * covering the canvas content?"). */ readonly hideCanvas?: boolean; readonly hideTextLayer?: boolean; readonly hideAnnotationLayer?: boolean; readonly hideXfaLayer?: boolean; } /** * Nudge the PDF scroll container so `highlight` sits vertically centred. * Best-effort: depends on highlight layer DOM being present after scroll. */ function centerHighlightInViewer( utils: PdfHighlighterUtils, highlight: Highlight, attempt = 0, ): void { const viewer = utils.getViewer(); const container = viewer?.container as HTMLElement | undefined; if (!container) return; const rect = getHighlightClientRects(highlight.id); if (!rect) { if (attempt < 12) { requestAnimationFrame(() => centerHighlightInViewer(utils, highlight, attempt + 1), ); } return; } const cRect = container.getBoundingClientRect(); const highlightCenterY = rect.top + rect.height / 2; const containerCenterY = cRect.top + cRect.height / 2; const delta = highlightCenterY - containerCenterY; if (Math.abs(delta) < 4) return; container.scrollTop += delta; } 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, scrollRequestKey, activeAnnotationId, onHighlightClicked, debugTextLayer, hideCanvas, hideTextLayer, hideAnnotationLayer, hideXfaLayer, } = props; const onHighlightClickedRef = useRef(onHighlightClicked); onHighlightClickedRef.current = onHighlightClicked; const handleHighlightClicked = useCallback((annotationId: string) => { onHighlightClickedRef.current?.(annotationId); }, []); const pdfLoaderDocument = useMemo( () => ({ url: pdfUrl, // PdfLoader's effect depends on `document` by reference — must be // stable across re-renders or the PDF reloads and scroll resets to top. cMapUrl: "/cmaps/", cMapPacked: true, standardFontDataUrl: "/standard_fonts/", }), [pdfUrl], ); const wrapperClasses = [ debugTextLayer ? "ce-debug-textlayer" : null, hideCanvas ? "ce-hide-canvas" : null, hideTextLayer ? "ce-hide-text-layer" : null, hideAnnotationLayer ? "ce-hide-annotation-layer" : null, hideXfaLayer ? "ce-hide-xfa-layer" : null, ] .filter((c): c is string => c !== null) .join(" "); const utilsRef = useRef(null); const scrollStateRef = useRef({ lastCompletedKey: null as string | 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]); const highlightsRef = useRef(highlights); highlightsRef.current = highlights; const highlightsSignature = useMemo( () => highlights.map((h) => h.id).join(","), [highlights], ); // Re-render highlight layers when focus moves so `data-ce-active` updates. const highlightsForViewer = useMemo( () => highlights, [highlights, activeAnnotationId], ); useEffect(() => { const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null; if (!requestKey || !scrollToAnnotationId) return; if (scrollStateRef.current.lastCompletedKey === requestKey) return; if (debugTextLayer) { console.log("[ce] scrollToAnnotation requested", { id: scrollToAnnotationId, requestKey, utilsAvailable: !!utilsRef.current, targetFound: !!highlightsRef.current.find((h) => h.id === scrollToAnnotationId), knownIds: highlightsRef.current.map((h) => h.id), }); } return runScrollToHighlightJob( { requestKey, annotationId: scrollToAnnotationId }, { getUtils: () => utilsRef.current, findHighlight: (id) => highlightsRef.current.find((h) => h.id === id), scrollToHighlight: (utils, target) => utils.scrollToHighlight(target), centerHighlight: (utils, target) => centerHighlightInViewer(utils, target), scheduleFrame: (fn) => requestAnimationFrame(fn), }, scrollStateRef.current, ); }, [scrollToAnnotationId, scrollRequestKey, highlightsSignature, debugTextLayer]); return (
0 ? wrapperClasses : undefined} style={{ height: "100%" }} > {(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 };