107 lines
2.9 KiB
TypeScript
107 lines
2.9 KiB
TypeScript
import { searchThreads, type SearchResult } from "$lib/search";
|
|
|
|
// Debounced-search state machine shared by the inline (homepage) and modal
|
|
// search shells, so behavior lives in one place and the shells only differ
|
|
// in chrome and positioning.
|
|
export function createSearchState() {
|
|
let query = $state("");
|
|
let results = $state<SearchResult[]>([]);
|
|
let resultsQuery = $state(""); // The query that produced the current results
|
|
let searching = $state(false);
|
|
let activeIndex = $state(-1);
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let seq = 0;
|
|
|
|
const terms = $derived(
|
|
resultsQuery.split(/\s+/).filter((t) => t.length >= 2),
|
|
);
|
|
|
|
function schedule() {
|
|
const q = query.trim();
|
|
if (timer) clearTimeout(timer);
|
|
activeIndex = -1;
|
|
if (q.length < 2) {
|
|
seq++; // Supersede any in-flight query
|
|
results = [];
|
|
resultsQuery = "";
|
|
searching = false;
|
|
return;
|
|
}
|
|
searching = true;
|
|
timer = setTimeout(async () => {
|
|
const id = ++seq;
|
|
try {
|
|
const r = await searchThreads(q);
|
|
if (id !== seq) return;
|
|
results = r;
|
|
resultsQuery = q;
|
|
} finally {
|
|
if (id === seq) searching = false;
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
function escapeRe(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// Split into alternating plain/matched segments for <mark> rendering
|
|
function highlight(text: string): { text: string; hit: boolean }[] {
|
|
if (!text || terms.length === 0) return [{ text, hit: false }];
|
|
const alts = terms.map(escapeRe).join("|");
|
|
const exact = new RegExp(`^(${alts})$`, "i");
|
|
return text
|
|
.split(new RegExp(`(${alts})`, "gi"))
|
|
.filter((s) => s !== "")
|
|
.map((s) => ({ text: s, hit: exact.test(s) }));
|
|
}
|
|
|
|
// Arrow/Enter handling; returns true when the event was consumed
|
|
function navigate(
|
|
e: KeyboardEvent,
|
|
pick: (r: SearchResult) => void,
|
|
): boolean {
|
|
if (results.length === 0) return false;
|
|
if (e.key === "ArrowDown") {
|
|
activeIndex = (activeIndex + 1) % results.length;
|
|
} else if (e.key === "ArrowUp") {
|
|
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
|
} else if (e.key === "Enter" && activeIndex >= 0) {
|
|
pick(results[activeIndex]);
|
|
} else {
|
|
return false;
|
|
}
|
|
e.preventDefault();
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
get query() {
|
|
return query;
|
|
},
|
|
set query(v: string) {
|
|
query = v;
|
|
},
|
|
get results() {
|
|
return results;
|
|
},
|
|
get searching() {
|
|
return searching;
|
|
},
|
|
get activeIndex() {
|
|
return activeIndex;
|
|
},
|
|
set activeIndex(v: number) {
|
|
activeIndex = v;
|
|
},
|
|
// True once a query has produced (or is about to produce) results
|
|
get active() {
|
|
return resultsQuery.trim().length >= 2;
|
|
},
|
|
schedule,
|
|
highlight,
|
|
navigate,
|
|
};
|
|
}
|
|
|
|
export type SearchState = ReturnType<typeof createSearchState>;
|