import React from 'react' import { Highlight } from '../types/highlights' export interface HighlightMatch { highlight: Highlight startIndex: number endIndex: number } /** * Find all occurrences of highlight text in the content */ export function findHighlightMatches( content: string, highlights: Highlight[] ): HighlightMatch[] { const matches: HighlightMatch[] = [] for (const highlight of highlights) { if (!highlight.content || highlight.content.trim().length === 0) { continue } const searchText = highlight.content.trim() let startIndex = 0 // Find all occurrences of this highlight in the content let index = content.indexOf(searchText, startIndex) while (index !== -1) { matches.push({ highlight, startIndex: index, endIndex: index + searchText.length }) startIndex = index + searchText.length index = content.indexOf(searchText, startIndex) } } // Sort by start index return matches.sort((a, b) => a.startIndex - b.startIndex) } /** * Apply highlights to text content by wrapping matched text in span elements */ export function applyHighlightsToText( text: string, highlights: Highlight[] ): React.ReactNode { const matches = findHighlightMatches(text, highlights) if (matches.length === 0) { return text } const result: React.ReactNode[] = [] let lastIndex = 0 for (let i = 0; i < matches.length; i++) { const match = matches[i] // Skip overlapping highlights (keep the first one) if (match.startIndex < lastIndex) { continue } // Add text before the highlight if (match.startIndex > lastIndex) { result.push(text.substring(lastIndex, match.startIndex)) } // Add the highlighted text const highlightedText = text.substring(match.startIndex, match.endIndex) const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : '' result.push( {highlightedText} ) lastIndex = match.endIndex } // Add remaining text if (lastIndex < text.length) { result.push(text.substring(lastIndex)) } return <>{result} } // Helper to normalize whitespace for flexible matching const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim() // Helper to create a mark element for a highlight function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement { const mark = document.createElement('mark') const levelClass = highlight.level ? ` level-${highlight.level}` : '' mark.className = `content-highlight-${highlightStyle}${levelClass}` mark.setAttribute('data-highlight-id', highlight.id) mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse') mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`) mark.textContent = matchText return mark } // Helper to replace text node with mark element function replaceTextWithMark(textNode: Text, before: string, after: string, mark: HTMLElement) { const parent = textNode.parentNode if (!parent) return if (before) parent.insertBefore(document.createTextNode(before), textNode) parent.insertBefore(mark, textNode) if (after) { textNode.textContent = after } else { parent.removeChild(textNode) } } // Helper to find and mark text in nodes function tryMarkInTextNodes( textNodes: Text[], searchText: string, highlight: Highlight, useNormalized: boolean, highlightStyle: 'marker' | 'underline' = 'marker' ): boolean { const normalizedSearch = normalizeWhitespace(searchText) for (const textNode of textNodes) { const text = textNode.textContent || '' const searchIn = useNormalized ? normalizeWhitespace(text) : text const searchFor = useNormalized ? normalizedSearch : searchText const index = searchIn.indexOf(searchFor) if (index === -1) continue console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50)) let actualIndex = index if (useNormalized) { // Map normalized index back to original text let normalizedIdx = 0 for (let i = 0; i < text.length && normalizedIdx < index; i++) { if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++ actualIndex = i + 1 } } const before = text.substring(0, actualIndex) const match = text.substring(actualIndex, actualIndex + searchText.length) const after = text.substring(actualIndex + searchText.length) const mark = createMarkElement(highlight, match, highlightStyle) replaceTextWithMark(textNode, before, after, mark) return true } return false } /** * Apply highlights to HTML content by injecting mark tags using DOM manipulation */ export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string { if (!html || highlights.length === 0) return html const tempDiv = document.createElement('div') tempDiv.innerHTML = html for (const highlight of highlights) { const searchText = highlight.content.trim() if (!searchText) continue // Collect all text nodes const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null) const textNodes: Text[] = [] let node: Node | null while ((node = walker.nextNode())) textNodes.push(node as Text) // Try exact match first, then normalized match tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) || tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle) } return tempDiv.innerHTML }