me / src /runtime /app /routing.js
cheymin's picture
Upload 136 files
e1ae2c6 verified
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 };
};