From d7cd1cb4bd3f05d3b0f1852fe0362700d8005001 Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 7 May 2026 19:13:56 +0530 Subject: [PATCH] fix: text highlight logic --- lib/text-highlight.ts | 97 ++++++++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts index 18e7687e..7c2d6d2d 100644 --- a/lib/text-highlight.ts +++ b/lib/text-highlight.ts @@ -103,11 +103,11 @@ export function extractMatchedText( const expectedEnd = idx + fragment.length; const suffixSearchStart = Math.max( idx, - expectedEnd - normalizedSuffix.length - 20, + expectedEnd - normalizedSuffix.length - 50, ); const suffixSearchEnd = Math.min( normalizedLower.length, - expectedEnd + 20, + expectedEnd + 50, ); const searchWindow = normalizedLower.slice( suffixSearchStart, @@ -175,44 +175,76 @@ export function findAndHighlightText(fragment: { } }); - // Build a concatenated string of all text content with node boundaries. - // Insert a space between text nodes from different block-level parents - // so that "BACK-TO-GODHEAD" in
1
and "AN INSTRUMENT" in
2
- // become "BACK-TO-GODHEAD AN INSTRUMENT" instead of "BACK-TO-GODHEADAN INSTRUMENT". + // Build a concatenated string of all text content. + // We keep two parallel strings: + // rawText — exact concatenation of text nodes (for accurate segment mapping) + // normText — with spaces inserted at element boundaries (for matching) + // And a posMap that maps each normText index back to a rawText index. const segments: { node: Text; start: number; end: number }[] = []; - let fullText = ""; + let rawText = ""; + let normText = ""; + const posMap: number[] = []; // posMap[normIdx] = rawIdx let prevParent: Element | null = null; + for (const node of allTextNodes) { const text = node.textContent || ""; const parent = node.parentElement; - // Add a space separator when crossing into a different block-level parent + // Insert a space in normText when crossing element boundaries + // (so cross-paragraph searches work), but NOT in rawText + // (so segment offsets stay accurate for DOM highlighting). if ( prevParent && parent !== prevParent && - fullText.length > 0 && - !fullText.endsWith(" ") + normText.length > 0 && + !normText.endsWith(" ") && + !text.startsWith(" ") ) { - fullText += " "; + posMap.push(rawText.length); // the synthetic space maps to current raw position + normText += " "; } prevParent = parent; + const segStart = rawText.length; segments.push({ node, - start: fullText.length, - end: fullText.length + text.length, + start: segStart, + end: segStart + text.length, }); - fullText += text; + + for (let i = 0; i < text.length; i++) { + posMap.push(rawText.length + i); + normText += text[i]; + } + rawText += text; } - // Normalize whitespace for matching - const normalizedFull = fullText.replace(/\s+/g, " "); - const normalizedFullLower = normalizedFull.toLowerCase(); + // Collapse whitespace in normText for matching, rebuilding posMap + let collapsedNorm = ""; + const collapsedPosMap: number[] = []; + let lastWasSpace = false; + for (let i = 0; i < normText.length; i++) { + const ch = normText[i]; + const isSpace = /\s/.test(ch); + if (isSpace) { + if (!lastWasSpace) { + collapsedPosMap.push(posMap[i]); + collapsedNorm += " "; + } + lastWasSpace = true; + } else { + collapsedPosMap.push(posMap[i]); + collapsedNorm += ch; + lastWasSpace = false; + } + } + + const normalizedFullLower = collapsedNorm.toLowerCase(); // Search for the prefix in the full text (case-insensitive) const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase(); let searchFrom = 0; - let matchStart = -1; + let matchStartNorm = -1; while (searchFrom < normalizedFullLower.length) { const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom); @@ -223,39 +255,46 @@ export function findAndHighlightText(fragment: { const normalizedSuffix = fragment.suffix .replace(/\s+/g, " ") .toLowerCase(); - const expectedEnd = idx + fragment.length; + // Search for suffix near the expected end position in the normalized string. + // Use a wider tolerance window (±50) since normalization can shift positions. + const expectedEndNorm = idx + fragment.length; const suffixSearchStart = Math.max( idx, - expectedEnd - normalizedSuffix.length - 20, + expectedEndNorm - normalizedSuffix.length - 50, ); const suffixSearchEnd = Math.min( normalizedFullLower.length, - expectedEnd + 20, + expectedEndNorm + 50, ); const searchWindow = normalizedFullLower.slice( suffixSearchStart, suffixSearchEnd, ); if (searchWindow.includes(normalizedSuffix)) { - matchStart = idx; + matchStartNorm = idx; break; } } else { - // No suffix — just match prefix + approximate length - matchStart = idx; + matchStartNorm = idx; break; } searchFrom = idx + 1; } - if (matchStart === -1) return false; + if (matchStartNorm === -1) return false; - // Slice from original (not lowercased) to preserve original casing in highlight - const matchEnd = Math.min( - matchStart + fragment.length, - normalizedFull.length, + // Map normalized match range back to raw text positions via posMap + const matchEndNorm = Math.min( + matchStartNorm + fragment.length, + collapsedNorm.length, ); + const matchStart = collapsedPosMap[matchStartNorm] ?? 0; + const matchEnd = + matchEndNorm < collapsedPosMap.length + ? collapsedPosMap[matchEndNorm] + : rawText.length; + // Map the match range back to DOM text nodes and wrap with for (const seg of segments) { const overlapStart = Math.max(matchStart, seg.start);