paper-burner/js/ui/sidebar-integration.js

782 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Sidebar Integration - 侧边栏集成模块
*
* 职责:
* 1. 从原有 TOC 数据同步到侧边栏 TOC
* 2. 从原有 Dock 统计同步到侧边栏统计
* 3. 处理侧边栏折叠/展开(桌面端)
* 4. 处理侧边栏显示/隐藏(移动端)
* 5. 处理可折叠区域的展开/收起
* 6. 保持状态到 localStorage
*
* 注意:仅在非沉浸模式下工作,沉浸模式不受影响
*/
(function() {
'use strict';
// ==================== 配置 ====================
const CONFIG = {
storageKeys: {
sidebarCollapsed: 'pbx_sidebar_collapsed',
tocSectionExpanded: 'pbx_sidebar_toc_expanded'
},
selectors: {
// Sidebar
appShell: '#app-shell',
appSidebar: '#appSidebar',
sidebarOverlay: '#sidebarOverlay',
sidebarToggleBtn: '#sidebarToggleBtn',
sidebarToggleIcon: '#sidebarToggleIcon',
sidebarCloseBtn: '#sidebarCloseBtn',
mobileMenuBtn: '#mobileMenuBtn',
sidebarLogo: '#sidebarLogo',
sidebarSettingsLink: '#sidebarSettingsLink',
// TOC Section
sidebarTocSection: '#sidebarTocSection',
sidebarTocToggle: '#sidebarTocToggle',
sidebarTocList: '#sidebarTocList',
originalTocList: '#toc-list',
// Sidebar Footer Stats (紧凑布局)
sidebarReadingProgress: '#sidebarReadingProgress',
sidebarHighlightCount: '#sidebarHighlightCount',
sidebarAnnotationCount: '#sidebarAnnotationCount',
sidebarImageCount: '#sidebarImageCount',
sidebarFormulaCount: '#sidebarFormulaCount',
sidebarTableCount: '#sidebarTableCount',
sidebarWordCount: '#sidebarWordCount',
sidebarReferenceCount: '#sidebarReferenceCount',
// Original Dock Elements
originalReadingProgress: '#reading-progress-percentage-verbose',
originalHighlightCount: '#highlight-count',
originalAnnotationCount: '#annotation-count',
originalImageCount: '#image-count',
originalFormulaCount: '#formula-count',
originalTableCount: '#table-count',
originalWordCount: '#total-word-count',
originalReferenceCount: '#reference-count',
// Immersive Mode
immersiveContainer: '#immersive-layout-container',
immersiveToggleBtn: '#toggle-immersive-btn',
// Settings Link
originalSettingsLink: '#settings-link'
},
logos: {
full: '../../public/h_with_name.svg',
pure: '../../public/pure.svg'
}
};
// ==================== 状态管理 ====================
let isImmersiveMode = false;
let isMobile = window.innerWidth < 768;
// ==================== DOM 元素缓存 ====================
const elements = {};
/**
* 初始化 DOM 元素缓存
*/
function cacheElements() {
for (const [key, selector] of Object.entries(CONFIG.selectors)) {
elements[key] = document.querySelector(selector);
}
}
// ==================== 侧边栏显示/隐藏 ====================
/**
* 显示或隐藏 App Shell根据沉浸模式
*/
function updateAppShellVisibility() {
if (!elements.appShell) return;
if (isImmersiveMode) {
elements.appShell.style.display = 'none';
} else {
elements.appShell.style.display = 'flex';
}
}
/**
* 监听沉浸模式切换
*/
function watchImmersiveMode() {
if (!elements.immersiveContainer) return;
// 使用 MutationObserver 监听 display 样式变化
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const display = elements.immersiveContainer.style.display;
const newImmersiveState = (display !== 'none');
if (newImmersiveState !== isImmersiveMode) {
isImmersiveMode = newImmersiveState;
updateAppShellVisibility();
console.log('[Sidebar] Immersive mode:', isImmersiveMode ? 'ON' : 'OFF');
}
}
});
});
observer.observe(elements.immersiveContainer, {
attributes: true,
attributeFilter: ['style']
});
// 初始状态
const initialDisplay = elements.immersiveContainer.style.display;
isImmersiveMode = (initialDisplay !== 'none');
updateAppShellVisibility();
}
// ==================== 桌面端侧边栏折叠 ====================
/**
* 设置侧边栏折叠状态
* @param {boolean} collapsed - 是否折叠
*/
function setSidebarCollapsed(collapsed) {
if (!elements.appSidebar || !elements.sidebarLogo) return;
if (collapsed) {
elements.appSidebar.classList.add('collapsed');
elements.sidebarLogo.src = CONFIG.logos.pure;
// 切换图标为"打开"图标
if (elements.sidebarToggleIcon) {
elements.sidebarToggleIcon.setAttribute('icon', 'carbon:side-panel-open');
}
} else {
elements.appSidebar.classList.remove('collapsed');
elements.sidebarLogo.src = CONFIG.logos.full;
// 切换图标为"关闭"图标
if (elements.sidebarToggleIcon) {
elements.sidebarToggleIcon.setAttribute('icon', 'carbon:side-panel-close');
}
}
// 保存状态
localStorage.setItem(CONFIG.storageKeys.sidebarCollapsed, collapsed.toString());
}
/**
* 初始化桌面端折叠状态
*/
function initDesktopCollapse() {
if (!elements.sidebarToggleBtn) return;
// 从 localStorage 读取状态
const savedState = localStorage.getItem(CONFIG.storageKeys.sidebarCollapsed);
const isCollapsed = savedState === 'true';
setSidebarCollapsed(isCollapsed);
// 绑定切换按钮
elements.sidebarToggleBtn.addEventListener('click', () => {
const currentlyCollapsed = elements.appSidebar.classList.contains('collapsed');
setSidebarCollapsed(!currentlyCollapsed);
});
}
// ==================== 移动端侧边栏显示/隐藏 ====================
/**
* 打开移动端侧边栏
*/
function openMobileSidebar() {
if (!elements.appSidebar || !elements.sidebarOverlay) return;
elements.appSidebar.classList.add('mobile-open');
elements.sidebarOverlay.classList.add('show');
}
/**
* 关闭移动端侧边栏
*/
function closeMobileSidebar() {
if (!elements.appSidebar || !elements.sidebarOverlay) return;
elements.appSidebar.classList.remove('mobile-open');
elements.sidebarOverlay.classList.remove('show');
}
/**
* 初始化移动端侧边栏
*/
function initMobileSidebar() {
// 绑定打开按钮
if (elements.mobileMenuBtn) {
elements.mobileMenuBtn.addEventListener('click', openMobileSidebar);
}
// 绑定关闭按钮
if (elements.sidebarCloseBtn) {
elements.sidebarCloseBtn.addEventListener('click', closeMobileSidebar);
}
// 绑定遮罩层点击关闭
if (elements.sidebarOverlay) {
elements.sidebarOverlay.addEventListener('click', closeMobileSidebar);
}
}
// ==================== 可折叠区域 ====================
/**
* 切换可折叠区域的展开/收起状态
* @param {HTMLElement} section - 区域元素
* @param {string} storageKey - localStorage 键名
*/
function toggleSection(section, storageKey) {
if (!section) return;
const isExpanded = section.classList.contains('expanded');
const newState = !isExpanded;
if (newState) {
section.classList.add('expanded');
} else {
section.classList.remove('expanded');
}
// 保存状态
if (storageKey) {
localStorage.setItem(storageKey, newState.toString());
}
}
/**
* 初始化可折叠区域
* @param {string} sectionSelector - 区域选择器
* @param {string} toggleSelector - 切换按钮选择器
* @param {string} storageKey - localStorage 键名
*/
function initCollapsibleSection(sectionSelector, toggleSelector, storageKey) {
const section = document.querySelector(sectionSelector);
const toggle = document.querySelector(toggleSelector);
if (!section || !toggle) return;
// 从 localStorage 读取状态
const savedState = localStorage.getItem(storageKey);
const isExpanded = savedState !== 'false'; // 默认展开
if (isExpanded) {
section.classList.add('expanded');
} else {
section.classList.remove('expanded');
}
// 绑定切换按钮
toggle.addEventListener('click', () => {
toggleSection(section, storageKey);
});
}
// ==================== TOC 数据同步 ====================
/**
* 从原有 TOC 同步数据到侧边栏 TOC
*/
function syncTocData() {
if (!elements.originalTocList || !elements.sidebarTocList) return;
const originalItems = elements.originalTocList.querySelectorAll('li');
if (originalItems.length === 0) {
// 显示空状态
elements.sidebarTocList.innerHTML = `
<li class="sidebar-empty-state">
<div class="sidebar-empty-icon">📄</div>
<div>暂无目录</div>
</li>
`;
return;
}
// 清空现有内容
elements.sidebarTocList.innerHTML = '';
// 复制 TOC 项目
originalItems.forEach(item => {
const originalLink = item.querySelector('a');
if (!originalLink) return;
// 获取并清理文本内容
const text = originalLink.textContent.trim();
const href = originalLink.href;
// 跳过占位符和空标题
// 1. 空标题
// 2. "未命名章节"占位符
// 3. placeholder- 开头的ID占位符标识
if (!text ||
text === '未命名章节' ||
text === 'undefined' ||
text === 'null' ||
href.includes('#placeholder-')) {
return;
}
const li = document.createElement('li');
li.className = 'sidebar-toc-item';
// 复制层级类toc-h2, toc-h3 等)
for (const className of item.classList) {
if (className.startsWith('toc-')) {
li.classList.add(className);
}
}
const link = document.createElement('a');
link.href = href;
link.className = 'sidebar-toc-link';
link.textContent = text; // 使用清理后的文本
// 复制 active 状态
if (originalLink.classList.contains('active')) {
link.classList.add('active');
}
// 绑定点击事件(跳转后自动关闭移动端侧边栏)
link.addEventListener('click', (e) => {
// 触发原有 TOC 链接的点击事件(保持原有滚动逻辑)
originalLink.click();
e.preventDefault();
// 移动端关闭侧边栏
if (isMobile) {
closeMobileSidebar();
}
});
li.appendChild(link);
elements.sidebarTocList.appendChild(li);
});
}
/**
* 监听原有 TOC 的变化并同步
*/
function watchTocChanges() {
if (!elements.originalTocList) return;
// 使用 MutationObserver 监听 TOC 列表的变化
const observer = new MutationObserver(() => {
syncTocData();
});
observer.observe(elements.originalTocList, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'] // 监听 active 类变化
});
// 初始同步
syncTocData();
}
// ==================== Dock 数据同步 ====================
/**
* 同步单个统计数据
* @param {string} originalSelector - 原始元素选择器
* @param {string} sidebarSelector - 侧边栏元素选择器
* @param {string} suffix - 后缀(如 '%'
*/
function syncStat(originalSelector, sidebarSelector, suffix = '') {
const originalEl = document.querySelector(originalSelector);
const sidebarEl = document.querySelector(sidebarSelector);
if (!originalEl || !sidebarEl) return;
let value = originalEl.textContent.trim();
// 如果需要添加后缀,先检查是否已存在
if (suffix && !value.endsWith(suffix)) {
value = value + suffix;
}
sidebarEl.textContent = value;
}
/**
* 检查 Dock 数据是否已初始化(不全是 0
*/
function isDockDataReady() {
const wordCountEl = document.querySelector(CONFIG.selectors.originalWordCount);
const wordCount = wordCountEl?.textContent.trim();
// 如果总字数不是 0说明 Dock 数据已计算完成
// 总字数是最可靠的指标,因为任何文档都应该有字数
return wordCount && wordCount !== '0';
}
/**
* 获取当前滚动容器(从 main 分支的 DockLogic 移植 + 非沉浸模式修正)
*/
function getCurrentScrollableElement() {
if (window.ImmersiveLayout && window.ImmersiveLayout.isActive && window.ImmersiveLayout.isActive()) {
// 沉浸模式:滚动容器取决于当前活动的标签页
const immersiveMainArea = document.getElementById('immersive-main-content-area');
if (immersiveMainArea) {
// 1. 优先查找带有内联样式的 .tab-content
const tabContentScroller = immersiveMainArea.querySelector('.tab-content[style*="overflow-y: auto"], .tab-content[style*="overflow: auto"]');
if (tabContentScroller) return tabContentScroller;
// 2. 查找活动的内容包装器
const activeTabContent = immersiveMainArea.querySelector('.tab-content .content-wrapper, .tab-content .chunk-compare-container');
if (activeTabContent) {
// 检查父元素 .tab-content 是否可滚动
const tabContentParent = activeTabContent.closest('.tab-content');
if (tabContentParent && (tabContentParent.style.overflowY === 'auto' ||
tabContentParent.style.overflow === 'auto' ||
getComputedStyle(tabContentParent).overflowY === 'auto')) {
return tabContentParent;
}
// 回退到内容包装器本身
return activeTabContent;
}
// 3. 尝试 .container
const mainContainerInImmersive = immersiveMainArea.querySelector('.container');
if (mainContainerInImmersive) return mainContainerInImmersive;
// 4. 最后回退到 immersiveMainArea
return immersiveMainArea;
}
}
// 非沉浸模式:使用 .app-main侧边栏布局中的主内容区
const appMain = document.querySelector('.app-main');
if (appMain) {
return appMain;
}
// 最终回退到 document.documentElement旧布局或特殊情况
return document.documentElement;
}
/**
* 计算并更新侧边栏的阅读进度(直接计算,不依赖 Dock
*/
function updateSidebarReadingProgress() {
const progressEl = document.querySelector(CONFIG.selectors.sidebarReadingProgress);
if (!progressEl) return;
// 动态获取当前滚动容器
const scrollableElement = getCurrentScrollableElement();
if (!scrollableElement) {
console.warn('[Sidebar] No scrollable element found');
return;
}
const scrollTop = scrollableElement.scrollTop;
const scrollHeight = scrollableElement.scrollHeight;
const clientHeight = scrollableElement.clientHeight;
// 如果内容适合视口(无滚动条),显示 100%
if (scrollHeight <= clientHeight) {
progressEl.textContent = '100%';
return;
}
// 计算滚动百分比
const maxScrollTop = scrollHeight - clientHeight;
const scrollFraction = maxScrollTop > 0 ? (scrollTop / maxScrollTop) : 0;
const percentage = Math.min(100, Math.max(0, Math.round(scrollFraction * 100)));
progressEl.textContent = percentage + '%';
}
/**
* 同步所有 Dock 统计数据到侧边栏
*/
function syncDockData() {
console.log('[Sidebar] Syncing Dock data...');
// 阅读进度:直接计算,不从 Dock 同步
updateSidebarReadingProgress();
// 其他统计:从 Dock 同步
syncStat(CONFIG.selectors.originalHighlightCount, CONFIG.selectors.sidebarHighlightCount);
syncStat(CONFIG.selectors.originalAnnotationCount, CONFIG.selectors.sidebarAnnotationCount);
syncStat(CONFIG.selectors.originalImageCount, CONFIG.selectors.sidebarImageCount);
syncStat(CONFIG.selectors.originalFormulaCount, CONFIG.selectors.sidebarFormulaCount);
syncStat(CONFIG.selectors.originalTableCount, CONFIG.selectors.sidebarTableCount);
syncStat(CONFIG.selectors.originalWordCount, CONFIG.selectors.sidebarWordCount);
syncStat(CONFIG.selectors.originalReferenceCount, CONFIG.selectors.sidebarReferenceCount);
// 调试:输出同步后的值
const progressEl = document.querySelector(CONFIG.selectors.sidebarReadingProgress);
const highlightEl = document.querySelector(CONFIG.selectors.sidebarHighlightCount);
const wordCountEl = document.querySelector(CONFIG.selectors.sidebarWordCount);
console.log('[Sidebar] Synced values - Progress:', progressEl?.textContent, 'Highlights:', highlightEl?.textContent, 'Words:', wordCountEl?.textContent);
}
/**
* 监听原有 Dock 的变化并同步
*/
function watchDockChanges() {
// 使用 MutationObserver 监听 Dock 元素的变化
const observer = new MutationObserver(() => {
syncDockData();
});
// 监听所有原始统计元素
const selectors = [
CONFIG.selectors.originalReadingProgress,
CONFIG.selectors.originalHighlightCount,
CONFIG.selectors.originalAnnotationCount,
CONFIG.selectors.originalImageCount,
CONFIG.selectors.originalFormulaCount,
CONFIG.selectors.originalTableCount,
CONFIG.selectors.originalWordCount,
CONFIG.selectors.originalReferenceCount
];
selectors.forEach(selector => {
const el = document.querySelector(selector);
if (el) {
observer.observe(el, {
childList: true,
characterData: true,
subtree: true
});
}
});
// 智能初始同步:等待 Dock 数据初始化完成
let retryCount = 0;
const maxRetries = 20; // 最多重试 20 次
const retryDelay = 200; // 每次间隔 200ms
function attemptInitialSync() {
if (isDockDataReady()) {
console.log('[Sidebar] Dock data is ready, syncing now');
syncDockData();
} else {
retryCount++;
if (retryCount < maxRetries) {
console.log(`[Sidebar] Dock data not ready yet, retry ${retryCount}/${maxRetries} in ${retryDelay}ms...`);
setTimeout(attemptInitialSync, retryDelay);
} else {
console.warn('[Sidebar] Dock data still not ready after max retries, syncing anyway');
syncDockData();
}
}
}
// 开始尝试初始同步
attemptInitialSync();
}
// ==================== 设置链接 ====================
/**
* 绑定侧边栏设置链接到原有设置链接
*/
function bindSettingsLink() {
if (!elements.sidebarSettingsLink || !elements.originalSettingsLink) return;
elements.sidebarSettingsLink.addEventListener('click', (e) => {
e.preventDefault();
elements.originalSettingsLink.click();
// 移动端关闭侧边栏
if (isMobile) {
closeMobileSidebar();
}
});
}
// ==================== 可点击统计项 ====================
/**
* 绑定可点击统计项(高亮、批注)
*/
function bindClickableStats() {
const clickableStats = document.querySelectorAll('.sidebar-quick-stat');
clickableStats.forEach(stat => {
stat.addEventListener('click', () => {
const statType = stat.dataset.statType;
const originalStat = document.querySelector(`.stat-item-clickable[data-stat-type="${statType}"]`);
if (originalStat && statType) {
// 触发原有统计项的点击事件
originalStat.click();
// 移动端关闭侧边栏
if (isMobile) {
closeMobileSidebar();
}
}
});
});
}
// ==================== 响应式处理 ====================
/**
* 动态绑定/解绑滚动事件到当前滚动容器
*/
let lastScrollableElement = null;
const debouncedProgressUpdate = debounce(updateSidebarReadingProgress, 100);
function bindScrollForCurrentScrollable() {
// 解绑旧的滚动监听
if (lastScrollableElement) {
lastScrollableElement.removeEventListener('scroll', debouncedProgressUpdate);
lastScrollableElement = null;
}
// 获取当前滚动元素
const el = getCurrentScrollableElement();
if (el) {
console.log(`[Sidebar] 绑定滚动事件到元素:`, el.id || el.className || el.tagName);
el.addEventListener('scroll', debouncedProgressUpdate);
lastScrollableElement = el;
// 立即更新一次阅读进度
setTimeout(() => updateSidebarReadingProgress(), 50);
} else {
console.warn(`[Sidebar] 未找到可滚动元素,无法绑定滚动事件`);
}
}
function unbindScrollForCurrentScrollable() {
if (lastScrollableElement) {
lastScrollableElement.removeEventListener('scroll', debouncedProgressUpdate);
lastScrollableElement = null;
}
}
/**
* 处理窗口大小变化
*/
function handleResize() {
const newIsMobile = window.innerWidth < 768;
if (newIsMobile !== isMobile) {
isMobile = newIsMobile;
// 切换到桌面端时,关闭移动端侧边栏
if (!isMobile) {
closeMobileSidebar();
}
}
}
// ==================== 初始化 ====================
/**
* 防抖函数
*/
function debounce(func, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* 初始化侧边栏集成模块
*/
function init() {
console.log('[Sidebar] Initializing sidebar integration...');
// 缓存 DOM 元素
cacheElements();
// 监听沉浸模式切换
watchImmersiveMode();
// 初始化桌面端折叠功能
initDesktopCollapse();
// 初始化移动端侧边栏
initMobileSidebar();
// 初始化可折叠区域
initCollapsibleSection(
CONFIG.selectors.sidebarTocSection,
CONFIG.selectors.sidebarTocToggle,
CONFIG.storageKeys.tocSectionExpanded
);
// 监听 TOC 变化并同步
watchTocChanges();
// 监听 Dock 变化并同步
watchDockChanges();
// 绑定设置链接
bindSettingsLink();
// 绑定可点击统计项
bindClickableStats();
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
// 动态绑定滚动事件到当前滚动容器
bindScrollForCurrentScrollable();
// 监听标签页切换事件(重新绑定滚动事件)
// 使用 MutationObserver 监听 .tab-content 的变化
const tabContent = document.getElementById('tabContent');
if (tabContent) {
const tabObserver = new MutationObserver(() => {
console.log('[Sidebar] Tab content changed, rebinding scroll event');
bindScrollForCurrentScrollable();
});
tabObserver.observe(tabContent, {
childList: true,
subtree: false
});
}
// 监听沉浸模式切换(重新绑定滚动事件)
window.addEventListener('immersive-mode-changed', () => {
console.log('[Sidebar] Immersive mode changed, rebinding scroll event');
setTimeout(() => bindScrollForCurrentScrollable(), 100);
});
console.log('[Sidebar] Sidebar integration initialized successfully');
}
// ==================== 导出 ====================
// DOM 加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 导出到全局(供调试使用)
window.SidebarIntegration = {
syncTocData,
syncDockData,
updateReadingProgress: updateSidebarReadingProgress,
bindScrollEvent: bindScrollForCurrentScrollable,
unbindScrollEvent: unbindScrollForCurrentScrollable,
openMobileSidebar,
closeMobileSidebar,
setSidebarCollapsed
};
})();