diff --git a/src/lib/pageHighlight.ts b/src/lib/pageHighlight.ts index ea0e182..b230ab4 100644 --- a/src/lib/pageHighlight.ts +++ b/src/lib/pageHighlight.ts @@ -10,10 +10,11 @@ function escapeRe(s: string): string { // Highlight the query inside the given roots using the CSS Custom Highlight // API, which paints ranges without touching the DOM, so it never conflicts -// with Svelte re-renders. Where the terms appear contiguously as the whole -// query, one range covers the phrase; individual terms are only marked -// outside those. Returns the first match in document order (for scrolling), -// or null. No-op on unsupported browsers. +// with Svelte re-renders. When the whole query appears contiguously anywhere +// on the page, only those phrase occurrences are painted; per-word marks are +// a fallback for pages that matched on scattered terms. Returns the first +// match in document order (for scrolling), or null. No-op on unsupported +// browsers. export function applyHighlights( roots: Iterable, query: string, @@ -29,23 +30,22 @@ export function applyHighlights( ? new RegExp(terms.map(escapeRe).join("\\s+"), "gi") : null; - const ranges: Range[] = []; + const phraseRanges: Range[] = []; + const termRanges: Range[] = []; for (const root of roots) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node: Node | null; while ((node = walker.nextNode())) { const text = node.textContent ?? ""; - const taken: [number, number][] = []; if (phraseRe) { phraseRe.lastIndex = 0; let m: RegExpExecArray | null; while ((m = phraseRe.exec(text))) { - taken.push([m.index, m.index + m[0].length]); const range = new Range(); range.setStart(node, m.index); 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) { let i = 0; 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(); - range.setStart(node, i); - range.setEnd(node, end); - ranges.push(range); - } - i = end; + const range = new Range(); + range.setStart(node, i); + range.setEnd(node, i + term.length); + termRanges.push(range); + i += term.length; } } } } + const ranges = phraseRanges.length > 0 ? phraseRanges : termRanges; if (ranges.length === 0) return null; CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges)); diff --git a/src/lib/searchState.svelte.ts b/src/lib/searchState.svelte.ts index 98e4b22..ef8c186 100644 --- a/src/lib/searchState.svelte.ts +++ b/src/lib/searchState.svelte.ts @@ -45,16 +45,20 @@ export function createSearchState() { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - // Split into alternating plain/matched segments for rendering. The - // whole query goes first in the alternation, so a contiguous phrase match - // becomes a single mark instead of one per word. + // Split into alternating plain/matched segments for rendering. + // When the text contains the whole query as a contiguous phrase, only the + // 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 }[] { if (!text || terms.length === 0) return [{ text, hit: false }]; const phrase = terms.length > 1 - ? [escapeRe(resultsQuery.trim()).replace(/\s+/g, "\\s+")] - : []; - const alts = [...phrase, ...terms.map(escapeRe)].join("|"); + ? escapeRe(resultsQuery.trim()).replace(/\s+/g, "\\s+") + : null; + const alts = + phrase && new RegExp(phrase, "i").test(text) + ? phrase + : terms.map(escapeRe).join("|"); const exact = new RegExp(`^(${alts})$`, "i"); return text .split(new RegExp(`(${alts})`, "gi"))