fix: text highlight logic
This commit is contained in:
+68
-29
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user