Files
org_web/assets/scripts/search.js
Zaine b84f5d2691
All checks were successful
Build Org Website / build (push) Successful in 51s
search improvements 3
2026-05-13 23:24:24 +01:00

451 lines
12 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let lunrIndex;
let documents = [];
let activeIndex = -1;
let currentResults = [];
let isSyncingHeaderInput = false;
let suppressHeaderFocus = false;
let searchReady = false;
const searchInput = document.getElementById("search-input");
const searchBtn = document.getElementById("search-btn");
const searchForm = searchInput?.closest("form");
const modal = document.createElement("div");
modal.id = "search-modal";
modal.setAttribute("aria-hidden", "true");
modal.innerHTML = `
<div class="search-modal-backdrop" data-search-close></div>
<section class="search-modal-content" role="dialog" aria-modal="true" aria-labelledby="search-pane-title">
<div class="search-pane-head">
<label class="search-pane-field" for="search-pane-input">
<span class="search-pane-icon" aria-hidden="true">⌕</span>
<input type="search" id="search-pane-input" placeholder="Search notes" autocomplete="off" spellcheck="false" />
</label>
<button class="search-pane-close" type="button" aria-label="Close search" data-search-close>×</button>
</div>
<div class="search-pane-meta">
<h2 id="search-pane-title">Search</h2>
<span id="search-pane-count">Start typing to search your notes.</span>
</div>
<div id="search-modal-results" class="search-pane-results" role="listbox" aria-label="Search results"></div>
<div class="search-pane-help" aria-hidden="true">
<span>↑↓ Navigate</span>
<span>Enter Open</span>
<span>Esc Close</span>
</div>
</section>
`;
document.body.appendChild(modal);
const pane = modal.querySelector(".search-modal-content");
const paneInput = modal.querySelector("#search-pane-input");
const modalResults = modal.querySelector("#search-modal-results");
const paneCount = modal.querySelector("#search-pane-count");
function extractDocuments(node, currentPath = "") {
if (node.type === "folder" && node.children) {
const nextPath = currentPath ? `${currentPath}/${node.name}` : node.name;
node.children.forEach((child) => {
extractDocuments(child, nextPath);
});
}
if (node.type === "file") {
documents.push({
id: documents.length.toString(),
title: node.name,
content: node.content || "",
path: node.url,
});
}
}
function buildIndex() {
lunrIndex = lunr(function () {
this.ref("id");
this.field("title", { boost: 10 });
this.field("url");
this.field("content");
documents.forEach((doc) => this.add(doc));
});
}
async function initSearch() {
try {
const response = await fetch("/test.json");
const json = await response.json();
extractDocuments(json);
buildIndex();
searchReady = true;
if (isSearchOpen()) renderResults(paneInput.value);
} catch (error) {
console.warn("Search index could not be loaded", error);
if (paneCount) paneCount.textContent = "Search is unavailable right now.";
}
}
initSearch();
function buildExactQuery(query) {
return query
.toLowerCase()
.split(/\s+/)
.filter((term) => term.length > 1)
.map((term) => `${term}*`)
.join(" ");
}
function buildSnippet(content, terms, radius = 90) {
if (!content || terms.length === 0) return "";
const lower = content.toLowerCase();
let index = -1;
let matchedTerm = "";
for (const term of terms) {
const i = lower.indexOf(term);
if (i !== -1 && (index === -1 || i < index)) {
index = i;
matchedTerm = term;
}
}
if (index === -1) {
return content.slice(0, radius * 2) + "…";
}
const start = Math.max(0, index - radius);
const end = Math.min(content.length, index + matchedTerm.length + radius);
const prefix = start > 0 ? "…" : "";
const suffix = end < content.length ? "…" : "";
return prefix + content.slice(start, end) + suffix;
}
function getSearchTerms(query) {
return query
.toLowerCase()
.split(/\s+/)
.filter((t) => t.length > 2);
}
function escapeHtml(text) {
return String(text || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function highlight(text, terms) {
const escapedText = escapeHtml(text);
if (!escapedText || terms.length === 0) return escapedText;
const escapedTerms = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escapedTerms.join("|")})`, "gi");
return escapedText.replace(regex, "<mark>$1</mark>");
}
function search(query) {
if (!lunrIndex) return [];
const q = buildExactQuery(query);
if (!q) return [];
const terms = getSearchTerms(query);
const results = lunrIndex.search(q);
return results
.map((r) => {
const doc = documents.find((d) => d.id === r.ref);
if (!doc) return null;
const snippet = buildSnippet(doc.content, terms);
return {
title: highlight(doc.title, terms),
path: doc.path,
score: r.score,
preview: highlight(snippet, terms),
};
})
.filter(Boolean);
}
function setHeaderSearchValue(value, dispatchInput = false) {
if (!searchInput) return;
isSyncingHeaderInput = true;
searchInput.value = value;
isSyncingHeaderInput = false;
if (dispatchInput) {
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function isSearchOpen() {
return modal.classList.contains("is-open");
}
function openSearchPane(initialValue = "") {
const value = initialValue || searchInput?.value || paneInput?.value || "";
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
document.documentElement.classList.add("search-open");
paneInput.value = value;
setHeaderSearchValue(value);
renderResults(value);
window.setTimeout(() => {
paneInput.focus();
paneInput.setSelectionRange(paneInput.value.length, paneInput.value.length);
}, 0);
}
function closeSearchPane() {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
document.documentElement.classList.remove("search-open");
activeIndex = -1;
suppressHeaderFocus = true;
searchInput?.focus();
window.setTimeout(() => {
suppressHeaderFocus = false;
}, 0);
}
function getResultItems() {
return Array.from(modalResults.querySelectorAll(".search-modal-result"));
}
function updateActiveResult() {
const items = getResultItems();
items.forEach((item, index) => {
const isActive = index === activeIndex;
item.classList.toggle("active", isActive);
item.setAttribute("aria-selected", String(isActive));
});
if (activeIndex >= 0 && activeIndex < items.length) {
items[activeIndex].scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
function setActiveResult(index) {
const items = getResultItems();
if (!items.length) {
activeIndex = -1;
return;
}
activeIndex = Math.max(0, Math.min(index, items.length - 1));
updateActiveResult();
}
function openActiveResult() {
const items = getResultItems();
if (!items.length) return;
const index = activeIndex >= 0 ? activeIndex : 0;
items[index]?.click();
}
function renderResults(query) {
const trimmed = query.trim();
currentResults = trimmed.length >= 2 ? search(trimmed) : [];
activeIndex = currentResults.length ? 0 : -1;
modalResults.innerHTML = "";
if (!trimmed) {
paneCount.textContent = "Start typing to search your notes.";
modalResults.innerHTML = `
<div class="search-empty">
Try a topic, page title, or phrase from a note.
</div>
`;
return;
}
if (trimmed.length < 2) {
paneCount.textContent = "Keep typing.";
modalResults.innerHTML = `
<div class="search-empty">
Use at least two characters.
</div>
`;
return;
}
if (!searchReady) {
paneCount.textContent = "Loading search index.";
modalResults.innerHTML = `
<div class="search-empty">
Loading results...
</div>
`;
return;
}
if (!currentResults.length) {
paneCount.textContent = "No results found.";
modalResults.innerHTML = `
<div class="search-empty">
No matches for "${escapeHtml(trimmed)}".
</div>
`;
return;
}
paneCount.textContent = `${currentResults.length} result${currentResults.length === 1 ? "" : "s"}`;
currentResults.slice(0, 20).forEach((result, i) => {
const item = document.createElement("a");
item.className = "search-modal-result";
item.href = result.path;
item.dataset.index = i;
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(i === activeIndex));
item.innerHTML = `
<span class="search-modal-title">${result.title}</span>
<span class="search-modal-preview">${result.preview}</span>
<span class="search-modal-path">${escapeHtml(result.path)}</span>
`;
item.addEventListener("mouseenter", () => setActiveResult(i));
modalResults.appendChild(item);
});
updateActiveResult();
}
function handleSearchKeydown(e) {
const items = getResultItems();
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (items.length) setActiveResult(activeIndex + 1);
break;
case "ArrowUp":
e.preventDefault();
if (items.length) setActiveResult(activeIndex <= 0 ? items.length - 1 : activeIndex - 1);
break;
case "Home":
if (items.length) {
e.preventDefault();
setActiveResult(0);
}
break;
case "End":
if (items.length) {
e.preventDefault();
setActiveResult(items.length - 1);
}
break;
case "Enter":
e.preventDefault();
openActiveResult();
break;
case "Escape":
e.preventDefault();
closeSearchPane();
break;
}
}
function shouldUseSlashShortcut(event) {
const target = event.target;
const isEditable =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable;
return event.key === "/" && !isEditable && !event.metaKey && !event.ctrlKey && !event.altKey;
}
if (searchInput && searchBtn && paneInput && modalResults && paneCount) {
searchInput.addEventListener("focus", () => {
if (!suppressHeaderFocus) openSearchPane(searchInput.value);
});
searchInput.addEventListener("click", () => openSearchPane(searchInput.value));
searchInput.addEventListener("input", () => {
if (isSyncingHeaderInput) return;
if (!isSearchOpen()) openSearchPane(searchInput.value);
paneInput.value = searchInput.value;
renderResults(searchInput.value);
});
paneInput.addEventListener("input", () => {
setHeaderSearchValue(paneInput.value, true);
renderResults(paneInput.value);
});
paneInput.addEventListener("keydown", handleSearchKeydown);
modalResults.addEventListener("keydown", handleSearchKeydown);
searchBtn.addEventListener("click", () => openSearchPane(searchInput.value));
modal.addEventListener("click", (e) => {
if (e.target.hasAttribute("data-search-close")) {
closeSearchPane();
}
});
pane.addEventListener("click", (e) => e.stopPropagation());
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openSearchPane(searchInput.value);
return;
}
if (shouldUseSlashShortcut(e)) {
e.preventDefault();
openSearchPane("");
}
if (e.key === "Escape" && isSearchOpen()) {
closeSearchPane();
}
});
window.addEventListener("pageshow", () => {
searchInput.value = "";
paneInput.value = "";
renderResults("");
});
window.addEventListener("beforeunload", () => {
searchInput.value = "";
});
if (searchForm) {
searchForm.addEventListener("submit", (e) => {
e.preventDefault();
openSearchPane(searchInput.value);
});
}
}