interface SerializedHighlightRange { highlightedText: string textOffset: number textLength: number textPrefix: string textSuffix: string } function collectTextContent(containerElement: HTMLElement): string { const treeWalker = document.createTreeWalker( containerElement, NodeFilter.SHOW_TEXT ) let fullText = "" while (treeWalker.nextNode()) { fullText += treeWalker.currentNode.textContent ?? "" } return fullText } function computeAbsoluteTextOffset( containerElement: HTMLElement, targetNode: Node, targetOffset: number ): number { const treeWalker = document.createTreeWalker( containerElement, NodeFilter.SHOW_TEXT ) let absoluteOffset = 0 while (treeWalker.nextNode()) { if (treeWalker.currentNode === targetNode) { return absoluteOffset + targetOffset } absoluteOffset += (treeWalker.currentNode.textContent ?? "").length } return absoluteOffset + targetOffset } function findTextNodeAtOffset( containerElement: HTMLElement, targetOffset: number ): { node: Text; offset: number } | null { const treeWalker = document.createTreeWalker( containerElement, NodeFilter.SHOW_TEXT ) let currentOffset = 0 while (treeWalker.nextNode()) { const textNode = treeWalker.currentNode as Text const nodeLength = (textNode.textContent ?? "").length if (currentOffset + nodeLength >= targetOffset) { return { node: textNode, offset: targetOffset - currentOffset } } currentOffset += nodeLength } return null } export function serializeSelectionRange( containerElement: HTMLElement, selectionRange: Range ): SerializedHighlightRange | null { const selectedText = selectionRange.toString() if (!selectedText.trim()) return null if (!containerElement.contains(selectionRange.startContainer)) return null const fullText = collectTextContent(containerElement) const textOffset = computeAbsoluteTextOffset( containerElement, selectionRange.startContainer, selectionRange.startOffset ) const textLength = selectedText.length const prefixStart = Math.max(0, textOffset - 50) const textPrefix = fullText.slice(prefixStart, textOffset) const textSuffix = fullText.slice( textOffset + textLength, textOffset + textLength + 50 ) return { highlightedText: selectedText, textOffset, textLength, textPrefix, textSuffix, } } export function deserializeHighlightRange( containerElement: HTMLElement, highlight: SerializedHighlightRange ): Range | null { const fullText = collectTextContent(containerElement) let matchOffset = -1 const candidateText = fullText.slice( highlight.textOffset, highlight.textOffset + highlight.textLength ) if (candidateText === highlight.highlightedText) { matchOffset = highlight.textOffset } if (matchOffset === -1) { const searchStart = Math.max(0, highlight.textOffset - 100) const searchEnd = Math.min( fullText.length, highlight.textOffset + highlight.textLength + 100 ) const searchWindow = fullText.slice(searchStart, searchEnd) const foundIndex = searchWindow.indexOf(highlight.highlightedText) if (foundIndex !== -1) { matchOffset = searchStart + foundIndex } } if (matchOffset === -1) { const globalIndex = fullText.indexOf(highlight.highlightedText) if (globalIndex !== -1) { matchOffset = globalIndex } } if (matchOffset === -1) return null const startPosition = findTextNodeAtOffset(containerElement, matchOffset) const endPosition = findTextNodeAtOffset( containerElement, matchOffset + highlight.textLength ) if (!startPosition || !endPosition) return null const highlightRange = document.createRange() highlightRange.setStart(startPosition.node, startPosition.offset) highlightRange.setEnd(endPosition.node, endPosition.offset) return highlightRange } interface TextNodeSegment { node: Text startOffset: number endOffset: number } function collectTextNodesInRange(range: Range): TextNodeSegment[] { const segments: TextNodeSegment[] = [] if ( range.startContainer === range.endContainer && range.startContainer.nodeType === Node.TEXT_NODE ) { segments.push({ node: range.startContainer as Text, startOffset: range.startOffset, endOffset: range.endOffset, }) return segments } const ancestor = range.commonAncestorContainer const walkRoot = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode! : ancestor const treeWalker = document.createTreeWalker( walkRoot, NodeFilter.SHOW_TEXT ) let foundStart = false while (treeWalker.nextNode()) { const textNode = treeWalker.currentNode as Text if (textNode === range.startContainer) { foundStart = true segments.push({ node: textNode, startOffset: range.startOffset, endOffset: (textNode.textContent ?? "").length, }) } else if (textNode === range.endContainer) { segments.push({ node: textNode, startOffset: 0, endOffset: range.endOffset, }) break } else if (foundStart) { segments.push({ node: textNode, startOffset: 0, endOffset: (textNode.textContent ?? "").length, }) } } return segments } export function applyHighlightToRange( highlightRange: Range, highlightIdentifier: string, color: string, hasNote: boolean ): void { const segments = collectTextNodesInRange(highlightRange) if (segments.length === 0) return for (const segment of segments) { let targetNode = segment.node if (segment.endOffset < (targetNode.textContent ?? "").length) { targetNode.splitText(segment.endOffset) } if (segment.startOffset > 0) { targetNode = targetNode.splitText(segment.startOffset) } const markElement = document.createElement("mark") markElement.setAttribute("data-highlight-identifier", highlightIdentifier) markElement.setAttribute("data-highlight-color", color) if (hasNote) { markElement.setAttribute("data-has-note", "true") } targetNode.parentNode!.insertBefore(markElement, targetNode) markElement.appendChild(targetNode) } } export function removeHighlightFromDom( containerElement: HTMLElement, highlightIdentifier: string ): void { const markElements = containerElement.querySelectorAll( `mark[data-highlight-identifier="${highlightIdentifier}"]` ) for (const markElement of markElements) { const parentNode = markElement.parentNode if (!parentNode) continue while (markElement.firstChild) { parentNode.insertBefore(markElement.firstChild, markElement) } parentNode.removeChild(markElement) parentNode.normalize() } }