162 lines
5.0 KiB
JavaScript
162 lines
5.0 KiB
JavaScript
|
|
/**
|
||
|
|
* search.js — Full-text search for sunvpy docs using Fuse.js
|
||
|
|
*/
|
||
|
|
(function () {
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
var fuse = null;
|
||
|
|
var searchIndex = null;
|
||
|
|
var searchOverlay = document.getElementById("searchOverlay");
|
||
|
|
var searchInput = document.getElementById("searchInput");
|
||
|
|
var searchResults = document.getElementById("searchResults");
|
||
|
|
var debounceTimer = null;
|
||
|
|
|
||
|
|
// Open search
|
||
|
|
function openSearch() {
|
||
|
|
searchOverlay.classList.add("active");
|
||
|
|
searchInput.value = "";
|
||
|
|
searchResults.innerHTML = '<div class="search-hint">输入关键词开始搜索</div>';
|
||
|
|
searchInput.focus();
|
||
|
|
loadIndex();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Close search
|
||
|
|
function closeSearch() {
|
||
|
|
searchOverlay.classList.remove("active");
|
||
|
|
searchInput.blur();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Lazy-load search index
|
||
|
|
async function loadIndex() {
|
||
|
|
if (searchIndex) return;
|
||
|
|
try {
|
||
|
|
var resp = await fetch("data/search-index.json");
|
||
|
|
searchIndex = await resp.json();
|
||
|
|
fuse = new Fuse(searchIndex, {
|
||
|
|
keys: [
|
||
|
|
{ name: "title", weight: 0.4 },
|
||
|
|
{ name: "headings", weight: 0.3 },
|
||
|
|
{ name: "content", weight: 0.3 },
|
||
|
|
],
|
||
|
|
threshold: 0.3,
|
||
|
|
ignoreLocation: true,
|
||
|
|
includeMatches: true,
|
||
|
|
minMatchCharLength: 2,
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
searchResults.innerHTML = '<div class="search-hint">无法加载搜索索引</div>';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Perform search
|
||
|
|
function doSearch(query) {
|
||
|
|
if (!fuse || !query.trim()) {
|
||
|
|
searchResults.innerHTML = '<div class="search-hint">输入关键词开始搜索</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var results = fuse.search(query, { limit: 20 });
|
||
|
|
|
||
|
|
if (results.length === 0) {
|
||
|
|
searchResults.innerHTML = '<div class="search-hint">未找到匹配结果</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var html = "";
|
||
|
|
results.forEach(function (result) {
|
||
|
|
var item = result.item;
|
||
|
|
var context = getContext(result.matches, item);
|
||
|
|
html += '<a class="search-result-item" href="#' + escAttr(item.slug) + '" onclick="closeSearch()">'
|
||
|
|
+ '<div class="result-title">' + escHtml(item.title) + "</div>"
|
||
|
|
+ '<div class="result-section">' + escHtml(item.section) + "</div>"
|
||
|
|
+ '<div class="result-context">' + context + "</div>"
|
||
|
|
+ "</a>";
|
||
|
|
});
|
||
|
|
searchResults.innerHTML = html;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get matching context snippet
|
||
|
|
function getContext(matches, item) {
|
||
|
|
if (!matches || matches.length === 0) return "";
|
||
|
|
|
||
|
|
// Prefer title/content match context
|
||
|
|
for (var i = 0; i < matches.length; i++) {
|
||
|
|
var m = matches[i];
|
||
|
|
if (m.key === "content" && m.indices && m.indices.length > 0) {
|
||
|
|
var idx = m.indices[0];
|
||
|
|
var start = Math.max(0, idx[0] - 30);
|
||
|
|
var end = Math.min(item.content.length, idx[1] + 30);
|
||
|
|
var snippet = (start > 0 ? "..." : "") + item.content.substring(start, end) + (end < item.content.length ? "..." : "");
|
||
|
|
return highlightMatch(snippet, m.value.substring(idx[0], idx[1] + 1));
|
||
|
|
}
|
||
|
|
if (m.key === "headings" && m.indices && m.indices.length > 0) {
|
||
|
|
var arr = item.headings;
|
||
|
|
for (var h = 0; h < arr.length; h++) {
|
||
|
|
if (arr[h].toLowerCase().indexOf(m.value.toLowerCase()) !== -1) {
|
||
|
|
return highlightMatch(arr[h], m.value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function highlightMatch(text, match) {
|
||
|
|
if (!match) return escHtml(text);
|
||
|
|
var escaped = escHtml(text);
|
||
|
|
var escapedMatch = escHtml(match);
|
||
|
|
return escaped.replace(new RegExp(escapeRegex(escapedMatch), "gi"), "<mark>$&</mark>");
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeRegex(s) {
|
||
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Close search global
|
||
|
|
window.closeSearch = closeSearch;
|
||
|
|
|
||
|
|
// Event listeners
|
||
|
|
searchInput.addEventListener("input", function () {
|
||
|
|
clearTimeout(debounceTimer);
|
||
|
|
debounceTimer = setTimeout(function () {
|
||
|
|
doSearch(searchInput.value);
|
||
|
|
}, 200);
|
||
|
|
});
|
||
|
|
|
||
|
|
document.getElementById("searchClose").addEventListener("click", closeSearch);
|
||
|
|
searchOverlay.addEventListener("click", function (e) {
|
||
|
|
if (e.target === searchOverlay) closeSearch();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Keyboard shortcut: Ctrl+K / Cmd+K
|
||
|
|
document.addEventListener("keydown", function (e) {
|
||
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||
|
|
e.preventDefault();
|
||
|
|
openSearch();
|
||
|
|
}
|
||
|
|
if (e.key === "Escape" && searchOverlay.classList.contains("active")) {
|
||
|
|
closeSearch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Sidebar search button
|
||
|
|
document.getElementById("sidebarSearchBtn").addEventListener("click", openSearch);
|
||
|
|
var mobileSearchBtn = document.getElementById("searchBtnMobile");
|
||
|
|
if (mobileSearchBtn) mobileSearchBtn.addEventListener("click", openSearch);
|
||
|
|
|
||
|
|
// Navigate on result click
|
||
|
|
searchResults.addEventListener("click", function (e) {
|
||
|
|
var item = e.target.closest(".search-result-item");
|
||
|
|
if (item) {
|
||
|
|
closeSearch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
function escHtml(s) {
|
||
|
|
if (!s) return "";
|
||
|
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
|
|
}
|
||
|
|
|
||
|
|
function escAttr(s) { return escHtml(s); }
|
||
|
|
})();
|