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
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.
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue