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>
This commit is contained in:
parent
bd7f56c111
commit
5b77dbd314
10 changed files with 720 additions and 23 deletions
35
README.md
35
README.md
|
|
@ -34,36 +34,41 @@ aliases.
|
|||
|
||||
```text
|
||||
src/
|
||||
index.ts public entrypoint (re-exports the surfaces below)
|
||||
index.ts public entrypoint — pure surface only
|
||||
types.ts adapter-side types: SelectionCapture,
|
||||
ResolvedAnchorTarget, AnchorResolution,
|
||||
HighlightRenderOptions, DocumentViewerAdapter
|
||||
selectors/
|
||||
css.d.ts ambient decl for side-effect .css imports
|
||||
selectors/ pure core — no viewer/UI deps
|
||||
index.ts createSelectors, resolveSelectors, DEFAULT_CONTEXT_CHARS
|
||||
create.ts selector creation from a captured selection
|
||||
resolve.ts resolution + the exact-match confidence ladder
|
||||
create.test.ts
|
||||
resolve.test.ts
|
||||
pdf/
|
||||
pdf-selector-math.ts page number + normalized page-rectangle math
|
||||
pdf/ adapter boundary — the only place viewer libs live
|
||||
index.ts subpath entry `evidence-anchor/pdf`
|
||||
pdf-selector-math.ts pure page + normalized-rect math (capture↔selectors)
|
||||
pdf-selector-math.test.ts
|
||||
pdf-viewer-adapter.tsx concrete PDF DocumentViewerAdapter (from the spike)
|
||||
highlight/
|
||||
scroll-job.ts scroll-to-target helper
|
||||
pdf-viewer-adapter-spike.tsx concrete PDF adapter (PdfSpikeViewer)
|
||||
scroll-job.ts retryable scroll-to-highlight helper
|
||||
scroll-job.test.ts
|
||||
highlight-styles.css highlight rendering styles
|
||||
debug-textlayer.css optional text-layer debugging styles
|
||||
```
|
||||
|
||||
Boundary rules for the layout:
|
||||
Boundary rules for the layout (enforced by `eslint.config.js`):
|
||||
|
||||
- viewer-library imports (`pdfjs`, `react-pdf-highlighter-plus`) are confined to
|
||||
`src/pdf/` — they never appear on `types.ts` or on the public surface;
|
||||
- `src/selectors/` is pure (no viewer/UI deps) and depends only on
|
||||
`citation-engine` shared types;
|
||||
- the public entrypoint re-exports the stable surface consumers rely on:
|
||||
`createSelectors`, `resolveSelectors`, the selector/resolution types, the
|
||||
`DocumentViewerAdapter` contract, and the PDF adapter.
|
||||
- viewer-library imports (`pdfjs-dist`, `react`, `react-pdf-highlighter-plus`)
|
||||
are confined to `src/pdf/` — the pure zone (`src/selectors/**`, `src/types.ts`)
|
||||
may not import them;
|
||||
- `src/selectors/` is pure and depends only on `citation-engine` shared types;
|
||||
- the **root** entrypoint (`evidence-anchor`) exposes only the pure surface —
|
||||
`createSelectors`, `resolveSelectors`, the selector/resolution types, and the
|
||||
`DocumentViewerAdapter` contract;
|
||||
- the concrete PDF adapter is reached through the **`evidence-anchor/pdf`**
|
||||
subpath, so pure consumers never pull PDF.js or React into their bundle. That
|
||||
adapter is still the explicitly-named `PdfSpikeViewer` spike; promoting it to
|
||||
a production `PDFViewerAdapter` is registered follow-on work (T06).
|
||||
|
||||
## Public API (target surface)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ export default tseslint.config(
|
|||
},
|
||||
},
|
||||
{
|
||||
// The pure core (selectors, resolution, highlight helpers) must not pull in
|
||||
// any concrete viewer library — those live only under src/pdf/.
|
||||
files: ["src/selectors/**/*.ts", "src/highlight/**/*.ts", "src/types.ts"],
|
||||
// The pure core (selectors, resolution, adapter-side types) must not pull
|
||||
// in any concrete viewer library — those live only under src/pdf/.
|
||||
files: ["src/selectors/**/*.ts", "src/types.ts"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
|
|
|
|||
3
src/css.d.ts
vendored
Normal file
3
src/css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Side-effect CSS imports (viewer styles). The bundler handles these; for the
|
||||
// typechecker they resolve to an empty module. Confined to the PDF adapter.
|
||||
declare module "*.css";
|
||||
59
src/pdf/debug-textlayer.css
Normal file
59
src/pdf/debug-textlayer.css
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Debug overlay for PDF text layer alignment.
|
||||
*
|
||||
* The text layer is normally invisible (`opacity: 0`) and selectable.
|
||||
* When `.ce-debug-textlayer` is on a parent, every text node becomes a
|
||||
* light grey box so it's obvious where text is selectable and where it
|
||||
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
|
||||
* text-layer shift caused by font fallbacks.
|
||||
*
|
||||
* Light grey was chosen so the debug overlay does not clash with the
|
||||
* citation-yellow used for evidence highlights (see highlight-styles.css).
|
||||
*
|
||||
* Toggle via the "Debug text layer" entry in SessionMenu.
|
||||
*/
|
||||
|
||||
.ce-debug-textlayer .textLayer {
|
||||
outline: 2px dashed rgba(120, 120, 120, 0.55);
|
||||
background: rgba(120, 120, 120, 0.06);
|
||||
}
|
||||
|
||||
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
|
||||
descendant so the entire selectable area is visible regardless of how
|
||||
the renderer nested things. */
|
||||
.ce-debug-textlayer .textLayer * {
|
||||
background: rgba(170, 170, 170, 0.4) !important;
|
||||
color: rgba(40, 40, 40, 0.85) !important;
|
||||
opacity: 1 !important;
|
||||
outline: 1px solid rgba(100, 100, 100, 0.35);
|
||||
}
|
||||
|
||||
/* Dim the canvas-rendered layer slightly so the debug overlay stands
|
||||
out by contrast. */
|
||||
.ce-debug-textlayer canvas {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/*
|
||||
* Layer-visibility toggles. Each `.ce-hide-<layer>` class is applied
|
||||
* to the same viewer-wrapper element so a single parent can hide any
|
||||
* combination of layers. Useful for diagnosing layer stacking issues
|
||||
* (e.g. "is the textLayer covering the canvas?") by elimination.
|
||||
*/
|
||||
|
||||
.ce-hide-canvas canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ce-hide-text-layer .textLayer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ce-hide-annotation-layer .annotationLayer,
|
||||
.ce-hide-annotation-layer .annotationEditorLayer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ce-hide-xfa-layer .xfaLayer {
|
||||
display: none !important;
|
||||
}
|
||||
38
src/pdf/highlight-styles.css
Normal file
38
src/pdf/highlight-styles.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Evidence highlight styling — matches the sidebar's "evidence card"
|
||||
* palette so the viewer and the sidebar speak the same visual language.
|
||||
*
|
||||
* .TextHighlight__part inactive highlight (light yellow fill,
|
||||
* thin amber border)
|
||||
* .TextHighlight--active … the currently-focused evidence — same
|
||||
* fill, thicker border
|
||||
*
|
||||
* The "active" class is applied by the spike viewer when the parent
|
||||
* wrapper is marked with `data-ce-active="true"` so a single
|
||||
* `activeAnnotationId` prop drives the entire viewer's focus state
|
||||
* without per-highlight component coupling.
|
||||
*
|
||||
* We override the library's red `--scrolledTo` box-shadow so an
|
||||
* activation doesn't flash a red ring that doesn't match the palette.
|
||||
*/
|
||||
|
||||
.TextHighlight__part {
|
||||
background: #fff8d6 !important;
|
||||
outline: 1px solid #e0c050 !important;
|
||||
outline-offset: 0;
|
||||
cursor: pointer;
|
||||
transition: outline 0.15s ease;
|
||||
}
|
||||
|
||||
[data-ce-active="true"] .TextHighlight__part {
|
||||
outline: 3px solid #b78b1c !important;
|
||||
background: #fff5b8 !important;
|
||||
}
|
||||
|
||||
/* The library applies `--scrolledTo` after a programmatic scroll. We
|
||||
override its red box-shadow so the "you just landed on this" cue
|
||||
sticks with the yellow palette. The thicker border from
|
||||
`data-ce-active` already conveys focus. */
|
||||
.TextHighlight--scrolledTo .TextHighlight__part {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
|
@ -1,13 +1,35 @@
|
|||
// Subpath entrypoint `evidence-anchor/pdf` — the PDF viewer adapter, its
|
||||
// helpers, and the pure capture→selector math. Keeping this behind a subpath
|
||||
// lets pure consumers use the selector/resolution core without pulling PDF.js
|
||||
// or React into their bundle.
|
||||
// scroll/highlight helpers, and the pure capture→selector math. Keeping this
|
||||
// behind a subpath (rather than the package root) confines PDF.js, React, and
|
||||
// react-pdf-highlighter-plus to consumers that actually render a PDF; the pure
|
||||
// selector/resolution core stays importable without them.
|
||||
//
|
||||
// The concrete `DocumentViewerAdapter` implementation is added in T04.
|
||||
// The concrete adapter is still the explicitly-named `PdfSpikeViewer` spike
|
||||
// that validated the round-trip end-to-end. Promoting it to a production
|
||||
// `PDFViewerAdapter` is registered follow-on work (see EANCH-WP-0001 T06), not
|
||||
// part of this extraction.
|
||||
|
||||
// Pure capture → selector math (no viewer libraries).
|
||||
export {
|
||||
selectorsFromPdfCapture,
|
||||
findPdfRectSelector,
|
||||
findTextQuoteSelector,
|
||||
unionRect,
|
||||
} from "./pdf-selector-math";
|
||||
|
||||
// Retryable scroll-to-highlight job (react-pdf-highlighter-plus types).
|
||||
export {
|
||||
runScrollToHighlightJob,
|
||||
DEFAULT_SCROLL_ATTEMPTS,
|
||||
type ScrollToHighlightJob,
|
||||
type ScrollToHighlightDeps,
|
||||
type ScrollToHighlightState,
|
||||
} from "./scroll-job";
|
||||
|
||||
// Concrete PDF viewer adapter (spike) + its contract surface.
|
||||
export {
|
||||
PdfSpikeViewer,
|
||||
getHighlightClientRects,
|
||||
type PdfSpikeViewerProps,
|
||||
type StoredAnnotation,
|
||||
} from "./pdf-viewer-adapter-spike";
|
||||
|
|
|
|||
424
src/pdf/pdf-viewer-adapter-spike.tsx
Normal file
424
src/pdf/pdf-viewer-adapter-spike.tsx
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
/**
|
||||
* 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 };
|
||||
73
src/pdf/scroll-job.test.ts
Normal file
73
src/pdf/scroll-job.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* CE-WP-0008-T02 — scroll job retries until utils and highlight exist.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
|
||||
|
||||
import { runScrollToHighlightJob } from "./scroll-job";
|
||||
|
||||
const TARGET = {
|
||||
id: "ann_test",
|
||||
type: "text",
|
||||
content: { text: "quote" },
|
||||
position: {
|
||||
boundingRect: {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
pageNumber: 2,
|
||||
},
|
||||
rects: [],
|
||||
},
|
||||
} as Highlight;
|
||||
|
||||
describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
|
||||
it("retries until utils and highlight are available", () => {
|
||||
const frames: Array<() => void> = [];
|
||||
const scrollToHighlight = vi.fn();
|
||||
const centerHighlight = vi.fn();
|
||||
let utils: PdfHighlighterUtils | null = null;
|
||||
const highlightRef: { current: Highlight | undefined } = { current: undefined };
|
||||
|
||||
const state = { lastCompletedKey: null as string | null };
|
||||
|
||||
const cancel = runScrollToHighlightJob(
|
||||
{ requestKey: "ann_test:1", annotationId: "ann_test" },
|
||||
{
|
||||
getUtils: () => utils,
|
||||
findHighlight: (id) => (id === "ann_test" ? highlightRef.current : undefined),
|
||||
scrollToHighlight: (_u, target) => scrollToHighlight(target),
|
||||
centerHighlight,
|
||||
scheduleFrame: (fn) => {
|
||||
frames.push(fn);
|
||||
return frames.length;
|
||||
},
|
||||
maxAttempts: 5,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(scrollToHighlight).not.toHaveBeenCalled();
|
||||
|
||||
// First two frames: still missing utils / highlight.
|
||||
frames.shift()?.();
|
||||
frames.shift()?.();
|
||||
expect(scrollToHighlight).not.toHaveBeenCalled();
|
||||
|
||||
utils = { scrollToHighlight: vi.fn() } as unknown as PdfHighlighterUtils;
|
||||
highlightRef.current = TARGET;
|
||||
frames.shift()?.();
|
||||
|
||||
expect(scrollToHighlight).toHaveBeenCalledWith(TARGET);
|
||||
expect(state.lastCompletedKey).toBe("ann_test:1");
|
||||
|
||||
frames.shift()?.();
|
||||
expect(centerHighlight).toHaveBeenCalledWith(utils, TARGET);
|
||||
|
||||
cancel();
|
||||
});
|
||||
});
|
||||
73
src/pdf/scroll-job.ts
Normal file
73
src/pdf/scroll-job.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Retryable scroll-to-highlight job for PdfSpikeViewer.
|
||||
*
|
||||
* The PDF highlighter's utils ref and highlight DOM are not always ready on
|
||||
* the first effect tick (especially for page-2+ passages). This helper retries
|
||||
* via rAF until both are available or attempts are exhausted.
|
||||
*/
|
||||
|
||||
import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
|
||||
|
||||
export const DEFAULT_SCROLL_ATTEMPTS = 40;
|
||||
|
||||
export interface ScrollToHighlightJob {
|
||||
readonly requestKey: string;
|
||||
readonly annotationId: string;
|
||||
}
|
||||
|
||||
export interface ScrollToHighlightDeps {
|
||||
readonly getUtils: () => PdfHighlighterUtils | null;
|
||||
readonly findHighlight: (annotationId: string) => Highlight | undefined;
|
||||
readonly scrollToHighlight: (
|
||||
utils: PdfHighlighterUtils,
|
||||
target: Highlight,
|
||||
) => void;
|
||||
readonly centerHighlight: (
|
||||
utils: PdfHighlighterUtils,
|
||||
target: Highlight,
|
||||
) => void;
|
||||
readonly scheduleFrame: (fn: () => void) => number;
|
||||
readonly maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface ScrollToHighlightState {
|
||||
lastCompletedKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt scroll for `job`. Returns a cancel function. Sets
|
||||
* `state.lastCompletedKey` only after a successful scroll.
|
||||
*/
|
||||
export function runScrollToHighlightJob(
|
||||
job: ScrollToHighlightJob,
|
||||
deps: ScrollToHighlightDeps,
|
||||
state: ScrollToHighlightState,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let attempt = 0;
|
||||
const maxAttempts = deps.maxAttempts ?? DEFAULT_SCROLL_ATTEMPTS;
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
if (state.lastCompletedKey === job.requestKey) return;
|
||||
|
||||
const utils = deps.getUtils();
|
||||
const target = deps.findHighlight(job.annotationId);
|
||||
if (!utils || !target) {
|
||||
if (attempt < maxAttempts) {
|
||||
attempt += 1;
|
||||
deps.scheduleFrame(tick);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deps.scrollToHighlight(utils, target);
|
||||
state.lastCompletedKey = job.requestKey;
|
||||
deps.scheduleFrame(() => deps.centerHighlight(utils, target));
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
|
@ -233,7 +233,7 @@ depend on the umbrella repo folder structure.
|
|||
|
||||
```task
|
||||
id: EANCH-WP-0001-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [T03]
|
||||
state_hub_task_id: "1deca610-8502-44e5-90c7-43e355489f55"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue