- 引入 ConfigError/TemplateError/BuildError/FileError 与 wrapAsyncError,统一错误输出 - generator 入口接入 wrapAsyncError,确保命令行执行路径一致 - 兜底逻辑使用 instanceof,保留 BuildError/TemplateError 上下文信息 - 合并格式化提交(仅缩进/换行调整)
450 lines
14 KiB
JavaScript
450 lines
14 KiB
JavaScript
const searchEngines = require('./searchEngines');
|
||
const highlightSearchTerm = require('./search/highlight');
|
||
|
||
module.exports = function initSearch(state, dom) {
|
||
const {
|
||
searchInput,
|
||
searchBox,
|
||
searchResultsPage,
|
||
searchSections,
|
||
searchEngineToggle,
|
||
searchEngineToggleIcon,
|
||
searchEngineToggleLabel,
|
||
searchEngineDropdown,
|
||
searchEngineOptions,
|
||
} = dom;
|
||
|
||
if (!state.searchIndex) {
|
||
state.searchIndex = { initialized: false, items: [] };
|
||
}
|
||
if (!state.currentSearchEngine) {
|
||
state.currentSearchEngine = 'local';
|
||
}
|
||
if (typeof state.isSearchActive !== 'boolean') {
|
||
state.isSearchActive = false;
|
||
}
|
||
|
||
// 初始化搜索索引
|
||
function initSearchIndex() {
|
||
if (state.searchIndex.initialized) return;
|
||
|
||
state.searchIndex.items = [];
|
||
|
||
try {
|
||
// 为每个页面创建索引
|
||
if (!state.pages) {
|
||
state.pages = document.querySelectorAll('.page');
|
||
}
|
||
|
||
state.pages.forEach((page) => {
|
||
if (page.id === 'search-results') return;
|
||
|
||
const pageId = page.id;
|
||
|
||
page.querySelectorAll('.site-card').forEach((card) => {
|
||
try {
|
||
// 排除“扩展写回影子结构”等不应参与搜索的卡片
|
||
if (card.closest('[data-search-exclude="true"]')) return;
|
||
|
||
// 兼容不同页面/卡片样式:优先取可见文本,其次回退到 data-*(确保 projects repo 卡片也能被搜索)
|
||
const dataTitle = card.dataset?.name || card.getAttribute('data-name') || '';
|
||
const dataDescription =
|
||
card.dataset?.description || card.getAttribute('data-description') || '';
|
||
|
||
const titleText =
|
||
card.querySelector('h3')?.textContent ||
|
||
card.querySelector('.repo-title')?.textContent ||
|
||
dataTitle;
|
||
const descriptionText =
|
||
card.querySelector('p')?.textContent ||
|
||
card.querySelector('.repo-desc')?.textContent ||
|
||
dataDescription;
|
||
|
||
const title = String(titleText || '').toLowerCase();
|
||
const description = String(descriptionText || '').toLowerCase();
|
||
const url = card.href || card.getAttribute('href') || '#';
|
||
const icon =
|
||
card.querySelector('i.icon-fallback')?.className ||
|
||
card.querySelector('i')?.className ||
|
||
'';
|
||
|
||
// 将卡片信息添加到索引中
|
||
state.searchIndex.items.push({
|
||
pageId,
|
||
title,
|
||
description,
|
||
url,
|
||
icon,
|
||
element: card,
|
||
// 预先计算搜索文本,提高搜索效率
|
||
searchText: (title + ' ' + description).toLowerCase(),
|
||
});
|
||
} catch (cardError) {
|
||
console.error('Error processing card:', cardError);
|
||
}
|
||
});
|
||
});
|
||
|
||
state.searchIndex.initialized = true;
|
||
} catch (error) {
|
||
console.error('Error initializing search index:', error);
|
||
state.searchIndex.initialized = true; // 防止反复尝试初始化
|
||
}
|
||
}
|
||
|
||
// 搜索功能
|
||
function performSearch(searchTerm) {
|
||
// 确保搜索索引已初始化
|
||
if (!state.searchIndex.initialized) {
|
||
initSearchIndex();
|
||
}
|
||
|
||
searchTerm = searchTerm.toLowerCase().trim();
|
||
|
||
// 如果搜索框为空,重置所有内容
|
||
if (!searchTerm) {
|
||
resetSearch();
|
||
return;
|
||
}
|
||
|
||
if (!state.isSearchActive) {
|
||
state.isSearchActive = true;
|
||
}
|
||
|
||
try {
|
||
// 使用搜索索引进行搜索
|
||
const searchResults = new Map();
|
||
let hasResults = false;
|
||
|
||
// 使用更高效的搜索算法
|
||
const matchedItems = state.searchIndex.items.filter((item) => {
|
||
return (
|
||
item.searchText.includes(searchTerm) || PinyinMatch.match(item.searchText, searchTerm)
|
||
);
|
||
});
|
||
|
||
// 按页面分组结果
|
||
matchedItems.forEach((item) => {
|
||
if (!searchResults.has(item.pageId)) {
|
||
searchResults.set(item.pageId, []);
|
||
}
|
||
// 克隆元素以避免修改原始 DOM
|
||
searchResults.get(item.pageId).push(item.element.cloneNode(true));
|
||
hasResults = true;
|
||
});
|
||
|
||
// 使用 requestAnimationFrame 批量更新 DOM,减少重排重绘
|
||
requestAnimationFrame(() => {
|
||
try {
|
||
// 清空并隐藏所有搜索区域
|
||
searchSections.forEach((section) => {
|
||
try {
|
||
const grid = section.querySelector('.sites-grid');
|
||
if (grid) {
|
||
grid.innerHTML = ''; // 使用 innerHTML 清空,比 removeChild 更高效
|
||
}
|
||
section.style.display = 'none';
|
||
} catch (sectionError) {
|
||
console.error('Error clearing search section');
|
||
}
|
||
});
|
||
|
||
// 使用 DocumentFragment 批量添加 DOM 元素,减少重排
|
||
searchResults.forEach((matches, pageId) => {
|
||
const section = searchResultsPage.querySelector(`[data-section="${pageId}"]`);
|
||
if (section) {
|
||
try {
|
||
const grid = section.querySelector('.sites-grid');
|
||
if (grid) {
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
matches.forEach((card) => {
|
||
// 高亮匹配文本
|
||
highlightSearchTerm(card, searchTerm);
|
||
fragment.appendChild(card);
|
||
});
|
||
|
||
grid.appendChild(fragment);
|
||
section.style.display = 'block';
|
||
}
|
||
} catch (gridError) {
|
||
console.error('Error updating search results grid');
|
||
}
|
||
}
|
||
});
|
||
|
||
// 更新搜索结果页面状态
|
||
const subtitle = searchResultsPage.querySelector('.subtitle');
|
||
if (subtitle) {
|
||
subtitle.textContent = hasResults
|
||
? `在所有页面中找到 ${matchedItems.length} 个匹配项`
|
||
: '未找到匹配的结果';
|
||
}
|
||
|
||
// 显示搜索结果页面
|
||
if (state.currentPageId !== 'search-results') {
|
||
state.currentPageId = 'search-results';
|
||
if (!state.pages) state.pages = document.querySelectorAll('.page');
|
||
state.pages.forEach((page) => {
|
||
page.classList.toggle('active', page.id === 'search-results');
|
||
});
|
||
}
|
||
|
||
// 更新搜索状态样式
|
||
searchBox.classList.toggle('has-results', hasResults);
|
||
searchBox.classList.toggle('no-results', !hasResults);
|
||
} catch (uiError) {
|
||
console.error('Error updating search UI');
|
||
}
|
||
});
|
||
} catch (searchError) {
|
||
console.error('Error performing search');
|
||
}
|
||
}
|
||
|
||
// 重置搜索状态
|
||
function resetSearch() {
|
||
if (!state.isSearchActive) return;
|
||
|
||
state.isSearchActive = false;
|
||
|
||
try {
|
||
requestAnimationFrame(() => {
|
||
try {
|
||
// 清空搜索结果
|
||
searchSections.forEach((section) => {
|
||
try {
|
||
const grid = section.querySelector('.sites-grid');
|
||
if (grid) {
|
||
while (grid.firstChild) {
|
||
grid.removeChild(grid.firstChild);
|
||
}
|
||
}
|
||
section.style.display = 'none';
|
||
} catch (sectionError) {
|
||
console.error('Error clearing search section');
|
||
}
|
||
});
|
||
|
||
// 移除搜索状态样式
|
||
searchBox.classList.remove('has-results', 'no-results');
|
||
|
||
// 恢复到当前激活的页面
|
||
const currentActiveNav = document.querySelector('.nav-item.active');
|
||
if (currentActiveNav) {
|
||
const targetPageId = currentActiveNav.getAttribute('data-page');
|
||
|
||
if (targetPageId && state.currentPageId !== targetPageId) {
|
||
state.currentPageId = targetPageId;
|
||
if (!state.pages) state.pages = document.querySelectorAll('.page');
|
||
state.pages.forEach((page) => {
|
||
page.classList.toggle('active', page.id === targetPageId);
|
||
});
|
||
}
|
||
} else {
|
||
// 如果没有激活的导航项,默认显示首页
|
||
state.currentPageId = state.homePageId;
|
||
if (!state.pages) state.pages = document.querySelectorAll('.page');
|
||
state.pages.forEach((page) => {
|
||
page.classList.toggle('active', page.id === state.homePageId);
|
||
});
|
||
}
|
||
} catch (resetError) {
|
||
console.error('Error resetting search UI');
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Error in resetSearch');
|
||
}
|
||
}
|
||
|
||
// 搜索输入事件(使用防抖)
|
||
const debounce = (fn, delay) => {
|
||
let timer = null;
|
||
return (...args) => {
|
||
if (timer) clearTimeout(timer);
|
||
timer = setTimeout(() => {
|
||
fn.apply(this, args);
|
||
timer = null;
|
||
}, delay);
|
||
};
|
||
};
|
||
|
||
const debouncedSearch = debounce(performSearch, 300);
|
||
|
||
searchInput.addEventListener('input', (e) => {
|
||
// 只有在选择了本地搜索时,才在输入时实时显示本地搜索结果
|
||
if (state.currentSearchEngine === 'local') {
|
||
debouncedSearch(e.target.value);
|
||
} else {
|
||
// 对于非本地搜索,重置之前的本地搜索结果(如果有)
|
||
if (state.isSearchActive) {
|
||
resetSearch();
|
||
}
|
||
}
|
||
});
|
||
|
||
// 更新搜索引擎 UI 显示
|
||
function updateSearchEngineUI() {
|
||
// 移除所有选项的激活状态
|
||
searchEngineOptions.forEach((option) => {
|
||
option.classList.remove('active');
|
||
|
||
// 如果是当前选中的搜索引擎,添加激活状态
|
||
if (option.getAttribute('data-engine') === state.currentSearchEngine) {
|
||
option.classList.add('active');
|
||
}
|
||
});
|
||
|
||
// 更新搜索引擎按钮(方案 B:前缀按钮显示当前引擎)
|
||
const engine = searchEngines[state.currentSearchEngine];
|
||
if (!engine) return;
|
||
const displayName = engine.shortName || engine.name.replace(/搜索$/, '');
|
||
|
||
if (searchEngineToggleIcon) {
|
||
if (engine.iconSvg) {
|
||
searchEngineToggleIcon.className = 'search-engine-icon search-engine-icon-svg';
|
||
searchEngineToggleIcon.innerHTML = engine.iconSvg;
|
||
} else {
|
||
searchEngineToggleIcon.innerHTML = '';
|
||
searchEngineToggleIcon.className = `search-engine-icon ${engine.icon}`;
|
||
}
|
||
}
|
||
if (searchEngineToggleLabel) {
|
||
searchEngineToggleLabel.textContent = displayName;
|
||
}
|
||
if (searchEngineToggle) {
|
||
searchEngineToggle.setAttribute('aria-label', `当前搜索引擎:${engine.name},点击切换`);
|
||
}
|
||
}
|
||
|
||
// 初始化搜索引擎设置
|
||
function initSearchEngine() {
|
||
// 从本地存储获取上次选择的搜索引擎
|
||
const savedEngine = localStorage.getItem('searchEngine');
|
||
if (savedEngine && searchEngines[savedEngine]) {
|
||
state.currentSearchEngine = savedEngine;
|
||
}
|
||
|
||
// 设置当前搜索引擎的激活状态及图标
|
||
updateSearchEngineUI();
|
||
|
||
// 初始化搜索引擎下拉菜单事件
|
||
const toggleEngineDropdown = () => {
|
||
if (!searchEngineDropdown) return;
|
||
const next = !searchEngineDropdown.classList.contains('active');
|
||
searchEngineDropdown.classList.toggle('active', next);
|
||
if (searchBox) {
|
||
searchBox.classList.toggle('dropdown-open', next);
|
||
}
|
||
if (searchEngineToggle) {
|
||
searchEngineToggle.setAttribute('aria-expanded', String(next));
|
||
}
|
||
};
|
||
|
||
if (searchEngineToggle) {
|
||
searchEngineToggle.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleEngineDropdown();
|
||
});
|
||
|
||
// 键盘可访问性:Enter/Space 触发
|
||
searchEngineToggle.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleEngineDropdown();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 点击搜索引擎选项
|
||
searchEngineOptions.forEach((option) => {
|
||
// 初始化激活状态
|
||
if (option.getAttribute('data-engine') === state.currentSearchEngine) {
|
||
option.classList.add('active');
|
||
}
|
||
|
||
option.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
|
||
// 获取选中的搜索引擎
|
||
const engine = option.getAttribute('data-engine');
|
||
|
||
// 更新当前搜索引擎
|
||
if (engine && searchEngines[engine]) {
|
||
// 如果搜索引擎变更,且之前有活跃的本地搜索结果,重置搜索状态
|
||
if (state.currentSearchEngine !== engine && state.isSearchActive) {
|
||
resetSearch();
|
||
}
|
||
|
||
state.currentSearchEngine = engine;
|
||
localStorage.setItem('searchEngine', engine);
|
||
|
||
// 更新 UI 显示
|
||
updateSearchEngineUI();
|
||
|
||
// 关闭下拉菜单
|
||
if (searchEngineDropdown) {
|
||
searchEngineDropdown.classList.remove('active');
|
||
}
|
||
if (searchBox) {
|
||
searchBox.classList.remove('dropdown-open');
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// 点击页面其他位置关闭下拉菜单
|
||
document.addEventListener('click', () => {
|
||
if (!searchEngineDropdown) return;
|
||
searchEngineDropdown.classList.remove('active');
|
||
if (searchBox) {
|
||
searchBox.classList.remove('dropdown-open');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 执行搜索(根据选择的搜索引擎)
|
||
function executeSearch(searchTerm) {
|
||
if (!searchTerm.trim()) return;
|
||
|
||
// 根据当前搜索引擎执行搜索
|
||
if (state.currentSearchEngine === 'local') {
|
||
// 执行本地搜索
|
||
performSearch(searchTerm);
|
||
} else {
|
||
// 使用外部搜索引擎
|
||
const engine = searchEngines[state.currentSearchEngine];
|
||
if (engine && engine.url) {
|
||
// 打开新窗口进行搜索
|
||
window.open(engine.url + encodeURIComponent(searchTerm), '_blank');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 搜索框事件处理
|
||
searchInput.addEventListener('keyup', (e) => {
|
||
if (e.key === 'Escape') {
|
||
searchInput.value = '';
|
||
resetSearch();
|
||
} else if (e.key === 'Enter') {
|
||
executeSearch(searchInput.value);
|
||
}
|
||
});
|
||
|
||
// 阻止搜索框的回车默认行为
|
||
searchInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
return {
|
||
initSearchIndex,
|
||
initSearchEngine,
|
||
resetSearch,
|
||
performSearch,
|
||
};
|
||
};
|