// js/dock_logic.js (function DockLogic(global) { // 传入 window // --- DOM Elements --- let dockElement = null; let progressPercentageSpan = null; // For collapsed display let progressPercentageVerboseSpan = null; // For expanded display let highlightCountElement = null; let annotationCountElement = null; let imageCountElement = null; let tableCountElement = null; let formulaCountElement = null; let totalWordCountElement = null; let dockToggleBtn = null; let settingsLink = null; // --- State --- let currentDocId = null; let currentVisibleTabIdForDock = null; // ADDED: To store current tab for dock logic let isContentLoadingForStats = false; // NEW: Flag to indicate if stats are pending full content let dockDisplayConfig = { readingProgress: true, highlights: true, annotations: true, images: true, tables: true, formulas: true, words: true }; // Helper function to get the current scrollable element function getCurrentScrollableElement() { if (global.ImmersiveLayout && global.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) { console.log("[DockLogic] 找到带内联样式的滚动容器:", tabContentScroller.className); 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')) { console.log("[DockLogic] 找到可滚动的 .tab-content 父元素"); return tabContentParent; } // 回退到内容包装器本身 console.log("[DockLogic] 回退到内容包装器:", activeTabContent.className); return activeTabContent; } // 3. 尝试 .container const mainContainerInImmersive = immersiveMainArea.querySelector('.container'); if (mainContainerInImmersive) { console.log("[DockLogic] 找到 .container"); return mainContainerInImmersive; } // 4. 最后回退到 immersiveMainArea console.log("[DockLogic] 使用兜底方案:immersiveMainArea"); return immersiveMainArea; } } // 非沉浸模式:使用 .app-main(侧边栏布局中的主内容区) const appMain = document.querySelector('.app-main'); if (appMain) { console.log("[DockLogic] 非沉浸模式,使用 .app-main"); return appMain; } // 最终回退到 document.documentElement(旧布局或特殊情况) console.log("[DockLogic] 最终回退到 document.documentElement"); return document.documentElement; } // --- 新增:动态绑定/解绑 scroll 事件 --- let lastScrollableElement = null; function bindScrollForCurrentScrollable() { // 解绑旧的 if (lastScrollableElement) { lastScrollableElement.removeEventListener('scroll', debouncedUpdateReadingProgress); lastScrollableElement = null; } // 获取当前滚动元素 const el = getCurrentScrollableElement(); if (el) { console.log(`[DockLogic] 绑定滚动事件到元素:`, el.id || el.className || el.tagName); el.addEventListener('scroll', debouncedUpdateReadingProgress); lastScrollableElement = el; // 立即更新一次阅读进度,确保显示正确 setTimeout(() => _updateReadingProgress(), 50); } else { console.warn(`[DockLogic] 未找到可滚动元素,无法绑定滚动事件`); } } function unbindScrollForCurrentScrollable() { if (lastScrollableElement) { lastScrollableElement.removeEventListener('scroll', debouncedUpdateReadingProgress); lastScrollableElement = null; } } function debounce(func, delay) { let timeout; return function(...args) { const context = this; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), delay); }; } function _updateReadingProgress() { if (!progressPercentageSpan || !progressPercentageVerboseSpan || !dockElement) { // console.warn("Dock progress elements not ready for updateReadingProgress"); return; } const scrollableElement = getCurrentScrollableElement(); const scrollTop = scrollableElement.scrollTop; const scrollHeight = scrollableElement.scrollHeight; const clientHeight = scrollableElement.clientHeight; if (scrollHeight <= clientHeight) { // Content fits viewport, no scrollbar progressPercentageSpan.textContent = '100'; progressPercentageVerboseSpan.textContent = '100'; dockElement.classList.add('visible'); return; } const maxScrollTop = scrollHeight - clientHeight; const scrollFraction = maxScrollTop > 0 ? (scrollTop / maxScrollTop) : 0; const percentage = Math.min(100, Math.max(0, Math.round(scrollFraction * 100))); progressPercentageSpan.textContent = percentage; progressPercentageVerboseSpan.textContent = percentage; dockElement.classList.add('visible'); } // Expose debounced version for scroll event const debouncedUpdateReadingProgress = debounce(_updateReadingProgress, 100); function _updateHighlightSummary(docData) { if (highlightCountElement && docData && Array.isArray(docData.annotations)) { const count = docData.annotations.filter(ann => ann.highlightColor).length; highlightCountElement.textContent = count; } else if (highlightCountElement) { highlightCountElement.textContent = '0'; } } function _updateAnnotationSummary(docData) { if (annotationCountElement && docData && Array.isArray(docData.annotations)) { const count = docData.annotations.filter(ann => ann.body && ann.body.length > 0 && ann.body[0].value && ann.body[0].value.trim() !== '' && ann.motivation === 'commenting' ).length; annotationCountElement.textContent = count; } else if (annotationCountElement) { annotationCountElement.textContent = '0'; } } function _updateImageCount(docData) { if (imageCountElement && docData && docData.images) { imageCountElement.textContent = docData.images.length; } else if (imageCountElement) { imageCountElement.textContent = '0'; } } function _updateTableCount(contentElement) { if (tableCountElement && contentElement) { // If content is still loading (indicated by flag and element having no direct children yet), show placeholder. if (isContentLoadingForStats && contentElement.children.length === 0) { tableCountElement.textContent = '...'; } else { const tables = contentElement.getElementsByTagName('table'); tableCountElement.textContent = tables.length; } } else if (tableCountElement) { tableCountElement.textContent = '0'; // Default if no contentElement } } function _updateFormulaCount(contentElement) { if (formulaCountElement && contentElement) { if (isContentLoadingForStats && contentElement.children.length === 0) { formulaCountElement.textContent = '...'; } else { const katexElements = contentElement.querySelectorAll('.katex, .katex-display'); const mermaidElements = contentElement.querySelectorAll('.mermaid > svg'); // Mermaid renders an SVG formulaCountElement.textContent = katexElements.length + mermaidElements.length; } } else if (formulaCountElement) { formulaCountElement.textContent = '0'; } } function _updateWordCount(contentElement) { if (totalWordCountElement && contentElement) { // Using a heuristic: if content is flagged as loading and text is very short or non-existent if (isContentLoadingForStats && (!contentElement.textContent || contentElement.textContent.trim().length < 10)) { totalWordCountElement.textContent = '...'; } else { const text = contentElement.textContent || contentElement.innerText || ""; const charCount = text.replace(/\\s/g, '').length; // Count non-whitespace characters totalWordCountElement.textContent = charCount > 0 ? charCount : '0'; } } else if (totalWordCountElement) { totalWordCountElement.textContent = '0'; } } /** * Updates all statistics displayed in the dock. * @param {Object} docData - The main data object for the current document. * @param {string} currentVisibleTabId - The ID of the currently visible tab (e.g., 'ocr', 'translation'). */ function _updateAllDockStats(docData, currentVisibleTabId) { currentVisibleTabIdForDock = currentVisibleTabId; if (!docData) { // console.warn("Doc data not available for updating dock stats."); if(highlightCountElement) highlightCountElement.textContent = '0'; if(annotationCountElement) annotationCountElement.textContent = '0'; if(imageCountElement) imageCountElement.textContent = '0'; if(tableCountElement) tableCountElement.textContent = '0'; if(formulaCountElement) formulaCountElement.textContent = '0'; if(totalWordCountElement) totalWordCountElement.textContent = '0'; isContentLoadingForStats = false; // No data, so not "loading stats" _applyDisplayConfigToElements(); return; } let activeContentElement = null; if (currentVisibleTabIdForDock === 'ocr' && document.getElementById('ocr-content-wrapper')) { activeContentElement = document.getElementById('ocr-content-wrapper'); } else if (currentVisibleTabIdForDock === 'translation' && document.getElementById('translation-content-wrapper')) { activeContentElement = document.getElementById('translation-content-wrapper'); } // Set loading flag if activeContentElement exists but seems empty (initial batch render ongoing) if (activeContentElement && activeContentElement.children.length === 0) { isContentLoadingForStats = true; // console.log("DockLogic: Content is loading, placeholder '...' may be shown for some stats."); } else { isContentLoadingForStats = false; // Content is present or not an OCR/Translation tab needing batch render // console.log("DockLogic: Content present or not OCR/Trans, proceeding with normal stat calculation."); } // Handle highlights and annotations based on tab (these don't typically show '...') if (currentVisibleTabIdForDock === 'chunk-compare') { if (highlightCountElement) highlightCountElement.textContent = '0'; if (annotationCountElement) annotationCountElement.textContent = '0'; // These will be hidden by _applyDisplayConfigToElements's override for chunk-compare } else { // OCR or Translation tabs: update based on config and data if (dockDisplayConfig.highlights) _updateHighlightSummary(docData); if (dockDisplayConfig.annotations) _updateAnnotationSummary(docData); } // Handle images (always based on docData, visibility by config + override) if (dockDisplayConfig.images) { _updateImageCount(docData); } // Stats dependent on activeContentElement (tables, formulas, words) if (activeContentElement) { // OCR or Translation content is active if (dockDisplayConfig.tables) _updateTableCount(activeContentElement); if (dockDisplayConfig.formulas) _updateFormulaCount(activeContentElement); if (dockDisplayConfig.words) _updateWordCount(activeContentElement); } else { // No active OCR/Translation content (e.g., chunk-compare or error state) if (tableCountElement) tableCountElement.textContent = '0'; if (formulaCountElement) formulaCountElement.textContent = '0'; if (totalWordCountElement) totalWordCountElement.textContent = '0'; // If it's not chunk-compare and no active element, but we previously thought content was loading for stats // ensure placeholders are shown if this update call happens too early. if (currentVisibleTabIdForDock !== 'chunk-compare' && isContentLoadingForStats) { if (tableCountElement && dockDisplayConfig.tables) tableCountElement.textContent = '...'; if (formulaCountElement && dockDisplayConfig.formulas) formulaCountElement.textContent = '...'; if (totalWordCountElement && dockDisplayConfig.words) totalWordCountElement.textContent = '...'; } } _applyDisplayConfigToElements(); // Fallback: If stats were showing '...' and are still '...' after a delay, revert to '0'. // This handles cases where the final, definitive update from history_detail might be missed or severely delayed for large docs. if (isContentLoadingForStats) { // Only set this timeout if we actually entered a "loading stats" state setTimeout(() => { // Check if we are still in a loading state for these stats // This inner check of isContentLoadingForStats is important because a proper update // might have occurred in the meantime, clearing the flag. if (isContentLoadingForStats) { // console.log("DockLogic: Fallback timeout executed. Converting '...' to '0' if still present."); if (tableCountElement && tableCountElement.textContent === '...' && dockDisplayConfig.tables) tableCountElement.textContent = '0'; if (formulaCountElement && formulaCountElement.textContent === '...' && dockDisplayConfig.formulas) formulaCountElement.textContent = '0'; if (totalWordCountElement && totalWordCountElement.textContent === '...' && dockDisplayConfig.words) totalWordCountElement.textContent = '0'; // It's probably safe to assume loading is "done" from the fallback's perspective, // but a subsequent call to _updateAllDockStats with full content will properly clear it. } }, 3000); // Adjust delay as needed, e.g., 3 seconds. } } /** * Applies the current display configuration to the DOM elements. * Overrides highlights and annotations to be hidden if in 'chunk-compare' mode. */ function _applyDisplayConfigToElements() { const elementsMap = { readingProgress: [progressPercentageVerboseSpan, progressPercentageSpan], highlights: [highlightCountElement], annotations: [annotationCountElement], images: [imageCountElement], tables: [tableCountElement], formulas: [formulaCountElement], words: [totalWordCountElement] }; const wrapperSelectors = { readingProgress: '.dock-stat-item-wrapper-progress', highlights: '.dock-stat-item-wrapper-highlight', annotations: '.dock-stat-item-wrapper-annotation', images: '.dock-stat-item-wrapper-img', tables: '.dock-stat-item-wrapper-tbl', formulas: '.dock-stat-item-wrapper-formula', words: '.dock-stat-item-wrapper-words' }; for (const key in dockDisplayConfig) { let originalShouldShow = dockDisplayConfig[key]; let effectiveShouldShow = originalShouldShow; if (currentVisibleTabIdForDock === 'chunk-compare') { if (key === 'readingProgress') { effectiveShouldShow = dockDisplayConfig.readingProgress; } else { effectiveShouldShow = false; } } const shouldShow = effectiveShouldShow; const wrapperSelector = wrapperSelectors[key]; // const statElements = elementsMap[key]; // Not directly used if relying on wrapperSelector if (wrapperSelector) { let wrapperEl = null; if (key === 'readingProgress') { const verboseWrapper = progressPercentageVerboseSpan ? progressPercentageVerboseSpan.closest(wrapperSelector) : null; const collapsedDisplay = document.getElementById('dock-collapsed-progress-display'); if (verboseWrapper) verboseWrapper.style.display = shouldShow ? '' : 'none'; if (collapsedDisplay) collapsedDisplay.style.display = shouldShow ? '' : 'none'; } else if (elementsMap[key] && elementsMap[key][0]) { // Use the first element in the map to find its wrapper wrapperEl = elementsMap[key][0].closest(wrapperSelector); if (wrapperEl) { wrapperEl.style.display = shouldShow ? '' : 'none'; } } } else if (elementsMap[key]) { elementsMap[key].forEach(el => { if(el) el.style.display = shouldShow ? '' : 'none'; }); } } } /** * Initializes the Dock component. * Caches DOM elements, restores toggle state, and attaches event listeners. * @param {string} docId - The ID of the current document. */ function initialize(docId) { currentDocId = docId; currentVisibleTabIdForDock = null; dockElement = document.getElementById('bottom-left-dock'); progressPercentageSpan = document.getElementById('reading-progress-percentage'); progressPercentageVerboseSpan = document.getElementById('reading-progress-percentage-verbose'); highlightCountElement = document.getElementById('highlight-count'); annotationCountElement = document.getElementById('annotation-count'); imageCountElement = document.getElementById('image-count'); tableCountElement = document.getElementById('table-count'); formulaCountElement = document.getElementById('formula-count'); totalWordCountElement = document.getElementById('total-word-count'); dockToggleBtn = document.getElementById('dock-toggle-btn'); settingsLink = document.getElementById('settings-link'); // 参考文献计数元素(可选) const referenceCountElement = document.getElementById('reference-count'); if (!dockElement || !progressPercentageSpan || !progressPercentageVerboseSpan || !highlightCountElement || !annotationCountElement || !imageCountElement || !tableCountElement || !formulaCountElement || !totalWordCountElement || !dockToggleBtn || !settingsLink) { console.error("DockLogic: One or more Dock UI elements not found during initialization."); return; } if (global.DockLogic && global.DockLogic.loadDisplayConfig) { global.DockLogic.loadDisplayConfig(); } _updateReadingProgress(); _applyDisplayConfigToElements(); const dockCollapsedKey = `dockCollapsed_${currentDocId}`; const isCollapsed = localStorage.getItem(dockCollapsedKey) === 'true'; if (isCollapsed) { dockElement.classList.add('dock-collapsed'); dockToggleBtn.innerHTML = ''; dockToggleBtn.title = '展开'; } else { dockElement.classList.remove('dock-collapsed'); dockToggleBtn.innerHTML = ''; dockToggleBtn.title = '折叠'; } dockToggleBtn.onclick = function(event) { event.preventDefault(); const currentlyCollapsed = dockElement.classList.toggle('dock-collapsed'); if (currentlyCollapsed) { this.innerHTML = ''; this.title = '展开'; localStorage.setItem(dockCollapsedKey, 'true'); } else { this.innerHTML = ''; this.title = '折叠'; localStorage.setItem(dockCollapsedKey, 'false'); } }; global.removeEventListener('scroll', debouncedUpdateReadingProgress); global.addEventListener('scroll', debouncedUpdateReadingProgress); // 新增:自动绑定到当前滚动容器 bindScrollForCurrentScrollable(); const highlightStatClickable = dockElement.querySelector('.dock-stat-item-wrapper-highlight .stat-item-clickable[data-stat-type="highlight"]'); const annotationStatClickable = dockElement.querySelector('.dock-stat-item-wrapper-annotation .stat-item-clickable[data-stat-type="annotation"]'); if (highlightStatClickable) { highlightStatClickable.addEventListener('click', function() { if (typeof global.openAnnotationsSummaryModal === 'function') { global.openAnnotationsSummaryModal('all', 'all'); } else { console.warn('openAnnotationsSummaryModal function not found on window.'); } }); } if (annotationStatClickable) { annotationStatClickable.addEventListener('click', function() { if (typeof global.openAnnotationsSummaryModal === 'function') { global.openAnnotationsSummaryModal('all', 'all'); } else { console.warn('openAnnotationsSummaryModal function not found on window.'); } }); } } // Expose public interface global.DockLogic = { init: initialize, updateStats: _updateAllDockStats, forceUpdateReadingProgress: function() { // 增加延迟,确保DOM结构已更新 setTimeout(() => { console.log("[DockLogic] 强制更新阅读进度"); _updateReadingProgress(); bindScrollForCurrentScrollable(); // 每次强制刷新后也重新绑定 }, 200); // 增加延迟时间 }, updateDisplayConfig: function(newConfig) { if (typeof newConfig === 'object' && newConfig !== null) { for (const key in dockDisplayConfig) { if (newConfig.hasOwnProperty(key) && typeof newConfig[key] === 'boolean') { dockDisplayConfig[key] = newConfig[key]; } } _applyDisplayConfigToElements(); if (currentDocId) { localStorage.setItem(`dockDisplayConfig_${currentDocId}`, JSON.stringify(dockDisplayConfig)); } else { localStorage.setItem('dockDisplayConfig_global', JSON.stringify(dockDisplayConfig)); } } }, loadDisplayConfig: function() { let savedConfig = null; if (currentDocId) { savedConfig = localStorage.getItem(`dockDisplayConfig_${currentDocId}`); } if (!savedConfig) { savedConfig = localStorage.getItem('dockDisplayConfig_global'); } if (savedConfig) { try { const parsedConfig = JSON.parse(savedConfig); dockDisplayConfig = { ...dockDisplayConfig, ...parsedConfig }; } catch (e) { console.error("Error parsing saved dock display config:", e); } } // _applyDisplayConfigToElements(); // This might be called too early if DOM elements for stats are not ready // It's better called within init after elements are cached, // and also after any _updateAllDockStats call. }, getCurrentDisplayConfig: function() { return { ...dockDisplayConfig }; }, bindScrollForCurrentScrollable, unbindScrollForCurrentScrollable }; })(window);