summaryrefslogtreecommitdiff
path: root/apps/web/lib/highlight-positioning.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/lib/highlight-positioning.ts')
-rw-r--r--apps/web/lib/highlight-positioning.ts258
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()
+ }
+}