evidence-anchor/src/pdf/pdf-viewer-adapter-spike.tsx
tegwick 5b77dbd314 EANCH-WP-0001 T04: extract PDF adapter + scroll/highlight helpers
Move pdf-viewer-adapter-spike.tsx, scroll-job.ts (+test), highlight-styles.css
and debug-textlayer.css into src/pdf/ (the adapter boundary). Rewrite the
spike's @shared import to @citation-evidence/engine/shared and its ./types to
../types. Add src/css.d.ts so tsc accepts side-effect .css imports.

Decisions:
- adapter stays the explicitly-named PdfSpikeViewer spike; promotion to a
  production PDFViewerAdapter is registered as T06 follow-on work
- viewer libs (pdfjs-dist, react, react-pdf-highlighter-plus) confined to
  src/pdf/ and exposed only via the evidence-anchor/pdf subpath; the root
  entrypoint stays pure. Boundary enforced by eslint no-restricted-imports.
- ported unit suites (pdf-selector-math round-trip + scroll-job retry) serve
  as the inspectable harness for capture->selectors->resolve->scroll.

Verified: pnpm test (30 passed), typecheck, lint all green. README layout updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:44:52 +02:00

424 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<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,
};
}
const ActiveAnnotationContext = createContext<string | null | undefined>(
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 (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
e.stopPropagation();
onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
}
/**
* 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<PdfHighlighterUtils | null>(null);
const scrollStateRef = useRef({ lastCompletedKey: null as string | null });
const highlights = useMemo<Highlight[]>(() => {
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 (
<div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader document={pdfLoaderDocument}>
{(pdfDocument) => (
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
<HighlightClickContext.Provider value={handleHighlightClicked}>
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlightsForViewer}
utilsRef={(u) => {
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);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)}
</PdfLoader>
</div>
);
}
// Re-export the §5 contract surface so callers see anchor as one entry point.
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };