generated from coulomb/repo-seed
Significant UX iteration: Visual palette - Debug text-layer overlay flips from yellow to light grey so it no longer collides with the evidence highlight colour. - New highlight-styles.css matches the sidebar's #fff8d6/#e0c050 palette so a passage marked in the document and its sidebar card speak the same visual language. - Active (focused) evidence: same fill, thick #b78b1c outline on both the highlight and the sidebar card. Library's red --scrolledTo box-shadow is suppressed. Activation model - Click an evidence card in the sidebar → activates that item + scrolls the viewer to the passage + thickens the borders (existing behaviour, now visually clearer). - Click a highlight in the document → activates the evidence that owns that annotation. New `findByAnnotationId()` on EvidenceService is the reverse lookup. Wired through a new `onHighlightClicked` prop on PdfSpikeViewer + `activeAnnotationId` prop that drives the data-ce-active attribute on the highlight wrapper. Inline edit - Each evidence card has a ✎ button that flips the card into an inline form with the citation (quote) and commentary fields. - Saving calls a new `AnnotationService.updateQuote()` + existing `EvidenceService.updateCommentary()`. The selectors are untouched, so the marked passage in the document stays put — the inline hint says so explicitly. - New `AnnotationUpdated` event added to the engine event vocabulary (SharedContracts.md §4 updated). Capture form placement - The yellow "New annotation" toolbar that lived above the viewer is gone. A new InlineCaptureForm component is now slotted into the sidebar between the cards that bracket the new selection in document flow (sorted by page + y of the first PdfRectSelector). If the new selection is before all existing evidence it appears at the top; if after all of them, at the bottom. - The legacy AnnotationToolbar.tsx is removed; the public surface re-exports `InlineCaptureForm` instead. Test updates - tests/integration/citation-card-export-e2e.dom.test.tsx: switched to the seed-session helper (matches the other E2Es) since the fixture-button click path is gone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
/**
|
||
* 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";
|
||
import "./highlight-styles.css";
|
||
import "./debug-textlayer.css";
|
||
|
||
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?"
|
||
*/
|
||
interface HighlightContainerProps {
|
||
readonly activeAnnotationId: string | null | undefined;
|
||
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
|
||
}
|
||
|
||
function makeSpikeHighlightContainer(props: HighlightContainerProps) {
|
||
return function SpikeHighlightContainer(): ReactNode {
|
||
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
||
const isActive = props.activeAnnotationId === highlight.id;
|
||
return (
|
||
<div
|
||
data-highlight-id={highlight.id}
|
||
data-ce-active={isActive ? "true" : "false"}
|
||
style={{ display: "contents" }}
|
||
onClickCapture={(e) => {
|
||
// Click-capture so the click registers before the library's
|
||
// built-in selection-clearing logic eats it. Stop propagation
|
||
// so the highlight click doesn't also start a new selection.
|
||
e.stopPropagation();
|
||
props.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;
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
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,
|
||
activeAnnotationId,
|
||
onHighlightClicked,
|
||
debugTextLayer,
|
||
} = props;
|
||
const HighlightContainer = useMemo(
|
||
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
|
||
[activeAnnotationId, onHighlightClicked],
|
||
);
|
||
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
||
const [didScroll, setDidScroll] = useState<string | null>(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]);
|
||
|
||
useEffect(() => {
|
||
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
|
||
const utils = utilsRef.current;
|
||
const target = highlights.find((h) => h.id === scrollToAnnotationId);
|
||
if (debugTextLayer) {
|
||
console.log("[ce] scrollToAnnotation requested", {
|
||
id: scrollToAnnotationId,
|
||
utilsAvailable: !!utils,
|
||
targetFound: !!target,
|
||
knownIds: highlights.map((h) => h.id),
|
||
});
|
||
}
|
||
if (!utils || !target) return;
|
||
utils.scrollToHighlight(target);
|
||
setDidScroll(scrollToAnnotationId);
|
||
}, [scrollToAnnotationId, highlights, didScroll, debugTextLayer]);
|
||
|
||
return (
|
||
<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);
|
||
}}
|
||
>
|
||
<HighlightContainer />
|
||
</PdfHighlighter>
|
||
)}
|
||
</PdfLoader>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Re-export the §5 contract surface so callers see anchor as one entry point.
|
||
export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture };
|