Highlight only the phrase when the full string matches

This commit is contained in:
dtonon 2026-08-13 20:57:54 +01:00
parent 81fde4c932
commit dadba06f8c
2 changed files with 24 additions and 22 deletions

View file

@ -10,10 +10,11 @@ function escapeRe(s: string): string {
// Highlight the query inside the given roots using the CSS Custom Highlight // Highlight the query inside the given roots using the CSS Custom Highlight
// API, which paints ranges without touching the DOM, so it never conflicts // API, which paints ranges without touching the DOM, so it never conflicts
// with Svelte re-renders. Where the terms appear contiguously as the whole // with Svelte re-renders. When the whole query appears contiguously anywhere
// query, one range covers the phrase; individual terms are only marked // on the page, only those phrase occurrences are painted; per-word marks are
// outside those. Returns the first match in document order (for scrolling), // a fallback for pages that matched on scattered terms. Returns the first
// or null. No-op on unsupported browsers. // match in document order (for scrolling), or null. No-op on unsupported
// browsers.
export function applyHighlights( export function applyHighlights(
roots: Iterable<Element>, roots: Iterable<Element>,
query: string, query: string,
@ -29,23 +30,22 @@ export function applyHighlights(
? new RegExp(terms.map(escapeRe).join("\\s+"), "gi") ? new RegExp(terms.map(escapeRe).join("\\s+"), "gi")
: null; : null;
const ranges: Range[] = []; const phraseRanges: Range[] = [];
const termRanges: Range[] = [];
for (const root of roots) { for (const root of roots) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null; let node: Node | null;
while ((node = walker.nextNode())) { while ((node = walker.nextNode())) {
const text = node.textContent ?? ""; const text = node.textContent ?? "";
const taken: [number, number][] = [];
if (phraseRe) { if (phraseRe) {
phraseRe.lastIndex = 0; phraseRe.lastIndex = 0;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = phraseRe.exec(text))) { while ((m = phraseRe.exec(text))) {
taken.push([m.index, m.index + m[0].length]);
const range = new Range(); const range = new Range();
range.setStart(node, m.index); range.setStart(node, m.index);
range.setEnd(node, m.index + m[0].length); range.setEnd(node, m.index + m[0].length);
ranges.push(range); phraseRanges.push(range);
} }
} }
@ -53,18 +53,16 @@ export function applyHighlights(
for (const term of terms) { for (const term of terms) {
let i = 0; let i = 0;
while ((i = lower.indexOf(term, i)) !== -1) { while ((i = lower.indexOf(term, i)) !== -1) {
const end = i + term.length;
if (!taken.some(([s, e]) => i < e && end > s)) {
const range = new Range(); const range = new Range();
range.setStart(node, i); range.setStart(node, i);
range.setEnd(node, end); range.setEnd(node, i + term.length);
ranges.push(range); termRanges.push(range);
} i += term.length;
i = end;
} }
} }
} }
} }
const ranges = phraseRanges.length > 0 ? phraseRanges : termRanges;
if (ranges.length === 0) return null; if (ranges.length === 0) return null;
CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges)); CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));

View file

@ -45,16 +45,20 @@ export function createSearchState() {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
// Split into alternating plain/matched segments for <mark> rendering. The // Split into alternating plain/matched segments for <mark> rendering.
// whole query goes first in the alternation, so a contiguous phrase match // When the text contains the whole query as a contiguous phrase, only the
// becomes a single mark instead of one per word. // phrase is marked; scattered single words are marked only as a fallback,
// to show why a phrase-less text matched at all.
function highlight(text: string): { text: string; hit: boolean }[] { function highlight(text: string): { text: string; hit: boolean }[] {
if (!text || terms.length === 0) return [{ text, hit: false }]; if (!text || terms.length === 0) return [{ text, hit: false }];
const phrase = const phrase =
terms.length > 1 terms.length > 1
? [escapeRe(resultsQuery.trim()).replace(/\s+/g, "\\s+")] ? escapeRe(resultsQuery.trim()).replace(/\s+/g, "\\s+")
: []; : null;
const alts = [...phrase, ...terms.map(escapeRe)].join("|"); const alts =
phrase && new RegExp(phrase, "i").test(text)
? phrase
: terms.map(escapeRe).join("|");
const exact = new RegExp(`^(${alts})$`, "i"); const exact = new RegExp(`^(${alts})$`, "i");
return text return text
.split(new RegExp(`(${alts})`, "gi")) .split(new RegExp(`(${alts})`, "gi"))