diff options
Diffstat (limited to 'apps/web/lib/highlight-positioning.ts')
| -rw-r--r-- | apps/web/lib/highlight-positioning.ts | 258 |
1 files changed, 258 insertions, 0 deletions
diff --git a/apps/web/lib/highlight-positioning.ts b/apps/web/lib/highlight-positioning.ts new file mode 100644 index 0000000..4c4c068 --- /dev/null +++ b/apps/web/lib/highlight-positioning.ts @@ -0,0 +1,258 @@ +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() + } +} |