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);