refactor: 模块化重构 generator 和 runtime
This commit is contained in:
440
src/runtime/app/search.js
Normal file
440
src/runtime/app/search.js
Normal file
@@ -0,0 +1,440 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user