muddle/modules/amezing/scripts/search-fuse.js
2026-10-23 08:53:07 -04:00

178 lines
5.2 KiB
JavaScript

// Based on https://codeberg.org/daudix/duckquill/issues/101#issuecomment-2377169
let searchSetup = false;
let fuse;
async function initIndex() {
if (searchSetup) return;
let data;
if (typeof window.searchIndex !== "undefined") {
data = window.searchIndex;
} else {
const response = await fetch(
`/search_index.${document.documentElement.lang}.json`,
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
data = await response.json();
}
const options = {
includeScore: false,
includeMatches: true,
ignoreLocation: true,
threshold: 0.15,
keys: [
{ name: "title", weight: 3 },
{ name: "description", weight: 2 },
{ name: "body", weight: 1 },
],
};
fuse = new Fuse(data, options);
searchSetup = true;
console.log("Search index initialized successfully");
}
function debounce(actual_fn, wait) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
actual_fn(...args);
}, wait);
};
}
function initSearch() {
const searchModal = document.getElementById("search-modal"); // Full-screen modal
const searchModalContent = document.getElementById("search-modal-content"); // Actual modal box
const searchInput = document.getElementById("search-input"); // Search input
const searchResults = document.getElementById("search-results"); // Search results
const searchButton = document.getElementById("search"); // Search button
const MAX_ITEMS = 10;
const MAX_RESULTS = 4;
let currentTerm = "";
// Open search modal when clicking the search button
if (searchButton) {
searchButton.addEventListener("click", function () {
searchModal.classList.add("active");
initIndex();
searchModal.addEventListener(
"transitionend",
function handler() {
searchInput.focus();
searchModal.removeEventListener("transitionend", handler);
},
{ once: true },
);
});
}
// Open search modal on "/" key press
window.addEventListener("keydown", (event) => {
if (
event.key === "/" &&
document.activeElement.tagName !== "INPUT" &&
document.activeElement.tagName !== "TEXTAREA"
) {
event.preventDefault();
initIndex();
searchModal.classList.add("active");
searchModal.addEventListener(
"transitionend",
function handler() {
searchInput.focus();
searchModal.removeEventListener("transitionend", handler);
},
{ once: true },
);
}
});
// Close search modal on Escape key
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
searchModal.classList.remove("active");
}
});
// Close search modal when clicking outside search-modal-content
searchModal.addEventListener("click", function (e) {
if (!searchModalContent.contains(e.target)) {
searchModal.classList.remove("active");
}
});
// Prevent clicks inside modal content from closing it
searchModalContent.addEventListener("click", function (e) {
e.stopPropagation(); // Stops event from reaching searchModal click handler
});
// Search input event
searchInput.addEventListener(
"keyup",
debounce(async function () {
var term = searchInput.value.trim();
if (term === currentTerm) return;
searchResults.style.display = term === "" ? "none" : "flex";
searchResults.innerHTML = ""; // Clear previous results
currentTerm = term;
if (term === "") return;
await initIndex();
var results = fuse.search(term, { limit: MAX_ITEMS });
if (results.length === 0) {
searchResults.style.display = "none";
return;
}
// Insert formatted search result items
for (const result of results) {
searchResults.innerHTML += makeTeaser(result, term);
}
}, 150),
);
function makeTeaser(result, searchVal) {
const TEASER_SIZE = 20;
let output = `<div class="search-result item"><a class="result-title" href=${result.item.url}>${result.item.title}</a>`;
for (const match of result.matches) {
if (match.key === "title") continue;
const indices = match.indices.sort((a, b) => Math.abs(a[1] - a[0] - searchVal.length) - Math.abs(b[1] - b[0] - searchVal.length)).slice(0, MAX_RESULTS);
const value = match.value;
for (const ind of indices) {
const start = Math.max(0, ind[0] - TEASER_SIZE);
const end = Math.min(value.length - 1, ind[1] + TEASER_SIZE);
output += "<span>"
+ value.substring(start, ind[0])
+ `<strong>${value.substring(ind[0], ind[1] + 1)}</strong>`
+ value.substring(ind[1] + 1, end)
+ "</span>";
}
if (match.indices.length > MAX_RESULTS) {
const moreMatchesText = document.getElementById("more-matches-text").textContent;
output += `<span class="more-matches">${moreMatchesText}</span>`.replace("$MATCHES", `+${match.indices.length - MAX_RESULTS}`);
}
}
return output + "</div>";
}
}
if (
document.readyState === "complete" ||
(document.readyState !== "loading" && !document.documentElement.doScroll)
) {
initSearch();
} else {
document.addEventListener("DOMContentLoaded", initSearch);
}