diff --git a/src/lib/pageHighlight.ts b/src/lib/pageHighlight.ts index 5cde149..ea0e182 100644 --- a/src/lib/pageHighlight.ts +++ b/src/lib/pageHighlight.ts @@ -4,35 +4,63 @@ function supported(): boolean { return typeof CSS !== "undefined" && "highlights" in CSS; } -// Highlight every occurrence of the terms 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. Returns the first match in -// document order (for scrolling), or null. No-op on unsupported browsers. +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// 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. export function applyHighlights( roots: Iterable, - terms: string[], + query: string, ): Range | null { if (!supported()) return null; CSS.highlights.delete(HIGHLIGHT_NAME); - const lowers = [...new Set(terms.map((t) => t.toLowerCase()))].filter( - (t) => t.length >= 2, - ); - if (lowers.length === 0) return null; + const terms = [ + ...new Set(query.toLowerCase().split(/\s+/).filter((t) => t.length >= 2)), + ]; + if (terms.length === 0) return null; + const phraseRe = + terms.length > 1 + ? new RegExp(terms.map(escapeRe).join("\\s+"), "gi") + : null; const ranges: Range[] = []; for (const root of roots) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node: Node | null; while ((node = walker.nextNode())) { - const lower = (node.textContent ?? "").toLowerCase(); - for (const term of lowers) { + 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); + } + } + + const lower = text.toLowerCase(); + for (const term of terms) { let i = 0; while ((i = lower.indexOf(term, i)) !== -1) { - const range = new Range(); - range.setStart(node, i); - range.setEnd(node, i + term.length); - ranges.push(range); - i += term.length; + 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; } } } diff --git a/src/lib/searchState.svelte.ts b/src/lib/searchState.svelte.ts index ae4b326..98e4b22 100644 --- a/src/lib/searchState.svelte.ts +++ b/src/lib/searchState.svelte.ts @@ -45,10 +45,16 @@ export function createSearchState() { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - // Split into alternating plain/matched segments for rendering + // 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. function highlight(text: string): { text: string; hit: boolean }[] { if (!text || terms.length === 0) return [{ text, hit: false }]; - const alts = terms.map(escapeRe).join("|"); + const phrase = + terms.length > 1 + ? [escapeRe(resultsQuery.trim()).replace(/\s+/g, "\\s+")] + : []; + const alts = [...phrase, ...terms.map(escapeRe)].join("|"); const exact = new RegExp(`^(${alts})$`, "i"); return text .split(new RegExp(`(${alts})`, "gi")) diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index 7cb43ec..b41355b 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -224,7 +224,7 @@ return; } const roots = threadEl.querySelectorAll("h1, [data-quote-post-index]"); - const first = applyHighlights(roots, q.split(/\s+/)); + const first = applyHighlights(roots, q); const key = `${page.params.id}|${q}`; if (first && scrolledFor !== key) { scrolledFor = key;