evidence-anchor/src/dom/html-viewer-adapter.tsx
tegwick bbeba96173
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
EANCH-WP-0004: HtmlViewer scroll parity and viewer-shell workplan
Export HtmlViewerAdapter from package root, add scrollRequestKey parity,
register finished workplan, update SCOPE.
2026-07-09 09:58:41 +02:00

163 lines
No EOL
5.4 KiB
TypeScript

/**
* 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;
/**
* Bumps when the same annotation should be re-scrolled (e.g. repeat click).
* Format is opaque — typically `${annotationId}:${version}`.
*/
readonly scrollRequestKey?: 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,
scrollRequestKey,
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(() => {
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
if (!requestKey || !scrollToAnnotationId || !rootRef.current) return;
const mark = rootRef.current.querySelector(
`[data-highlight-id="${CSS.escape(scrollToAnnotationId)}"]`,
);
mark?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [scrollToAnnotationId, scrollRequestKey, 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.
}