285 lines
8.1 KiB
JavaScript
285 lines
8.1 KiB
JavaScript
let initialized = false;
|
||
let resolveResultsFn = null;
|
||
let onSelectResultFn = null;
|
||
let currentResults = [];
|
||
let activeIndex = -1;
|
||
let searchTimerId = null;
|
||
let isOpen = false;
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function getElements() {
|
||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||
return {
|
||
modal: document.getElementById("search-modal"),
|
||
backdrop: document.getElementById("search-backdrop"),
|
||
input: document.getElementById(
|
||
isMobile ? "mobile-earth-search-input" : "earth-search-input",
|
||
),
|
||
clear: document.getElementById(
|
||
isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
|
||
),
|
||
meta: document.getElementById(
|
||
isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
|
||
),
|
||
results: document.getElementById(
|
||
isMobile ? "mobile-earth-search-results" : "earth-search-results",
|
||
),
|
||
empty: document.getElementById(
|
||
isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
|
||
),
|
||
close: document.getElementById("search-close"),
|
||
};
|
||
}
|
||
|
||
function setMeta(text) {
|
||
const { meta } = getElements();
|
||
if (meta) meta.textContent = text;
|
||
}
|
||
|
||
function updateEmptyState(query) {
|
||
const { empty } = getElements();
|
||
if (!empty) return;
|
||
if (!query) {
|
||
empty.textContent = "支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。";
|
||
return;
|
||
}
|
||
empty.textContent = "未找到匹配对象,可尝试名称、地点、NORAD、ASN、前缀等关键词。";
|
||
}
|
||
|
||
function renderResults(query) {
|
||
const { results, empty } = getElements();
|
||
if (!results || !empty) return;
|
||
|
||
results.innerHTML = "";
|
||
const hasResults = currentResults.length > 0;
|
||
empty.hidden = hasResults;
|
||
updateEmptyState(query);
|
||
|
||
if (!hasResults) return;
|
||
|
||
currentResults.forEach((result, index) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "earth-search-result";
|
||
button.setAttribute("role", "option");
|
||
button.dataset.index = String(index);
|
||
button.innerHTML = `
|
||
<span class="earth-search-result-icon" aria-hidden="true">
|
||
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
|
||
</span>
|
||
<span class="earth-search-result-copy">
|
||
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
|
||
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
|
||
</span>
|
||
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
|
||
`;
|
||
button.addEventListener("click", async () => {
|
||
await selectResult(index);
|
||
});
|
||
results.appendChild(button);
|
||
});
|
||
|
||
syncActiveResult();
|
||
}
|
||
|
||
function syncActiveResult() {
|
||
const { results } = getElements();
|
||
if (!results) return;
|
||
Array.from(results.children).forEach((node, index) => {
|
||
node.classList.toggle("is-active", index === activeIndex);
|
||
});
|
||
}
|
||
|
||
function moveActiveResult(delta) {
|
||
if (currentResults.length === 0) return;
|
||
activeIndex =
|
||
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
|
||
currentResults.length;
|
||
syncActiveResult();
|
||
const { results } = getElements();
|
||
const activeNode = results?.children?.[activeIndex];
|
||
activeNode?.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
async function selectResult(index) {
|
||
const result = currentResults[index];
|
||
if (!result || typeof onSelectResultFn !== "function") return;
|
||
closeSearchPanel();
|
||
try {
|
||
await onSelectResultFn(result);
|
||
} catch (error) {
|
||
console.error("Search selection failed:", error);
|
||
}
|
||
}
|
||
|
||
async function runSearch() {
|
||
const { input, clear } = getElements();
|
||
if (!input) return;
|
||
|
||
const query = input.value.trim();
|
||
if (clear) {
|
||
clear.hidden = query.length === 0;
|
||
}
|
||
|
||
if (!query) {
|
||
currentResults = [];
|
||
activeIndex = -1;
|
||
setMeta("输入关键词以搜索当前地球对象");
|
||
renderResults("");
|
||
return;
|
||
}
|
||
|
||
setMeta("正在检索…");
|
||
|
||
try {
|
||
const nextResults = await resolveResultsFn?.(query);
|
||
currentResults = Array.isArray(nextResults) ? nextResults : [];
|
||
activeIndex = currentResults.length > 0 ? 0 : -1;
|
||
setMeta(`找到 ${currentResults.length} 个结果`);
|
||
renderResults(query);
|
||
} catch (error) {
|
||
console.error("Search failed:", error);
|
||
currentResults = [];
|
||
activeIndex = -1;
|
||
setMeta("搜索失败");
|
||
renderResults(query);
|
||
}
|
||
}
|
||
|
||
function scheduleSearch() {
|
||
if (searchTimerId) {
|
||
clearTimeout(searchTimerId);
|
||
}
|
||
searchTimerId = window.setTimeout(() => {
|
||
searchTimerId = null;
|
||
runSearch();
|
||
}, 120);
|
||
}
|
||
|
||
function handleKeydown(event) {
|
||
const { modal, input } = getElements();
|
||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||
if (!isMobile && !modal?.classList.contains("is-open")) return;
|
||
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
closeSearchPanel();
|
||
return;
|
||
}
|
||
|
||
if (event.target !== input) return;
|
||
|
||
if (event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
moveActiveResult(1);
|
||
} else if (event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
moveActiveResult(-1);
|
||
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||
event.preventDefault();
|
||
selectResult(activeIndex).catch((error) => {
|
||
console.warn("Selecting search result failed:", error);
|
||
});
|
||
}
|
||
}
|
||
|
||
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
|
||
resolveResultsFn = resolveResults;
|
||
onSelectResultFn = onSelectResult;
|
||
|
||
if (initialized) return;
|
||
initialized = true;
|
||
|
||
const inputs = ["earth-search-input", "mobile-earth-search-input"]
|
||
.map((id) => document.getElementById(id))
|
||
.filter((node) => node instanceof HTMLInputElement);
|
||
const clears = ["earth-search-clear", "mobile-earth-search-clear"]
|
||
.map((id) => document.getElementById(id))
|
||
.filter((node) => node instanceof HTMLButtonElement);
|
||
const close = document.getElementById("search-close");
|
||
const backdrop = document.getElementById("search-backdrop");
|
||
|
||
inputs.forEach((input) => {
|
||
input.addEventListener("input", scheduleSearch);
|
||
input.addEventListener("keydown", handleKeydown);
|
||
});
|
||
clears.forEach((clear) => {
|
||
clear.addEventListener("click", () => {
|
||
const { input } = getElements();
|
||
if (!input) return;
|
||
input.value = "";
|
||
input.focus();
|
||
runSearch().catch((error) => {
|
||
console.warn("Clearing search failed:", error);
|
||
});
|
||
});
|
||
});
|
||
close?.addEventListener("click", () => {
|
||
closeSearchPanel();
|
||
});
|
||
backdrop?.addEventListener("click", () => {
|
||
closeSearchPanel();
|
||
});
|
||
document.addEventListener("keydown", handleKeydown);
|
||
}
|
||
|
||
export function openSearchPanel() {
|
||
const { modal, input } = getElements();
|
||
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
|
||
if (isOpen) return;
|
||
isOpen = true;
|
||
document.body.classList.add("earth-search-open");
|
||
modal?.classList.add("is-open");
|
||
modal?.setAttribute("aria-hidden", "false");
|
||
window.dispatchEvent(
|
||
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
|
||
);
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
input?.focus();
|
||
input?.select();
|
||
runSearch().catch((error) => {
|
||
console.warn("Running search failed:", error);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
export function focusSearchInput({ select = false } = {}) {
|
||
const { input } = getElements();
|
||
if (!(input instanceof HTMLInputElement)) return;
|
||
input.focus();
|
||
if (select) {
|
||
input.select();
|
||
}
|
||
}
|
||
|
||
export function refreshSearchResults() {
|
||
return runSearch();
|
||
}
|
||
|
||
export function closeSearchPanel() {
|
||
const { modal } = getElements();
|
||
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
|
||
if (!isOpen) return;
|
||
isOpen = false;
|
||
document.body.classList.remove("earth-search-open");
|
||
modal?.classList.remove("is-open");
|
||
modal?.setAttribute("aria-hidden", "true");
|
||
window.dispatchEvent(
|
||
new CustomEvent("earth:search-open-change", { detail: { open: false } }),
|
||
);
|
||
}
|
||
|
||
export function isSearchPanelOpen() {
|
||
return isOpen;
|
||
}
|