diff --git a/package.json b/package.json index 98c083d..df93bca 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@citation-evidence/engine": "link:../citation-engine", "@citation-evidence/evidence-source": "link:../evidence-source", + "@citation-evidence/evidence-anchor": "link:../evidence-anchor", "jszip": "^3.10.1", "pdfjs-dist": "^4.4.168", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba43ac..e2d7e1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@citation-evidence/engine': specifier: link:../citation-engine version: link:../citation-engine + '@citation-evidence/evidence-anchor': + specifier: link:../evidence-anchor + version: link:../evidence-anchor '@citation-evidence/evidence-source': specifier: link:../evidence-source version: link:../evidence-source diff --git a/src/anchor/README.md b/src/anchor/README.md deleted file mode 100644 index 7f20b0d..0000000 --- a/src/anchor/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# `src/anchor/` — selector creation, resolution, viewer adapter contract - -Future home: `evidence-anchor`. -Owns: `createSelectors`, `resolveSelectors`, the `DocumentViewerAdapter` -contract, and concrete viewer adapters (PDF first). - -May import from: `shared/`, `engine/` (`wiki/DependencyMap.md` §4). diff --git a/src/anchor/debug-textlayer.css b/src/anchor/debug-textlayer.css deleted file mode 100644 index 6c029f0..0000000 --- a/src/anchor/debug-textlayer.css +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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/anchor/highlight-styles.css b/src/anchor/highlight-styles.css deleted file mode 100644 index a74120e..0000000 --- a/src/anchor/highlight-styles.css +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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/anchor/index.ts b/src/anchor/index.ts deleted file mode 100644 index 497fa98..0000000 --- a/src/anchor/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from "./types"; -export { - PdfSpikeViewer, - getHighlightClientRects, - selectorsFromPdfCapture, - type PdfSpikeViewerProps, - type StoredAnnotation, -} from "./pdf-viewer-adapter-spike"; -export { - createSelectors, - resolveSelectors, - DEFAULT_CONTEXT_CHARS, - type CreateSelectorsOptions, -} from "./selectors"; diff --git a/src/anchor/pdf-selector-math.test.ts b/src/anchor/pdf-selector-math.test.ts deleted file mode 100644 index dbc7c33..0000000 --- a/src/anchor/pdf-selector-math.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Round-trip tests for the spike's pure transformation layer. - * - * These tests are CE-WP-0002-T02's machine-verifiable evidence that the - * adapter's data round-trip is lossless: a captured PDF selection becomes - * a `Selector[]`, the `Selector[]` round-trips through JSON - * (localStorage-equivalent), and the reconstructed PDF rect + page match - * the original. The browser-side selection-capture path is exercised in - * T09 against production code. - */ - -import { describe, expect, it } from "vitest"; -import { - findPdfRectSelector, - findTextQuoteSelector, - selectorsFromPdfCapture, - unionRect, -} from "./pdf-selector-math"; -import type { PdfSelectionCapture } from "./types"; -import type { NormalizedRect, Selector } from "@shared/selector"; - -const SAMPLE_CAPTURE: PdfSelectionCapture = { - kind: "pdf", - text: "Mitglied beim Lohnsteuerhilfeverein Vereinigte Lohnsteuerhilfe e.V.", - page: 1, - rects: [ - { x: 0.12, y: 0.34, width: 0.55, height: 0.02 }, - { x: 0.12, y: 0.37, width: 0.31, height: 0.02 }, - ], - boundingRect: { x: 0.12, y: 0.34, width: 0.55, height: 0.05 }, -}; - -describe("selectorsFromPdfCapture", () => { - it("produces a TextQuoteSelector and PdfRectSelector from a normal capture", () => { - const sels = selectorsFromPdfCapture(SAMPLE_CAPTURE); - expect(sels.map((s) => s.type)).toEqual(["TextQuoteSelector", "PdfRectSelector"]); - }); - - it("includes the verbatim quote on the TextQuoteSelector", () => { - const tq = findTextQuoteSelector(selectorsFromPdfCapture(SAMPLE_CAPTURE)); - expect(tq?.exact).toBe(SAMPLE_CAPTURE.text); - }); - - it("preserves page + rects 1:1 on the PdfRectSelector", () => { - const rect = findPdfRectSelector(selectorsFromPdfCapture(SAMPLE_CAPTURE)); - expect(rect?.page).toBe(SAMPLE_CAPTURE.page); - expect(rect?.rects).toEqual(SAMPLE_CAPTURE.rects); - }); - - it("omits TextQuoteSelector when text is empty", () => { - const sels = selectorsFromPdfCapture({ ...SAMPLE_CAPTURE, text: "" }); - expect(sels.map((s) => s.type)).toEqual(["PdfRectSelector"]); - }); - - it("omits PdfRectSelector when no rects are present", () => { - const sels = selectorsFromPdfCapture({ ...SAMPLE_CAPTURE, rects: [] }); - expect(sels.map((s) => s.type)).toEqual(["TextQuoteSelector"]); - }); -}); - -describe("Selector[] JSON round-trip", () => { - it("survives JSON.stringify/parse without loss (the localStorage path)", () => { - const original = selectorsFromPdfCapture(SAMPLE_CAPTURE); - const blob = JSON.stringify(original); - const restored = JSON.parse(blob) as Selector[]; - expect(restored).toEqual(original); - }); - - it("the restored PdfRectSelector still resolves to the same page and rects", () => { - const restored = JSON.parse(JSON.stringify(selectorsFromPdfCapture(SAMPLE_CAPTURE))) as Selector[]; - const rect = findPdfRectSelector(restored); - expect(rect).not.toBeNull(); - expect(rect?.page).toBe(SAMPLE_CAPTURE.page); - expect(rect?.rects).toEqual(SAMPLE_CAPTURE.rects); - }); -}); - -describe("unionRect", () => { - it("returns null for an empty input", () => { - expect(unionRect([])).toBeNull(); - }); - - it("returns the single rect when given exactly one", () => { - const r: NormalizedRect = { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }; - const u = unionRect([r]); - expect(u).not.toBeNull(); - expect(u!.x).toBeCloseTo(r.x, 9); - expect(u!.y).toBeCloseTo(r.y, 9); - expect(u!.width).toBeCloseTo(r.width, 9); - expect(u!.height).toBeCloseTo(r.height, 9); - }); - - it("computes the bounding box of multi-line text rects", () => { - const u = unionRect(SAMPLE_CAPTURE.rects); - expect(u).not.toBeNull(); - expect(u!.x).toBeCloseTo(0.12, 5); - expect(u!.y).toBeCloseTo(0.34, 5); - expect(u!.width).toBeCloseTo(0.55, 5); - expect(u!.height).toBeCloseTo(0.05, 5); - }); - - it("is order-independent", () => { - const reversed = [...SAMPLE_CAPTURE.rects].reverse(); - const forward = unionRect(SAMPLE_CAPTURE.rects)!; - const back = unionRect(reversed)!; - expect(back.x).toBeCloseTo(forward.x, 9); - expect(back.y).toBeCloseTo(forward.y, 9); - expect(back.width).toBeCloseTo(forward.width, 9); - expect(back.height).toBeCloseTo(forward.height, 9); - }); -}); diff --git a/src/anchor/pdf-selector-math.ts b/src/anchor/pdf-selector-math.ts deleted file mode 100644 index b7f0247..0000000 --- a/src/anchor/pdf-selector-math.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Pure, library-free transformations between the adapter's - * `PdfSelectionCapture` and the shared `Selector[]` shapes. - * - * Extracted from `pdf-viewer-adapter-spike.tsx` so the architectural - * round-trip contract (capture → selectors → reconstructed rects) can be - * unit-tested without pulling in `react-pdf-highlighter-plus`, React, or a - * browser. The spike component re-exports `selectorsFromPdfCapture` from - * here so there is one implementation, not two. - * - * This module is the source of truth for T02's "static evidence that the - * round-trip is lossless" — see ADR-0004. - */ - -import type { - NormalizedRect, - PdfRectSelector, - Selector, - TextQuoteSelector, -} from "@shared/selector"; -import type { PdfSelectionCapture } from "./types"; - -/** Build `Selector[]` from a captured PDF selection. */ -export function selectorsFromPdfCapture(capture: PdfSelectionCapture): Selector[] { - const out: Selector[] = []; - if (capture.text.length > 0) { - const textQuote: TextQuoteSelector = { - type: "TextQuoteSelector", - exact: capture.text, - }; - out.push(textQuote); - } - if (capture.rects.length > 0) { - const rect: PdfRectSelector = { - type: "PdfRectSelector", - page: capture.page, - rects: capture.rects, - }; - out.push(rect); - } - return out; -} - -/** Find the `PdfRectSelector` in a selector list, if any. */ -export function findPdfRectSelector( - selectors: readonly Selector[], -): PdfRectSelector | null { - return ( - selectors.find((s): s is PdfRectSelector => s.type === "PdfRectSelector") ?? null - ); -} - -/** Find the `TextQuoteSelector` in a selector list, if any. */ -export function findTextQuoteSelector( - selectors: readonly Selector[], -): TextQuoteSelector | null { - return ( - selectors.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector") ?? - null - ); -} - -/** Bounding rectangle of a non-empty list of normalized rects. */ -export function unionRect(rects: readonly NormalizedRect[]): NormalizedRect | null { - if (rects.length === 0) return null; - const first = rects[0]!; - let minX = first.x; - let minY = first.y; - let maxX = first.x + first.width; - let maxY = first.y + first.height; - for (let i = 1; i < rects.length; i++) { - const r = rects[i]!; - if (r.x < minX) minX = r.x; - if (r.y < minY) minY = r.y; - if (r.x + r.width > maxX) maxX = r.x + r.width; - if (r.y + r.height > maxY) maxY = r.y + r.height; - } - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; -} diff --git a/src/anchor/pdf-viewer-adapter-spike.tsx b/src/anchor/pdf-viewer-adapter-spike.tsx deleted file mode 100644 index 8578289..0000000 --- a/src/anchor/pdf-viewer-adapter-spike.tsx +++ /dev/null @@ -1,424 +0,0 @@ -/** - * 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 "@shared/selector"; -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/anchor/scroll-job.test.ts b/src/anchor/scroll-job.test.ts deleted file mode 100644 index 5e9c0bb..0000000 --- a/src/anchor/scroll-job.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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/anchor/scroll-job.ts b/src/anchor/scroll-job.ts deleted file mode 100644 index a9d3c2d..0000000 --- a/src/anchor/scroll-job.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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/src/anchor/selectors/create.test.ts b/src/anchor/selectors/create.test.ts deleted file mode 100644 index e39c516..0000000 --- a/src/anchor/selectors/create.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DocumentRepresentation } from "@shared/document"; -import type { DocumentId, RepresentationId } from "@shared/ids"; -import type { - PdfPageTextSelector, - PdfRectSelector, - TextPositionSelector, - TextQuoteSelector, -} from "@shared/selector"; -import { createSelectors } from "./create"; -import type { PdfSelectionCapture } from "../types"; - -function repr(canonicalText: string): DocumentRepresentation { - const pageLength = canonicalText.length; - return { - id: "rep_test" as RepresentationId, - documentId: "doc_test" as DocumentId, - representationType: "pdf-text", - contentHash: "test", - canonicalText, - pageMap: [{ page: 1, width: 595, height: 842 }], - offsetMap: [ - { page: 1, globalStart: 0, globalEnd: pageLength, pageLength }, - ], - generatedAt: "2026-05-25T00:00:00.000Z", - }; -} - -function capture(text: string, page = 1, rectsCount = 1): PdfSelectionCapture { - return { - kind: "pdf", - text, - page, - rects: Array.from({ length: rectsCount }, (_, i) => ({ - x: 0.1, - y: 0.2 + i * 0.05, - width: 0.5, - height: 0.04, - })), - boundingRect: { x: 0.1, y: 0.2, width: 0.5, height: 0.04 * rectsCount }, - }; -} - -describe("createSelectors", () => { - const text = "The quick brown fox jumps over the lazy dog near the river bank."; - const representation = repr(text); - - it("always includes a TextQuoteSelector with prefix and suffix from canonical text", () => { - const sels = createSelectors(capture("brown fox"), representation); - const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector"); - expect(quote).toBeDefined(); - expect(quote!.exact).toBe("brown fox"); - expect(quote!.prefix).toBe("The quick "); - expect(quote!.suffix).toBe(" jumps over the lazy dog near th"); - }); - - it("includes a TextPositionSelector pointing at the matched offset", () => { - const sels = createSelectors(capture("brown fox"), representation); - const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector"); - expect(pos).toBeDefined(); - expect(pos!.start).toBe(text.indexOf("brown fox")); - expect(pos!.end).toBe(text.indexOf("brown fox") + "brown fox".length); - }); - - it("includes a PdfRectSelector mirroring the capture's page and rects", () => { - const c = capture("brown fox", 1, 2); - const sels = createSelectors(c, representation); - const rect = sels.find((s): s is PdfRectSelector => s.type === "PdfRectSelector"); - expect(rect).toBeDefined(); - expect(rect!.page).toBe(1); - expect(rect!.rects).toEqual(c.rects); - }); - - it("includes a PdfPageTextSelector when the match falls inside the capture's page range", () => { - const sels = createSelectors(capture("brown fox"), representation); - const pageText = sels.find((s): s is PdfPageTextSelector => s.type === "PdfPageTextSelector"); - expect(pageText).toBeDefined(); - expect(pageText!.page).toBe(1); - expect(pageText!.start).toBe(text.indexOf("brown fox")); - }); - - it("omits the TextPositionSelector when the quote cannot be found in canonical text", () => { - const sels = createSelectors(capture("nonexistent phrase"), representation); - const pos = sels.find((s) => s.type === "TextPositionSelector"); - expect(pos).toBeUndefined(); - const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector"); - expect(quote!.exact).toBe("nonexistent phrase"); - expect(quote!.prefix).toBeUndefined(); - expect(quote!.suffix).toBeUndefined(); - }); - - it("clamps prefix at the start of the canonical text", () => { - const sels = createSelectors(capture("The quick"), representation); - const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!; - expect(quote.prefix).toBeUndefined(); - expect(quote.suffix).toBe(" brown fox jumps over the lazy d"); - }); - - it("clamps suffix at the end of the canonical text", () => { - const sels = createSelectors(capture("river bank."), representation); - const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!; - expect(quote.prefix).toBe("umps over the lazy dog near the "); - expect(quote.suffix).toBeUndefined(); - }); - - it("honors a custom contextChars option", () => { - const sels = createSelectors(capture("brown fox"), representation, { contextChars: 4 }); - const quote = sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")!; - expect(quote.prefix).toBe("ick "); - expect(quote.suffix).toBe(" jum"); - }); - - it("prefers the on-page match when the quote appears on multiple pages", () => { - // Two-page representation where the quote appears once per page. - const canonical = "alpha echo bravo" + "\n\n" + "charlie echo delta"; - const rep: DocumentRepresentation = { - id: "rep_multi" as RepresentationId, - documentId: "doc_multi" as DocumentId, - representationType: "pdf-text", - contentHash: "h", - canonicalText: canonical, - pageMap: [ - { page: 1, width: 100, height: 100 }, - { page: 2, width: 100, height: 100 }, - ], - offsetMap: [ - { page: 1, globalStart: 0, globalEnd: 18, pageLength: 18 }, - { page: 2, globalStart: 18, globalEnd: canonical.length, pageLength: canonical.length - 18 }, - ], - generatedAt: "2026-05-25T00:00:00.000Z", - }; - const sels = createSelectors(capture("echo", 2), rep); - const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector")!; - expect(pos.start).toBe(canonical.indexOf("echo", 18)); - }); -}); diff --git a/src/anchor/selectors/create.ts b/src/anchor/selectors/create.ts deleted file mode 100644 index 5eb9ebc..0000000 --- a/src/anchor/selectors/create.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Build the maximal `Selector[]` from a viewer's `SelectionCapture`. - * - * Implements the "always store all selector types that are available" rule - * from `wiki/SharedContracts.md` §3 (selector redundancy) and the create - * half of the `AnchorAdapter` contract in - * `wiki/ArchitectureOverview.md` §3.3. - * - * Output guarantee: every returned `Selector[]` includes a - * `TextQuoteSelector` (always) and adds `TextPositionSelector`, - * `PdfRectSelector`, `PdfPageTextSelector` only when the underlying data - * actually supports them. Resolvers can rely on the union being trimmed — - * a missing selector means "not available", not "skipped". - */ - -import type { DocumentRepresentation } from "@shared/document"; -import { normalize } from "@shared/text/normalize"; -import type { - PdfPageTextSelector, - PdfRectSelector, - Selector, - TextPositionSelector, - TextQuoteSelector, -} from "@shared/selector"; - -import type { PdfSelectionCapture, SelectionCapture } from "../types"; - -/** Default characters of prefix/suffix context stored on TextQuoteSelector. */ -export const DEFAULT_CONTEXT_CHARS = 32; - -export interface CreateSelectorsOptions { - readonly contextChars?: number; -} - -export function createSelectors( - capture: SelectionCapture, - representation: DocumentRepresentation, - options: CreateSelectorsOptions = {}, -): Selector[] { - // `SelectionCapture` is a discriminated union. The DOM branch is `never` - // in MVP, so the only runtime shape is `PdfSelectionCapture`. - return createSelectorsFromPdfCapture(capture, representation, options); -} - -function createSelectorsFromPdfCapture( - capture: PdfSelectionCapture, - representation: DocumentRepresentation, - options: CreateSelectorsOptions, -): Selector[] { - const contextChars = options.contextChars ?? DEFAULT_CONTEXT_CHARS; - const normalizedQuote = normalize(capture.text).text; - const out: Selector[] = []; - - const canonicalText = representation.canonicalText ?? ""; - const positions = canonicalText.length > 0 && normalizedQuote.length > 0 - ? findAllOccurrences(canonicalText, normalizedQuote) - : []; - - // Locate the match that falls on the capture's page (when offsetMap is - // known); otherwise fall back to the first match. If there is no match, - // we still emit a quote-only TextQuoteSelector so the annotation is - // recoverable later if the representation is rebuilt. - const pageRange = representation.offsetMap?.find((r) => r.page === capture.page); - const matchOffset = pickMatch(positions, pageRange); - - // 1. TextQuoteSelector — always included. - if (normalizedQuote.length > 0) { - const quote = matchOffset !== null - ? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars) - : ({ type: "TextQuoteSelector", exact: normalizedQuote } satisfies TextQuoteSelector); - out.push(quote); - } - - // 2. TextPositionSelector — only when we have a unique-enough match. - if (matchOffset !== null) { - const pos: TextPositionSelector = { - type: "TextPositionSelector", - start: matchOffset, - end: matchOffset + normalizedQuote.length, - }; - out.push(pos); - } - - // 3. PdfRectSelector — straight from the capture; viewer-coordinate truth. - if (capture.rects.length > 0) { - const rect: PdfRectSelector = { - type: "PdfRectSelector", - page: capture.page, - rects: capture.rects, - }; - out.push(rect); - } - - // 4. PdfPageTextSelector — when we have offsetMap and a unique-enough match - // that falls inside the capture's page range. - if (matchOffset !== null && pageRange) { - if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) { - const pageText: PdfPageTextSelector = { - type: "PdfPageTextSelector", - page: capture.page, - start: matchOffset - pageRange.globalStart, - end: matchOffset - pageRange.globalStart + normalizedQuote.length, - }; - out.push(pageText); - } - } - - return out; -} - -function findAllOccurrences(haystack: string, needle: string): number[] { - if (needle.length === 0) return []; - const out: number[] = []; - let from = 0; - for (;;) { - const idx = haystack.indexOf(needle, from); - if (idx === -1) break; - out.push(idx); - from = idx + 1; - } - return out; -} - -function pickMatch( - positions: readonly number[], - pageRange: { globalStart: number; globalEnd: number } | undefined, -): number | null { - if (positions.length === 0) return null; - if (positions.length === 1) return positions[0]!; - if (pageRange) { - const onPage = positions.find( - (p) => p >= pageRange.globalStart && p < pageRange.globalEnd, - ); - if (onPage !== undefined) return onPage; - } - // Multiple matches and no page hint — return the first; resolve.ts will - // need prefix/suffix to disambiguate. - return positions[0]!; -} - -function buildQuoteSelectorWithContext( - canonicalText: string, - matchOffset: number, - exact: string, - contextChars: number, -): TextQuoteSelector { - const prefixStart = Math.max(0, matchOffset - contextChars); - const suffixEnd = Math.min(canonicalText.length, matchOffset + exact.length + contextChars); - const prefix = canonicalText.slice(prefixStart, matchOffset); - const suffix = canonicalText.slice(matchOffset + exact.length, suffixEnd); - return { - type: "TextQuoteSelector", - exact, - ...(prefix.length > 0 ? { prefix } : {}), - ...(suffix.length > 0 ? { suffix } : {}), - }; -} diff --git a/src/anchor/selectors/index.ts b/src/anchor/selectors/index.ts deleted file mode 100644 index f47543c..0000000 --- a/src/anchor/selectors/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - createSelectors, - DEFAULT_CONTEXT_CHARS, - type CreateSelectorsOptions, -} from "./create"; -export { resolveSelectors } from "./resolve"; diff --git a/src/anchor/selectors/resolve.test.ts b/src/anchor/selectors/resolve.test.ts deleted file mode 100644 index 028f95a..0000000 --- a/src/anchor/selectors/resolve.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DocumentRepresentation } from "@shared/document"; -import type { DocumentId, RepresentationId } from "@shared/ids"; -import type { Selector } from "@shared/selector"; -import { resolveSelectors } from "./resolve"; - -function repr(canonicalText: string, pages = 1): DocumentRepresentation { - const segmentLen = pages === 1 - ? canonicalText.length - : Math.floor(canonicalText.length / pages); - const offsetMap = []; - for (let i = 0; i < pages; i++) { - const start = i * segmentLen; - const end = i === pages - 1 ? canonicalText.length : start + segmentLen; - offsetMap.push({ page: i + 1, globalStart: start, globalEnd: end, pageLength: end - start }); - } - return { - id: "rep_test" as RepresentationId, - documentId: "doc_test" as DocumentId, - representationType: "pdf-text", - contentHash: "test", - canonicalText, - pageMap: Array.from({ length: pages }, (_, i) => ({ page: i + 1, width: 595, height: 842 })), - offsetMap, - generatedAt: "2026-05-25T00:00:00.000Z", - }; -} - -describe("resolveSelectors", () => { - const text = "The quick brown fox jumps over the lazy dog."; - const representation = repr(text); - const brownFoxStart = text.indexOf("brown fox"); - const brownFoxEnd = brownFoxStart + "brown fox".length; - - it("returns 1.0 confidence when position and quote agree exactly", () => { - const selectors: Selector[] = [ - { type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd }, - { type: "TextQuoteSelector", exact: "brown fox" }, - ]; - const r = resolveSelectors(selectors, representation); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(1.0); - expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd }); - expect(r.candidates[0]?.page).toBe(1); - expect(r.usedSelectorTypes).toEqual(["TextPositionSelector", "TextQuoteSelector"]); - }); - - it("falls back to quote search when position is stale, and records a warning", () => { - const selectors: Selector[] = [ - { type: "TextPositionSelector", start: 0, end: 9 }, // "The quick" - { type: "TextQuoteSelector", exact: "brown fox" }, - ]; - const r = resolveSelectors(selectors, representation); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(0.95); - expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd }); - expect(r.warnings?.[0]).toMatch(/did not match/); - expect(r.usedSelectorTypes).toEqual(["TextQuoteSelector"]); - }); - - it("returns 0.85 for a position-only selector with no quote to verify", () => { - const selectors: Selector[] = [ - { type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd }, - ]; - const r = resolveSelectors(selectors, representation); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(0.85); - }); - - it("returns 0.95 when only TextQuoteSelector is present and the quote is unique", () => { - const r = resolveSelectors( - [{ type: "TextQuoteSelector", exact: "brown fox" }], - representation, - ); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(0.95); - }); - - it("returns 0.9 when a duplicated quote is disambiguated by prefix/suffix", () => { - const dup = "alpha echo bravo charlie echo delta"; - const r = resolveSelectors( - [{ type: "TextQuoteSelector", exact: "echo", prefix: "charlie ", suffix: " delta" }], - repr(dup), - ); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(0.9); - expect(r.candidates[0]?.textPosition?.start).toBe(dup.indexOf("echo", 10)); - }); - - it("returns ambiguous when a duplicated quote cannot be disambiguated", () => { - const dup = "echo and echo"; - const r = resolveSelectors( - [{ type: "TextQuoteSelector", exact: "echo" }], - repr(dup), - ); - expect(r.status).toBe("ambiguous"); - expect(r.confidence).toBe(0.5); - }); - - it("falls back to PdfPageTextSelector via the OffsetMap", () => { - // Single page, "brown fox" at offset 10..19. - const r = resolveSelectors( - [{ type: "PdfPageTextSelector", page: 1, start: brownFoxStart, end: brownFoxEnd }], - representation, - ); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(0.8); - expect(r.candidates[0]?.textPosition).toEqual({ start: brownFoxStart, end: brownFoxEnd }); - expect(r.candidates[0]?.page).toBe(1); - }); - - it("falls back to PdfRectSelector with page+rects only at 0.7 confidence", () => { - const r = resolveSelectors( - [{ - type: "PdfRectSelector", - page: 2, - rects: [{ x: 0.1, y: 0.2, width: 0.3, height: 0.04 }], - }], - repr(text, 1), - ); - expect(r.status).toBe("resolved"); - expect(r.confidence).toBe(0.7); - expect(r.candidates[0]?.page).toBe(2); - expect(r.candidates[0]?.textPosition).toBeUndefined(); - expect(r.candidates[0]?.rects).toHaveLength(1); - }); - - it("returns unresolved when nothing matches", () => { - const r = resolveSelectors( - [{ type: "TextQuoteSelector", exact: "missing string" }], - representation, - ); - expect(r.status).toBe("unresolved"); - expect(r.confidence).toBe(0); - expect(r.candidates).toEqual([]); - }); -}); diff --git a/src/anchor/selectors/resolve.ts b/src/anchor/selectors/resolve.ts deleted file mode 100644 index af3efb6..0000000 --- a/src/anchor/selectors/resolve.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Resolve a `Selector[]` against a `DocumentRepresentation`. - * - * Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7, - * MVP-trimmed: - * - * 1. Try `TextPositionSelector` (cheapest — direct slice). - * 2. Verify with `TextQuoteSelector` at that position. - * 3. Try `TextQuoteSelector` on its own. If multiple matches, disambiguate - * by prefix/suffix. - * 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap). - * 5. Fall back to `PdfRectSelector` for a page+rects-only target. - * 6. Return `unresolved` if nothing above succeeds. - * - * Fuzzy matching is out of scope here; a later workplan owns it. - * - * Confidence ladder (0..1): - * 1.00 — TextPosition + TextQuote agree exactly - * 0.95 — TextQuote unique match (no position to cross-check) - * 0.90 — TextQuote disambiguated by prefix/suffix - * 0.85 — TextPosition only (no quote to cross-check) - * 0.80 — PdfPageTextSelector resolved via OffsetMap - * 0.70 — PdfRectSelector only (page+rects, no text verification) - */ - -import type { DocumentRepresentation } from "@shared/document"; -import type { - PdfPageTextSelector, - PdfRectSelector, - Selector, - SelectorType, - TextPositionSelector, - TextQuoteSelector, -} from "@shared/selector"; - -import type { AnchorResolution, ResolvedAnchorTarget } from "../types"; - -export function resolveSelectors( - selectors: readonly Selector[], - representation: DocumentRepresentation, -): AnchorResolution { - const canonicalText = representation.canonicalText ?? ""; - const offsetMap = representation.offsetMap ?? []; - const representationId = representation.id; - - const byType = indexByType(selectors); - const used: SelectorType[] = []; - const warnings: string[] = []; - - // 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector. - if (byType.TextPositionSelector && canonicalText.length > 0) { - const pos = byType.TextPositionSelector; - const slice = sliceSafely(canonicalText, pos.start, pos.end); - if (slice !== null) { - const quote = byType.TextQuoteSelector; - if (quote) { - if (slice === quote.exact) { - used.push("TextPositionSelector", "TextQuoteSelector"); - return resolved( - { representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) }, - 1.0, - used, - warnings, - ); - } - warnings.push( - "TextPositionSelector slice did not match TextQuoteSelector.exact; falling back to quote search.", - ); - } else { - // Position with no quote to verify — accept at lower confidence. - used.push("TextPositionSelector"); - return resolved( - { representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) }, - 0.85, - used, - warnings, - ); - } - } - } - - // 3. TextQuoteSelector on its own (or after the position fallback above). - if (byType.TextQuoteSelector && canonicalText.length > 0) { - const quoteResult = resolveByQuote(canonicalText, byType.TextQuoteSelector); - if (quoteResult) { - used.push("TextQuoteSelector"); - return resolved( - { - representationId, - textPosition: { start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, - ...pageFor({ start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, offsetMap), - }, - quoteResult.confidence, - used, - warnings, - quoteResult.status, - ); - } - } - - // 4. PdfPageTextSelector through OffsetMap. - if (byType.PdfPageTextSelector && offsetMap.length > 0) { - const pageText = byType.PdfPageTextSelector; - const range = offsetMap.find((r) => r.page === pageText.page); - if (range && pageText.start >= 0 && pageText.end <= range.pageLength && pageText.start < pageText.end) { - const globalStart = range.globalStart + pageText.start; - const globalEnd = range.globalStart + pageText.end; - used.push("PdfPageTextSelector"); - return resolved( - { - representationId, - page: pageText.page, - textPosition: { start: globalStart, end: globalEnd }, - }, - 0.8, - used, - warnings, - ); - } - } - - // 5. PdfRectSelector fallback (no text verification possible). - if (byType.PdfRectSelector) { - const rect = byType.PdfRectSelector; - used.push("PdfRectSelector"); - return resolved( - { representationId, page: rect.page, rects: rect.rects }, - 0.7, - used, - warnings, - ); - } - - return unresolved(warnings); -} - -interface QuoteResolutionResult { - readonly offset: number; - readonly confidence: number; - readonly status: "resolved" | "ambiguous"; -} - -function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteResolutionResult | null { - const positions = findAllOccurrences(canonicalText, quote.exact); - if (positions.length === 0) return null; - if (positions.length === 1) { - return { offset: positions[0]!, confidence: 0.95, status: "resolved" }; - } - // Multiple matches — try to disambiguate by prefix/suffix. - const filtered = positions.filter((p) => prefixSuffixMatches(canonicalText, p, quote)); - if (filtered.length === 1) { - return { offset: filtered[0]!, confidence: 0.9, status: "resolved" }; - } - if (filtered.length > 1) { - return { offset: filtered[0]!, confidence: 0.5, status: "ambiguous" }; - } - // No prefix/suffix info or no matches with context — return ambiguous on first. - return { offset: positions[0]!, confidence: 0.5, status: "ambiguous" }; -} - -function prefixSuffixMatches( - canonicalText: string, - offset: number, - quote: TextQuoteSelector, -): boolean { - if (quote.prefix !== undefined) { - const prefixEnd = offset; - const prefixStart = Math.max(0, prefixEnd - quote.prefix.length); - const actualPrefix = canonicalText.slice(prefixStart, prefixEnd); - if (!actualPrefix.endsWith(quote.prefix)) return false; - } - if (quote.suffix !== undefined) { - const suffixStart = offset + quote.exact.length; - const suffixEnd = Math.min(canonicalText.length, suffixStart + quote.suffix.length); - const actualSuffix = canonicalText.slice(suffixStart, suffixEnd); - if (!actualSuffix.startsWith(quote.suffix)) return false; - } - return true; -} - -interface SelectorIndex { - TextQuoteSelector?: TextQuoteSelector; - TextPositionSelector?: TextPositionSelector; - PdfRectSelector?: PdfRectSelector; - PdfPageTextSelector?: PdfPageTextSelector; -} - -function indexByType(selectors: readonly Selector[]): SelectorIndex { - const idx: SelectorIndex = {}; - for (const s of selectors) { - switch (s.type) { - case "TextQuoteSelector": - idx.TextQuoteSelector = s; - break; - case "TextPositionSelector": - idx.TextPositionSelector = s; - break; - case "PdfRectSelector": - idx.PdfRectSelector = s; - break; - case "PdfPageTextSelector": - idx.PdfPageTextSelector = s; - break; - } - } - return idx; -} - -function sliceSafely(text: string, start: number, end: number): string | null { - if (start < 0 || end > text.length || start >= end) return null; - return text.slice(start, end); -} - -function pageFor( - span: { start: number; end: number }, - offsetMap: readonly { page: number; globalStart: number; globalEnd: number }[], -): { page?: number } { - if (offsetMap.length === 0) return {}; - const range = offsetMap.find((r) => span.start >= r.globalStart && span.end <= r.globalEnd); - return range ? { page: range.page } : {}; -} - -function findAllOccurrences(haystack: string, needle: string): number[] { - if (needle.length === 0) return []; - const out: number[] = []; - let from = 0; - for (;;) { - const idx = haystack.indexOf(needle, from); - if (idx === -1) break; - out.push(idx); - from = idx + 1; - } - return out; -} - -function resolved( - target: ResolvedAnchorTarget, - confidence: number, - used: readonly SelectorType[], - warnings: readonly string[], - status: "resolved" | "ambiguous" = "resolved", -): AnchorResolution { - return { - status, - confidence, - candidates: [target], - usedSelectorTypes: used, - ...(warnings.length > 0 ? { warnings } : {}), - }; -} - -function unresolved(warnings: readonly string[]): AnchorResolution { - return { - status: "unresolved", - confidence: 0, - candidates: [], - usedSelectorTypes: [], - ...(warnings.length > 0 ? { warnings } : {}), - }; -} diff --git a/src/anchor/types.ts b/src/anchor/types.ts deleted file mode 100644 index 038fd35..0000000 --- a/src/anchor/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Adapter-side types owned by `evidence-anchor`. - * - * Implements the contract surface from `wiki/SharedContracts.md` §5 and the - * resolution result shape from `wiki/ArchitectureOverview.md` §3.3 / §7. - * - * Anything that mentions a concrete viewer library (pdfjs, react-pdf-highlighter-plus) - * lives *behind* this surface, never on it. `src/shared/` and `src/engine/` - * must never import this file. - */ - -import type { Document, DocumentRepresentation } from "@shared/document"; -import type { Selector } from "@shared/selector"; -import type { AnnotationResolutionStatus } from "@shared/annotation"; -import type { NormalizedRect } from "@shared/selector"; - -/** - * The raw selection captured from a viewer adapter — an opaque payload that - * the adapter understands. The shape is intentionally permissive: each - * concrete adapter narrows the `kind` discriminator and adds its own - * payload. The shared layer never inspects the payload directly. - */ -export type SelectionCapture = - | PdfSelectionCapture - | DomSelectionCapture; - -export interface PdfSelectionCapture { - readonly kind: "pdf"; - /** Verbatim selected text, before canonical normalisation. */ - readonly text: string; - /** 1-indexed physical page number the selection started on. */ - readonly page: number; - /** Page-relative normalized rectangles covering the selection (0..1). */ - readonly rects: readonly NormalizedRect[]; - /** Optional bounding rectangle (page-relative, normalized). */ - readonly boundingRect?: NormalizedRect; -} - -/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */ -export type DomSelectionCapture = never; - -/** - * A passage located inside a representation, ready to be scrolled to and - * highlighted. - */ -export interface ResolvedAnchorTarget { - readonly representationId: string; - /** 1-indexed page (PDF) or undefined for HTML/Markdown. */ - readonly page?: number; - /** Page-relative normalized rectangles to highlight. */ - readonly rects?: readonly NormalizedRect[]; - /** Canonical-text offsets, when known. */ - readonly textPosition?: { readonly start: number; readonly end: number }; -} - -/** - * The outcome of asking the adapter to resolve a `Selector[]`. - * Matches `wiki/ArchitectureOverview.md` §3.3. - */ -export interface AnchorResolution { - readonly status: AnnotationResolutionStatus; - /** 0..1 confidence in the best candidate. */ - readonly confidence: number; - readonly candidates: readonly ResolvedAnchorTarget[]; - /** Names of the selector kinds that produced a usable candidate. */ - readonly usedSelectorTypes: readonly string[]; - readonly warnings?: readonly string[]; -} - -export interface HighlightRenderOptions { - readonly color?: string; - readonly opacity?: number; -} - -/** - * The format-neutral viewer adapter contract from `wiki/SharedContracts.md` §5. - * - * Concrete implementations live alongside the viewer they wrap (e.g. the - * PDF spike in `src/anchor/pdf-viewer-adapter-spike.tsx`). The shared/engine - * layers depend only on this interface. - */ -export interface DocumentViewerAdapter { - readonly mediaTypes: readonly string[]; - load(document: Document, representation?: DocumentRepresentation): Promise; - getCurrentSelection(): Promise; - createSelectorsFromSelection(selection: SelectionCapture): Promise; - resolveSelectors(selectors: readonly Selector[]): Promise; - scrollToResolvedTarget( - target: ResolvedAnchorTarget, - opts?: { readonly center?: boolean; readonly behavior?: "auto" | "smooth" }, - ): Promise; - renderHighlight( - target: ResolvedAnchorTarget, - opts?: HighlightRenderOptions, - ): Promise; - getHighlightClientRects(annotationId: string): Promise; -} diff --git a/src/app/forms/HighlightRectBridge.tsx b/src/app/forms/HighlightRectBridge.tsx index 5c9c061..a6d6aec 100644 --- a/src/app/forms/HighlightRectBridge.tsx +++ b/src/app/forms/HighlightRectBridge.tsx @@ -21,7 +21,7 @@ import { useEffect } from "react"; -import { getHighlightClientRects } from "@anchor/index"; +import { getHighlightClientRects } from "@citation-evidence/evidence-anchor"; import { useActiveState, useRectRegistryContext, diff --git a/src/work/EngineContext.tsx b/src/work/EngineContext.tsx index 1727b33..d2c892a 100644 --- a/src/work/EngineContext.tsx +++ b/src/work/EngineContext.tsx @@ -30,7 +30,7 @@ import { restoreFromStorage, type Engine, } from "@engine/index"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import { createPdfByteStore, type PdfByteStore } from "@source/index"; import { useContext as useReactContext } from "react"; import { SessionInternalContext } from "./SessionContextInternal"; diff --git a/src/work/InlineCaptureForm.tsx b/src/work/InlineCaptureForm.tsx index 1a22fcb..3a297f4 100644 --- a/src/work/InlineCaptureForm.tsx +++ b/src/work/InlineCaptureForm.tsx @@ -22,7 +22,7 @@ import { useEffect, useState } from "react"; -import { createSelectors } from "@anchor/index"; +import { createSelectors } from "@citation-evidence/evidence-anchor"; import { useActiveDocument, diff --git a/src/work/ViewerShell.tsx b/src/work/ViewerShell.tsx index cb5ca7b..5e42083 100644 --- a/src/work/ViewerShell.tsx +++ b/src/work/ViewerShell.tsx @@ -14,7 +14,7 @@ */ import { useCallback, useMemo } from "react"; -import { PdfSpikeViewer, type StoredAnnotation } from "@anchor/index"; +import { PdfSpikeViewer, type StoredAnnotation } from "@citation-evidence/evidence-anchor"; import { resolvePdfViewerUrl } from "@source/pdf/viewer-url"; import type { AnnotationId } from "@shared/ids"; import { diff --git a/tests/integration/anchor-source-roundtrip.test.ts b/tests/integration/anchor-source-roundtrip.test.ts index 289cf43..5f0709c 100644 --- a/tests/integration/anchor-source-roundtrip.test.ts +++ b/tests/integration/anchor-source-roundtrip.test.ts @@ -18,8 +18,8 @@ import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; import { ingestPdf } from "@citation-evidence/evidence-source"; -import { createSelectors, resolveSelectors } from "@anchor/selectors"; -import type { PdfSelectionCapture } from "@anchor/types"; +import { createSelectors, resolveSelectors } from "@citation-evidence/evidence-anchor/selectors"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor/types"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/tests/integration/app-prd-scenario.dom.test.tsx b/tests/integration/app-prd-scenario.dom.test.tsx index 4f6c8c4..89c29d1 100644 --- a/tests/integration/app-prd-scenario.dom.test.tsx +++ b/tests/integration/app-prd-scenario.dom.test.tsx @@ -28,7 +28,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; // --------------------------------------------------------------------------- @@ -56,8 +56,8 @@ const viewerSnapshot: ViewerSnapshot = { onSelectionCaptured: null, }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.storedAnnotationIds = props.storedAnnotations.map((a) => a.id); @@ -114,7 +114,7 @@ function syntheticCaptureFor(fixturePage: number, text: string): PdfSelectionCap async function loadApp() { // Late import so the vi.mock calls take effect before the module graph - // pulls in @anchor / @source. + // pulls in @citation-evidence/evidence-anchor / evidence-source. const { App } = await import("@app/App"); return render(); } diff --git a/tests/integration/capture-session-persist.dom.test.tsx b/tests/integration/capture-session-persist.dom.test.tsx index 56f9eb0..ef4c267 100644 --- a/tests/integration/capture-session-persist.dom.test.tsx +++ b/tests/integration/capture-session-persist.dom.test.tsx @@ -12,8 +12,8 @@ import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; import { captureStateKey, loadCaptureState } from "@app/forms/capture-persistence"; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = () => (
); diff --git a/tests/integration/citation-card-export-e2e.dom.test.tsx b/tests/integration/citation-card-export-e2e.dom.test.tsx index baaa796..63ea6e3 100644 --- a/tests/integration/citation-card-export-e2e.dom.test.tsx +++ b/tests/integration/citation-card-export-e2e.dom.test.tsx @@ -25,7 +25,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; import { seedSessionWithDoc } from "./helpers/seed-session"; @@ -50,8 +50,8 @@ const viewerSnapshot: ViewerSnapshot = { onSelectionCaptured: null, }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; diff --git a/tests/integration/forms-active-cycling.dom.test.tsx b/tests/integration/forms-active-cycling.dom.test.tsx index aa6fdd6..62d0520 100644 --- a/tests/integration/forms-active-cycling.dom.test.tsx +++ b/tests/integration/forms-active-cycling.dom.test.tsx @@ -22,7 +22,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; interface ViewerProps { @@ -46,8 +46,8 @@ const viewerSnapshot: ViewerSnapshot = { storedAnnotationIds: [], }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.scrollToAnnotationId = props.scrollToAnnotationId ?? null; diff --git a/tests/integration/forms-field-edit.dom.test.tsx b/tests/integration/forms-field-edit.dom.test.tsx index c4afffc..19dc5f1 100644 --- a/tests/integration/forms-field-edit.dom.test.tsx +++ b/tests/integration/forms-field-edit.dom.test.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; interface ViewerProps { @@ -24,8 +24,8 @@ const viewerSnapshot: { onSelectionCaptured: ViewerProps["onSelectionCaptured"] onSelectionCaptured: null, }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; return
; diff --git a/tests/integration/forms-field-values.dom.test.tsx b/tests/integration/forms-field-values.dom.test.tsx index 67f4166..1e3d026 100644 --- a/tests/integration/forms-field-values.dom.test.tsx +++ b/tests/integration/forms-field-values.dom.test.tsx @@ -10,8 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = () => (
); diff --git a/tests/integration/forms-link-flow.dom.test.tsx b/tests/integration/forms-link-flow.dom.test.tsx index a61a643..b209fed 100644 --- a/tests/integration/forms-link-flow.dom.test.tsx +++ b/tests/integration/forms-link-flow.dom.test.tsx @@ -14,7 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; interface ViewerProps { @@ -30,8 +30,8 @@ interface ViewerSnapshot { const viewerSnapshot: ViewerSnapshot = { onSelectionCaptured: null }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; return
; diff --git a/tests/integration/forms-overlay-e2e.dom.test.tsx b/tests/integration/forms-overlay-e2e.dom.test.tsx index 50913c9..92e72d9 100644 --- a/tests/integration/forms-overlay-e2e.dom.test.tsx +++ b/tests/integration/forms-overlay-e2e.dom.test.tsx @@ -29,7 +29,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; interface ViewerProps { @@ -60,8 +60,8 @@ function fakeHighlightRect(): DOMRect { } as DOMRect; } -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.scrollToAnnotationId = props.scrollToAnnotationId ?? null; diff --git a/tests/integration/forms-strip-filter.dom.test.tsx b/tests/integration/forms-strip-filter.dom.test.tsx index 9e58f97..0e057a9 100644 --- a/tests/integration/forms-strip-filter.dom.test.tsx +++ b/tests/integration/forms-strip-filter.dom.test.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; interface ViewerProps { @@ -24,8 +24,8 @@ const viewerSnapshot: { onSelectionCaptured: ViewerProps["onSelectionCaptured"] onSelectionCaptured: null, }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; return
; diff --git a/tests/integration/session-export-reimport.dom.test.tsx b/tests/integration/session-export-reimport.dom.test.tsx index e92feda..d51adaa 100644 --- a/tests/integration/session-export-reimport.dom.test.tsx +++ b/tests/integration/session-export-reimport.dom.test.tsx @@ -28,7 +28,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Document, DocumentRepresentation } from "@shared/document"; import type { DocumentId, RepresentationId } from "@shared/ids"; import type { Selector } from "@shared/selector"; -import type { PdfSelectionCapture } from "@anchor/index"; +import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; // --------------------------------------------------------------------------- @@ -52,8 +52,8 @@ const viewerSnapshot: ViewerSnapshot = { onSelectionCaptured: null, }; -vi.mock("@anchor/index", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@citation-evidence/evidence-anchor", async (importOriginal) => { + const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; diff --git a/tsconfig.json b/tsconfig.json index 4b0faa7..9ef6412 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,6 @@ "paths": { "@shared/*": ["../citation-engine/src/shared/*"], "@engine/*": ["../citation-engine/src/engine/*"], - "@anchor/*": ["src/anchor/*"], "@source/*": ["src/source/*"], "@work/*": ["src/work/*"], "@binder/*": ["src/binder/*"], diff --git a/vite.config.ts b/vite.config.ts index 56e273c..afcbf73 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,7 +33,6 @@ export default defineConfig({ alias: { "@shared": resolve(__dirname, "../citation-engine/src/shared"), "@engine": resolve(__dirname, "../citation-engine/src/engine"), - "@anchor": resolve(__dirname, "src/anchor"), "@source": resolve(__dirname, "src/source"), "@binder": resolve(__dirname, "src/binder"), "@work": resolve(__dirname, "src/work"),