// 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);