475 lines
16 KiB
JavaScript
475 lines
16 KiB
JavaScript
const nested = require('../nested');
|
||
|
||
module.exports = function initRouting(state, dom, api) {
|
||
const { ui, search } = api;
|
||
const { searchInput, content, sidebar } = dom;
|
||
|
||
function showPage(pageId, skipSearchReset = false) {
|
||
if (state.currentPageId === pageId && !skipSearchReset && !state.isInitialLoad) return;
|
||
|
||
state.currentPageId = pageId;
|
||
|
||
// 使用 RAF 确保动画流畅
|
||
requestAnimationFrame(() => {
|
||
if (!state.pages) {
|
||
state.pages = document.querySelectorAll('.page');
|
||
}
|
||
|
||
state.pages.forEach((page) => {
|
||
const shouldBeActive = page.id === pageId;
|
||
if (shouldBeActive !== page.classList.contains('active')) {
|
||
page.classList.toggle('active', shouldBeActive);
|
||
}
|
||
});
|
||
|
||
// 通知:页面已切换(供按需组件初始化,如 github-calendar)
|
||
// 注意:必须在 active class 切换之后触发,否则监听方可能认为页面仍不可见。
|
||
try {
|
||
document.dispatchEvent(
|
||
new CustomEvent('menav:pageChanged', {
|
||
detail: {
|
||
pageId,
|
||
},
|
||
})
|
||
);
|
||
} catch (error) {
|
||
// ignore
|
||
}
|
||
|
||
// 初始加载完成后设置标志
|
||
if (state.isInitialLoad) {
|
||
state.isInitialLoad = false;
|
||
document.body.classList.add('loaded');
|
||
}
|
||
});
|
||
|
||
// 重置滚动位置并更新进度条
|
||
content.scrollTop = 0;
|
||
|
||
// 只有在非搜索状态下才重置搜索
|
||
if (!skipSearchReset) {
|
||
searchInput.value = '';
|
||
search.resetSearch();
|
||
}
|
||
}
|
||
|
||
// 初始化(在 window load 时执行)
|
||
window.addEventListener('load', () => {
|
||
// 获取可能在 HTML 生成后才存在的 DOM 元素
|
||
const categories = document.querySelectorAll('.category');
|
||
const navItems = document.querySelectorAll('.nav-item');
|
||
const navItemWrappers = document.querySelectorAll('.nav-item-wrapper');
|
||
const submenuItems = document.querySelectorAll('.submenu-item');
|
||
state.pages = document.querySelectorAll('.page');
|
||
|
||
// 方案 A:用 ?page=<id> 作为页面深链接(兼容 GitHub Pages 静态托管)
|
||
const normalizeText = (value) =>
|
||
String(value === null || value === undefined ? '' : value).trim();
|
||
|
||
// 侧边栏子菜单面板:将“当前页面的分类列表”放到独立区域滚动,避免挤压“页面列表”
|
||
const submenuPanel = document.querySelector('.sidebar-submenu-panel');
|
||
const submenuByPageId = new Map();
|
||
let submenuPanelPageId = '';
|
||
|
||
navItemWrappers.forEach((wrapper) => {
|
||
const nav = wrapper.querySelector('.nav-item');
|
||
const pageId = nav ? normalizeText(nav.getAttribute('data-page')) : '';
|
||
const submenu = wrapper.querySelector('.submenu');
|
||
if (!pageId || !submenu) return;
|
||
submenuByPageId.set(pageId, { wrapper, submenu });
|
||
});
|
||
|
||
const isSidebarCollapsed = () => Boolean(sidebar && sidebar.classList.contains('collapsed'));
|
||
|
||
const clearSubmenuPanel = () => {
|
||
if (!submenuPanel) return;
|
||
|
||
const pageId = normalizeText(submenuPanelPageId);
|
||
if (pageId) {
|
||
const entry = submenuByPageId.get(pageId);
|
||
if (entry && entry.wrapper && entry.submenu) {
|
||
entry.wrapper.appendChild(entry.submenu);
|
||
}
|
||
}
|
||
|
||
submenuPanel.textContent = '';
|
||
submenuPanelPageId = '';
|
||
};
|
||
|
||
const renderSubmenuPanelForPage = (pageId) => {
|
||
if (!submenuPanel) return;
|
||
|
||
const id = normalizeText(pageId);
|
||
if (!id) {
|
||
clearSubmenuPanel();
|
||
return;
|
||
}
|
||
|
||
// 折叠态:子菜单使用 hover 弹出,不使用面板
|
||
if (isSidebarCollapsed()) {
|
||
clearSubmenuPanel();
|
||
return;
|
||
}
|
||
|
||
const entry = submenuByPageId.get(id);
|
||
if (!entry || !entry.wrapper || !entry.submenu) {
|
||
clearSubmenuPanel();
|
||
return;
|
||
}
|
||
|
||
// 仅当 wrapper 处于 expanded 时展示(与 UI 行为保持一致)
|
||
if (!entry.wrapper.classList.contains('expanded')) {
|
||
clearSubmenuPanel();
|
||
return;
|
||
}
|
||
|
||
if (normalizeText(submenuPanelPageId) === id && submenuPanel.contains(entry.submenu)) {
|
||
return;
|
||
}
|
||
|
||
clearSubmenuPanel();
|
||
submenuPanel.appendChild(entry.submenu);
|
||
submenuPanelPageId = id;
|
||
};
|
||
|
||
// 监听侧边栏折叠状态变化:折叠时归还子菜单;展开时渲染当前页子菜单
|
||
if (sidebar && typeof MutationObserver === 'function') {
|
||
const observer = new MutationObserver(() => {
|
||
const activeNav = document.querySelector('.nav-item.active');
|
||
const activePageId = activeNav ? normalizeText(activeNav.getAttribute('data-page')) : '';
|
||
renderSubmenuPanelForPage(activePageId);
|
||
});
|
||
|
||
observer.observe(sidebar, { attributes: true, attributeFilter: ['class'] });
|
||
}
|
||
|
||
const isValidPageId = (pageId) => {
|
||
const id = normalizeText(pageId);
|
||
if (!id) return false;
|
||
const el = document.getElementById(id);
|
||
return Boolean(el && el.classList && el.classList.contains('page'));
|
||
};
|
||
|
||
const getRawPageIdFromUrl = () => {
|
||
try {
|
||
const url = new URL(window.location.href);
|
||
return normalizeText(url.searchParams.get('page'));
|
||
} catch (error) {
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const getPageIdFromUrl = () => {
|
||
try {
|
||
const url = new URL(window.location.href);
|
||
const pageId = normalizeText(url.searchParams.get('page'));
|
||
return isValidPageId(pageId) ? pageId : '';
|
||
} catch (error) {
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const setUrlState = (next, options = {}) => {
|
||
const { replace = true } = options;
|
||
try {
|
||
const url = new URL(window.location.href);
|
||
|
||
if (next && typeof next.pageId === 'string') {
|
||
const pageId = normalizeText(next.pageId);
|
||
if (pageId) {
|
||
url.searchParams.set('page', pageId);
|
||
} else {
|
||
url.searchParams.delete('page');
|
||
}
|
||
}
|
||
|
||
if (next && Object.prototype.hasOwnProperty.call(next, 'hash')) {
|
||
const hash = normalizeText(next.hash);
|
||
url.hash = hash ? `#${hash}` : '';
|
||
}
|
||
|
||
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
||
const fn = replace ? history.replaceState : history.pushState;
|
||
fn.call(history, null, '', nextUrl);
|
||
} catch (error) {
|
||
// 忽略 URL/History API 异常,避免影响主流程
|
||
}
|
||
};
|
||
|
||
const setActiveNavByPageId = (pageId) => {
|
||
const id = normalizeText(pageId);
|
||
let activeItem = null;
|
||
|
||
navItems.forEach((nav) => {
|
||
const isActive = nav.getAttribute('data-page') === id;
|
||
nav.classList.toggle('active', isActive);
|
||
if (isActive) activeItem = nav;
|
||
});
|
||
|
||
// 同步子菜单展开状态:只展开当前激活项
|
||
navItemWrappers.forEach((wrapper) => {
|
||
const nav = wrapper.querySelector('.nav-item');
|
||
if (!nav) return;
|
||
const pageId = normalizeText(nav.getAttribute('data-page'));
|
||
const hasSubmenu = pageId ? submenuByPageId.has(pageId) : false;
|
||
const shouldExpand = hasSubmenu && nav === activeItem;
|
||
wrapper.classList.toggle('expanded', shouldExpand);
|
||
});
|
||
|
||
renderSubmenuPanelForPage(id);
|
||
};
|
||
|
||
const escapeSelector = (value) => {
|
||
if (value === null || value === undefined) return '';
|
||
const text = String(value);
|
||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
|
||
// 回退:尽量避免打断选择器(不追求完全覆盖所有边界字符)
|
||
return text.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
|
||
};
|
||
|
||
const escapeAttrValue = (value) => {
|
||
if (value === null || value === undefined) return '';
|
||
return String(value).replace(/\\/g, '\\\\').replace(/\"/g, '\\"');
|
||
};
|
||
|
||
const getHashFromUrl = () => {
|
||
const rawHash = window.location.hash ? String(window.location.hash).slice(1) : '';
|
||
if (!rawHash) return '';
|
||
try {
|
||
return decodeURIComponent(rawHash).trim();
|
||
} catch (error) {
|
||
return rawHash.trim();
|
||
}
|
||
};
|
||
|
||
const scrollToCategoryInPage = (pageId, options = {}) => {
|
||
const id = normalizeText(pageId);
|
||
if (!id) return false;
|
||
|
||
const targetPage = document.getElementById(id);
|
||
if (!targetPage) return false;
|
||
|
||
const categoryId = normalizeText(options.categoryId);
|
||
const categoryName = normalizeText(options.categoryName);
|
||
|
||
let targetCategory = null;
|
||
|
||
// 优先使用 slug/data-id 精准定位(解决重复命名始终命中第一个的问题)
|
||
if (categoryId) {
|
||
const escapedId = escapeSelector(categoryId);
|
||
targetCategory =
|
||
targetPage.querySelector(`#${escapedId}`) ||
|
||
targetPage.querySelector(
|
||
`[data-type="category"][data-id="${escapeAttrValue(categoryId)}"]`
|
||
);
|
||
}
|
||
|
||
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
|
||
if (!targetCategory && categoryName) {
|
||
targetCategory = Array.from(targetPage.querySelectorAll('.category h2')).find((heading) =>
|
||
heading.textContent.trim().includes(categoryName)
|
||
);
|
||
}
|
||
|
||
if (!targetCategory) return false;
|
||
|
||
// 优化的滚动实现:滚动到使目标分类位于视口 1/4 处(更靠近顶部位置)
|
||
try {
|
||
// 直接获取所需元素和属性,减少重复查询
|
||
const contentElement = document.querySelector('.content');
|
||
|
||
if (contentElement && contentElement.scrollHeight > contentElement.clientHeight) {
|
||
// 获取目标元素相对于内容区域的位置
|
||
const rect = targetCategory.getBoundingClientRect();
|
||
const containerRect = contentElement.getBoundingClientRect();
|
||
|
||
// 计算目标应该在视口中的位置(视口高度的 1/4 处)
|
||
const desiredPosition = containerRect.height / 4;
|
||
|
||
// 计算需要滚动的位置
|
||
const scrollPosition =
|
||
contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
|
||
|
||
// 执行滚动
|
||
contentElement.scrollTo({
|
||
top: scrollPosition,
|
||
behavior: 'smooth',
|
||
});
|
||
} else {
|
||
// 回退到基本滚动方式
|
||
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
} catch (error) {
|
||
console.error('Error during scroll:', error);
|
||
// 回退到基本滚动方式
|
||
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
// 初始化主题
|
||
ui.initTheme();
|
||
|
||
// 初始化侧边栏状态
|
||
ui.initSidebarState();
|
||
|
||
// 初始化搜索引擎选择
|
||
search.initSearchEngine();
|
||
|
||
// 初始化 MeNav 对象版本信息
|
||
try {
|
||
const config = window.MeNav.getConfig();
|
||
if (config && config.version) {
|
||
window.MeNav.version = config.version;
|
||
console.log('MeNav API initialized with version:', config.version);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error initializing MeNav API:', error);
|
||
}
|
||
|
||
// 立即执行初始化,不再使用 requestAnimationFrame 延迟
|
||
// 支持 ?page=<id> 直接打开对应页面;无效时回退到首页
|
||
const rawPageIdFromUrl = getRawPageIdFromUrl();
|
||
const validatedPageIdFromUrl = getPageIdFromUrl();
|
||
const initialPageId =
|
||
validatedPageIdFromUrl || (isValidPageId(state.homePageId) ? state.homePageId : 'home');
|
||
|
||
setActiveNavByPageId(initialPageId);
|
||
showPage(initialPageId);
|
||
|
||
// 当输入了不存在的 page id 时,自动纠正 URL,避免“内容回退但地址栏仍错误”
|
||
if (rawPageIdFromUrl && !validatedPageIdFromUrl) {
|
||
setUrlState({ pageId: initialPageId }, { replace: true });
|
||
}
|
||
|
||
// 初始深链接:支持 ?page=<id>#<categorySlug>
|
||
const initialHash = getHashFromUrl();
|
||
if (initialHash) {
|
||
setTimeout(() => {
|
||
const found = scrollToCategoryInPage(initialPageId, {
|
||
categoryId: initialHash,
|
||
categoryName: initialHash,
|
||
});
|
||
|
||
// hash 存在但未命中时,不做强制修正,避免误伤其他用途的 hash
|
||
if (!found) return;
|
||
}, 50);
|
||
}
|
||
|
||
// 添加载入动画
|
||
categories.forEach((category, index) => {
|
||
setTimeout(() => {
|
||
category.style.opacity = '1';
|
||
}, index * 100);
|
||
});
|
||
|
||
// 导航项点击效果
|
||
navItems.forEach((item) => {
|
||
item.addEventListener('click', (e) => {
|
||
if (item.getAttribute('target') === '_blank') return;
|
||
|
||
e.preventDefault();
|
||
|
||
// 获取当前项的父级 wrapper
|
||
const wrapper = item.closest('.nav-item-wrapper');
|
||
const pageId = normalizeText(item.getAttribute('data-page'));
|
||
const hasSubmenu = Boolean(wrapper && pageId && submenuByPageId.has(pageId));
|
||
|
||
if (!pageId) return;
|
||
|
||
// 处理子菜单展开/折叠
|
||
if (hasSubmenu && item.classList.contains('active')) {
|
||
// 当前页:保持子菜单展开状态,不做任何操作
|
||
return;
|
||
} else {
|
||
// 切换页面:统一由 setActiveNavByPageId 管理 active/expanded
|
||
setActiveNavByPageId(pageId);
|
||
}
|
||
|
||
const prevPageId = state.currentPageId;
|
||
showPage(pageId);
|
||
|
||
// 切换页面时同步 URL(清空旧 hash,避免跨页残留)
|
||
if (normalizeText(prevPageId) !== normalizeText(pageId)) {
|
||
setUrlState({ pageId, hash: '' }, { replace: true });
|
||
}
|
||
|
||
// 在移动端视图下点击导航项后自动收起侧边栏
|
||
if (ui.isMobile() && state.isSidebarOpen && !hasSubmenu) {
|
||
ui.closeAllPanels();
|
||
}
|
||
});
|
||
});
|
||
|
||
// 子菜单项点击效果
|
||
submenuItems.forEach((item) => {
|
||
item.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
|
||
// 获取页面 ID 和分类名称
|
||
const pageId = item.getAttribute('data-page');
|
||
const categoryName = item.getAttribute('data-category');
|
||
const categoryId = item.getAttribute('data-category-id');
|
||
|
||
if (pageId) {
|
||
// 清除所有子菜单项的激活状态
|
||
submenuItems.forEach((subItem) => {
|
||
subItem.classList.remove('active');
|
||
});
|
||
|
||
// 激活当前子菜单项
|
||
item.classList.add('active');
|
||
|
||
// 激活导航项并同步子菜单展开状态
|
||
setActiveNavByPageId(pageId);
|
||
|
||
// 显示对应页面
|
||
showPage(pageId);
|
||
// 先同步 page 参数并清空旧 hash,避免跨页残留;后续若找到分类再写入新的 hash
|
||
setUrlState({ pageId, hash: '' }, { replace: true });
|
||
|
||
// 等待页面切换完成后滚动到对应分类
|
||
setTimeout(() => {
|
||
const found = scrollToCategoryInPage(pageId, { categoryId, categoryName });
|
||
if (!found) return;
|
||
|
||
// 由于对子菜单 click 做了 preventDefault,这里手动同步 hash(不触发浏览器默认跳转)
|
||
const nextHash = normalizeText(categoryId) || normalizeText(categoryName);
|
||
if (nextHash) {
|
||
setUrlState({ pageId, hash: nextHash }, { replace: true });
|
||
}
|
||
}, 25); // 延迟时间
|
||
|
||
// 在移动端视图下点击子菜单项后自动收起侧边栏
|
||
if (ui.isMobile() && state.isSidebarOpen) {
|
||
ui.closeAllPanels();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// 初始化嵌套分类功能
|
||
nested.initializeNestedCategories();
|
||
|
||
// 初始化分类切换按钮
|
||
const categoryToggleBtn = document.getElementById('category-toggle');
|
||
if (categoryToggleBtn) {
|
||
categoryToggleBtn.addEventListener('click', function () {
|
||
window.MeNav.toggleCategories();
|
||
});
|
||
} else {
|
||
console.error('Category toggle button not found');
|
||
}
|
||
|
||
// 初始化搜索索引(使用 requestIdleCallback 或 setTimeout 延迟初始化,避免影响页面加载)
|
||
if ('requestIdleCallback' in window) {
|
||
requestIdleCallback(() => search.initSearchIndex());
|
||
} else {
|
||
setTimeout(search.initSearchIndex, 1000);
|
||
}
|
||
});
|
||
|
||
return { showPage };
|
||
};
|