Implement EANCH-WP-0002 and EANCH-WP-0003 anchor hardening and DOM selectors
All checks were successful
CI Smoke / host-smoke (push) Successful in 4s
CI Smoke / container-smoke (push) Successful in 31s

WP-0002: stale vs unresolved resolution, orphaned flag/helper, bounded
fuzzy quote recovery, and PdfViewerAdapter promotion with resolveSelectors
integration. WP-0003: DomSelectionCapture, DOM create/resolve ladder,
HtmlViewerAdapter under evidence-anchor/dom, and finished workplans.

Verification: 42 anchor tests, citation-evidence typecheck + 51 tests green.
This commit is contained in:
tegwick 2026-07-09 09:18:42 +02:00
parent e4582b52fa
commit a1af0c6a45
22 changed files with 1118 additions and 105 deletions

30
src/dom/dom-path.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path";
describe("dom-path", () => {
it("round-trips a child-index path", () => {
const win = new Window();
const doc = win.document;
doc.body.innerHTML = "<article><p>Hello <b>world</b></p></article>";
const bold = doc.querySelector("b")!;
const root = doc.body as unknown as Node;
const path = domPathToNode(root, bold as unknown as Node);
expect(path).toBeDefined();
expect(nodeAtPath(root, path!)).toBe(bold);
});
it("captures a range as DomSelectionCapture", () => {
const win = new Window();
const doc = win.document;
doc.body.innerHTML = "<p>The quick brown fox</p>";
const textNode = doc.querySelector("p")!.firstChild!;
const range = doc.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 14);
const capture = captureFromRange(doc.body as unknown as Node, range as unknown as Range, "quick brown");
expect(capture?.kind).toBe("dom");
expect(capture?.text).toBe("quick brown");
expect(capture?.startPath).toBeDefined();
});
});

58
src/dom/dom-path.ts Normal file
View file

@ -0,0 +1,58 @@
import type { DomNodePath } from "@citation-evidence/engine/shared";
import type { DomSelectionCapture } from "../types";
/** Walk a DOM tree and return the child-index path to `node`. */
export function domPathToNode(root: Node, node: Node): DomNodePath | null {
if (root === node) return [];
const path: number[] = [];
function walk(current: Node, target: Node): boolean {
if (current === target) return true;
const children = childNodes(current);
for (let i = 0; i < children.length; i++) {
path.push(i);
if (walk(children[i]!, target)) return true;
path.pop();
}
return false;
}
return walk(root, node) ? path : null;
}
/** Resolve a child-index path from `root` to a node, or null if invalid. */
export function nodeAtPath(root: Node, path: DomNodePath): Node | null {
let current: Node = root;
for (const index of path) {
const children = childNodes(current);
const child = children[index];
if (!child) return null;
current = child;
}
return current;
}
function childNodes(node: Node): Node[] {
return Array.from(node.childNodes);
}
/** Build a `DomSelectionCapture` from a browser `Range` relative to `root`. */
export function captureFromRange(
root: Node,
range: Range,
text: string,
structuralPath?: DomSelectionCapture["structuralPath"],
): DomSelectionCapture | null {
const startPath = domPathToNode(root, range.startContainer);
const endPath = domPathToNode(root, range.endContainer);
if (!startPath || !endPath) return null;
return {
kind: "dom",
text,
startPath,
startOffset: range.startOffset,
endPath,
endOffset: range.endOffset,
...(structuralPath ? { structuralPath } : {}),
};
}

View file

