Highlight contiguous phrase matches as one block
This commit is contained in:
parent
46176511f3
commit
2ab69e4cd3
3 changed files with 53 additions and 19 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue