fix: text highlight logic

This commit is contained in:
gosangam
2026-05-07 19:13:56 +05:30
parent 6e9fa4ee25
commit d7cd1cb4bd
+68 -29
View File
@@ -103,11 +103,11 @@ export function extractMatchedText(
const expectedEnd = idx + fragment.length; const expectedEnd = idx + fragment.length;
const suffixSearchStart = Math.max( const suffixSearchStart = Math.max(
idx, idx,
expectedEnd - normalizedSuffix.length - 20, expectedEnd - normalizedSuffix.length - 50,
); );
const suffixSearchEnd = Math.min( const suffixSearchEnd = Math.min(
normalizedLower.length, normalizedLower.length,
expectedEnd + 20, expectedEnd + 50,
); );
const searchWindow = normalizedLower.slice( const searchWindow = normalizedLower.slice(
suffixSearchStart, suffixSearchStart,
@@ -175,44 +175,76 @@ export function findAndHighlightText(fragment: {
} }
}); });
// Build a concatenated string of all text content with node boundaries. // Build a concatenated string of all text content.
// Insert a space between text nodes from different block-level parents // We keep two parallel strings:
// so that "BACK-TO-GODHEAD" in <div>1</div> and "AN INSTRUMENT" in <div>2</div> // rawText — exact concatenation of text nodes (for accurate segment mapping)
// become "BACK-TO-GODHEAD AN INSTRUMENT" instead of "BACK-TO-GODHEADAN INSTRUMENT". // 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 }[] = []; 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; let prevParent: Element | null = null;
for (const node of allTextNodes) { for (const node of allTextNodes) {
const text = node.textContent || ""; const text = node.textContent || "";
const parent = node.parentElement; 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 ( if (
prevParent && prevParent &&
parent !== prevParent && parent !== prevParent &&
fullText.length > 0 && normText.length > 0 &&
!fullText.endsWith(" ") !normText.endsWith(" ") &&
!text.startsWith(" ")
) { ) {
fullText += " "; posMap.push(rawText.length); // the synthetic space maps to current raw position
normText += " ";
} }
prevParent = parent; prevParent = parent;
const segStart = rawText.length;
segments.push({ segments.push({
node, node,
start: fullText.length, start: segStart,
end: fullText.length + text.length, 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 // Collapse whitespace in normText for matching, rebuilding posMap
const normalizedFull = fullText.replace(/\s+/g, " "); let collapsedNorm = "";
const normalizedFullLower = normalizedFull.toLowerCase(); 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) // Search for the prefix in the full text (case-insensitive)
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase(); const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ").toLowerCase();
let searchFrom = 0; let searchFrom = 0;
let matchStart = -1; let matchStartNorm = -1;
while (searchFrom < normalizedFullLower.length) { while (searchFrom < normalizedFullLower.length) {
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom); const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
@@ -223,39 +255,46 @@ export function findAndHighlightText(fragment: {
const normalizedSuffix = fragment.suffix const normalizedSuffix = fragment.suffix
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.toLowerCase(); .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( const suffixSearchStart = Math.max(
idx, idx,
expectedEnd - normalizedSuffix.length - 20, expectedEndNorm - normalizedSuffix.length - 50,
); );
const suffixSearchEnd = Math.min( const suffixSearchEnd = Math.min(
normalizedFullLower.length, normalizedFullLower.length,
expectedEnd + 20, expectedEndNorm + 50,
); );
const searchWindow = normalizedFullLower.slice( const searchWindow = normalizedFullLower.slice(
suffixSearchStart, suffixSearchStart,
suffixSearchEnd, suffixSearchEnd,
); );
if (searchWindow.includes(normalizedSuffix)) { if (searchWindow.includes(normalizedSuffix)) {
matchStart = idx; matchStartNorm = idx;
break; break;
} }
} else { } else {
// No suffix — just match prefix + approximate length matchStartNorm = idx;
matchStart = idx;
break; break;
} }
searchFrom = idx + 1; searchFrom = idx + 1;
} }
if (matchStart === -1) return false; if (matchStartNorm === -1) return false;
// Slice from original (not lowercased) to preserve original casing in highlight // Map normalized match range back to raw text positions via posMap
const matchEnd = Math.min( const matchEndNorm = Math.min(
matchStart + fragment.length, matchStartNorm + fragment.length,
normalizedFull.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 <mark> // Map the match range back to DOM text nodes and wrap with <mark>
for (const seg of segments) { for (const seg of segments) {
const overlapStart = Math.max(matchStart, seg.start); const overlapStart = Math.max(matchStart, seg.start);