@ -0,0 +1,156 @@
/**
* HTML/Markdown viewer adapter for non-paginated documents.
*
* Renders canonical HTML, captures DOM selections, and supports scroll/highlight
* via resolved text positions. Viewer-specific code stays under `src/dom/`.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import type { DocumentRepresentation, Selector } from "@citation-evidence/engine/shared";
import { createSelectors, resolveSelectors } from "../selectors";
import type {
AnchorResolution,
DomSelectionCapture,
HighlightRenderOptions,
ResolvedAnchorTarget,
} from "../types";
import { captureFromRange } from "./dom-path";
export interface HtmlViewerDebugOptions {
readonly logSelections?: boolean;
}
export interface HtmlViewerAdapterProps {
readonly html: string;
readonly representation: DocumentRepresentation;
readonly storedAnnotations?: readonly StoredHtmlAnnotation[];
readonly scrollToAnnotationId?: string;
readonly activeAnnotationId?: string | null;
readonly debug?: HtmlViewerDebugOptions;
onSelectionCaptured?(capture: DomSelectionCapture, selectors: Selector[]): void;
onHighlightClicked?(annotationId: string): void;
}
export interface StoredHtmlAnnotation {
readonly id: string;
readonly text: string;
readonly selectors: readonly Selector[];
}
export function HtmlViewerAdapter(props: HtmlViewerAdapterProps): ReactNode {
const {
html,
representation,
storedAnnotations = [],
scrollToAnnotationId,
activeAnnotationId,
debug,
onSelectionCaptured,
onHighlightClicked,
} = props;
const rootRef = useRef<HTMLDivElement | null>(null);
const handleMouseUp = useCallback(() => {
const root = rootRef.current;
if (!root || typeof window === "undefined") return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!root.contains(range.commonAncestorContainer)) return;
const text = selection.toString();
if (!text) return;
const capture = captureFromRange(root, range, text);
if (!capture) return;
const selectors = createSelectors(capture, representation);
if (debug?.logSelections) {
console.log("[evidence-anchor/dom] selection", { capture, selectors });
}
onSelectionCaptured?.(capture, selectors);
}, [representation, debug, onSelectionCaptured]);
const renderedHtml = useMemo(() => {
let body = html;
for (const ann of storedAnnotations) {
const resolution = resolveSelectors(ann.selectors, representation);
const span = resolution.candidates[0]?.textPosition;
if (!span || !representation.canonicalText) continue;
const quote = representation.canonicalText.slice(span.start, span.end);
if (!quote) continue;
const mark = `<mark data-highlight-id="${ann.id}" data-ce-active="${activeAnnotationId === ann.id ? "true" : "false"}">${quote}</mark>`;
body = body.replace(quote, mark);
}
return body;
}, [html, storedAnnotations, representation, activeAnnotationId]);
useEffect(() => {
if (!scrollToAnnotationId || !rootRef.current) return;
const mark = rootRef.current.querySelector(
`[data-highlight-id="${CSS.escape(scrollToAnnotationId)}"]`,
);
mark?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [scrollToAnnotationId, renderedHtml]);
return (
<div
ref={rootRef}
className="ea-html-viewer"
onMouseUp={handleMouseUp}
onClick={(e) => {
const target = (e.target as HTMLElement).closest("[data-highlight-id]");
if (target instanceof HTMLElement && target.dataset.highlightId) {
onHighlightClicked?.(target.dataset.highlightId);
}
}}
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
);
}
export async function resolveHtmlSelectors(
selectors: readonly Selector[],
representation: DocumentRepresentation,
): Promise<AnchorResolution> {
return resolveSelectors(selectors, representation);
}
export async function scrollToHtmlTarget(
root: HTMLElement,
target: ResolvedAnchorTarget,
representation: DocumentRepresentation,
): Promise<void> {
const span = target.textPosition;
if (!span || !representation.canonicalText) return;
const quote = representation.canonicalText.slice(span.start, span.end);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent ?? "";
const idx = text.indexOf(quote);
if (idx === -1) continue;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + quote.length);
const rect = range.getBoundingClientRect();
if (rect.height > 0) {
range.startContainer.parentElement?.scrollIntoView({ block: "center", behavior: "smooth" });
return;
}
}
}
export async function renderHtmlHighlight(
target: ResolvedAnchorTarget,
opts?: HighlightRenderOptions,
): Promise<void> {
void target;
void opts;
// Highlights are rendered inline during `HtmlViewerAdapter` render via stored annotations.
}

11
src/dom/index.ts Normal file
View file

@ -0,0 +1,11 @@
export {
HtmlViewerAdapter,
resolveHtmlSelectors,
scrollToHtmlTarget,
renderHtmlHighlight,
type HtmlViewerAdapterProps,
type HtmlViewerDebugOptions,
type StoredHtmlAnnotation,
} from "./html-viewer-adapter";
export { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path";