diff --git a/src/lib/components/SearchInline.svelte b/src/lib/components/SearchInline.svelte index 40e0155..0f4635e 100644 --- a/src/lib/components/SearchInline.svelte +++ b/src/lib/components/SearchInline.svelte @@ -15,7 +15,7 @@ function select(r: SearchResult) { open = false; - goto(`/thread/${r.threadId}`); + goto(search.threadUrl(r.threadId)); } function onKeydown(e: KeyboardEvent) { diff --git a/src/lib/components/SearchModal.svelte b/src/lib/components/SearchModal.svelte index 9d07dbf..1fa474e 100644 --- a/src/lib/components/SearchModal.svelte +++ b/src/lib/components/SearchModal.svelte @@ -21,7 +21,7 @@ function select(r: SearchResult) { closeSearch(); - goto(`/thread/${r.threadId}`); + goto(search.threadUrl(r.threadId)); } // Global "/" opens the search from any page, unless typing somewhere else diff --git a/src/lib/components/SearchResults.svelte b/src/lib/components/SearchResults.svelte index a8585c1..7361927 100644 --- a/src/lib/components/SearchResults.svelte +++ b/src/lib/components/SearchResults.svelte @@ -41,7 +41,7 @@ {#each search.results as r, i (r.threadId)} , + 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); +} diff --git a/src/lib/searchState.svelte.ts b/src/lib/searchState.svelte.ts index beb9870..ae4b326 100644 --- a/src/lib/searchState.svelte.ts +++ b/src/lib/searchState.svelte.ts @@ -98,6 +98,13 @@ export function createSearchState() { get active() { 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, highlight, navigate, diff --git a/src/routes/layout.css b/src/routes/layout.css index 3c08d32..f84cc74 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -65,6 +65,15 @@ body { } } +/* Search terms painted on thread pages via the CSS Custom Highlight API, + matching the 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 typography plugin's em-based heading margins; the first block stays flush. */ .prose :is(h1, h2, h3, h4, h5, h6) { diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index fe9b37a..7cb43ec 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -24,6 +24,7 @@ openJoinModal, } from "$lib/join.svelte"; import { setActiveGroup } from "$lib/active.svelte"; + import { applyHighlights, clearHighlights } from "$lib/pageHighlight"; import { RELAY_URL, MODE } from "$lib/config"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import MessageEditor from "$lib/components/MessageEditor.svelte"; @@ -210,6 +211,28 @@ 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(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 // replies target the right room. $effect(() => { @@ -421,7 +444,7 @@ {/snippet} {#if detail} -
+