Highlight the searched terms in the thread page
This commit is contained in:
parent
d61ec05963
commit
c9227ee8fb
7 changed files with 92 additions and 4 deletions
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
function select(r: SearchResult) {
|
function select(r: SearchResult) {
|
||||||
open = false;
|
open = false;
|
||||||
goto(`/thread/${r.threadId}`);
|
goto(search.threadUrl(r.threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
function select(r: SearchResult) {
|
function select(r: SearchResult) {
|
||||||
closeSearch();
|
closeSearch();
|
||||||
goto(`/thread/${r.threadId}`);
|
goto(search.threadUrl(r.threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global "/" opens the search from any page, unless typing somewhere else
|
// Global "/" opens the search from any page, unless typing somewhere else
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
{#each search.results as r, i (r.threadId)}
|
{#each search.results as r, i (r.threadId)}
|
||||||
<a
|
<a
|
||||||
id="{idBase}-{i}"
|
id="{idBase}-{i}"
|
||||||
href="/thread/{r.threadId}"
|
href={search.threadUrl(r.threadId)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === search.activeIndex}
|
aria-selected={i === search.activeIndex}
|
||||||
class="block rounded px-4 py-2 {i === search.activeIndex
|
class="block rounded px-4 py-2 {i === search.activeIndex
|
||||||
|
|
|
||||||
49
src/lib/pageHighlight.ts
Normal file
49
src/lib/pageHighlight.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
const HIGHLIGHT_NAME = "search-terms";
|
||||||
|
|
||||||
|
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.
|
||||||
|
export function applyHighlights(
|
||||||
|
roots: Iterable<Element>,
|
||||||
|
terms: 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 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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ranges.length === 0) return null;
|
||||||
|
|
||||||
|
CSS.highlights.set(HIGHLIGHT_NAME, new Highlight(...ranges));
|
||||||
|
ranges.sort((a, b) => a.compareBoundaryPoints(Range.START_TO_START, b));
|
||||||
|
return ranges[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHighlights() {
|
||||||
|
if (supported()) CSS.highlights.delete(HIGHLIGHT_NAME);
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,13 @@ export function createSearchState() {
|
||||||
get active() {
|
get active() {
|
||||||
return resultsQuery.trim().length >= 2;
|
return resultsQuery.trim().length >= 2;
|
||||||
},
|
},
|
||||||
|
get resultsQuery() {
|
||||||
|
return resultsQuery;
|
||||||
|
},
|
||||||
|
// Thread link carrying the query, so the page can highlight the terms
|
||||||
|
threadUrl(threadId: string) {
|
||||||
|
return `/thread/${threadId}?q=${encodeURIComponent(resultsQuery.trim())}`;
|
||||||
|
},
|
||||||
schedule,
|
schedule,
|
||||||
highlight,
|
highlight,
|
||||||
navigate,
|
navigate,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,15 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search terms painted on thread pages via the CSS Custom Highlight API,
|
||||||
|
matching the <mark> tint used in the search results. */
|
||||||
|
::highlight(search-terms) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-secondary) 40%, transparent);
|
||||||
|
}
|
||||||
|
.dark ::highlight(search-terms) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-secondary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
/* Tighter heading rhythm for all rendered (prose) content. Overrides the
|
||||||
typography plugin's em-based heading margins; the first block stays flush. */
|
typography plugin's em-based heading margins; the first block stays flush. */
|
||||||
.prose :is(h1, h2, h3, h4, h5, h6) {
|
.prose :is(h1, h2, h3, h4, h5, h6) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
openJoinModal,
|
openJoinModal,
|
||||||
} from "$lib/join.svelte";
|
} from "$lib/join.svelte";
|
||||||
import { setActiveGroup } from "$lib/active.svelte";
|
import { setActiveGroup } from "$lib/active.svelte";
|
||||||
|
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
|
||||||
import { RELAY_URL, MODE } from "$lib/config";
|
import { RELAY_URL, MODE } from "$lib/config";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||||
|
|
@ -210,6 +211,28 @@
|
||||||
if (page.params.id) loadThread(page.params.id);
|
if (page.params.id) loadThread(page.params.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Arriving from search (?q=…): paint the terms over the title and post
|
||||||
|
// bodies, and scroll to the first match once per navigation. Re-runs as
|
||||||
|
// replies stream in so late matches get painted too.
|
||||||
|
let threadEl = $state<HTMLElement | null>(null);
|
||||||
|
let scrolledFor = "";
|
||||||
|
$effect(() => {
|
||||||
|
const q = page.url.searchParams.get("q")?.trim() ?? "";
|
||||||
|
allPosts; // Repaint when posts load or change
|
||||||
|
if (!threadEl || !q) {
|
||||||
|
clearHighlights();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roots = threadEl.querySelectorAll("h1, [data-quote-post-index]");
|
||||||
|
const first = applyHighlights(roots, q.split(/\s+/));
|
||||||
|
const key = `${page.params.id}|${q}`;
|
||||||
|
if (first && scrolledFor !== key) {
|
||||||
|
scrolledFor = key;
|
||||||
|
first.startContainer.parentElement?.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
return clearHighlights;
|
||||||
|
});
|
||||||
|
|
||||||
// A thread belongs to its own group; make that the active group so chat and
|
// A thread belongs to its own group; make that the active group so chat and
|
||||||
// replies target the right room.
|
// replies target the right room.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -421,7 +444,7 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if detail}
|
{#if detail}
|
||||||
<div class="flex items-start gap-6">
|
<div class="flex items-start gap-6" bind:this={threadEl}>
|
||||||
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
<div class="min-w-0 flex-1 {!scrubberVisible ? 'md:pr-18' : ''}">
|
||||||
<div
|
<div
|
||||||
class="relative z-10 bg-white pb-1 md:sticky md:-top-2 md:-mx-10 md:-mt-6 md:px-10 md:pt-6 md:pb-3 dark:bg-neutral-900"
|
class="relative z-10 bg-white pb-1 md:sticky md:-top-2 md:-mx-10 md:-mt-6 md:px-10 md:pt-6 md:pb-3 dark:bg-neutral-900"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue