Highlight contiguous phrase matches as one block

This commit is contained in:
dtonon 2026-08-12 19:30:17 +01:00
parent 46176511f3
commit 2ab69e4cd3
3 changed files with 53 additions and 19 deletions

View file

@ -4,35 +4,63 @@ function supported(): boolean {
return typeof CSS !== "undefined" && "highlights" in CSS; return typeof CSS !== "undefined" && "highlights" in CSS;
} }
// Highlight every occurrence of the terms inside the given roots using the function escapeRe(s: string): string {
// CSS Custom Highlight API, which paints ranges without touching the DOM, so return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// it never conflicts with Svelte re-renders. Returns the first match in }
// document order (for scrolling), or null. No-op on unsupported browsers.
// 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( export function applyHighlights(
roots: Iterable<Element>, roots: Iterable<Element>,
terms: string[], query: string,
): Range | null { ): Range | null {
if (!supported()) return null; if (!supported()) return null;
CSS.highlights.delete(HIGHLIGHT_NAME); CSS.highlights.delete(HIGHLIGHT_NAME);
const lowers = [...new Set(terms.map((t) => t.toLowerCase()))].filter( const terms = [
(t) => t.length >= 2, ...new Set(query.toLowerCase().split(/\s+/).filter((t) => t.length >= 2)),
); ];
if (lowers.length === 0) return null; if (terms.length === 0) return null;
const phraseRe =
terms.length > 1
? new RegExp(terms.map(escapeRe).join("\\s+"), "gi")
: null;
const ranges: Range[] = []; const ranges: 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 lower = (node.textContent ?? "").toLowerCase(); const text = node.textContent ?? "";
for (const term of lowers) { 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; 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, i + term.length); range.setEnd(node, end);
ranges.push(range); ranges.push(range);
i += term.length; }
i = end;
} }
} }
} }

View file

@ -45,10 +45,16 @@ export function createSearchState() {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
// Split into alternating plain/matched segments for <mark> rendering // Split into alternating plain/matched segments for <mark> 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 }[] { 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 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"); const exact = new RegExp(`^(${alts})$`, "i");
return text return text
.split(new RegExp(`(${alts})`, "gi")) .split(new RegExp(`(${alts})`, "gi"))

View file

@ -224,7 +224,7 @@
return; return;
} }
const roots = threadEl.querySelectorAll("h1, [data-quote-post-index]"); 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}`; const key = `${page.params.id}|${q}`;
if (first && scrolledFor !== key) { if (first && scrolledFor !== key) {
scrolledFor = key; scrolledFor = key;