diff --git a/README.md b/README.md index eabce3d..4f6a111 100644 --- a/README.md +++ b/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) diff --git a/eslint.config.js b/eslint.config.js index 48b7eae..318a05c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", diff --git a/src/css.d.ts b/src/css.d.ts new file mode 100644 index 0000000..b8bf1a7 --- /dev/null +++ b/src/css.d.ts @@ -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"; diff --git a/src/pdf/debug-textlayer.css b/src/pdf/debug-textlayer.css new file mode 100644 index 0000000..6c029f0 --- /dev/null +++ b/src/pdf/debug-textlayer.css @@ -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-` 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; +} diff --git a/src/pdf/highlight-styles.css b/src/pdf/highlight-styles.css new file mode 100644 index 0000000..a74120e --- /dev/null +++ b/src/pdf/highlight-styles.css @@ -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; +} diff --git a/src/pdf/index.ts b/src/pdf/index.ts index 847387d..570f3bd 100644 --- a/src/pdf/index.ts +++ b/src/pdf/index.ts @@ -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"; diff --git a/src/pdf/pdf-viewer-adapter-spike.tsx b/src/pdf/pdf-viewer-adapter-spike.tsx new file mode 100644 index 0000000..4e2aa03 --- /dev/null +++ b/src/pdf/pdf-viewer-adapter-spike.tsx @@ -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((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 }; diff --git a/src/pdf/scroll-job.test.ts b/src/pdf/scroll-job.test.ts new file mode 100644 index 0000000..5e9c0bb --- /dev/null +++ b/src/pdf/scroll-job.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/pdf/scroll-job.ts b/src/pdf/scroll-job.ts new file mode 100644 index 0000000..a9d3c2d --- /dev/null +++ b/src/pdf/scroll-job.ts @@ -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; + }; +} \ No newline at end of file diff --git a/workplans/EANCH-WP-0001-intent-placeholder.md b/workplans/EANCH-WP-0001-intent-placeholder.md index fd8c8b4..1293239 100644 --- a/workplans/EANCH-WP-0001-intent-placeholder.md +++ b/workplans/EANCH-WP-0001-intent-placeholder.md @@ -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"