2026-05-25 02:21:31 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
2026-05-26 21:43:15 +02:00
|
|
|
|
import "./debug-textlayer.css";
|
2026-05-25 02:21:31 +02:00
|
|
|
|
|
|
|
|
|
|
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<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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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?"
|
|
|
|
|
|
*/
|
|
|
|
|
|
function SpikeHighlightContainer(): ReactNode {
|
|
|
|
|
|
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
- src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
- src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
- enum-conformance test parses SharedContracts.md and asserts the
runtime lists match exactly
T02 Binding service + in-memory link repo + active-state machine
- src/binder/repos/in-memory-links.ts: Map-backed EvidenceLinkRepository
- src/binder/services/bindings.ts: link/unlink/list/update/setActive
emitting §4 EvidenceLinkCreated / EvidenceLinkUpdated /
EvidenceItemActivated
- src/binder/state/active.ts: (target, evidence, annotation) reducer
+ ActiveStateProvider + useActiveState hook
- extended engine/events/types.ts with EvidenceLinkCreated,
EvidenceLinkUpdated, FormFieldActivated payloads
T03 Rect registry (SharedContracts §7 — contract FROZEN)
- src/binder/visual-guide/rect-registry.ts: register/getRect/subscribe
+ invalidate + getVersion for useSyncExternalStore
- events.ts: scroll/resize/focus pumps via window + ResizeObserver +
IntersectionObserver, rAF-throttled
- react-hooks.ts: RectRegistryProvider, useRegisterRect(kind,id,ref),
useRectRegistryVersion
T04 Form schema + renderer
- src/app/forms/demo-schema.ts: text/textarea/date minimal schema
- src/binder/FormRenderer.tsx: renders schema, each field registers
as rect kind="field"; active field gets aria-current="true"
- placed in binder/ (not work/) because work cannot import binder per
DependencyMap.md §2 and the renderer needs the rect-registry hook;
workplan T04 was amended in-place to document this
T05 Side-by-side Forms layout + click-to-link
- src/app/forms/FormsApp.tsx + src/app/App.tsx top-bar router with
hash route #/forms/demo
- BinderProvider mounted at app root so links survive tab switching
- stage-evidence-then-click-field linking interaction with banner
+ per-field link-count chip
T06 Active-evidence cycling
- src/app/forms/ActiveEvidenceChips.tsx: chips per active target,
Tab cycles natively, first chip auto-activates on field focus,
each chip registers as rect kind="evidence-card"
- ScrollBridge in FormsApp wires activeAnnotationId to viewer scroll
- EvidenceSidebar + EvidenceStrip highlight the active item via the
new useLastActivatedEvidence hook in work/EngineContext
T07 SVG visual-guide overlay
- src/binder/visual-guide/Overlay.tsx: single fixed-positioned SVG,
draws field→card and card→highlight bezier curves for the active
triple, rAF-throttled via the registry
- src/anchor exposes getHighlightClientRects(annotationId); the
spike viewer wraps highlights in [data-highlight-id] so the helper
can locate them
- src/app/forms/HighlightRectBridge.tsx: registers the active
annotation's rect via that helper
T08 End-to-end test (PRD scenario steps 5-9)
- tests/integration/forms-overlay-e2e.dom.test.tsx: full path from
Review-mode capture through Forms-mode link to active triple +
aria-current assertions + 2 SVG paths in the overlay
- additional integration coverage: forms-link-flow + forms-active-cycling
Gates: typecheck ✓ · lint ✓ · build ✓ · 152/152 tests across 21 files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:53:17 +02:00
|
|
|
|
// Wrap the highlight in a data-tagged container so the visual-guide
|
|
|
|
|
|
// overlay's HighlightRectBridge can locate it via DOM query. The
|
|
|
|
|
|
// wrapper uses display: contents so it doesn't affect layout — the
|
|
|
|
|
|
// bounding rect is gathered from the live TextHighlight children at
|
|
|
|
|
|
// query time.
|
2026-05-25 02:21:31 +02:00
|
|
|
|
return (
|
Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
- src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
- src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
- enum-conformance test parses SharedContracts.md and asserts the
runtime lists match exactly
T02 Binding service + in-memory link repo + active-state machine
- src/binder/repos/in-memory-links.ts: Map-backed EvidenceLinkRepository
- src/binder/services/bindings.ts: link/unlink/list/update/setActive
emitting §4 EvidenceLinkCreated / EvidenceLinkUpdated /
EvidenceItemActivated
- src/binder/state/active.ts: (target, evidence, annotation) reducer
+ ActiveStateProvider + useActiveState hook
- extended engine/events/types.ts with EvidenceLinkCreated,
EvidenceLinkUpdated, FormFieldActivated payloads
T03 Rect registry (SharedContracts §7 — contract FROZEN)
- src/binder/visual-guide/rect-registry.ts: register/getRect/subscribe
+ invalidate + getVersion for useSyncExternalStore
- events.ts: scroll/resize/focus pumps via window + ResizeObserver +
IntersectionObserver, rAF-throttled
- react-hooks.ts: RectRegistryProvider, useRegisterRect(kind,id,ref),
useRectRegistryVersion
T04 Form schema + renderer
- src/app/forms/demo-schema.ts: text/textarea/date minimal schema
- src/binder/FormRenderer.tsx: renders schema, each field registers
as rect kind="field"; active field gets aria-current="true"
- placed in binder/ (not work/) because work cannot import binder per
DependencyMap.md §2 and the renderer needs the rect-registry hook;
workplan T04 was amended in-place to document this
T05 Side-by-side Forms layout + click-to-link
- src/app/forms/FormsApp.tsx + src/app/App.tsx top-bar router with
hash route #/forms/demo
- BinderProvider mounted at app root so links survive tab switching
- stage-evidence-then-click-field linking interaction with banner
+ per-field link-count chip
T06 Active-evidence cycling
- src/app/forms/ActiveEvidenceChips.tsx: chips per active target,
Tab cycles natively, first chip auto-activates on field focus,
each chip registers as rect kind="evidence-card"
- ScrollBridge in FormsApp wires activeAnnotationId to viewer scroll
- EvidenceSidebar + EvidenceStrip highlight the active item via the
new useLastActivatedEvidence hook in work/EngineContext
T07 SVG visual-guide overlay
- src/binder/visual-guide/Overlay.tsx: single fixed-positioned SVG,
draws field→card and card→highlight bezier curves for the active
triple, rAF-throttled via the registry
- src/anchor exposes getHighlightClientRects(annotationId); the
spike viewer wraps highlights in [data-highlight-id] so the helper
can locate them
- src/app/forms/HighlightRectBridge.tsx: registers the active
annotation's rect via that helper
T08 End-to-end test (PRD scenario steps 5-9)
- tests/integration/forms-overlay-e2e.dom.test.tsx: full path from
Review-mode capture through Forms-mode link to active triple +
aria-current assertions + 2 SVG paths in the overlay
- additional integration coverage: forms-link-flow + forms-active-cycling
Gates: typecheck ✓ · lint ✓ · build ✓ · 152/152 tests across 21 files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:53:17 +02:00
|
|
|
|
<div data-highlight-id={highlight.id} style={{ display: "contents" }}>
|
|
|
|
|
|
<MonitoredHighlightContainer>
|
|
|
|
|
|
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
|
|
|
|
|
</MonitoredHighlightContainer>
|
|
|
|
|
|
</div>
|
2026-05-25 02:21:31 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement CE-WP-0003 T01-T08: form binding + visual guide overlay
T01 EvidenceLink/EvidenceSet types
- src/shared/evidence-link.ts: status (§2.4), relation (§2.5), target
- src/shared/evidence-set.ts: ordered group + activeEvidenceItemId
- enum-conformance test parses SharedContracts.md and asserts the
runtime lists match exactly
T02 Binding service + in-memory link repo + active-state machine
- src/binder/repos/in-memory-links.ts: Map-backed EvidenceLinkRepository
- src/binder/services/bindings.ts: link/unlink/list/update/setActive
emitting §4 EvidenceLinkCreated / EvidenceLinkUpdated /
EvidenceItemActivated
- src/binder/state/active.ts: (target, evidence, annotation) reducer
+ ActiveStateProvider + useActiveState hook
- extended engine/events/types.ts with EvidenceLinkCreated,
EvidenceLinkUpdated, FormFieldActivated payloads
T03 Rect registry (SharedContracts §7 — contract FROZEN)
- src/binder/visual-guide/rect-registry.ts: register/getRect/subscribe
+ invalidate + getVersion for useSyncExternalStore
- events.ts: scroll/resize/focus pumps via window + ResizeObserver +
IntersectionObserver, rAF-throttled
- react-hooks.ts: RectRegistryProvider, useRegisterRect(kind,id,ref),
useRectRegistryVersion
T04 Form schema + renderer
- src/app/forms/demo-schema.ts: text/textarea/date minimal schema
- src/binder/FormRenderer.tsx: renders schema, each field registers
as rect kind="field"; active field gets aria-current="true"
- placed in binder/ (not work/) because work cannot import binder per
DependencyMap.md §2 and the renderer needs the rect-registry hook;
workplan T04 was amended in-place to document this
T05 Side-by-side Forms layout + click-to-link
- src/app/forms/FormsApp.tsx + src/app/App.tsx top-bar router with
hash route #/forms/demo
- BinderProvider mounted at app root so links survive tab switching
- stage-evidence-then-click-field linking interaction with banner
+ per-field link-count chip
T06 Active-evidence cycling
- src/app/forms/ActiveEvidenceChips.tsx: chips per active target,
Tab cycles natively, first chip auto-activates on field focus,
each chip registers as rect kind="evidence-card"
- ScrollBridge in FormsApp wires activeAnnotationId to viewer scroll
- EvidenceSidebar + EvidenceStrip highlight the active item via the
new useLastActivatedEvidence hook in work/EngineContext
T07 SVG visual-guide overlay
- src/binder/visual-guide/Overlay.tsx: single fixed-positioned SVG,
draws field→card and card→highlight bezier curves for the active
triple, rAF-throttled via the registry
- src/anchor exposes getHighlightClientRects(annotationId); the
spike viewer wraps highlights in [data-highlight-id] so the helper
can locate them
- src/app/forms/HighlightRectBridge.tsx: registers the active
annotation's rect via that helper
T08 End-to-end test (PRD scenario steps 5-9)
- tests/integration/forms-overlay-e2e.dom.test.tsx: full path from
Review-mode capture through Forms-mode link to active triple +
aria-current assertions + 2 SVG paths in the overlay
- additional integration coverage: forms-link-flow + forms-active-cycling
Gates: typecheck ✓ · lint ✓ · build ✓ · 152/152 tests across 21 files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:53:17 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 02:21:31 +02:00
|
|
|
|
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;
|
2026-05-26 21:43:15 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* When true, paint the PDF text-layer spans in yellow 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;
|
2026-05-25 02:21:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-05-26 21:43:15 +02:00
|
|
|
|
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId, debugTextLayer } = props;
|
2026-05-25 02:21:31 +02:00
|
|
|
|
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
|
|
|
|
|
const [didScroll, setDidScroll] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const highlights = useMemo<Highlight[]>(() => {
|
|
|
|
|
|
const out: Highlight[] = [];
|
2026-05-26 22:05:13 +02:00
|
|
|
|
const skipped: { id: string; reason: string }[] = [];
|
2026-05-25 02:21:31 +02:00
|
|
|
|
for (const a of storedAnnotations) {
|
|
|
|
|
|
const h = highlightFromSelectors(a.id, a.text, a.selectors);
|
|
|
|
|
|
if (h) out.push(h);
|
2026-05-26 22:05:13 +02:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-05-25 02:21:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
return out;
|
2026-05-26 22:05:13 +02:00
|
|
|
|
}, [storedAnnotations, debugTextLayer]);
|
2026-05-25 02:21:31 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
|
|
|
|
|
|
const utils = utilsRef.current;
|
|
|
|
|
|
const target = highlights.find((h) => h.id === scrollToAnnotationId);
|
2026-05-26 22:05:13 +02:00
|
|
|
|
if (debugTextLayer) {
|
|
|
|
|
|
console.log("[ce] scrollToAnnotation requested", {
|
|
|
|
|
|
id: scrollToAnnotationId,
|
|
|
|
|
|
utilsAvailable: !!utils,
|
|
|
|
|
|
targetFound: !!target,
|
|
|
|
|
|
knownIds: highlights.map((h) => h.id),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-25 02:21:31 +02:00
|
|
|
|
if (!utils || !target) return;
|
|
|
|
|
|
utils.scrollToHighlight(target);
|
|
|
|
|
|
setDidScroll(scrollToAnnotationId);
|
2026-05-26 22:05:13 +02:00
|
|
|
|
}, [scrollToAnnotationId, highlights, didScroll, debugTextLayer]);
|
2026-05-25 02:21:31 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-26 21:43:15 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className={debugTextLayer ? "ce-debug-textlayer" : undefined}
|
|
|
|
|
|
style={{ height: "100%" }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PdfLoader document={pdfUrl}>
|
|
|
|
|
|
{(pdfDocument) => (
|
|
|
|
|
|
<PdfHighlighter
|
|
|
|
|
|
pdfDocument={pdfDocument}
|
|
|
|
|
|
highlights={highlights}
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</PdfLoader>
|
|
|
|
|
|
</div>
|
2026-05-25 02:21:31 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Re-export the §5 contract surface so callers see anchor as one entry point.
|
|
|
|
|
|
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };
|