/** * 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(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 = `${quote}`; 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 (
{ 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 { return resolveSelectors(selectors, representation); } export async function scrollToHtmlTarget( root: HTMLElement, target: ResolvedAnchorTarget, representation: DocumentRepresentation, ): Promise { 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 target; void opts; // Highlights are rendered inline during `HtmlViewerAdapter` render via stored annotations. }