From 59ecc29b9cd3bcfbc2ca2744fe88fdf492288487 Mon Sep 17 00:00:00 2001 From: Gigi Date: Tue, 7 Oct 2025 21:54:41 +0100 Subject: [PATCH] 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 --- src/utils/highlightMatching.tsx | 168 +------------------- src/utils/highlightMatching/domUtils.ts | 84 ++++++++++ src/utils/highlightMatching/htmlMatching.ts | 60 +++++++ src/utils/highlightMatching/textMatching.ts | 46 ++++++ 4 files changed, 195 insertions(+), 163 deletions(-) create mode 100644 src/utils/highlightMatching/domUtils.ts create mode 100644 src/utils/highlightMatching/htmlMatching.ts create mode 100644 src/utils/highlightMatching/textMatching.ts diff --git a/src/utils/highlightMatching.tsx b/src/utils/highlightMatching.tsx index 15451a24..848f1c6d 100644 --- a/src/utils/highlightMatching.tsx +++ b/src/utils/highlightMatching.tsx @@ -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 -} diff --git a/src/utils/highlightMatching/domUtils.ts b/src/utils/highlightMatching/domUtils.ts new file mode 100644 index 00000000..44db8283 --- /dev/null +++ b/src/utils/highlightMatching/domUtils.ts @@ -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 +} + diff --git a/src/utils/highlightMatching/htmlMatching.ts b/src/utils/highlightMatching/htmlMatching.ts new file mode 100644 index 00000000..467d6d79 --- /dev/null +++ b/src/utils/highlightMatching/htmlMatching.ts @@ -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 +} + diff --git a/src/utils/highlightMatching/textMatching.ts b/src/utils/highlightMatching/textMatching.ts new file mode 100644 index 00000000..4e1d809d --- /dev/null +++ b/src/utils/highlightMatching/textMatching.ts @@ -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) +} +