Implement EANCH-WP-0002 and EANCH-WP-0003 anchor hardening and DOM selectors
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:
parent
e4582b52fa
commit
a1af0c6a45
22 changed files with 1118 additions and 105 deletions
30
src/dom/dom-path.test.ts
Normal file
30
src/dom/dom-path.test.ts
Normal 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
58
src/dom/dom-path.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
156
src/dom/html-viewer-adapter.tsx
Normal file
156
src/dom/html-viewer-adapter.tsx
Normal 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
11
src/dom/index.ts
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue