/** * useAnnotationHighlighter — annotation infrastructure for Viewer. * * Manages: web-highlighter lifecycle, toolbar/popover/quicklabel state, * annotation creation, text-based restoration (drafts/shares), scroll-to-selected. */ import { useEffect, useRef, useState, useCallback, type RefObject } from 'react'; import Highlighter from '@plannotator/web-highlighter'; import type { Annotation, EditorMode, HostDraft, ImageAttachment } from '../types'; import { AnnotationType } from '../types'; import type { QuickLabel } from '../utils/quickLabels'; import { getIdentity } from '../utils/identity'; import { annotationOwnsHighlight } from '../utils/annotationOwnsHighlight'; import { transformPlainText } from '../utils/inlineTransforms'; // --- Exported state types --- export interface ToolbarState { element: HTMLElement; source: any; selectionText: string; } export interface CommentPopoverState { anchorEl: HTMLElement; contextText: string; selectedText?: string; initialText?: string; source?: any; draftKey: string; /** `data-block-id`s of the blocks the pending selection covers, in * document order (a host maps them to source lines for Ask AI). */ blockIds?: string[]; } export interface QuickLabelPickerState { anchorEl: HTMLElement; cursorHint?: { x: number; y: number }; source?: any; } type MathAnnotationSource = { kind: 'math'; element: HTMLElement; text: string; blockId: string; displayMode: boolean; }; const isMathAnnotationSource = (source: any): source is MathAnnotationSource => source?.kind === 'math'; function commentDraftTargetKey(source: any, selectedText: string): string { if (isMathAnnotationSource(source)) { return `math:${source.blockId}:${source.text}`; } const start = source?.startMeta; const end = source?.endMeta; const startKey = start ? `${start.parentTagName}:${start.parentIndex}:${start.textOffset}` : 'unknown'; const endKey = end ? `${end.parentTagName}:${end.parentIndex}:${end.textOffset}` : 'unknown'; return `selection:${startKey}:${endKey}:${selectedText}`; } /** The blocks a pending selection covers: a math source names its own block; * a text selection is read off the highlighter's pending marks, each mapped * to its nearest `[data-block-id]` (the block its annotation would carry). */ function selectionBlockIds(highlighter: any, source: any): string[] { if (isMathAnnotationSource(source)) return [source.blockId]; if (!highlighter || !source?.id) return []; let doms: unknown[] = []; try { doms = highlighter.getDoms(source.id) ?? []; } catch { return []; } const ids: string[] = []; for (const dom of doms) { if (!(dom instanceof HTMLElement)) continue; const id = dom.closest('[data-block-id]')?.dataset.blockId; if (id && !ids.includes(id)) ids.push(id); } return ids; } type MathAnnotationTarget = { element: HTMLElement; blockId: string; tex: string; displayMode: boolean; }; const elementFromNode = (node: Node | null): HTMLElement | null => { if (!node) return null; if (node.nodeType === Node.ELEMENT_NODE) return node as HTMLElement; return node.parentNode instanceof HTMLElement ? node.parentNode : null; }; const closestMathElement = (node: Node | null, root: HTMLElement | null): HTMLElement | null => { const element = elementFromNode(node); const math = element?.closest('.math-annotatable[data-math-tex]'); if (!math || !root?.contains(math)) return null; return math; }; const mathElementFromSelection = (selection: Selection | null, root: HTMLElement | null): HTMLElement | null => { if (!selection || selection.rangeCount === 0) return null; return ( closestMathElement(selection.anchorNode, root) || closestMathElement(selection.focusNode, root) || closestMathElement(selection.getRangeAt(0).commonAncestorContainer, root) ); }; const selectionContainsNode = (range: Range, node: Node): boolean => { try { return range.intersectsNode(node); } catch { return false; } }; const mathSourceFromElement = (element: HTMLElement): MathAnnotationSource | null => { const text = element.dataset.mathTex; if (!text) return null; const block = element.closest('[data-block-id]'); const blockId = block?.dataset.blockId; if (!blockId) return null; return { kind: 'math', element, text, blockId, displayMode: element.dataset.mathDisplay === 'true', }; }; const mathTargetsFromSelection = ( selection: Selection | null, root: HTMLElement | null, ): MathAnnotationTarget[] => { if (!selection || selection.rangeCount === 0 || !root) return []; const seen = new Set(); const targets: MathAnnotationTarget[] = []; const candidates = root.querySelectorAll('.math-annotatable[data-math-tex]'); for (let i = 0; i < selection.rangeCount; i += 1) { const range = selection.getRangeAt(i); candidates.forEach(element => { if (seen.has(element) || !selectionContainsNode(range, element)) return; const source = mathSourceFromElement(element); if (!source) return; seen.add(element); targets.push({ element, blockId: source.blockId, tex: source.text, displayMode: source.displayMode, }); }); } return targets; }; const selectionHasNonMathContent = ( selection: Selection | null, root: HTMLElement | null, ): boolean => { if (!selection || selection.rangeCount === 0 || !root) return false; for (let i = 0; i < selection.rangeCount; i += 1) { const range = selection.getRangeAt(i); if (!root.contains(range.commonAncestorContainer)) continue; // Whole selection sits inside a single formula (both endpoints within the // rendered KaTeX): a pure-math selection, not mixed content. if (closestMathElement(range.commonAncestorContainer, root)) continue; // Otherwise clone the selection, drop any whole math elements it covers, // and see if meaningful text is left. Leftover text = genuinely mixed // (prose + formula) and should fall through to the normal text path. // Nothing left = the selection only spans formulas — even when an endpoint // spills into an adjacent text node at offset 0, which the browser does on // nearly every drag-select of an inline formula. const fragment = range.cloneContents(); fragment.querySelectorAll?.('.math-annotatable[data-math-tex]').forEach(element => { element.remove(); }); if (fragment.textContent?.trim()) return true; } return false; }; const annotationId = (): string => { const cryptoRef = globalThis.crypto; if (cryptoRef && typeof cryptoRef.randomUUID === 'function') { return cryptoRef.randomUUID(); } return `ann-${Date.now()}-${Math.random().toString(36).slice(2)}`; }; const escapeAttrValue = (value: string): string => { if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') { return globalThis.CSS.escape(value); } return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }; // web-highlighter 0.8.x accepts only class, ID, and tag exclusions. const ANNOTATION_EXCLUDED_SELECTOR = '.annotation-exclude'; const isAnnotationExcludedTextNode = (node: Node): boolean => Boolean(node.parentElement?.closest(ANNOTATION_EXCLUDED_SELECTOR)); /** * Content-only comparison: the painted highlight and the browser's selection * string legitimately differ in whitespace (`Selection.toString()` inserts a * blank line between block elements, the wrapper ``s concatenated with * no separator do not), so only the characters themselves are compared. * * Used by BOTH the quote repair below and the restore verification, which * compares `originalText` against the text the stored positions actually * painted. Whitespace must be REMOVED there rather than collapsed: a quote * spanning two blocks carries the browser's "\n\n" where the painted marks * carry nothing at all, so collapsing to a single space rejects every correct * cross-block restore. Content drift — the case that verification exists for * (#1509) — still differs once whitespace is gone. */ const compactText = (value: string): string => value.replace(/\s+/g, ''); /** * A quote with nothing but whitespace in it is not an annotation (#881). * * A double-click at a block boundary — just past the end of a heading, a * paragraph, a list item, a fence — leaves a non-collapsed selection whose * whole string is the break BETWEEN two blocks ("\n"). Acting on it produces * an invisible highlight and an `originalText: "\n"` annotation that no * reload can re-anchor, and which is still counted and exported. * * `compactText` rather than `trim`: it also removes the non-breaking spaces * and other Unicode whitespace a rendered document is full of. */ const isBlankQuote = (value: string | null | undefined): boolean => compactText(value ?? '') === ''; /** * Whether web-highlighter can serialize a range boundary (#881). * * A non-text boundary is resolved by the library as `childNodes[offset]` and * that node is then serialized. The DOM lets `offset` equal `childNodes.length` * (one past the last child) and browsers really do report that for a * double-click at the end of the last block, which leaves the library holding * `undefined` and throwing `Cannot read properties of undefined (reading * 'parentNode')` out of its own pointer-end listener — an uncaught error, with * no annotation created either way. */ const boundaryResolves = (node: Node, offset: number): boolean => { // Text, CDATASection and Comment boundaries are offsets INTO the node. if (node.nodeType === 3 || node.nodeType === 4 || node.nodeType === 8) return true; return offset >= 0 && offset < node.childNodes.length && node.childNodes[offset] != null; }; const isSerializableRange = (range: Range): boolean => boundaryResolves(range.startContainer, range.startOffset) && boundaryResolves(range.endContainer, range.endOffset); /** * One line per page load: a highlighter failure is degraded UX (the selection * is ignored), not something to flood the console — or the user — over. */ let warnedHighlighterFailure = false; const warnHighlighterFailure = (context: string, error: unknown): void => { if (warnedHighlighterFailure) return; warnedHighlighterFailure = true; console.warn(`[plannotator] highlighter ${context} failed; the selection was ignored`, error); }; /** * The text a set of painted highlight wrappers shows the reader. * * Not `textContent`: a wrapper can legitimately contain `.annotation-exclude` * chrome (a list marker the selection crossed, an alert's visually hidden type * word), and that chrome is in neither the browser's selection string nor the * quote derived from it. Comparing raw `textContent` against `originalText` * therefore rejected correct restores over anything non-selectable. */ const paintedTextOf = (doms: readonly HTMLElement[]): string => { let painted = ''; for (const dom of doms) { if (!dom) continue; if (dom.closest?.(ANNOTATION_EXCLUDED_SELECTOR)) continue; const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT); let node: Node | null; while ((node = walker.nextNode())) { if (isAnnotationExcludedTextNode(node)) continue; painted += node.textContent ?? ''; } } return painted; }; /** The block element a painted highlight sits in, found the way an * annotation's `blockId` is found when it is created (walk up from the * parent of the first highlighted node to the nearest `data-block-id`), so an * unchanged document resolves every restore to the block it was made in. */ const blockElementOf = (dom: Node | null | undefined): HTMLElement | null => { let parent = dom?.parentElement ?? null; while (parent && !parent.dataset.blockId) parent = parent.parentElement; return parent; }; /** Where `quote` starts in `text`, matching whitespace-insensitively (the * same comparison the restore verification uses: a quote that crossed a * block boundary or a soft wrap carries different whitespace than the * block's text). 0 when it is not there, as at creation. */ const quoteOffsetIn = (text: string, quote: string): number => { const exact = text.indexOf(quote); if (exact !== -1) return exact; const needle = compactText(quote); if (!needle) return 0; // Compact the text while remembering each kept character's original index. const positions: number[] = []; let compact = ''; for (let i = 0; i < text.length; i++) { if (/\s/.test(text[i])) continue; compact += text[i]; positions.push(i); } const at = compact.indexOf(needle); return at === -1 ? 0 : positions[at]; }; /** * Tags whose boxes a browser separates with a line break in a selection string. * * Read by {@link blockBoundaryOffsets} only; a computed-style check would be * more precise but is unavailable under the test DOM and would cost a layout * read per text node on every restore. */ const BLOCK_LEVEL_TAGS = new Set([ 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'BODY', 'DD', 'DETAILS', 'DIALOG', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL', ]); const nearestBlockAncestor = (node: Node): Element | null => { let element = node.parentElement; while (element) { if (BLOCK_LEVEL_TAGS.has(element.tagName)) return element; element = element.parentElement; } return null; }; /** * Offsets in the concatenated text stream at which a new block box starts. * * The document's text nodes are joined with nothing between them, but the * browser's selection string puts a blank line between two block elements — * so a quote spanning two blocks carries whitespace the search stream does * not, and the whitespace-collapsing fallback could never match it. Every * cross-block annotation therefore lost its highlight the moment it had to * fall back to text search (which is every one of them after an Edit Mode * commit, where `applyEditedDocument` strips the stored positions of any * annotation whose quote is not contained in one block). * * Reported as offsets rather than inserted into the stream so the existing * offset-to-node mapping keeps working untouched. */ const blockBoundaryOffsets = (textNodes: readonly Text[]): Set => { const boundaries = new Set(); let offset = 0; let previousBlock: Element | null = null; let seenAny = false; for (const node of textNodes) { const block = nearestBlockAncestor(node); if (seenAny && block !== previousBlock && offset > 0) boundaries.add(offset); previousBlock = block; seenAny = true; offset += node.textContent?.length ?? 0; } return boundaries; }; /** The node a range's start boundary actually addresses: an element boundary * addresses the child at its offset, which is where web-highlighter descends * (`formatDomNode`). */ const startBoundaryNode = (range: Range): Node => { const { startContainer, startOffset } = range; if (startContainer.nodeType === Node.ELEMENT_NODE) { return startContainer.childNodes[startOffset] ?? startContainer; } return startContainer; }; const excludedAncestor = (node: Node | null): HTMLElement | null => { if (!node) return null; const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement; return element?.closest(ANNOTATION_EXCLUDED_SELECTOR) ?? null; }; /** * Move a range's start off any `.annotation-exclude` subtree it begins inside, * onto the first annotatable text position the range covers. * * web-highlighter never ENTERS an excluded subtree (`painter/dom.ts` skips it * before the "are we at the start node" check), so a range that starts inside * one never flips its in-selection flag: every intermediate run is dropped and * only the trailing text node is painted. A drag from a GitHub alert's icon — * where the visually hidden "Tip: " lives — through the alert body therefore * highlighted the body alone and left the title unpainted, while the quote * still carried the invisible word. Snapping fixes both at once: the painted * extent covers what the reviewer dragged over, and the quote (which web- * highlighter derives from this same range/selection) no longer contains the * hidden chrome. * * Snapping is by NODE IDENTITY, never by matching text. Returns whether the * range was changed. */ const snapRangeStartPastExcluded = (range: Range): boolean => { const excluded = excludedAncestor(startBoundaryNode(range)); if (!excluded) return false; const scopeNode = range.commonAncestorContainer; const scope = scopeNode.nodeType === Node.ELEMENT_NODE ? (scopeNode as Element) : scopeNode.parentElement; // A range that lies entirely inside the excluded subtree has nothing to snap // to; leave it alone (it paints nothing, exactly as before). if (!scope || excluded.contains(scope)) return false; const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); let node: Node | null; while ((node = walker.nextNode())) { const text = node as Text; if (!text.length) continue; // Skip everything at or before the excluded subtree, and any other // excluded run that follows it. if (excluded.contains(text)) continue; const position = excluded.compareDocumentPosition(text); if (!(position & Node.DOCUMENT_POSITION_FOLLOWING)) continue; if (isAnnotationExcludedTextNode(text)) continue; // Never past the range's own end. if (text === range.endContainer) { if (range.endOffset === 0) return false; range.setStart(text, 0); return true; } const toEnd = text.compareDocumentPosition(range.endContainer); if (!(toEnd & Node.DOCUMENT_POSITION_FOLLOWING)) return false; range.setStart(text, 0); return true; } return false; }; /** A resolved restore boundary: web-highlighter's own `DomNode` shape. */ interface RestoreBoundary { $node: Node; offset: number; } /** The nearest text node on one side of `node` that annotation painting can * reach, skipping every `.annotation-exclude` run. */ const annotatableTextNeighbour = ( root: Element, node: Node, direction: 1 | -1, ): Text | null => { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const all: Text[] = []; let current: Node | null; while ((current = walker.nextNode())) all.push(current as Text); const index = all.indexOf(node as Text); if (index < 0) return null; for (let i = index + direction; i >= 0 && i < all.length; i += direction) { const text = all[i]!; if (!text.length) continue; if (isAnnotationExcludedTextNode(text)) continue; return text; } return null; }; /** * Move a RESTORED boundary off any `.annotation-exclude` subtree it resolved * into, the same snap {@link snapRangeStartPastExcluded} applies to a live * selection. * * A stored `textOffset` counts every text node under the recorded parent, * excluded chrome included — and so does the resolver that reads it back * (`getTextChildByOffset`), which resolves a boundary sitting exactly at the * end of one text node onto THAT node rather than the start of the next. A * drag from a GitHub alert's icon therefore stores a start of 5, the length of * the hidden "Tip: ", and restores onto the hidden span — where painting never * enters, so every run before the last one was dropped, the verification * rejected what was left, and the text search could not bridge the title into * the body either. The annotation came back from its own draft unpainted. * * Normalizing the stored metas at creation time instead is not available: they * are only meaningful in the resolver's own coordinates, which count the * excluded text. Snapping on restore also covers every draft already on disk. */ const snapRestoredBoundary = ( root: Element, boundary: RestoreBoundary, direction: 1 | -1, ): RestoreBoundary => { const node = boundary.$node; if (!node || node.nodeType !== Node.TEXT_NODE) return boundary; if (!isAnnotationExcludedTextNode(node)) return boundary; const neighbour = annotatableTextNeighbour(root, node, direction); if (!neighbour) return boundary; return { $node: neighbour, offset: direction === 1 ? 0 : neighbour.length }; }; /** Whether a snapped pair would describe a backwards range. */ const restoreBoundariesCross = (start: RestoreBoundary, end: RestoreBoundary): boolean => { if (start.$node === end.$node) return start.offset > end.offset; const position = start.$node.compareDocumentPosition(end.$node); return !(position & Node.DOCUMENT_POSITION_FOLLOWING); }; /** One clipped text run of a range, and whether it is excluded chrome. */ interface RangeTextPiece { text: string; excluded: boolean; } /** * The range's own text runs in document order, clipped to its boundaries. * * Read BEFORE the highlight is painted: painting splits and re-parents text * nodes, which leaves the range's boundaries stale. The runs are plain strings * and survive that. * * Returns null for a shape this cannot read (an element-node boundary, or an * end boundary the walk never reaches), so callers leave the quote alone. */ const rangeTextPieces = (range: Range): RangeTextPiece[] | null => { const { startContainer, endContainer, startOffset, endOffset } = range; if (startContainer?.nodeType !== Node.TEXT_NODE) return null; if (endContainer?.nodeType !== Node.TEXT_NODE) return null; const scopeNode = range.commonAncestorContainer; const scope = scopeNode?.nodeType === Node.ELEMENT_NODE ? (scopeNode as Element) : scopeNode?.parentElement; if (!scope) return null; const pieces: RangeTextPiece[] = []; const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); let started = false; let node: Node | null; while ((node = walker.nextNode())) { const text = node as Text; if (!started) { if (text !== startContainer) continue; started = true; } const from = text === startContainer ? startOffset : 0; const to = text === endContainer ? endOffset : text.length; const slice = (text.textContent ?? '').slice(from, to); if (slice) pieces.push({ text: slice, excluded: isAnnotationExcludedTextNode(text) }); if (text === endContainer) return pieces; } return null; }; /** * Drop `.annotation-exclude` chrome from a new annotation's quote. * * Excluded nodes are never PAINTED (they are in the highlighter's * `exceptSelectors`) and never searched (the restore TreeWalker rejects * them), but the browser's selection string still contains them — a selection * that runs THROUGH a GitHub alert's title row picks up the visually hidden * "Tip: " that keeps the alert type in its accessible name (#1511). That * string becomes `originalText`: the quote in the panel, the quote handed to * the agent, and the only handle a share link has for re-finding the * highlight — which it then never can, because the search stream skips the * excluded text. (A selection that BEGINS inside such a node is handled * earlier, by {@link snapRangeStartPastExcluded}.) * * The removal is positional, driven by the range's own nodes rather than by * searching the quote for the chrome's text: the excluded runs are located in * the quote's whitespace-free coordinate space, where the selection string and * the concatenated runs agree character for character. Searching by text * removed the wrong occurrence whenever the body prose legitimately contained * the same words ("Tip: " as real copy), which then failed the final check and * silently kept the invisible word. The painted highlight remains the last * word on CONTENT: an unrecognized shape leaves the quote exactly as before. */ const quoteWithoutExcludedText = ( pieces: RangeTextPiece[] | null, selectionText: string, paintedText: string, ): string => { if (!selectionText) return selectionText; if (compactText(selectionText) === compactText(paintedText)) return selectionText; if (!pieces || !pieces.some(piece => piece.excluded)) return selectionText; // Excluded spans in compacted (whitespace-free) coordinates. const spans: { start: number; end: number }[] = []; let compactLength = 0; for (const piece of pieces) { const length = compactText(piece.text).length; if (piece.excluded && length > 0) { spans.push({ start: compactLength, end: compactLength + length }); } compactLength += length; } if (spans.length === 0) return selectionText; // The selection string and the range's runs must describe the same // characters for positions to mean anything. if (compactText(selectionText).length !== compactLength) return selectionText; let cursor = 0; let quote = ''; for (const char of selectionText) { if (/\s/.test(char)) { // Whitespace has no compacted position of its own: drop it only when it // sits inside an excluded run or immediately after one, so removing // "Tip:" takes the space that followed it with it. if (!spans.some(span => cursor > span.start && cursor <= span.end)) quote += char; continue; } if (!spans.some(span => cursor >= span.start && cursor < span.end)) quote += char; cursor += 1; } return compactText(quote) === compactText(paintedText) ? quote.trim() : selectionText; }; const applyMathAnnotationClass = ( element: HTMLElement, id: string, type: AnnotationType, displayMode: boolean, ) => { element.classList.add( 'annotation-highlight', displayMode ? 'math-block-annotation' : 'math-inline-annotation', ); element.classList.remove('deletion', 'comment'); if (type === AnnotationType.DELETION) { element.classList.add('deletion'); } else if (type === AnnotationType.COMMENT) { element.classList.add('comment'); } element.dataset.bindId = id; element.dataset.mathAnnotation = 'true'; }; const applyMathTargets = ( targets: MathAnnotationTarget[], id: string, type: AnnotationType, ) => { targets.forEach(target => { applyMathAnnotationClass(target.element, id, type, target.displayMode); }); }; const clearMathAnnotationClass = (element: Element) => { element.classList.remove( 'annotation-highlight', 'math-block-annotation', 'math-inline-annotation', 'deletion', 'comment', 'focused', ); delete (element as HTMLElement).dataset.bindId; delete (element as HTMLElement).dataset.mathAnnotation; }; // --- Hook options & return --- export interface UseAnnotationHighlighterOptions { containerRef: RefObject; annotations: Annotation[]; onAddAnnotation?: (ann: Annotation) => void; onSelectAnnotation?: (id: string | null) => void; selectedAnnotationId: string | null; mode: EditorMode; enabled?: boolean; /** Opt-in: after a meta-based restore (`fromStore`), verify the painted text * matches the annotation's `originalText` (whitespace-normalized). On * mismatch the wrong highlight is removed and the text-search fallback * runs instead; if that also fails, `onRestoreMismatch` fires and nothing * is painted. Default false — today's behavior (positions are trusted). */ verifyRestoredContent?: boolean; /** Fires when a restore was rejected (content mismatch) and the text-search * fallback could not re-anchor the annotation either. */ onRestoreMismatch?: (annotation: Annotation, restoredText: string) => void; /** Fires once per `applyAnnotations` pass with what that pass tried and what * it could not anchor, so a host can mark the leftovers in its panel. */ onRestoreReport?: (report: AnnotationRestoreReport) => void; /** * The composer handed to the host (see `HostDraft`). Set: whenever a * selection's toolbar or composer would open, the host gets the draft * instead (and null when it closes); the host draws no toolbar or composer * from this hook's state. Absent: unchanged. */ onHostDraft?: (draft: HostDraft | null) => void; } /** The outcome of one `applyAnnotations` pass. */ export interface AnnotationRestoreReport { /** Ids the pass considered — including ones already painted, which are * anchored by definition. A host clears their unanchored marks. */ attempted: string[]; /** Of those, the ones left with no highlight: neither the stored positions * (absent, unresolvable, or resolved onto the wrong text) nor a search for * the quote found it in the document. */ unanchored: string[]; /** Annotations whose stored `blockId` no longer names where their text is, * because the document changed since they were made (a draft restored * after an edit, a plan revision). Each entry says where the text is NOW: * `blockId` is the block the highlight landed in, or `''` when the text is * gone (every unanchored id is listed here too). Annotations whose stored * block still holds their text are never listed, so an unchanged document * reports nothing here. A host that exports line numbers from `blockId` * should write these back. Optional so older reporters (the diagram * overlay) need not produce it. */ moved?: RestoredAnchor[]; } /** Where a restored annotation's text sits now (see `moved`). */ export interface RestoredAnchor { id: string; /** The block the highlight landed in; `''` when the text is gone. */ blockId: string; /** Offset of the quote in that block's text (same rule as creation); * absent when the text is gone. */ startOffset?: number; /** The stored `startMeta`/`endMeta` did not lead to the text (unresolvable, * or onto other text) and should be dropped so later restores search by * text instead. False when the stored positions themselves were verified. */ positionsStale: boolean; } /** Annotation UI state and mutation commands owned by one rendered document. */ export interface UseAnnotationHighlighterReturn { highlighterRef: RefObject; toolbarState: ToolbarState | null; commentPopover: CommentPopoverState | null; quickLabelPicker: QuickLabelPickerState | null; handleAnnotate: (type: AnnotationType) => void; handleQuickLabel: (label: QuickLabel) => void; handleToolbarClose: () => void; handleRequestComment: (initialChar?: string) => void; /** The composer's submit. `mentions` arrives only from a `CommentPopover` * the host gave a `mentionSource`; the ids ride onto the new annotation. */ handleCommentSubmit: ( text: string, images?: ImageAttachment[], mentions?: readonly string[], ) => void; handleCommentClose: () => void; handleFloatingQuickLabel: (label: QuickLabel) => void; handleQuickLabelPickerDismiss: () => void; /** Paint a caller-created DOM range through the same pipeline as pointer selection. */ highlightRange: (range: Range, modeOverride?: EditorMode) => void; /** Annotate one rendered formula through the same path as a pointer click. */ highlightMathElement: (element: HTMLElement, modeOverride?: EditorMode) => void; removeHighlight: (id: string) => void; clearAllHighlights: () => void; applyAnnotations: (annotations: Annotation[]) => void; /** For a host that composes on a touch screen: the page selection as a draft now (see `ViewerHandle.takeSelection`). */ takeSelection?: () => boolean; } /** * Own pointer and caller-supplied range annotations for a Markdown container. * * Highlight restoration failures are reported through `onRestoreMismatch` * when verification is enabled; malformed or stale ranges are ignored. */ export function useAnnotationHighlighter({ containerRef, annotations, onAddAnnotation, onSelectAnnotation, selectedAnnotationId, mode, enabled = true, verifyRestoredContent = false, onRestoreMismatch, onRestoreReport, onHostDraft, }: UseAnnotationHighlighterOptions): UseAnnotationHighlighterReturn { const highlighterRef = useRef(null); const modeRef = useRef(mode); const onAddAnnotationRef = useRef(onAddAnnotation); const onSelectAnnotationRef = useRef(onSelectAnnotation); const pendingSourceRef = useRef(null); const pendingMathTargetsRef = useRef([]); const pendingMathElementRef = useRef(null); const pendingModeOverrideRef = useRef(null); const justCreatedIdRef = useRef(null); const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const mouseDownMathRef = useRef(null); /** The text runs of the range the highlight about to be created came from, * captured before painting (which invalidates the range itself). The CREATE * handler repairs the quote from them. */ const pendingRangeRunsRef = useRef(null); const [toolbarState, setToolbarState] = useState(null); const [commentPopover, setCommentPopover] = useState(null); const [quickLabelPicker, setQuickLabelPicker] = useState(null); // Keep refs in sync useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); useEffect(() => { onSelectAnnotationRef.current = onSelectAnnotation; }, [onSelectAnnotation]); const onRestoreMismatchRef = useRef(onRestoreMismatch); useEffect(() => { onRestoreMismatchRef.current = onRestoreMismatch; }, [onRestoreMismatch]); const onRestoreReportRef = useRef(onRestoreReport); useEffect(() => { onRestoreReportRef.current = onRestoreReport; }, [onRestoreReport]); const clearPendingSelection = useCallback(() => { pendingSourceRef.current = null; pendingMathTargetsRef.current = []; // Strip the pre-submission preview highlight if the annotation was never // finalized (createAnnotationFromMathSource nulls this ref once committed). if (pendingMathElementRef.current) { clearMathAnnotationClass(pendingMathElementRef.current); pendingMathElementRef.current = null; } }, []); // Paint a preview highlight on a formula the moment its toolbar/popover opens, // so the target is visible before the user commits the annotation. const showPendingMathPreview = useCallback((source: MathAnnotationSource) => { applyMathAnnotationClass(source.element, 'pending-math', AnnotationType.COMMENT, source.displayMode); pendingMathElementRef.current = source.element; }, []); // A host that composes (onHostDraft) on a touch screen takes the selection // itself, from its own Comment action (`takeSelection`): painting it as it // settles would replace the system selection, and its edit menu with it. const hostComposesRef = useRef(false); hostComposesRef.current = onHostDraft !== undefined; const takeTouchSelectionRef = useRef<() => boolean>(() => false); const takeSelection = useCallback(() => takeTouchSelectionRef.current(), []); // Track mouse position for quick label picker useEffect(() => { const track = (e: MouseEvent) => { lastMousePosRef.current = { x: e.clientX, y: e.clientY }; }; document.addEventListener('mouseup', track, true); return () => document.removeEventListener('mouseup', track, true); }, [clearPendingSelection]); // --- Helpers --- const findTextInDOM = useCallback((searchText: string): Range | null => { if (!containerRef.current) return null; // Search for an exact substring match inside the container's text tree. // Falls back to a multi-text-node walk when the match spans siblings. const searchOnce = (needle: string): Range | null => { if (!needle || !containerRef.current) return null; const walker = document.createTreeWalker( containerRef.current, NodeFilter.SHOW_TEXT, { acceptNode: (node) => isAnnotationExcludedTextNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, }, ); const textNodes: Text[] = []; let currentNode: Text | null; while ((currentNode = walker.nextNode() as Text | null)) { textNodes.push(currentNode); } const rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => { let charCount = 0; let startNode: Text | null = null; let startOffset = 0; let endNode: Text | null = null; let endOffset = 0; for (const node of textNodes) { const nodeLength = node.textContent?.length || 0; if (!startNode && charCount + nodeLength > startIndex) { startNode = node; startOffset = startIndex - charCount; } if (startNode && charCount + nodeLength >= endIndex) { endNode = node; endOffset = endIndex - charCount; break; } charCount += nodeLength; } if (startNode && endNode) { const range = document.createRange(); range.setStart(startNode, startOffset); range.setEnd(endNode, endOffset); return range; } return null; }; // `boundaries` names offsets at which a new block box starts. They are // normalized as if a space stood there, because that is what the // browser's selection string carries at the same place — which is the // only way a cross-block quote can match this stream. const normalizeWithMap = ( text: string, boundaries?: ReadonlySet, ): { text: string; map: number[] } => { let normalized = ''; const map: number[] = []; let inWhitespace = false; for (let i = 0; i < text.length; i++) { if (boundaries?.has(i) && !inWhitespace && normalized.length > 0) { normalized += ' '; map.push(i); inWhitespace = true; } const ch = text[i]; if (/\s/.test(ch)) { if (!inWhitespace) { normalized += ' '; map.push(i); inWhitespace = true; } } else { normalized += ch; map.push(i); inWhitespace = false; } } let start = 0; let end = normalized.length; while (start < end && normalized[start] === ' ') start++; while (end > start && normalized[end - 1] === ' ') end--; return { text: normalized.slice(start, end), map: map.slice(start, end), }; }; for (const node of textNodes) { const text = node.textContent || ''; const index = text.indexOf(needle); if (index !== -1) { const range = document.createRange(); range.setStart(node, index); range.setEnd(node, index + needle.length); return range; } } const fullText = textNodes.map((node) => node.textContent ?? '').join(''); const searchIndex = fullText.indexOf(needle); if (searchIndex !== -1) { return rangeFromTextOffsets(searchIndex, searchIndex + needle.length); } const haystack = normalizeWithMap(fullText, blockBoundaryOffsets(textNodes)); const normalizedNeedle = normalizeWithMap(needle).text; const normalizedIndex = haystack.text.indexOf(normalizedNeedle); if (normalizedNeedle && normalizedIndex !== -1) { const originalStart = haystack.map[normalizedIndex]; const originalEnd = haystack.map[normalizedIndex + normalizedNeedle.length - 1] + 1; return rangeFromTextOffsets(originalStart, originalEnd); } return null; }; // First try the literal text. If that misses, re-try with the same // transform the renderer applies to plain text (emoji shortcodes + // smart punctuation) so annotations made before those transforms // shipped can still re-bind to their target after reload. const direct = searchOnce(searchText); if (direct) return direct; const transformed = transformPlainText(searchText); if (transformed !== searchText) { return searchOnce(transformed); } return null; }, []); /** The annotation a selection's source makes, or null for a blank quote. No side effects. */ const buildAnnotationFromSource = ( highlighter: Highlighter, source: any, type: AnnotationType, text?: string, images?: ImageAttachment[], isQuickLabel?: boolean, quickLabelTip?: string, mentions?: readonly string[], ): Annotation | null => { // #881: the last line before an annotation exists. A quote of pure // whitespace cannot be re-anchored on the next load, so it would come back // as an invisible row that is still counted and still exported. if (isBlankQuote(source?.text)) return null; const doms = highlighter.getDoms(source.id); let blockId = ''; let startOffset = 0; if (doms?.length > 0) { const el = doms[0] as HTMLElement; let parent = el.parentElement; while (parent && !parent.dataset.blockId) { parent = parent.parentElement; } if (parent?.dataset.blockId) { blockId = parent.dataset.blockId; const blockText = parent.textContent || ''; const beforeText = blockText.split(source.text)[0]; startOffset = beforeText?.length || 0; } } const mathTargets = pendingMathTargetsRef.current; const newAnnotation: Annotation = { id: source.id, blockId, startOffset, endOffset: startOffset + source.text.length, type, text, originalText: source.text, createdA: Date.now(), author: getIdentity(), startMeta: source.startMeta, endMeta: source.endMeta, images, // Host capability: present only when a mentionSource was supplied AND a // token survived, so an annotation created without one is unchanged. ...(mentions && mentions.length > 0 ? { mentions } : {}), ...(mathTargets.length > 0 ? { mathTargets: mathTargets.map(target => ({ blockId: target.blockId, tex: target.tex, displayMode: target.displayMode, })), } : {}), ...(isQuickLabel ? { isQuickLabel: true } : {}), ...(quickLabelTip ? { quickLabelTip } : {}), }; return newAnnotation; }; const createAnnotationFromSource = ( highlighter: Highlighter, source: any, type: AnnotationType, text?: string, images?: ImageAttachment[], isQuickLabel?: boolean, quickLabelTip?: string, mentions?: readonly string[], ) => { const newAnnotation = buildAnnotationFromSource(highlighter, source, type, text, images, isQuickLabel, quickLabelTip, mentions); if (!newAnnotation) return; const mathTargets = pendingMathTargetsRef.current; if (type === AnnotationType.DELETION) { highlighter.addClass('deletion', source.id); } else if (type === AnnotationType.COMMENT) { highlighter.addClass('comment', source.id); } applyMathTargets(mathTargets, source.id, type); justCreatedIdRef.current = newAnnotation.id; onAddAnnotationRef.current?.(newAnnotation); }; const createAnnotationFromMathSource = ( source: MathAnnotationSource, type: AnnotationType, text?: string, images?: ImageAttachment[], isQuickLabel?: boolean, quickLabelTip?: string, mentions?: readonly string[], ) => { const id = annotationId(); applyMathAnnotationClass(source.element, id, type, source.displayMode); // Committed — hand ownership of the highlight to the annotation so a later // clearPendingSelection() doesn't strip it. pendingMathElementRef.current = null; const newAnnotation: Annotation = { id, blockId: source.blockId, startOffset: 0, endOffset: source.text.length, type, text, originalText: source.text, createdA: Date.now(), author: getIdentity(), images, ...(mentions && mentions.length > 0 ? { mentions } : {}), ...(isQuickLabel ? { isQuickLabel: true } : {}), ...(quickLabelTip ? { quickLabelTip } : {}), }; justCreatedIdRef.current = newAnnotation.id; onAddAnnotationRef.current?.(newAnnotation); }; const findMathElementForAnnotation = useCallback((ann: Annotation): HTMLElement | null => { if (!containerRef.current || !ann.blockId || !ann.originalText) return null; const block = containerRef.current.querySelector( `[data-block-id="${escapeAttrValue(ann.blockId)}"]` ); if (!block) return null; if ( block.matches('.math-annotatable[data-math-tex]') && block.dataset.mathTex === ann.originalText ) { return block; } const candidates = block.querySelectorAll('.math-annotatable[data-math-tex]'); for (const candidate of Array.from(candidates)) { if (candidate.dataset.mathTex === ann.originalText) return candidate; } return null; }, []); const findMathElementsForAnnotation = useCallback((ann: Annotation): MathAnnotationTarget[] => { if (!containerRef.current) return []; if (ann.mathTargets?.length) { const targets: MathAnnotationTarget[] = []; const seen = new Set(); ann.mathTargets.forEach(target => { const block = containerRef.current?.querySelector( `[data-block-id="${escapeAttrValue(target.blockId)}"]` ); if (!block) return; const candidates = [ ...(block.matches('.math-annotatable[data-math-tex]') ? [block] : []), ...Array.from(block.querySelectorAll('.math-annotatable[data-math-tex]')), ]; const element = candidates.find(candidate => candidate.dataset.mathTex === target.tex); if (!element || seen.has(element)) return; seen.add(element); targets.push({ element, blockId: target.blockId, tex: target.tex, displayMode: target.displayMode, }); }); return targets; } const mathElement = findMathElementForAnnotation(ann); if (!mathElement) return []; return [{ element: mathElement, blockId: ann.blockId, tex: mathElement.dataset.mathTex ?? ann.originalText, displayMode: mathElement.dataset.mathDisplay === 'true', }]; }, [findMathElementForAnnotation]); // --- Imperative methods --- const applyAnnotationsInternal = useCallback((anns: Annotation[]) => { const highlighter = highlighterRef.current; if (!highlighter || !containerRef.current) return; const attempted: string[] = []; const unanchored: string[] = []; const moved: RestoredAnchor[] = []; // Record where a restored annotation's text sits when its stored blockId // no longer names that place. Block ids are positional (`block-N`), so // after the document changed the stored id can name a different block, // and a line label read from it would point at the wrong line. Nothing is // recorded while the stored block still holds the quote — an unchanged // document, or a quote that also appears in the stored block — so the // stored id (and the export built from it) stays exactly as it was. const noteAnchor = (ann: Annotation, firstDom: Node | null | undefined, positionsStale: boolean) => { if (!ann.blockId) return; const blockEl = blockElementOf(firstDom); const blockId = blockEl?.dataset.blockId; if (!blockEl || !blockId || blockId === ann.blockId) return; const stored = containerRef.current?.querySelector( `[data-block-id="${escapeAttrValue(ann.blockId)}"]`, ); const quote = compactText(ann.originalText); if (stored && quote && compactText(stored.textContent ?? '').includes(quote)) return; moved.push({ id: ann.id, blockId, startOffset: quoteOffsetIn(blockEl.textContent ?? '', ann.originalText), positionsStale }); }; anns.forEach(ann => { if (ann.type === AnnotationType.GLOBAL_COMMENT) return; // Diff-view comments (`diff-block-N`, drawn by the diff view) and // checkbox toggles (raw-markdown quotes keyed by their block) are never // text highlights. Callers filter them out; skipping them here as well // keeps a stray one from being reported unanchored or moved, which would // rewrite its blockId. if (!annotationOwnsHighlight(ann)) return; // A comment on a rendered diagram part has no text anchor: the // diagram overlay restores it against its render and reports its own // verdict, so it is neither attempted nor unanchored here (the same // rule the raw-HTML pinpoints follow on their surface). if (ann.diagramAnchor) return; // An answer to a `:::question` block quotes the prompt but is not a // selection: the question block draws it from the annotation, so it is // never painted as a highlight (and never reported as unanchored here). if (ann.questionAnswer) return; // #881: a row whose quote is only whitespace has nothing to anchor to. // Older drafts carry such rows (a double-click at a block boundary used // to create one), and the stored positions still resolve, so restoring // it paints an invisible mark on a spot the reviewer never chose. Skip // it outright — neither attempted nor unanchored, the same treatment a // diagram row gets, so a legacy draft does not raise an "Unanchored" // chip and a toast on every load. if (isBlankQuote(ann.originalText)) return; attempted.push(ann.id); // Skip if already highlighted try { const existingDoms = highlighter.getDoms(ann.id); if (existingDoms && existingDoms.length > 0) return; } catch {} const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`); if (existingManual) return; const mathTargets = findMathElementsForAnnotation(ann); const paintedMath = mathTargets.length > 0; if (paintedMath) { applyMathTargets(mathTargets, ann.id, ann.type); if (!ann.startMeta && !ann.endMeta && !ann.mathTargets?.length) { return; } } // When a meta-based restore paints text that no longer matches the // anchor's originalText (document drift), remember what it painted so // the mismatch can be reported if the text fallback also fails. let rejectedRestoreText: string | null = null; if (ann.startMeta && ann.endMeta) { try { highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id); const restoredDoms = highlighter.getDoms(ann.id); if (restoredDoms && restoredDoms.length > 0) { const restoredText = paintedTextOf(restoredDoms as HTMLElement[]); if ( verifyRestoredContent && compactText(restoredText) !== compactText(ann.originalText) ) { // Positions resolved, but onto the WRONG text — remove the bad // highlight and fall through to the text-search fallback. try { highlighter.remove(ann.id); } catch {} rejectedRestoreText = restoredText; } else { if (ann.type === AnnotationType.DELETION) { highlighter.addClass('deletion', ann.id); } else if (ann.type === AnnotationType.COMMENT) { highlighter.addClass('comment', ann.id); } if (!paintedMath) noteAnchor(ann, restoredDoms[0] as Node, false); return; } } } catch {} } const range = findTextInDOM(ann.originalText); if (!range) { if (rejectedRestoreText !== null) { onRestoreMismatchRef.current?.(ann, rejectedRestoreText); } // Nothing painted it: not the stored positions (absent, unresolvable, // or onto other text) and not the search. A formula the math path // painted above is anchored, whatever its quote search says. if (!paintedMath) { unanchored.push(ann.id); moved.push({ id: ann.id, blockId: '', positionsStale: true }); } console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`); return; } try { const textNodes: { node: Text; start: number; end: number }[] = []; // Excluded chrome is rejected by the search that produced this range, // so a run of it inside the range is not part of the quote; wrapping it // anyway would paint a list marker the reviewer never selected (and, // on the next reload, make the painted text disagree with the quote). const walker = document.createTreeWalker( range.commonAncestorContainer.nodeType === Node.TEXT_NODE ? range.commonAncestorContainer.parentNode! : range.commonAncestorContainer, NodeFilter.SHOW_TEXT, { acceptNode: (node) => isAnnotationExcludedTextNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, }, ); let node: Text | null; let inRange = false; while ((node = walker.nextNode() as Text | null)) { if (node === range.startContainer) { inRange = true; const start = range.startOffset; const end = node === range.endContainer ? range.endOffset : node.length; if (end > start) { textNodes.push({ node, start, end }); } if (node === range.endContainer) break; continue; } if (node === range.endContainer) { if (inRange) { const end = range.endOffset; if (end > 0) { textNodes.push({ node, start: 0, end }); } } break; } if (inRange && node.length > 0) { textNodes.push({ node, start: 0, end: node.length }); } } if (textNodes.length === 0) { console.warn(`No text nodes found for annotation ${ann.id}`); return; } if (!paintedMath) noteAnchor(ann, textNodes[0].node, true); textNodes.reverse().forEach(({ node, start, end }) => { try { const nodeRange = document.createRange(); nodeRange.setStart(node, start); nodeRange.setEnd(node, end); const mark = document.createElement('mark'); mark.className = 'annotation-highlight'; mark.dataset.bindId = ann.id; if (ann.type === AnnotationType.DELETION) { mark.classList.add('deletion'); } else if (ann.type === AnnotationType.COMMENT) { mark.classList.add('comment'); } nodeRange.surroundContents(mark); mark.addEventListener('click', () => { onSelectAnnotationRef.current?.(ann.id); }); } catch (e) { console.warn(`Failed to wrap text node for annotation ${ann.id}:`, e); } }); } catch (e) { console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e); } }); if (attempted.length > 0) { onRestoreReportRef.current?.({ attempted, unanchored, ...(moved.length > 0 ? { moved } : {}) }); } }, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]); const removeHighlight = useCallback((id: string) => { highlighterRef.current?.remove(id); const mathHighlights = containerRef.current?.querySelectorAll( `[data-bind-id="${id}"][data-math-annotation="true"]` ); mathHighlights?.forEach(clearMathAnnotationClass); const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`); manualHighlights?.forEach(el => { if ((el as HTMLElement).dataset.mathAnnotation === 'true') return; const parent = el.parentNode; while (el.firstChild) { parent?.insertBefore(el.firstChild, el); } el.remove(); }); }, []); const clearAllHighlights = useCallback(() => { const mathHighlights = containerRef.current?.querySelectorAll('[data-math-annotation="true"]'); mathHighlights?.forEach(clearMathAnnotationClass); const manualHighlights = containerRef.current?.querySelectorAll('[data-bind-id]'); manualHighlights?.forEach(el => { if ((el as HTMLElement).dataset.mathAnnotation === 'true') return; const parent = el.parentNode; while (el.firstChild) { parent?.insertBefore(el.firstChild, el); } el.remove(); }); const webHighlights = containerRef.current?.querySelectorAll('.annotation-highlight'); webHighlights?.forEach(el => { if ((el as HTMLElement).dataset.mathAnnotation === 'true') return; const parent = el.parentNode; while (el.firstChild) { parent?.insertBefore(el.firstChild, el); } el.remove(); }); }, []); // --- Effects --- // Initialize web-highlighter (no callback deps — reads from refs) useEffect(() => { if (!containerRef.current || !enabled) return; const highlighter = new Highlighter({ $root: containerRef.current, exceptSelectors: [ '.annotation-toolbar', 'button', '.math-annotatable', '.katex', ANNOTATION_EXCLUDED_SELECTOR, ], wrapTag: 'mark', style: { className: 'annotation-highlight' }, }); highlighterRef.current = highlighter; // Stored positions can resolve into chrome the reviewer could never have // selected; painting never enters such a subtree, so a boundary left there // silently loses every run up to it. Snap both ends onto annotatable text // before the range is built. highlighter.hooks.Serialize.Restore.tap((...args: unknown[]) => { const [, storedStart, storedEnd] = args as [unknown, RestoreBoundary, RestoreBoundary]; const root = containerRef.current; if (!root || !storedStart || !storedEnd) return [storedStart, storedEnd]; const start = snapRestoredBoundary(root, storedStart, 1); const end = snapRestoredBoundary(root, storedEnd, -1); if (start === storedStart && end === storedEnd) return [storedStart, storedEnd]; if (restoreBoundariesCross(start, end)) return [storedStart, storedEnd]; return [start, end]; }); highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => { if (type === 'from-store') return; if (sources.length > 0) { const source = sources[0]; const doms = highlighter.getDoms(source.id); if (doms?.length > 0) { // Repair the quote before anything reads it: the popover preview, // the comment draft key, and the annotation's own `originalText` all // come from `source.text`. source.text = quoteWithoutExcludedText( pendingRangeRunsRef.current, source.text, doms.map((dom: HTMLElement) => dom.textContent ?? '').join(''), ); // #881, belt to the capture-phase guard's braces: every producer of // a selection highlight lands here (the library's own pointer-end // listener, the touch selectionchange bridge, vim and pinpoint // ranges), and the quote repair above can itself leave nothing but // whitespace. A blank quote opens no toolbar, no composer and // creates no annotation; the painted-but-invisible highlight goes // with it, and the pending selection already on screen is untouched. if (isBlankQuote(source.text)) { try { highlighter.remove(source.id); } catch (error) { warnHighlighterFailure('cleanup of a blank selection', error); } pendingRangeRunsRef.current = null; window.getSelection()?.removeAllRanges(); return; } // Clean up previous pending if (pendingSourceRef.current) { highlighter.remove(pendingSourceRef.current.id); } clearPendingSelection(); pendingMathTargetsRef.current = mathTargetsFromSelection(window.getSelection(), containerRef.current); setCommentPopover(null); setQuickLabelPicker(null); const effectiveMode = pendingModeOverrideRef.current ?? modeRef.current; pendingModeOverrideRef.current = null; if (effectiveMode === 'redline') { createAnnotationFromSource(highlighter, source, AnnotationType.DELETION); window.getSelection()?.removeAllRanges(); } else if (effectiveMode === 'comment') { pendingSourceRef.current = source; setCommentPopover({ anchorEl: doms[0] as HTMLElement, contextText: source.text.slice(0, 80), selectedText: source.text, source, draftKey: commentDraftTargetKey(source, source.text), blockIds: selectionBlockIds(highlighter, source), }); } else if (effectiveMode === 'quickLabel') { pendingSourceRef.current = source; setQuickLabelPicker({ anchorEl: doms[0] as HTMLElement, cursorHint: lastMousePosRef.current, source, }); } else { // Selection mode — show toolbar pendingSourceRef.current = source; setToolbarState({ element: doms[0] as HTMLElement, source, selectionText: source.text, }); } } } }); highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => { onSelectAnnotationRef.current?.(id); }); // web-highlighter's own pointer-end handler reads the LIVE selection, so // the range it paints and quotes has to be corrected before that handler // runs: registered on the capture phase of the same element, and before // `run()` so registration order settles the at-target case too. const handlePointerEndCapture = () => { const container = containerRef.current; const selection = window.getSelection(); if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) { pendingRangeRunsRef.current = null; return; } const range = selection.getRangeAt(0).cloneRange(); // #881: fail closed on a selection there is nothing to annotate in, and // on one the library cannot serialize. Collapsing it HERE — on the // capture phase, before the library's own pointer-end listener reads the // live selection — is what makes its handler a no-op: it bails on a // collapsed selection rather than painting an empty highlight or // throwing out of an event listener we do not own. // // Ahead of the containment check on purpose: the library's handler does // not check containment either. A double-click past the end of the LAST // block leaves a selection whose common ancestor is the scroll container // AROUND the document and whose end boundary is an empty
— the // exact shape that throws — so a containment bail here would hand that // selection straight to the serializer. if (isBlankQuote(selection.toString()) || !isSerializableRange(range)) { pendingRangeRunsRef.current = null; selection.removeAllRanges(); return; } if (!container.contains(range.commonAncestorContainer)) { pendingRangeRunsRef.current = null; return; } if (snapRangeStartPastExcluded(range)) { selection.removeAllRanges(); selection.addRange(range); } pendingRangeRunsRef.current = rangeTextPieces(range); }; const container = containerRef.current; container.addEventListener('mouseup', handlePointerEndCapture, true); container.addEventListener('touchend', handlePointerEndCapture, true); // On a touch screen a host that composes takes the selection when the // person chooses its Comment (takeSelection); the library painting it at // touchend would end the system selection and its edit menu first. const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches; if (!(isTouchPrimary && hostComposesRef.current)) highlighter.run(); const handleMathMouseDown = (event: MouseEvent) => { mouseDownMathRef.current = closestMathElement(event.target as Node, containerRef.current); if (mouseDownMathRef.current) { event.stopPropagation(); } }; const handleMathMouseUp = (event: MouseEvent) => { if (!containerRef.current) return; const selection = window.getSelection(); const mathElement = closestMathElement(event.target as Node, containerRef.current) || mouseDownMathRef.current || mathElementFromSelection(selection, containerRef.current); mouseDownMathRef.current = null; if (!mathElement) return; const existingId = mathElement.dataset.bindId; const selectedText = selection?.toString().trim() ?? ''; if (!selectedText && existingId && modeRef.current !== 'redline') { event.preventDefault(); event.stopPropagation(); onSelectAnnotationRef.current?.(existingId); return; } if (selectionHasNonMathContent(selection, containerRef.current)) { return; } event.preventDefault(); event.stopPropagation(); const source = mathSourceFromElement(mathElement); if (!source) return; if (pendingSourceRef.current && !isMathAnnotationSource(pendingSourceRef.current)) { highlighter.remove(pendingSourceRef.current.id); } clearPendingSelection(); setToolbarState(null); setCommentPopover(null); setQuickLabelPicker(null); if (modeRef.current === 'redline') { createAnnotationFromMathSource(source, AnnotationType.DELETION); selection?.removeAllRanges(); return; } if (modeRef.current === 'comment') { pendingSourceRef.current = source; showPendingMathPreview(source); setCommentPopover({ anchorEl: source.element, contextText: source.text.slice(0, 80), selectedText: source.text, source, draftKey: commentDraftTargetKey(source, source.text), blockIds: [source.blockId], }); return; } if (modeRef.current === 'quickLabel') { pendingSourceRef.current = source; showPendingMathPreview(source); setQuickLabelPicker({ anchorEl: source.element, cursorHint: lastMousePosRef.current, source, }); return; } pendingSourceRef.current = source; showPendingMathPreview(source); setToolbarState({ element: source.element, source, selectionText: source.text, }); }; containerRef.current.addEventListener('mousedown', handleMathMouseDown, true); containerRef.current.addEventListener('mouseup', handleMathMouseUp, true); // Mobile bridge let selectionTimer: ReturnType; // The page's selection, painted as a pending highlight; false when there is none to take. const takeTouchSelection = (): boolean => { const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false; if (!containerRef.current?.contains(sel.anchorNode)) return false; const range = sel.getRangeAt(0).cloneRange(); // Same fail-closed rule as the pointer-end guard (#881): touch // selections settle on block boundaries just as often. if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return false; snapRangeStartPastExcluded(range); pendingRangeRunsRef.current = rangeTextPieces(range); try { highlighter.fromRange(range); return true; } catch (error) { pendingRangeRunsRef.current = null; warnHighlighterFailure('painting a touch selection', error); return false; } }; takeTouchSelectionRef.current = takeTouchSelection; const handleSelectionChange = isTouchPrimary ? () => { clearTimeout(selectionTimer); if (hostComposesRef.current) return; selectionTimer = setTimeout(takeTouchSelection, 400); } : null; if (handleSelectionChange) { document.addEventListener('selectionchange', handleSelectionChange); } return () => { if (handleSelectionChange) { clearTimeout(selectionTimer); document.removeEventListener('selectionchange', handleSelectionChange); } containerRef.current?.removeEventListener('mousedown', handleMathMouseDown, true); containerRef.current?.removeEventListener('mouseup', handleMathMouseUp, true); container.removeEventListener('mouseup', handlePointerEndCapture, true); container.removeEventListener('touchend', handlePointerEndCapture, true); highlighter.dispose(); }; }, [clearPendingSelection, enabled]); const highlightRange = useCallback((range: Range, modeOverride?: EditorMode) => { const highlighter = highlighterRef.current; const container = containerRef.current; if (!highlighter || !container || range.collapsed) return; if (!container.contains(range.commonAncestorContainer)) return; // A caller-built range (vim, pinpoint) can name a boundary the library // cannot serialize just as a pointer selection can (#881); refuse it // rather than let the serializer throw through this call. if (!isSerializableRange(range)) return; // Pinpoint clicks and vim visual selections anchor on the first // annotatable text node of a block, which for a titled GitHub alert is the // visually hidden type word; snap off it before painting (the caller's own // range is left untouched). const painted = range.cloneRange(); snapRangeStartPastExcluded(painted); pendingRangeRunsRef.current = rangeTextPieces(painted); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(painted.cloneRange()); pendingModeOverrideRef.current = modeOverride ?? null; try { highlighter.fromRange(painted); } catch (error) { pendingRangeRunsRef.current = null; warnHighlighterFailure('painting a range', error); } finally { pendingModeOverrideRef.current = null; selection?.removeAllRanges(); } }, [containerRef]); const highlightMathElement = useCallback(( element: HTMLElement, modeOverride?: EditorMode, ) => { const container = containerRef.current; const mathElement = closestMathElement(element, container); if (!container || !mathElement) return; const effectiveMode = modeOverride ?? modeRef.current; const existingId = mathElement.dataset.bindId; if (existingId && effectiveMode !== 'redline') { onSelectAnnotationRef.current?.(existingId); return; } const source = mathSourceFromElement(mathElement); if (!source) return; const highlighter = highlighterRef.current; if (pendingSourceRef.current && highlighter && !isMathAnnotationSource(pendingSourceRef.current)) { highlighter.remove(pendingSourceRef.current.id); } clearPendingSelection(); setToolbarState(null); setCommentPopover(null); setQuickLabelPicker(null); if (effectiveMode === 'redline') { createAnnotationFromMathSource(source, AnnotationType.DELETION); return; } pendingSourceRef.current = source; showPendingMathPreview(source); if (effectiveMode === 'comment') { setCommentPopover({ anchorEl: source.element, contextText: source.text.slice(0, 80), selectedText: source.text, source, draftKey: commentDraftTargetKey(source, source.text), blockIds: [source.blockId], }); return; } if (effectiveMode === 'quickLabel') { setQuickLabelPicker({ anchorEl: source.element, cursorHint: lastMousePosRef.current, source, }); return; } setToolbarState({ element: source.element, source, selectionText: source.text, }); }, [clearPendingSelection, containerRef, showPendingMathPreview]); // Apply CSS classes to existing annotations useEffect(() => { const highlighter = highlighterRef.current; if (!highlighter) return; annotations.forEach(ann => { try { const doms = highlighter.getDoms(ann.id); if (doms && doms.length > 0) { if (ann.type === AnnotationType.DELETION) { highlighter.addClass('deletion', ann.id); } else if (ann.type === AnnotationType.COMMENT) { highlighter.addClass('comment', ann.id); } } } catch {} }); }, [annotations]); // Scroll to selected annotation useEffect(() => { if (!containerRef.current) return; // Clear all previously focused highlights containerRef.current.querySelectorAll('.annotation-highlight.focused').forEach(el => { el.classList.remove('focused'); }); if (!selectedAnnotationId) return; // Skip scroll if we just created this annotation if (justCreatedIdRef.current === selectedAnnotationId) { justCreatedIdRef.current = null; return; } const highlighter = highlighterRef.current; let targetElements: Element[] = []; if (highlighter) { try { const doms = highlighter.getDoms(selectedAnnotationId); if (doms && doms.length > 0) targetElements = Array.from(doms); } catch {} } if (targetElements.length === 0) { const manualMarks = containerRef.current.querySelectorAll( `[data-bind-id="${selectedAnnotationId}"]` ); if (manualMarks.length > 0) targetElements = Array.from(manualMarks); } if (targetElements.length === 0) return; targetElements.forEach(el => el.classList.add('focused')); targetElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); const timer = setTimeout(() => { targetElements.forEach(el => el.classList.remove('focused')); }, 2000); return () => clearTimeout(timer); }, [selectedAnnotationId]); // --- Handlers --- const handleAnnotate = (type: AnnotationType) => { const highlighter = highlighterRef.current; if (!toolbarState) return; if (isMathAnnotationSource(toolbarState.source)) { createAnnotationFromMathSource(toolbarState.source, type); clearPendingSelection(); setToolbarState(null); window.getSelection()?.removeAllRanges(); return; } if (!highlighter) return; createAnnotationFromSource(highlighter, toolbarState.source, type); clearPendingSelection(); setToolbarState(null); window.getSelection()?.removeAllRanges(); }; const handleQuickLabel = (label: QuickLabel) => { const highlighter = highlighterRef.current; if (!toolbarState) return; if (isMathAnnotationSource(toolbarState.source)) { createAnnotationFromMathSource( toolbarState.source, AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip ); clearPendingSelection(); setToolbarState(null); window.getSelection()?.removeAllRanges(); return; } if (!highlighter) return; createAnnotationFromSource( highlighter, toolbarState.source, AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip ); clearPendingSelection(); setToolbarState(null); window.getSelection()?.removeAllRanges(); }; const handleToolbarClose = () => { if (toolbarState && highlighterRef.current && !isMathAnnotationSource(toolbarState.source)) { highlighterRef.current.remove(toolbarState.source.id); } clearPendingSelection(); setToolbarState(null); window.getSelection()?.removeAllRanges(); }; const handleRequestComment = (initialChar?: string) => { if (!toolbarState) return; setCommentPopover({ anchorEl: toolbarState.element, contextText: toolbarState.selectionText.slice(0, 80), selectedText: toolbarState.selectionText, initialText: initialChar, source: toolbarState.source, draftKey: commentDraftTargetKey(toolbarState.source, toolbarState.selectionText), blockIds: selectionBlockIds(highlighterRef.current, toolbarState.source), }); setToolbarState(null); }; const handleCommentSubmit = ( text: string, images?: ImageAttachment[], mentions?: readonly string[], ) => { if (!commentPopover) return; if (isMathAnnotationSource(commentPopover.source)) { createAnnotationFromMathSource( commentPopover.source, AnnotationType.COMMENT, text, images, undefined, undefined, mentions, ); clearPendingSelection(); window.getSelection()?.removeAllRanges(); setCommentPopover(null); return; } if (commentPopover.source && highlighterRef.current) { createAnnotationFromSource( highlighterRef.current, commentPopover.source, AnnotationType.COMMENT, text, images, undefined, undefined, mentions ); clearPendingSelection(); window.getSelection()?.removeAllRanges(); } setCommentPopover(null); }; const handleCommentClose = useCallback(() => { setCommentPopover(prev => { if (prev?.source && highlighterRef.current && !isMathAnnotationSource(prev.source)) { highlighterRef.current.remove(prev.source.id); } return null; }); clearPendingSelection(); window.getSelection()?.removeAllRanges(); }, [clearPendingSelection]); const handleFloatingQuickLabel = useCallback((label: QuickLabel) => { if (!quickLabelPicker?.source) return; if (isMathAnnotationSource(quickLabelPicker.source)) { createAnnotationFromMathSource( quickLabelPicker.source, AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip, ); clearPendingSelection(); setQuickLabelPicker(null); window.getSelection()?.removeAllRanges(); return; } if (!highlighterRef.current) return; createAnnotationFromSource( highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip ); clearPendingSelection(); setQuickLabelPicker(null); window.getSelection()?.removeAllRanges(); }, [clearPendingSelection, quickLabelPicker]); const handleQuickLabelPickerDismiss = useCallback(() => { if ( quickLabelPicker?.source && highlighterRef.current && !isMathAnnotationSource(quickLabelPicker.source) ) { highlighterRef.current.remove(quickLabelPicker.source.id); } clearPendingSelection(); setQuickLabelPicker(null); window.getSelection()?.removeAllRanges(); }, [clearPendingSelection, quickLabelPicker]); // --- The composer handed to the host (onHostDraft) --- // The toolbar and composer states still drive everything here; the host is // told about them instead of seeing them drawn. A math source has no id of // its own until it is committed, so its draft id is minted once per source. const onHostDraftRef = useRef(onHostDraft); onHostDraftRef.current = onHostDraft; const hostDraftIdsRef = useRef(new WeakMap()); const hostDraftOn = onHostDraft !== undefined; useEffect(() => { const report = onHostDraftRef.current; if (!report) return; const open = commentPopover ?? toolbarState; const source = open?.source; const highlighter = highlighterRef.current; if (!source || !highlighter) { report(null); return; } let annotation: Annotation | null; if (isMathAnnotationSource(source)) { let id = hostDraftIdsRef.current.get(source); if (!id) { id = annotationId(); hostDraftIdsRef.current.set(source, id); } annotation = { id, blockId: source.blockId, startOffset: 0, endOffset: source.text.length, type: AnnotationType.COMMENT, text: '', originalText: source.text, createdA: Date.now(), author: getIdentity(), }; } else { annotation = buildAnnotationFromSource(highlighter, source, AnnotationType.COMMENT, ''); } if (!annotation) { report(null); return; } report({ annotation, intent: commentPopover ? 'compose' : 'selection', cancel: () => { // A newer selection owns the pending state now: leave it alone. if (pendingSourceRef.current !== source) return; if (!isMathAnnotationSource(source)) highlighterRef.current?.remove(source.id); clearPendingSelection(); setToolbarState(null); setCommentPopover(null); window.getSelection()?.removeAllRanges(); }, }); // buildAnnotationFromSource reads refs only; the states are the inputs. // eslint-disable-next-line react-hooks/exhaustive-deps }, [commentPopover, toolbarState, hostDraftOn, clearPendingSelection]); return { highlighterRef, toolbarState, commentPopover, quickLabelPicker, handleAnnotate, handleQuickLabel, handleToolbarClose, handleRequestComment, handleCommentSubmit, handleCommentClose, handleFloatingQuickLabel, handleQuickLabelPickerDismiss, highlightRange, highlightMathElement, removeHighlight, clearAllHighlights, applyAnnotations: applyAnnotationsInternal, takeSelection, }; }