refactor(highlights): split highlighting utilities into modules

- Create textMatching module for text search utilities
- Create domUtils module for DOM manipulation helpers
- Create htmlMatching module for HTML highlight application
- Reduce highlightMatching.tsx from 217 lines to 59 lines
- All files now under 210 lines
This commit is contained in:
Gigi
2025-10-07 21:54:41 +01:00
parent 9ae918f744
commit 59ecc29b9c
4 changed files with 195 additions and 163 deletions

View File

@@ -1,46 +1,11 @@
import React from 'react'
import { Highlight } from '../types/highlights'
export interface HighlightMatch {
highlight: Highlight
startIndex: number
endIndex: number
}
export type { HighlightMatch } from './highlightMatching/textMatching'
export { findHighlightMatches } from './highlightMatching/textMatching'
export { applyHighlightsToHTML } from './highlightMatching/htmlMatching'
/**
* 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)
}
import { findHighlightMatches as _findHighlightMatches } from './highlightMatching/textMatching'
/**
* Apply highlights to text content by wrapping matched text in span elements
@@ -49,7 +14,7 @@ export function applyHighlightsToText(
text: string,
highlights: Highlight[]
): React.ReactNode {
const matches = findHighlightMatches(text, highlights)
const matches = _findHighlightMatches(text, highlights)
if (matches.length === 0) {
return text
@@ -61,17 +26,14 @@ export function applyHighlightsToText(
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(
@@ -89,129 +51,9 @@ export function applyHighlightsToText(
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
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) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', { htmlLength: html?.length, highlightsCount: highlights.length })
return html
}
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
let appliedCount = 0
for (const highlight of highlights) {
const searchText = highlight.content.trim()
if (!searchText) {
console.warn('⚠️ Empty highlight content:', highlight.id)
continue
}
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// 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)
console.log('📄 Found', textNodes.length, 'text nodes to search')
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
}
}
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML
}

View File

@@ -0,0 +1,84 @@
import { Highlight } from '../../types/highlights'
import { normalizeWhitespace } from './textMatching'
/**
* Create a mark element for a highlight
*/
export 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
}
/**
* Replace text node with mark element
*/
export function replaceTextWithMark(
textNode: Text,
before: string,
after: string,
mark: HTMLElement
): void {
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)
}
}
/**
* Try to find and mark text in text nodes
*/
export 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
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
}

View File

@@ -0,0 +1,60 @@
import { Highlight } from '../../types/highlights'
import { tryMarkInTextNodes } from './domUtils'
/**
* 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) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', {
htmlLength: html?.length,
highlightsCount: highlights.length
})
return html
}
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
let appliedCount = 0
for (const highlight of highlights) {
const searchText = highlight.content.trim()
if (!searchText) {
console.warn('⚠️ Empty highlight content:', highlight.id)
continue
}
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// 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)
console.log('📄 Found', textNodes.length, 'text nodes to search')
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
}
}
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML
}

View File

@@ -0,0 +1,46 @@
import { Highlight } from '../../types/highlights'
export interface HighlightMatch {
highlight: Highlight
startIndex: number
endIndex: number
}
/**
* Normalize whitespace for flexible matching
*/
export const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
/**
* 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
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)
}
}
return matches.sort((a, b) => a.startIndex - b.startIndex)
}