paper-burner/js/history/history_detail_show_tab.js

2263 lines
122 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.

// ========== Phase 2.1: 性能优化 - DOM 缓存和防抖 ==========
// DOM 元素缓存:避免重复查询
const DOM_CACHE = {
tabs: {
ocr: null,
translation: null,
chunkCompare: null,
pdfCompare: null,
originalFile: null
},
layout: {
title: null,
meta: null,
tabsContainer: null
},
// 初始化缓存
init: function() {
this.tabs.ocr = document.getElementById('tab-ocr');
this.tabs.translation = document.getElementById('tab-translation');
this.tabs.chunkCompare = document.getElementById('tab-chunk-compare');
this.tabs.pdfCompare = document.getElementById('tab-pdf-compare');
this.tabs.originalFile = document.getElementById('tab-original-file');
this.layout.title = document.getElementById('fileName');
this.layout.meta = document.getElementById('fileMeta');
this.layout.tabsContainer = document.querySelector('.tabs-container');
},
// 懒初始化:第一次使用时自动初始化
ensureInitialized: function() {
if (!this.tabs.ocr) {
this.init();
}
}
};
// 防抖定时器:防止快速切换标签导致重复渲染
let showTabDebounceTimer = null;
let pendingTab = null;
/**
* 带防抖的标签切换函数(用户接口)
* 快速点击多个标签时,只渲染最后一个
*/
function showTab(tab) {
// 记录待处理的标签
pendingTab = tab;
// 清除之前的定时器
if (showTabDebounceTimer) {
clearTimeout(showTabDebounceTimer);
}
// 设置新的延迟执行100ms
showTabDebounceTimer = setTimeout(() => {
showTabDebounceTimer = null;
showTabImmediate(pendingTab);
}, 100);
}
/**
* 立即执行标签切换(内部函数)
* 原 showTab 逻辑移至此处
*/
function showTabImmediate(tab) {
// 确保 DOM 缓存已初始化
DOM_CACHE.ensureInitialized();
// Phase 2.3: 清空批注系统缓存(标签切换时)
if (window.AnnotationDOMCache && window.AnnotationDOMCache.initialized) {
window.AnnotationDOMCache.clear();
}
// ========== 先尝试清理上一视图的资源(尤其 chunk-compare ==========
try {
const prevTab = window.currentVisibleTabId;
if (prevTab === 'chunk-compare' && tab !== 'chunk-compare') {
// 断开观察器并清空优化器缓存
if (window.ChunkCompareOptimizer && window.ChunkCompareOptimizer.instance && typeof window.ChunkCompareOptimizer.instance.cleanup === 'function') {
window.ChunkCompareOptimizer.instance.cleanup();
}
// 清空分块解析缓存,释放大数组
if (window.chunkParseCache) {
Object.keys(window.chunkParseCache).forEach(k => delete window.chunkParseCache[k]);
}
// 释放块级原始内容引用
if (typeof window.__lastChunkCompareTotalBlocks === 'number') {
for (let i = 0; i <= window.__lastChunkCompareTotalBlocks; i++) {
try { delete window[`blockRawContent_${i}`]; } catch(e) { /* ignore */ }
}
window.__lastChunkCompareTotalBlocks = 0;
}
// 清理大型临时数据
if (window.largeDocumentData) window.largeDocumentData = null;
}
} catch (e) {
console.warn('[showTab] 清理上一视图资源时出错:', e);
}
// === 新增:没有翻译内容时禁止切换到翻译和对比页 ===
if ((tab === 'translation' || tab === 'chunk-compare') && (!data || !data.translation || data.translation.trim() === "")) {
alert('没有翻译内容,无法显示该页面');
return;
}
// ========== 保证内容标识符始终正确 ==========
if (tab === 'ocr') {
window.globalCurrentContentIdentifier = 'ocr';
} else if (tab === 'translation') {
window.globalCurrentContentIdentifier = 'translation';
} else {
window.globalCurrentContentIdentifier = '';
}
// ========== 防抖锁:防止同一 tab 重复渲染 ==========
if (renderingTab === tab) {
console.log(`[showTab] Tab ${tab} 正在渲染中,跳过重复渲染`);
return;
}
renderingTab = tab;
// ================================================
// 性能测试断点 - 总渲染
console.time('[性能] showTab_总渲染');
currentVisibleTabId = tab; // Update global current tab ID
window.currentVisibleTabId = tab; // 同时更新挂载到 window 对象上的变量
window.currentBlockTokensForCopy = window.currentBlockTokensForCopy || {}; // Initialize if not exists
// ========== 新增:用局部变量保存内容标识符 ==========
let contentIdentifier = '';
if (tab === 'ocr') {
contentIdentifier = 'ocr';
} else if (tab === 'translation') {
contentIdentifier = 'translation';
}
window.globalCurrentContentIdentifier = contentIdentifier;
console.log('[showTab] 设置 window.globalCurrentContentIdentifier =', contentIdentifier);
// ==================================================
if (docIdForLocalStorage) {
const activeTabKey = `activeTab_${docIdForLocalStorage}`;
localStorage.setItem(activeTabKey, tab);
// console.log(`Saved active tab for ${docIdForLocalStorage}: ${tab}`);
}
// 使用缓存的 DOM 元素:移除所有标签的 active 状态
DOM_CACHE.tabs.ocr.classList.remove('active');
DOM_CACHE.tabs.translation.classList.remove('active');
// document.getElementById('tab-compare').classList.remove('active'); // 对应按钮已注释,此行也可注释
DOM_CACHE.tabs.chunkCompare.classList.remove('active');
if (DOM_CACHE.tabs.pdfCompare) DOM_CACHE.tabs.pdfCompare.classList.remove('active');
if (DOM_CACHE.tabs.originalFile) DOM_CACHE.tabs.originalFile.classList.remove('active');
// 恢复顶部区域显示(退出 PDF 对照模式时)- 使用缓存
if (DOM_CACHE.layout.title) DOM_CACHE.layout.title.style.display = '';
if (DOM_CACHE.layout.meta) DOM_CACHE.layout.meta.style.display = '';
if (DOM_CACHE.layout.tabsContainer) DOM_CACHE.layout.tabsContainer.style.display = '';
let html = '';
let contentContainerId = ''; // 用于 applyAnnotationsToContent
let activeContentElement = null; // 用于 applyAnnotationsToContent
const significantTokenTypes = ['paragraph', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'hr'];
// ---- 增加日志 ----
// 日志现在可以准确反映 globalCurrentContentIdentifier
console.log(`[showTab - ${tab}] 即将渲染。当前 window.globalCurrentContentIdentifier:`, window.globalCurrentContentIdentifier);
if (data && data.annotations) {
console.log(`[showTab - ${tab}] data.annotations (长度 ${data.annotations.length}):`, JSON.parse(JSON.stringify(data.annotations)));
} else {
console.log(`[showTab - ${tab}] data.annotations 不可用或为空。`);
}
// ---- 日志结束 ----
if (tab === 'chunk-compare') {
// 安全校验:需要 ocrChunks/transChunks 同步存在且长度一致
if (!data || !Array.isArray(data.ocrChunks) || !Array.isArray(data.translatedChunks) || data.ocrChunks.length === 0 || data.translatedChunks.length === 0 || data.ocrChunks.length !== data.translatedChunks.length) {
DOM_CACHE.tabs.chunkCompare.classList.add('active');
const warn = `<div class="warning-box" style="padding:12px;border:1px solid #fbbf24;background:#fffbeb;color:#92400e;border-radius:8px;">`
+ `无法进入"分块对比":当前记录的原文分块数量与译文分块数量不一致,或缺少分块信息。`
+ `<br>请先查看"仅OCR/仅翻译",或重新生成分块以使用对比功能。`
+ `</div>`;
document.getElementById('tabContent').innerHTML = warn;
if (typeof window.refreshTocList === 'function') window.refreshTocList();
renderingTab = null;
console.timeEnd && console.timeEnd('[性能] showTab_总渲染');
return;
}
}
if(tab === 'ocr') {
DOM_CACHE.tabs.ocr.classList.add('active');
contentContainerId = 'ocr-content-wrapper';
let ocrText = data.ocr || '';
// 性能测试断点 - OCR渲染
console.time('[性能] OCR分批渲染');
html = `<h3>OCR内容</h3><div id="${contentContainerId}" class="markdown-body content-wrapper"></div>`;
} else if(tab === 'translation') {
DOM_CACHE.tabs.translation.classList.add('active');
contentContainerId = 'translation-content-wrapper';
html = `<h3>翻译内容</h3><div id="${contentContainerId}" class="markdown-body content-wrapper"></div>`;
console.time('[性能] 翻译分批渲染');
} else if (tab === 'original-file') {
// ========== 原始 PDF 查看器iframe 嵌入官方 pdf.js viewer ==========
if (DOM_CACHE.tabs.originalFile) DOM_CACHE.tabs.originalFile.classList.add('active');
// 检查数据
if (!data.metadata || !data.metadata.originalPdfBase64) {
document.getElementById('tabContent').innerHTML = `
<div style="padding:40px;text-align:center;color:#666;">
<i class="fa fa-file-pdf-o" style="font-size:48px;margin-bottom:12px;display:block;"></i>
<p>当前记录没有保存原始 PDF 数据。</p>
</div>`;
if (typeof window.refreshTocList === 'function') window.refreshTocList();
renderingTab = null;
console.timeEnd && console.timeEnd('[性能] showTab_总渲染');
return;
}
// 构建 iframe 容器
document.getElementById('tabContent').innerHTML = `
<div id="pdf-iframe-wrapper" style="
width: 100%; height: 100%;
min-height: 500px; position: relative;
background: #525659;
">
<div id="pdf-viewer-loading" style="
">
<i class="fa fa-spinner fa-spin" style="font-size:32px;display:block;margin-bottom:12px;"></i>
正在加载 PDF 查看器...
</div>
<iframe id="pdf-viewer-iframe" style="
width:100%; height:100%; min-height:500px; border:none; display:none;
" allowfullscreen></iframe>
</div>`;
// 同源直接调用 viewer API 打开 PDF
(async () => {
try {
// base64 → Uint8Array
const base64 = data.metadata.originalPdfBase64;
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
// 加载 viewer.html
const viewerBase = '../../public/pdfjs/web/viewer.html';
const iframe = document.getElementById('pdf-viewer-iframe');
const loading = document.getElementById('pdf-viewer-loading');
if (!iframe) return; // 标签已切换
iframe.onload = () => {
if (loading) loading.style.display = 'none';
if (iframe) iframe.style.display = 'block';
// 等待 viewer.js 初始化完成后直接调用 open()
let retries = 0;
const maxRetries = 30;
const openPdf = () => {
retries++;
try {
const viewerApp = iframe.contentWindow && iframe.contentWindow.PDFViewerApplication;
if (viewerApp && viewerApp.initialized) {
// 直接在 iframe 上下文中创建 Blob URL 并打开(与官方文件上传一致)
const iframeBlob = new iframe.contentWindow.Blob([bytes], { type: 'application/pdf' });
const iframeBlobUrl = iframe.contentWindow.URL.createObjectURL(iframeBlob);
viewerApp.open({ url: iframeBlobUrl, originalUrl: data.name || 'document.pdf' });
console.log('[PDF Viewer] 直接调用 open() 成功 (第' + retries + '次尝试)');
} else if (retries < maxRetries) {
setTimeout(openPdf, 200);
} else {
console.error('[PDF Viewer] viewer 初始化超时');
}
} catch(e) {
console.error('[PDF Viewer] open 调用失败:', e);
if (retries < maxRetries) setTimeout(openPdf, 200);
}
};
setTimeout(openPdf, 300);
};
iframe.src = viewerBase;
} catch (err) {
console.error('[PDF Viewer] 加载失败:', err);
const wrapper = document.getElementById('pdf-iframe-wrapper');
if (wrapper) wrapper.innerHTML = `
<div style="padding:40px;color:#ff6b6b;text-align:center;">
<i class="fa fa-exclamation-triangle" style="font-size:32px;display:block;margin-bottom:12px;"></i>
PDF 加载失败: ${err.message}
</div>`;
} finally {
renderingTab = null;
console.timeEnd && console.timeEnd('[性能] showTab_总渲染');
}
})();
if (typeof window.refreshTocList === 'function') window.refreshTocList();
return; // 异步渲染,提前返回
} else if (tab === 'pdf-compare') {
// ========== MinerU PDF 对照视图 ==========
if (DOM_CACHE.tabs.pdfCompare) DOM_CACHE.tabs.pdfCompare.classList.add('active');
// 隐藏顶部区域以获得更大空间 - 使用缓存
if (DOM_CACHE.layout.title) DOM_CACHE.layout.title.style.display = 'none';
if (DOM_CACHE.layout.meta) DOM_CACHE.layout.meta.style.display = 'none';
if (DOM_CACHE.layout.tabsContainer) DOM_CACHE.layout.tabsContainer.style.display = 'none';
// 检查是否有必要的结构化翻译数据
const hasStructuredData = data.metadata && data.metadata.originalPdfBase64 && data.metadata.contentListJson && data.metadata.translatedContentList;
if (!hasStructuredData) {
// 缺少结构化翻译数据,弹出确认对话框询问用户
(async () => {
await showPdfCompareConfirmDialog();
})();
return;
}
// 设置 HTML 容器
document.getElementById('tabContent').innerHTML = '<div id="pdf-compare-container"></div>';
// 创建并初始化 PDF 对照视图
(async () => {
try {
// 清理之前的实例
if (window.pdfCompareViewInstance) {
window.pdfCompareViewInstance.destroy();
}
// 创建新实例
const pdfCompareView = new PDFCompareView();
window.pdfCompareViewInstance = pdfCompareView;
console.log('[PDFCompareView] 开始初始化 PDF 对照视图');
await pdfCompareView.initialize(
data.metadata.originalPdfBase64,
data.metadata.contentListJson,
data.metadata.translatedContentList,
data.metadata.layoutJson // 传入 layoutJson
);
// 为多轮检索生成chunks如果还没有的话
if (!data.ocrChunks || data.ocrChunks.length === 0) {
console.log('[PDFCompareView] 检测到缺少chunks数据尝试从contentListJson生成');
if (typeof generateChunksFromContentList === 'function') {
const chunks = generateChunksFromContentList(
data.metadata.contentListJson,
data.metadata.translatedContentList
);
window.data.ocrChunks = chunks.ocrChunks;
window.data.translatedChunks = chunks.translatedChunks;
console.log(`[PDFCompareView] 已生成 ${chunks.ocrChunks.length} 个chunks用于多轮检索`);
} else {
// 备用方案从完整文本生成chunks
if (data.ocr && typeof generateChunksFromFullText === 'function') {
const chunks = generateChunksFromFullText(data.ocr, data.translation);
window.data.ocrChunks = chunks.ocrChunks;
window.data.translatedChunks = chunks.translatedChunks;
console.log(`[PDFCompareView] 使用备用方案从完整文本生成了 ${chunks.ocrChunks.length} 个chunks`);
} else {
console.warn('[PDFCompareView] 无法生成chunks多轮检索功能将受限');
}
}
} else {
console.log(`[PDFCompareView] 使用现有的 ${data.ocrChunks.length} 个chunks`);
}
await pdfCompareView.render('pdf-compare-container');
console.log('[PDFCompareView] PDF 对照视图渲染完成');
} catch (error) {
console.error('[PDFCompareView] 渲染失败:', error);
document.getElementById('pdf-compare-container').innerHTML = `
<div class="error-box" style="padding:12px;border:1px solid #ef4444;background:#fef2f2;color:#991b1b;border-radius:8px;">
PDF 对照视图加载失败: ${error.message}
</div>
`;
} finally {
renderingTab = null;
console.timeEnd && console.timeEnd('[性能] showTab_总渲染');
}
})();
// 提前返回,因为是异步渲染
if (typeof window.refreshTocList === 'function') window.refreshTocList();
return;
} else if (tab === 'chunk-compare') {
// 性能监控:记录分块对比开始时间
window.chunkCompareStartTime = performance.now();
console.log(`[性能] 开始渲染分块对比,总块数: ${data.ocrChunks ? data.ocrChunks.length : 0}`);
// ========== 超长文本降级策略开关 ==========
(function computeLargeDocFlag(){
const LARGE_TEXT_THRESHOLD = 120000; // 8万字阈值
let totalLen = 0;
try {
if (Array.isArray(data.ocrChunks)) {
for (let i = 0; i < data.ocrChunks.length; i++) totalLen += (data.ocrChunks[i] ? data.ocrChunks[i].length : 0);
}
if (Array.isArray(data.translatedChunks)) {
for (let i = 0; i < data.translatedChunks.length; i++) totalLen += (data.translatedChunks[i] ? data.translatedChunks[i].length : 0);
}
} catch(e) { /* ignore */ }
window.disableEqualizeForLargeDoc = totalLen > LARGE_TEXT_THRESHOLD;
console.log(`[Chunk-Compare] 文本总长度=${totalLen},等高降级=${window.disableEqualizeForLargeDoc}`);
// 记录用于后续清理的块数量
try { window.__lastChunkCompareTotalBlocks = (Array.isArray(data.ocrChunks) ? data.ocrChunks.length : 0); } catch(e) { window.__lastChunkCompareTotalBlocks = 0; }
})();
// window.globalCurrentContentIdentifier = ''; // 已在函数开头正确设置
DOM_CACHE.tabs.chunkCompare.classList.add('active');
if (data.ocrChunks && data.ocrChunks.length > 0 && data.translatedChunks && data.translatedChunks.length === data.ocrChunks.length) {
// 使用优化器进行分块对比渲染
if (false && window.ChunkCompareOptimizer && window.ChunkCompareOptimizer.instance) {
console.log('[性能] 使用优化后的分块对比渲染器');
html = window.ChunkCompareOptimizer.instance.optimizeChunkComparison(
data.ocrChunks,
data.translatedChunks,
{
images: data.images,
isOriginalFirst: window.isOriginalFirstInChunkCompare !== false
}
);
} else {
// 回退到原有渲染逻辑
console.log('[性能] 使用旧版分块对比渲染');
html = `
<div class="chunk-compare-title-bar">
<h3>分块对比</h3>
<button id="swap-chunks-btn" title="切换原文/译文位置">⇆</button>
</div>
<div class="chunk-compare-container">
`;
// 继续使用原有的渲染逻辑...
}
// 强制走旧版渲染逻辑(禁用优化器路径)
if (true) {
/**
* 解析Markdown文本为逻辑块数组主要基于标题进行分割。
* 代码块 (```...```) 会被视为单个块的一部分,不会被分割。
* @param {string} md - Markdown文本。
* @returns {Array<Object>} 每个对象包含 `{ content: string }`。
*/
function parseMarkdownBlocks(md) {
const lines = (md || '').split(/\r?\n/);
const blocks = [];
let buffer = [];
let inCode = false;
let isFirstBlock = true;
function flush() {
if (buffer.length) {
blocks.push({ content: buffer.join('\n') });
buffer = [];
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s*```/.test(line)) { // 代码块
inCode = !inCode;
buffer.push(line);
continue;
}
if (inCode) {
buffer.push(line);
continue;
}
if (/^\s*#/.test(line)) { // 标题作为新分块的起点
if (!isFirstBlock) flush();
isFirstBlock = false;
buffer.push(line);
continue;
}
// 普通内容、列表、空行等都合并到当前块
buffer.push(line);
}
flush();
return blocks;
}
/**
* 对齐两组Markdown逻辑块用于并排显示。
* 简单地按索引逐个配对,如果某一组块少,则对应位置为空字符串。
* @param {Array<Object>} blocks1 - 第一组块。
* @param {Array<Object>} blocks2 - 第二组块。
* @returns {Array<Array<string>>} 每个内部数组包含两个字符串 `[block1_content, block2_content]`。
*/
function alignBlocks(blocks1, blocks2) {
// 简单按类型和顺序对齐
const maxLen = Math.max(blocks1.length, blocks2.length);
const aligned = [];
for (let i = 0; i < maxLen; i++) {
aligned.push([
blocks1[i] ? blocks1[i].content : '',
blocks2[i] ? blocks2[i].content : ''
]);
}
return aligned;
}
/**
* 渲染单个OCR块和其对应的翻译块的对齐视图支持分层结构。
* - 它首先使用 `parseMarkdownBlocks` 将OCR和翻译文本分割成小块基于标题
* - 然后使用 `alignBlocks` 对齐这些小块。
* - 为每个对齐的小块对生成并排的HTML结构用于显示原文和译文。
* - 提供工具栏按钮,用于切换显示模式(对比、仅原文、仅译文)、复制整块内容以及导航到上下块。
* - 原始块内容存储在 `window.blockRawContent_[blockIndex]` 中,供复制功能使用。
*
* @param {string} ocrChunk - OCR文本块。
* @param {string} translatedChunk - 对应的翻译文本块。
* @param {Array<Object>} images - 与此文档关联的图片数据。
* @param {number} blockIndex - 当前大块在整个文档分块中的索引。
* @param {number} totalBlocks - 文档分块的总数。
* @returns {string} 生成的HTML字符串用于显示对齐的块内容。
*/
function renderLevelAlignedFlex(ocrChunk, translatedChunk, images, blockIndex, totalBlocks) {
// 性能优化:缓存解析结果避免重复计算
const cacheKey = `${blockIndex}_${ocrChunk.length}_${translatedChunk.length}`;
if (window.chunkParseCache && window.chunkParseCache[cacheKey]) {
const cachedResult = window.chunkParseCache[cacheKey];
const ocrBlocks = cachedResult.ocrBlocks;
const transBlocks = cachedResult.transBlocks;
const aligned = cachedResult.aligned;
let showMode = window[`showMode_block_${blockIndex}`] || 'both';
// 使用缓存的解析结果渲染HTML
return renderAlignedHTML(ocrBlocks, transBlocks, aligned, images, blockIndex, totalBlocks, showMode);
}
const ocrBlocks = parseMarkdownBlocks(ocrChunk);
const transBlocks = parseMarkdownBlocks(translatedChunk);
const aligned = alignBlocks(ocrBlocks, transBlocks);
// 缓存解析结果
if (!window.chunkParseCache) window.chunkParseCache = {};
window.chunkParseCache[cacheKey] = { ocrBlocks, transBlocks, aligned };
let showMode = window[`showMode_block_${blockIndex}`] || 'both';
return renderAlignedHTML(ocrBlocks, transBlocks, aligned, images, blockIndex, totalBlocks, showMode);
}
/**
* 渲染对齐后的HTML内容
*/
function renderAlignedHTML(ocrBlocks, transBlocks, aligned, images, blockIndex, totalBlocks, showMode) {
// ========== 辅助:媒体与段落对齐增强 ==========
function normalizeHtml(html) {
return (html || '')
.replace(/\s+/g, ' ')
.replace(/\u00A0/g, ' ')
.trim();
}
function extractFirstMatch(html, regex) {
const m = (html || '').match(regex);
if (!m) return { match: null, rest: html };
const before = html.slice(0, m.index);
const after = html.slice(m.index + m[0].length);
return { match: m[0], rest: before + after };
}
function extractFirstTable(html) {
// 支持 HTML 表格Markdown 表格在本阶段不易可靠识别,先处理 HTML
const res = extractFirstMatch(html, /<table[\s\S]*?<\/table>/i);
return { table: res.match, rest: res.rest };
}
function extractFirstImage(html) {
// 支持 <img> 或 markdown 图片
const imgHtml = extractFirstMatch(html, /<img\b[\s\S]*?>/i);
if (imgHtml.match) return { image: imgHtml.match, rest: imgHtml.rest };
const mdImg = extractFirstMatch(html, /!\[[^\]]*\]\([^\)]+\)/);
return { image: mdImg.match, rest: mdImg.rest };
}
function isSameImage(imgA, imgB) {
if (!imgA || !imgB) return false;
const srcA = (imgA.match(/src\s*=\s*"([^"]+)"/) || [])[1] || (imgA.match(/\]\(([^\)]+)\)/) || [])[1];
const srcB = (imgB.match(/src\s*=\s*"([^"]+)"/) || [])[1] || (imgB.match(/\]\(([^\)]+)\)/) || [])[1];
if (!srcA || !srcB) return false;
return normalizeHtml(srcA) === normalizeHtml(srcB);
}
function areTablesSimilar(tblA, tblB) {
if (!tblA || !tblB) return false;
const a = normalizeHtml(tblA.replace(/<thead[\s\S]*?<\/thead>/ig, '').replace(/<tbody[\s\S]*?<\/tbody>/ig, '').replace(/<[^>]+>/g, ''));
const b = normalizeHtml(tblB.replace(/<thead[\s\S]*?<\/thead>/ig, '').replace(/<tbody[\s\S]*?<\/tbody>/ig, '').replace(/<[^>]+>/g, ''));
if (!a || !b) return false;
const lenRatio = Math.min(a.length, b.length) / Math.max(a.length, b.length);
return lenRatio >= 0.7;
}
function splitParagraphs(text) {
return (text || '')
.split(/\n{2,}/)
.map(s => s.trim())
.filter(Boolean);
}
// 统一去掉段落首尾多余空格/空行,避免不可见换行导致高度不一致
function stripEdgeWhitespace(md) {
if (!md) return md;
// 标准化不可见空白NBSP、零宽字符
md = md.replace(/\u00A0/g, ' '); // NBSP → 普通空格
md = md.replace(/[\u200B-\u200D\uFEFF]/g, ''); // 零宽空白
md = md.replace(/^\uFEFF/, ''); // BOM
md = md.replace(/^[\s\t\r\n]+/, ''); // 开头空白/换行
md = md.replace(/[\s\t\r\n]+$/, ''); // 结尾空白/换行
// 再次清理尾随 NBSP有的浏览器不把 NBSP 视为 \s
md = md.replace(/\u00A0+$/, '');
return md;
}
// 判断是否包含表格语法markdown 管道表格或已渲染 HTML 表格)
function containsTableSyntax(src) {
if (!src) return false;
if (/<table\b/i.test(src)) return true; // 已渲染 HTML 表格
// 仅当“块的起始位置”就是 Markdown 表格表头,才认为是表格
const lines = src.split(/\n/).map(l => (l || '').trim());
// 找到首个非空行
let i = 0; while (i < lines.length && lines[i] === '') i++;
if (i >= lines.length - 1) return false;
const a = lines[i];
// 找到表头后的首个非空行
let j = i + 1; while (j < lines.length && lines[j] === '') j++;
if (j >= lines.length) return false;
const b = lines[j];
const looksLikeHeader = /^\|.*\|$/.test(a);
const looksLikeDivider = /^\|\s*:?[-]{2,}.*\|$/.test(b);
return looksLikeHeader && looksLikeDivider;
}
// ========== 新增:对比模式“软换行” ===========
function softWrapLongFormulasInCompare(container) {
if (!container || !window.katex) return;
const blocks = container.querySelectorAll('.align-content .katex-block');
blocks.forEach(function(el){
try {
const parent = el.parentElement;
const cw = parent ? parent.clientWidth : 0;
const rect = el.getBoundingClientRect();
const w = rect.width || 0;
const tex = el.getAttribute('data-original-text') || '';
if (!tex || /\\begin\{|\\\\\\\n/.test(tex)) return; // 已有环境/显式换行不处理
if (cw > 0 && w > cw * 1.05) {
const wrapped = buildWrappedTeX(tex, Math.max(48, Math.floor(cw / 7)));
try {
const html = katex.renderToString(wrapped, { displayMode: true, throwOnError: true, strict: 'ignore', output: 'html' });
el.innerHTML = html;
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
});
}
function buildWrappedTeX(tex, maxLen) {
const src = String(tex || '').replace(/\s+/g, ' ').trim();
if (/\\begin\{|\\end\{|aligned|matrix|cases|array/.test(src)) return src;
const hasEq = /=/.test(src);
const parts = [];
let buf = '';
let depthBrace = 0;
for (let i = 0; i < src.length; i++) {
const ch = src[i];
if (ch === '{') depthBrace++;
if (ch === '}') depthBrace = Math.max(0, depthBrace - 1);
buf += ch;
if (depthBrace === 0 && (ch === ',' || ch === ';' || ch === '+' || ch === '-' || ch === '=')) {
if (buf.length >= maxLen) { parts.push(buf.trim()); buf = ''; }
}
if (buf.length >= maxLen * 1.5) { parts.push(buf.trim()); buf = ''; }
}
if (buf.trim()) parts.push(buf.trim());
if (parts.length <= 1) return src;
const lines = parts.map(function(line){ return hasEq ? line.replace(/=\s*/, '&= ') : line; });
return `\\begin{aligned} ${lines.join(' \\ ')} \\ \\end{aligned}`;
}
// 针对整块层级做一次媒体“拿出来”与对齐增强
let oWhole = (ocrBlocks || []).map(b => b.content).join('\n\n');
let tWhole = (transBlocks || []).map(b => b.content).join('\n\n');
let hoistedParts = [];
// 表格不再抽出合并,保持左右对齐并在开屏时逐对等高微调
// 3) 图片相同则抽出一个显示
const exImgO = extractFirstImage(oWhole);
const exImgT = extractFirstImage(tWhole);
if (exImgO.image && exImgT.image && isSameImage(exImgO.image, exImgT.image)) {
hoistedParts.push({ type: 'merged-image', html: exImgO.image });
oWhole = exImgO.rest.trim();
tWhole = exImgT.rest.trim();
}
// 4) 对剩余内容尝试逐段落对齐(仅当段落数一致)
let paragraphPairs = null;
const parasO = splitParagraphs(oWhole);
const parasT = splitParagraphs(tWhole);
if (parasO.length > 0 && parasO.length === parasT.length) {
paragraphPairs = parasO.map((p, i) => [p, parasT[i]]);
}
// 在分块对比内部也尝试使用自定义渲染器
// 注意:这里的 annotations 应该是整个文档的contentIdentifier 需要根据当前块是原文还是译文来确定
// 为了简化,我们暂时假设分块对比中的内容不直接参与这种精细的预标注,
// 或者需要更复杂的逻辑来传递正确的 contentIdentifier
// MODIFIED: Pass empty array for annotations in chunk-compare mode to disable highlights/annotations
const annotationsForChunkRender = [];
const ocrRenderer = createCustomMarkdownRenderer(annotationsForChunkRender, 'ocr', MarkdownProcessor.renderWithKatexFailback);
const transRenderer = createCustomMarkdownRenderer(annotationsForChunkRender, 'translation', MarkdownProcessor.renderWithKatexFailback);
// 整块复制按钮
let html = `
<div class="block-toolbar" data-block-toolbar="${blockIndex}">
<div class="block-toolbar-left">
<span class="block-mode-btn ${showMode === 'both' ? 'active' : ''}" data-mode="both" data-block="${blockIndex}">对比</span>
<span class="block-mode-btn ${showMode === 'ocr' ? 'active' : ''}" data-mode="ocr" data-block="${blockIndex}">原文</span>
<span class="block-mode-btn ${showMode === 'trans' ? 'active' : ''}" data-mode="trans" data-block="${blockIndex}">译文</span>
<button class="block-copy-btn" data-block="${blockIndex}" title="复制本块内容">复制本块</button>
</div>
<div class="block-toolbar-right">
${blockIndex > 0 ? `<button class="block-nav-btn" data-dir="prev" data-block="${blockIndex}" title="上一段">↑</button>` : ''}
${blockIndex < totalBlocks-1 ? `<button class="block-nav-btn" data-dir="next" data-block="${blockIndex}" title="下一段">↓</button>` : ''}
</div>
</div>
`;
// 性能优化批量构建HTML字符串按序先 hoisted再对齐对再fallback
const alignedHTML = [];
// 先渲染抽出的媒体(图片单列)
if (hoistedParts.length > 0) {
hoistedParts.forEach((part, idx) => {
if (part.type === 'merged-image') {
const rendered = (window.MarkdownProcessor && window.MarkdownProcessor.renderWithKatexFailback)
? window.MarkdownProcessor.renderWithKatexFailback(window.MarkdownProcessor.safeMarkdown(part.html, images))
: part.html;
alignedHTML.push(`
<div class="align-flex block-flex block-flex-${blockIndex} block-mode-both" data-block="${blockIndex}" data-align-index="hoisted-${idx}">
<div class="align-block" style="flex:1 1 100%">
<div class="align-title"><span>图片</span></div>
<div class="align-content markdown-body">${rendered}</div>
</div>
</div>
`);
}
});
}
// 再渲染逐段落对齐(若可用)
if (paragraphPairs) {
for (let i = 0; i < paragraphPairs.length; i++) {
const [pO, pT] = paragraphPairs[i];
const isTablePair = containsTableSyntax(pO) && containsTableSyntax(pT);
const titleLeft = isTablePair ? '表格' : '原文';
const titleRight = isTablePair ? '表格' : '译文';
alignedHTML.push(`
<div class="align-flex block-flex block-flex-${blockIndex} ${isTablePair ? 'table-pair ' : ''}${showMode==='ocr'?'block-mode-ocr-only':showMode==='trans'?'block-mode-trans-only':'block-mode-both'}" data-block="${blockIndex}" data-align-index="para-${i}">
<div class="align-block align-block-ocr">
<div class="align-title">
<span>${titleLeft}</span>
<div class="align-actions">
<button class="block-struct-copy-btn block-icon-btn" data-block="${blockIndex}" data-type="ocr" data-idx="${i}" title="复制原文结构">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button class="block-edit-btn block-icon-btn" data-block="${blockIndex}" data-type="ocr" data-idx="${i}" title="编辑此段(仅供预览)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
</button>
<button class="block-edit-reset-btn block-icon-btn" data-block="${blockIndex}" data-type="ocr" data-idx="${i}" title="重置为原文" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
</button>
</div>
</div>
<div class="align-content markdown-body" data-raw-markdown="${encodeURIComponent(stripEdgeWhitespace(pO))}">${MarkdownProcessor.renderWithKatexFailback(MarkdownProcessor.safeMarkdown(stripEdgeWhitespace((()=>{const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${i}_ocr`; const ov = localStorage.getItem(key); return ov ? ov : pO;})()), images), isOriginalFirstInChunkCompare ? ocrRenderer : transRenderer)}</div>
<div class="align-edit-panel" style="display:none;">
<textarea class="align-edit-area" style="width:100%;min-height:120px;box-sizing:border-box;"></textarea>
<div class="align-edit-actions" style="margin-top:6px;display:flex;gap:8px;">
<button class="align-edit-save" data-block="${blockIndex}" data-type="ocr" data-idx="para-${i}">保存</button>
<button class="align-edit-cancel" data-block="${blockIndex}" data-type="ocr" data-idx="para-${i}">取消</button>
</div>
</div>
</div>
<div class="splitter" title="拖动调整比例"></div>
<div class="align-block align-block-trans">
<div class="align-title">
<span>${titleRight}</span>
<div class="align-actions">
<button class="block-struct-copy-btn block-icon-btn" data-block="${blockIndex}" data-type="trans" data-idx="${i}" title="复制译文结构">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button class="block-edit-btn block-icon-btn" data-block="${blockIndex}" data-type="trans" data-idx="${i}" title="编辑此段(仅供预览)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
</button>
<button class="block-edit-reset-btn block-icon-btn" data-block="${blockIndex}" data-type="trans" data-idx="${i}" title="重置为原文" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
</button>
</div>
</div>
<div class="align-content markdown-body" data-raw-markdown="${encodeURIComponent(stripEdgeWhitespace(pT))}">${MarkdownProcessor.renderWithKatexFailback(MarkdownProcessor.safeMarkdown(stripEdgeWhitespace((()=>{const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${i}_trans`; const ov = localStorage.getItem(key); return ov ? ov : pT;})()), images), isOriginalFirstInChunkCompare ? transRenderer : ocrRenderer)}</div>
<div class="align-edit-panel" style="display:none;">
<textarea class="align-edit-area" style="width:100%;min-height:120px;"></textarea>
<div class="align-edit-actions" style="margin-top:6px;display:flex;gap:8px;">
<button class="align-edit-save" data-block="${blockIndex}" data-type="trans" data-idx="para-${i}">保存</button>
<button class="align-edit-cancel" data-block="${blockIndex}" data-type="trans" data-idx="para-${i}">取消</button>
</div>
</div>
</div>
</div>
`);
}
} else {
// 最后 fallback使用原有的 aligned 对
for (let i = 0; i < aligned.length; i++) {
const isTablePair = containsTableSyntax(aligned[i][0]) && containsTableSyntax(aligned[i][1]);
const titleLeft = isTablePair ? '表格' : '原文';
const titleRight = isTablePair ? '表格' : '译文';
alignedHTML.push(`
<div class="align-flex block-flex block-flex-${blockIndex} ${isTablePair ? 'table-pair ' : ''}${showMode==='ocr'?'block-mode-ocr-only':showMode==='trans'?'block-mode-trans-only':'block-mode-both'}" data-block="${blockIndex}" data-align-index="${i}">
<div class="align-block align-block-ocr">
<div class="align-title">
<span>${titleLeft}</span>
<div class="align-actions">
<button class="block-struct-copy-btn block-icon-btn" data-block="${blockIndex}" data-type="ocr" data-idx="${i}" title="复制原文结构">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button class="block-edit-btn block-icon-btn" data-block="${blockIndex}" data-type="ocr" data-idx="${i}" title="编辑此段(仅供预览)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
</button>
<button class="block-edit-reset-btn block-icon-btn" data-block="${blockIndex}" data-type="ocr" data-idx="${i}" title="重置为原文" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
</button>
</div>
</div>
<div class="align-content markdown-body" data-raw-markdown="${encodeURIComponent(stripEdgeWhitespace(aligned[i][0]))}">${MarkdownProcessor.renderWithKatexFailback(MarkdownProcessor.safeMarkdown(stripEdgeWhitespace((()=>{const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${i}_ocr`; const ov = localStorage.getItem(key); return ov ? ov : aligned[i][0];})()), images), isOriginalFirstInChunkCompare ? ocrRenderer : transRenderer)}</div>
<div class="align-edit-panel" style="display:none;">
<textarea class="align-edit-area" style="width:100%;min-height:120px;"></textarea>
<div class="align-edit-actions" style="margin-top:6px;display:flex;gap:8px;">
<button class="align-edit-save" data-block="${blockIndex}" data-type="ocr" data-idx="${i}">保存</button>
<button class="align-edit-cancel" data-block="${blockIndex}" data-type="ocr" data-idx="${i}">取消</button>
</div>
</div>
</div>
<div class="splitter" title="拖动调整比例"></div>
<div class="align-block align-block-trans">
<div class="align-title">
<span>${titleRight}</span>
<div class="align-actions">
<button class="block-struct-copy-btn block-icon-btn" data-block="${blockIndex}" data-type="trans" data-idx="${i}" title="复制译文结构">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button class="block-edit-btn block-icon-btn" data-block="${blockIndex}" data-type="trans" data-idx="${i}" title="编辑此段(仅供预览)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
</button>
<button class="block-edit-reset-btn block-icon-btn" data-block="${blockIndex}" data-type="trans" data-idx="${i}" title="重置为原文" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
</button>
</div>
</div>
<div class="align-content markdown-body" data-raw-markdown="${encodeURIComponent(stripEdgeWhitespace(aligned[i][1]))}">${MarkdownProcessor.renderWithKatexFailback(MarkdownProcessor.safeMarkdown(stripEdgeWhitespace((()=>{const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${i}_trans`; const ov = localStorage.getItem(key); return ov ? ov : aligned[i][1];})()), images), isOriginalFirstInChunkCompare ? transRenderer : ocrRenderer)}</div>
<div class="align-edit-panel" style="display:none;">
<textarea class="align-edit-area" style="width:100%;min-height:120px;"></textarea>
<div class="align-edit-actions" style="margin-top:6px;display:flex;gap:8px;">
<button class="align-edit-save" data-block="${blockIndex}" data-type="trans" data-idx="${i}">保存</button>
<button class="align-edit-cancel" data-block="${blockIndex}" data-type="trans" data-idx="${i}">取消</button>
</div>
</div>
</div>
</div>
`);
}
}
html += alignedHTML.join('');
// 记录原始内容,供复制用
window[`blockRawContent_${blockIndex}`] = aligned;
return html;
}
// 恢复原始渲染逻辑渲染每个分块增加唯一id
for (let i = 0; i < data.ocrChunks.length; i++) {
const ocrChunk = data.ocrChunks[i] || '';
const translatedChunk = data.translatedChunks[i] || '';
let blockHtmlToRender;
let outerBlockTitle;
if (isOriginalFirstInChunkCompare) {
// 当原文在左侧时,调用 renderLevelAlignedFlex(原文, 译文)
// window[`blockRawContent_${i}`] 将存储 [原文子块, 译文子块]
blockHtmlToRender = renderLevelAlignedFlex(ocrChunk, translatedChunk, data.images, i, data.ocrChunks.length);
outerBlockTitle = `原文块 ${i+1}`;
} else {
// 当译文在左侧时,调用 renderLevelAlignedFlex(译文, 原文)
// window[`blockRawContent_${i}`] 将存储 [译文子块, 原文子块]
blockHtmlToRender = renderLevelAlignedFlex(translatedChunk, ocrChunk, data.images, i, data.ocrChunks.length);
outerBlockTitle = `译文块 ${i+1}`; // 标题也反映左侧内容
}
html += `<div class="chunk-pair">`;
html += `<div id="block-${i}" class="block-outer">`; // id 用于导航
html += `<h4>${outerBlockTitle}</h4>`;
html += blockHtmlToRender;
html += `</div></div>`;
}
// 绑定每个分块的切换按钮和导航按钮事件
setTimeout(() => {
// 拖动分割条实现 - 使用CSS变量而不是直接设置样式
let ratio = window.chunkCompareRatio;
if (typeof ratio !== 'number' || isNaN(ratio)) ratio = 0.5;
window.chunkCompareRatio = ratio;
function applyRatioToAll() {
const currentRatio = window.chunkCompareRatio || 0.5;
document.querySelectorAll('.align-flex').forEach(flex => {
// 优先使用每对的等高比例
if (flex.hasAttribute('data-equalized')) {
const ratioSaved = parseFloat(flex.getAttribute('data-equalized'));
if (isFinite(ratioSaved)) {
flex.style.setProperty('--ocr-ratio', (ratioSaved * 100) + '%');
flex.style.setProperty('--trans-ratio', ((1 - ratioSaved) * 100) + '%');
return;
}
}
const hasTableOCR = !!flex.querySelector('.align-block-ocr table');
const hasTableTRANS = !!flex.querySelector('.align-block-trans table');
const anyTable = hasTableOCR || hasTableTRANS;
// 表格对若未等高临时使用0.5;文本对使用全局
const ratio = anyTable ? 0.5 : currentRatio;
flex.style.setProperty('--ocr-ratio', (ratio * 100) + '%');
flex.style.setProperty('--trans-ratio', ((1 - ratio) * 100) + '%');
});
}
// 一次性、逐卡片的简化等高(避免视口进入时抖动):
// 在当前布局基础上,以 0.5 为初始,按 hL/(hL+hR) 估算每对的专属比例,仅设置一次
function equalizePairsOnce() {
if (window.disableEqualizeForLargeDoc) {
// 超长文本时跳过逐对等高,避免大量 reflow
return;
}
const pairs = document.querySelectorAll('.align-flex');
pairs.forEach(flex => {
if (flex.hasAttribute('data-equalized')) return;
const left = flex.querySelector('.align-block-ocr .align-content');
const right = flex.querySelector('.align-block-trans .align-content');
if (!left || !right) return;
// 统一先设为 0.5 以获得一致的初始测量
flex.style.setProperty('--ocr-ratio', '50%');
flex.style.setProperty('--trans-ratio', '50%');
const hL = left.getBoundingClientRect().height;
const hR = right.getBoundingClientRect().height;
if (!isFinite(hL) || !isFinite(hR) || (hL + hR) === 0) return;
let r = hL / (hL + hR);
r = Math.max(0.3, Math.min(0.7, r));
flex.setAttribute('data-equalized', String(r));
flex.style.setProperty('--ocr-ratio', (r * 100) + '%');
flex.style.setProperty('--trans-ratio', ((1 - r) * 100) + '%');
});
}
// 超长文本时不对所有对齐对写入比例,保留默认 50/50避免全量 DOM 循环)
if (!window.disableEqualizeForLargeDoc) {
applyRatioToAll();
// 简化的一次性等高(无复验、无视口触发,避免抖动)
try { equalizePairsOnce(); } catch {}
}
// 渲染后为对比区域的大公式做软换行
try {
const container = document.querySelector('.chunk-compare-container');
softWrapLongFormulasInCompare(container);
} catch (e) { /* ignore */ }
// 性能优化缓存DOM查询结果
const blockModeButtons = document.querySelectorAll('.block-mode-btn');
const allSplitters = document.querySelectorAll('.splitter');
const blockCopyButtons = document.querySelectorAll('.block-copy-btn');
const blockStructCopyButtons = document.querySelectorAll('.block-struct-copy-btn');
const blockNavButtons = document.querySelectorAll('.block-nav-btn');
// 性能优化:使用事件委托减少事件监听器数量
const chunkCompareContainer = document.querySelector('.chunk-compare-container');
if (chunkCompareContainer && !chunkCompareContainer.dataset.delegateSet) {
// 为复制按钮添加事件委托
chunkCompareContainer.addEventListener('click', function(e) {
// 段落编辑:进入编辑
if (e.target.classList.contains('block-edit-btn') || e.target.closest('.block-edit-btn')) {
const btn = e.target.closest('.block-edit-btn');
const blockIndex = btn.dataset.block;
const idx = btn.dataset.idx;
const type = btn.dataset.type; // 'ocr' | 'trans'
const flex = btn.closest('.align-block');
const content = flex.querySelector('.align-content.markdown-body');
const panel = flex.querySelector('.align-edit-panel');
const textarea = panel && panel.querySelector('.align-edit-area');
if (!content || !panel || !textarea) return;
// 初始文案:优先已保存覆盖,其次 data-raw-markdown
const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${idx}_${type}`;
const saved = localStorage.getItem(key);
const raw = decodeURIComponent(content.getAttribute('data-raw-markdown') || '') || '';
textarea.value = saved || raw;
// 让编辑区域尽量占满当前卡片高度
const h = Math.max(content.offsetHeight, 120);
textarea.style.minHeight = h + 'px';
content.style.display = 'none';
panel.style.display = '';
// 显示重置按钮(如果存在覆盖)
const resetBtn = flex.querySelector('.block-edit-reset-btn[data-block="'+blockIndex+'"][data-type="'+type+'"][data-idx="'+idx+'"]');
if (resetBtn) resetBtn.style.display = saved ? '' : 'none';
return;
}
// 段落编辑:保存
if (e.target.classList.contains('align-edit-save')) {
const btn = e.target;
const blockIndex = btn.dataset.block;
const idx = btn.dataset.idx;
const type = btn.dataset.type; // 'ocr' | 'trans'
const block = btn.closest('.align-block');
const content = block.querySelector('.align-content.markdown-body');
const panel = block.querySelector('.align-edit-panel');
const textarea = panel && panel.querySelector('.align-edit-area');
if (!content || !panel || !textarea) return;
const md = (textarea.value || '').trim();
const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${idx}_${type}`;
if (md) localStorage.setItem(key, md); else localStorage.removeItem(key);
// 重新渲染该侧
try {
const imgs = (window.data && window.data.images) || [];
const html = window.MarkdownProcessor && window.MarkdownProcessor.renderWithKatexFailback
? window.MarkdownProcessor.renderWithKatexFailback(window.MarkdownProcessor.safeMarkdown(md, imgs))
: md.replace(/\n/g, '<br>');
content.innerHTML = html;
content.setAttribute('data-raw-markdown', encodeURIComponent(md));
} catch { content.textContent = md; }
panel.style.display = 'none';
content.style.display = '';
// 显示重置按钮
const resetBtn = block.querySelector('.block-edit-reset-btn[data-block="'+blockIndex+'"][data-type="'+type+'"][data-idx="'+idx+'"]');
if (resetBtn) resetBtn.style.display = md ? '' : 'none';
return;
}
// 段落编辑:取消
if (e.target.classList.contains('align-edit-cancel')) {
const btn = e.target;
const block = btn.closest('.align-block');
const content = block.querySelector('.align-content.markdown-body');
const panel = block.querySelector('.align-edit-panel');
if (!content || !panel) return;
panel.style.display = 'none';
content.style.display = '';
return;
}
// 段落编辑:重置覆盖
if (e.target.classList.contains('block-edit-reset-btn')) {
const btn = e.target;
const blockIndex = btn.dataset.block;
const idx = btn.dataset.idx;
const type = btn.dataset.type;
const key = `chunkOverride_${window.docIdForLocalStorage||'default'}_${blockIndex}_${idx}_${type}`;
localStorage.removeItem(key);
// 还原为原始 data-raw-markdown
const block = btn.closest('.align-block');
const content = block.querySelector('.align-content.markdown-body');
const panel = block.querySelector('.align-edit-panel');
if (content) {
const md = decodeURIComponent(content.getAttribute('data-raw-markdown') || '') || '';
try {
const imgs = (window.data && window.data.images) || [];
const html = window.MarkdownProcessor && window.MarkdownProcessor.renderWithKatexFailback
? window.MarkdownProcessor.renderWithKatexFailback(window.MarkdownProcessor.safeMarkdown(md, imgs))
: md.replace(/\n/g, '<br>');
content.innerHTML = html;
} catch { content.textContent = md; }
}
if (panel) panel.style.display = 'none';
if (content) content.style.display = '';
// 隐藏重置按钮
btn.style.display = 'none';
return;
}
if (e.target.classList.contains('block-copy-btn')) {
// 复制按钮逻辑保持不变,但通过事件委托触发
const btn = e.target;
const blockIndex = btn.dataset.block;
const rawBlockContent = window[`blockRawContent_${blockIndex}`];
const currentMode = window[`showMode_block_${blockIndex}`] || 'both';
// 复制逻辑保持原样...
if (rawBlockContent && Array.isArray(rawBlockContent)) {
let textToCopy = "";
let alertMessage = "";
if (currentMode === 'ocr') {
rawBlockContent.forEach(pair => {
const ocrText = isOriginalFirstInChunkCompare ? (pair && pair[0]) : (pair && pair[1]);
if (ocrText) textToCopy += ocrText + "\n\n";
});
textToCopy = textToCopy.trim();
alertMessage = `${parseInt(blockIndex) + 1} 块的 原文 已复制!`;
} else if (currentMode === 'trans') {
rawBlockContent.forEach(pair => {
const transText = isOriginalFirstInChunkCompare ? (pair && pair[1]) : (pair && pair[0]);
if (transText) textToCopy += transText + "\n\n";
});
textToCopy = textToCopy.trim();
alertMessage = `${parseInt(blockIndex) + 1} 块的 译文 已复制!`;
} else {
rawBlockContent.forEach(pair => {
const ocrText = isOriginalFirstInChunkCompare ? (pair && pair[0]) : (pair && pair[1]);
const transText = isOriginalFirstInChunkCompare ? (pair && pair[1]) : (pair && pair[0]);
if (ocrText) textToCopy += "原文:\n" + ocrText + "\n\n";
if (transText) textToCopy += "译文:\n" + transText + "\n\n";
});
textToCopy = textToCopy.trim();
alertMessage = `${parseInt(blockIndex) + 1} 块的 原文和译文 已复制!`;
}
if (textToCopy) {
navigator.clipboard.writeText(textToCopy)
.then(() => alert(alertMessage))
.catch(err => {
console.error('复制失败:', err);
alert('复制失败,请查看控制台。');
});
} else {
alert('没有内容可复制。');
}
} else {
alert('没有内容可复制。');
}
}
});
chunkCompareContainer.dataset.delegateSet = 'true';
}
// 保留原有的独立事件绑定(为了兼容性)
blockModeButtons.forEach(btn => {
btn.onclick = function() {
const blockIndex = this.dataset.block;
const mode = this.dataset.mode;
window[`showMode_block_${blockIndex}`] = mode;
// 更新按钮激活状态
document.querySelectorAll(`.block-mode-btn[data-block="${blockIndex}"]`).forEach(b => {
b.classList.remove('active');
});
this.classList.add('active');
// 使用CSS类管理显示模式而不是直接操作样式
document.querySelectorAll(`.block-flex-${blockIndex}`).forEach(flexPair => {
// 移除所有模式类
flexPair.classList.remove('block-mode-ocr-only', 'block-mode-trans-only', 'block-mode-both');
// 添加对应的模式类
if (mode === 'ocr') {
flexPair.classList.add('block-mode-ocr-only');
} else if (mode === 'trans') {
flexPair.classList.add('block-mode-trans-only');
} else { // mode === 'both'
flexPair.classList.add('block-mode-both');
// 当切换回 both 模式时,重新应用拖动条的比例
applyRatioToAllFlexPairsInBlock(blockIndex);
}
});
};
});
// 辅助函数将当前拖动比例应用到指定blockIndex的所有flexPair
function applyRatioToAllFlexPairsInBlock(blockIndexToUpdate) {
const currentRatio = window.chunkCompareRatio || 0.5;
document.querySelectorAll(`.block-flex-${blockIndexToUpdate}`).forEach(flex => {
if (flex.hasAttribute('data-equalized')) {
const ratioSaved = parseFloat(flex.getAttribute('data-equalized'));
if (isFinite(ratioSaved)) {
flex.style.setProperty('--ocr-ratio', (ratioSaved * 100) + '%');
flex.style.setProperty('--trans-ratio', ((1 - ratioSaved) * 100) + '%');
return;
}
}
const hasTableOCR = !!flex.querySelector('.align-block-ocr table');
const hasTableTRANS = !!flex.querySelector('.align-block-trans table');
const anyTable = hasTableOCR || hasTableTRANS;
const ratio = anyTable ? 0.5 : currentRatio;
flex.style.setProperty('--ocr-ratio', (ratio * 100) + '%');
flex.style.setProperty('--trans-ratio', ((1 - ratio) * 100) + '%');
});
}
// 初始化拖动比例应用到所有分块的所有对比对
// 确保在按钮事件绑定之后,但在第一次渲染时就能正确设置
if (document.querySelector('.align-flex')) { // 确保有可操作的元素
const allBlockIndexes = new Set();
document.querySelectorAll('[data-block]').forEach(el => allBlockIndexes.add(el.dataset.block));
allBlockIndexes.forEach(idx => {
if(window[`showMode_block_${idx}`] === undefined || window[`showMode_block_${idx}`] === 'both') {
applyRatioToAllFlexPairsInBlock(idx);
}
});
}
// 拖动分割条实现
let draggingSplitterInfo = null; // {splitter, flexContainer, startX, initialOcrBasisPx}
allSplitters.forEach(splitter => {
splitter.onmousedown = function(e) {
const flexContainer = e.target.closest('.align-flex');
if (!flexContainer) return;
const ocrBlock = flexContainer.querySelector('.align-block-ocr');
if (!ocrBlock || getComputedStyle(ocrBlock).display === 'none') return; // 只在对比模式下拖动
// 用户手动操作该对比对,清除自动等高的固定比例标记
if (flexContainer.hasAttribute('data-equalized')) {
flexContainer.removeAttribute('data-equalized');
}
draggingSplitterInfo = {
splitter: e.target,
flexContainer: flexContainer,
startX: e.clientX,
initialOcrBasisPx: ocrBlock.offsetWidth
};
e.target.classList.add('active');
// 使用CSS类而不是直接设置样式
document.body.classList.add('dragging-cursor');
e.preventDefault();
};
});
document.addEventListener('mousemove', function(e) {
if (!draggingSplitterInfo) return;
const { splitter, flexContainer, startX, initialOcrBasisPx } = draggingSplitterInfo;
const ocrBlock = flexContainer.querySelector('.align-block-ocr');
const transBlock = flexContainer.querySelector('.align-block-trans');
if (!ocrBlock || !transBlock) return;
const dx = e.clientX - startX;
const containerWidth = flexContainer.offsetWidth;
if (containerWidth === 0) return;
let newOcrWidthPx = initialOcrBasisPx + dx;
// 限制最小/最大宽度例如总宽度的20%到80%
const minWidthPx = containerWidth * 0.2;
const maxWidthPx = containerWidth * 0.8;
newOcrWidthPx = Math.max(minWidthPx, Math.min(newOcrWidthPx, maxWidthPx));
const newOcrRatio = newOcrWidthPx / containerWidth;
// 更新当前拖动的块的比例
flexContainer.style.setProperty('--ocr-ratio', (newOcrRatio * 100) + '%');
flexContainer.style.setProperty('--trans-ratio', ((1 - newOcrRatio) * 100) + '%');
// 仅更新当前对比对比例(取消联动)
draggingSplitterInfo.currentRatio = newOcrRatio;
});
document.addEventListener('mouseup', function() {
if (draggingSplitterInfo) {
draggingSplitterInfo.splitter.classList.remove('active');
// 使用CSS类而不是直接设置样式
document.body.classList.remove('dragging-cursor');
// 将当前对比对的最终比例写入专属 data-equalized保持独立
try {
const { flexContainer, currentRatio } = draggingSplitterInfo;
if (flexContainer && typeof currentRatio === 'number' && isFinite(currentRatio)) {
flexContainer.setAttribute('data-equalized', String(currentRatio));
// 若之前有硬等高 min-height拖动后移除完全以用户设定为准
const left = flexContainer.querySelector('.align-block-ocr .align-content');
const right = flexContainer.querySelector('.align-block-trans .align-content');
if (left) left.style.minHeight = '';
if (right) right.style.minHeight = '';
}
} catch {}
draggingSplitterInfo = null;
}
});
// ============= 智能比例建议(仅默认比例、每文档一次) =============
(function smartRatioBootstrap() {
try {
const docId = window.docIdForLocalStorage;
if (!docId) return;
const promptFlagKey = `chunkCompareSmartRatioPromptShown_${docId}`;
const alreadyPrompted = localStorage.getItem(promptFlagKey) === 'true';
// 若已有用户自定义比例,或已弹过提示,则不再计算
const savedRatioText = localStorage.getItem(`chunkCompareRatio_${docId}`);
const hasCustomRatio = savedRatioText !== null && !isNaN(parseFloat(savedRatioText)) && parseFloat(savedRatioText) !== 0.5;
if (alreadyPrompted || hasCustomRatio) return;
// 延迟执行,等待列表 DOM 渲染完成
setTimeout(() => maybeSuggestSmartRatio(docId, promptFlagKey), 800);
} catch (e) { console.warn('smartRatioBootstrap error:', e); }
})();
// 等高工具:更稳的帧同步+测量、粗采样+细化、困难对硬等高
if (false) (function setupPairEqualization() {
const docId = window.docIdForLocalStorage || 'default';
window.__pairEqualizer = window.__pairEqualizer || {};
const state = window.__pairEqualizer;
const raf2 = () => new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
async function waitImages(el, ms=150) {
const imgs = el.querySelectorAll('img');
if (imgs.length === 0) { await new Promise(r=>setTimeout(r, ms)); return; }
await Promise.race([
new Promise(r=>setTimeout(r, ms)),
Promise.all(Array.from(imgs).map(img=> new Promise(r=>{ if (img.complete) return r(); img.addEventListener('load', r, {once:true}); img.addEventListener('error', r, {once:true}); })))
]);
}
async function measureDiff(flex, leftEl, rightEl, ratio) {
flex.style.setProperty('--ocr-ratio', (ratio * 100) + '%');
flex.style.setProperty('--trans-ratio', ((1 - ratio) * 100) + '%');
await raf2();
const hL = leftEl.getBoundingClientRect().height;
const hR = rightEl.getBoundingClientRect().height;
return { diff: hL - hR, hL, hR };
}
async function equalizePair(flex) {
try {
if (flex.dataset.equalizedDone === 'true') return;
const left = flex.querySelector('.align-block-ocr .align-content');
const right = flex.querySelector('.align-block-trans .align-content');
if (!left || !right) { flex.dataset.equalizedDone = 'true'; return; }
if (flex.classList.contains('block-mode-ocr-only') || flex.classList.contains('block-mode-trans-only')) { return; }
await waitImages(flex, 120);
await raf2();
const isTablePair = !!flex.querySelector('.align-block-ocr table') && !!flex.querySelector('.align-block-trans table');
const coarse = [0.35,0.45,0.5,0.55,0.65];
let best = 0.5, bestAbs = Infinity;
for (const r of coarse) {
const m = await measureDiff(flex, left, right, r);
const a = Math.abs(m.diff);
if (a < bestAbs) { bestAbs = a; best = r; }
}
let low = Math.max(0.3, best - 0.1);
let high = Math.min(0.7, best + 0.1);
const tol = isTablePair ? 4 : 6;
for (let i=0;i<(isTablePair?7:6);i++) {
const mid = (low+high)/2;
const m = await measureDiff(flex, left, right, mid);
const a = Math.abs(m.diff);
if (a < bestAbs) { bestAbs = a; best = mid; }
if (a <= tol) break;
if (m.diff > 0) { low = mid; } else { high = mid; }
}
// 复验微调
for (let step=0.04, k=0; k<3; k++, step*=0.5) {
const candidates = [best-step, best, best+step].map(r=> Math.max(0.3, Math.min(0.7, r)));
for (const r of candidates) {
const m = await measureDiff(flex, left, right, r);
const a = Math.abs(m.diff);
if (a < bestAbs) { bestAbs = a; best = r; }
}
if (bestAbs <= (isTablePair?2:3)) break;
}
// 困难对硬等高:误差仍过大
if (bestAbs > (isTablePair?4:6)) {
const last = await measureDiff(flex, left, right, best);
const target = Math.max(last.hL, last.hR);
left.style.minHeight = target + 'px';
right.style.minHeight = target + 'px';
flex.setAttribute('data-equalized', 'hard');
} else {
flex.setAttribute('data-equalized', String(best));
}
flex.dataset.equalizedDone = 'true';
} catch (e) { /* ignore per pair */ }
}
// 进入视口再等高(首屏优先)
if (!state.observer) {
state.observer = new IntersectionObserver(async entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
const flex = entry.target;
state.observer.unobserve(flex);
await equalizePair(flex);
}
}
}, { root: null, rootMargin: '200px', threshold: 0.1 });
}
document.querySelectorAll('.align-flex').forEach(flex => {
// 清理因硬等高设置的 min-height避免历史残留影响
const left = flex.querySelector('.align-block-ocr .align-content');
const right = flex.querySelector('.align-block-trans .align-content');
if (left) left.style.minHeight = '';
if (right) right.style.minHeight = '';
state.observer.observe(flex);
});
})();
// 控制台调试入口window.forceSmartRatioPrompt({ resetPrompt:true, resetRatio:true })
window.forceSmartRatioPrompt = function(opts={}) {
try {
const docId = window.docIdForLocalStorage;
if (!docId) return console.warn('forceSmartRatioPrompt: missing docId');
const promptFlagKey = `chunkCompareSmartRatioPromptShown_${docId}`;
if (opts.resetPrompt) localStorage.removeItem(promptFlagKey);
if (opts.resetRatio) localStorage.removeItem(`chunkCompareRatio_${docId}`);
maybeSuggestSmartRatio(docId, promptFlagKey);
} catch(e) { console.warn('forceSmartRatioPrompt error:', e); }
};
async function maybeSuggestSmartRatio(docId, promptFlagKey) {
try {
// 再次确认是否已经设置过比例
const savedRatioText = localStorage.getItem(`chunkCompareRatio_${docId}`);
const hasCustomRatio = savedRatioText !== null && !isNaN(parseFloat(savedRatioText)) && parseFloat(savedRatioText) !== 0.5;
if (hasCustomRatio) return;
if (!window.data || !Array.isArray(window.data.ocrChunks) || !Array.isArray(window.data.translatedChunks)) return;
const total = Math.min(window.data.ocrChunks.length, window.data.translatedChunks.length);
if (total === 0) return;
// 选取最多15个候选块优先无图、长度>=150
const candidates = selectCandidateBlockIndices(window.data.ocrChunks, window.data.translatedChunks, 15);
// 调试日志
try { console.log('[SmartRatio] candidates:', candidates); } catch {}
// 需要至少2个候选块
if (candidates.length < 2) return;
// 统一设置当前比率为0.5,确保测量一致
window.chunkCompareRatio = 0.5;
applyRatioToAll();
const ratios = [];
// 逐个确保加载完整块并测量
for (const idx of candidates) {
const ok = await ensureChunkPresent(idx, 4000);
if (!ok) continue;
const loaded = await ensureChunkLoaded(idx, 6000);
if (!loaded) continue;
// 对新加载的flex对也应用0.5比例
applyRatioToAll();
const r = measureRecommendedRatioForBlock(idx);
if (typeof r === 'number' && isFinite(r) && r > 0 && r < 1) {
ratios.push(r);
}
if (ratios.length >= 15) break;
}
// 至少需要2个有效测量结果
if (ratios.length < 2) { try { console.log('[SmartRatio] Not enough measured ratios:', ratios); } catch {}; return; }
// 剔除极端值简单去头去尾10%n>=6时各去1个
ratios.sort((a,b)=>a-b);
let trimmed = ratios.slice();
if (ratios.length >= 6) {
trimmed = ratios.slice(1, ratios.length - 1);
}
const avg = trimmed.reduce((s,v)=>s+v,0) / trimmed.length;
let suggested = Math.max(0.3, Math.min(0.7, avg));
try { console.log('[SmartRatio] ratios:', ratios, 'trimmed:', trimmed, 'avg:', avg, 'suggested:', suggested); } catch {}
// 主动弹窗(每文档只弹一次)
localStorage.setItem(promptFlagKey, 'true');
const pct = Math.round(suggested * 100);
const use = confirm(`已根据前 ${trimmed.length} 个块估算出建议对比比例为 ${pct}%(原文)/ ${100-pct}%(译文)。是否应用?`);
if (use) {
window.chunkCompareRatio = suggested;
applyRatioToAll();
localStorage.setItem(`chunkCompareRatio_${docId}`, String(suggested));
if (typeof showNotification === 'function') {
showNotification(`已应用智能比例:原文 ${pct}%`, 'success');
}
}
} catch (e) {
console.warn('maybeSuggestSmartRatio error:', e);
}
}
function selectCandidateBlockIndices(ocrChunks, transChunks, limit) {
const n = Math.min(ocrChunks.length, transChunks.length);
const items = [];
for (let i = 0; i < n; i++) {
const o = ocrChunks[i] || '';
const t = transChunks[i] || '';
const lenOk = (o.length >= 150) && (t.length >= 150);
const hasImg = /!\[[^\]]*\]\([^)]*\)|<img\b/i.test(o) || /!\[[^\]]*\]\([^)]*\)|<img\b/i.test(t);
const hasMdTable = /(^|[\r\n])\s*\|.*\|/m.test(o) || /(^|[\r\n])\s*\|.*\|/m.test(t) || /<table\b/i.test(o) || /<table\b/i.test(t);
items.push({ idx: i, lenOk, hasImg, hasMdTable });
}
// 过滤:长度达标
const filtered = items.filter(it => it.lenOk && !it.hasMdTable);
// 优先无图,再有图,再按索引
filtered.sort((a,b)=> (a.hasImg===b.hasImg?0:(a.hasImg?1:-1)) || (a.idx-b.idx));
return filtered.slice(0, limit).map(it=>it.idx);
}
function measureRecommendedRatioForBlock(blockIndex) {
try {
// 兼容旧版与新版容器:优先旧版 #block-{i},再尝试 #chunk-{i} 或 data-chunk-index
const container =
document.getElementById(`block-${blockIndex}`) ||
document.getElementById(`chunk-${blockIndex}`) ||
document.querySelector(`.chunk-pair[data-chunk-index="${blockIndex}"]`);
if (!container) return null;
// 跳过包含表格的块,避免对建议比例产生干扰
if (container.querySelector('table')) return null;
const ocrNodes = container.querySelectorAll('.align-block-ocr .align-content');
const transNodes = container.querySelectorAll('.align-block-trans .align-content');
if (ocrNodes.length === 0 || transNodes.length === 0) return null;
let hOcr = 0, hTrans = 0;
ocrNodes.forEach(n => { hOcr += n.getBoundingClientRect().height; });
transNodes.forEach(n => { hTrans += n.getBoundingClientRect().height; });
if (!isFinite(hOcr) || !isFinite(hTrans) || hOcr <= 0 || hTrans <= 0) return null;
// 基于 h ~ A/r, 推导 r* = hOcr / (hOcr + hTrans)
const r = hOcr / (hOcr + hTrans);
return r;
} catch (e) {
return null;
}
}
function wait(ms) { return new Promise(res => setTimeout(res, ms)); }
async function ensureChunkPresent(index, timeoutMs = 4000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const el = document.getElementById(`block-${index}`) ||
document.getElementById(`chunk-${index}`) ||
document.querySelector(`.chunk-pair[data-chunk-index="${index}"]`);
if (el) return true;
await wait(60);
}
return false;
}
async function ensureChunkLoaded(index, timeoutMs = 6000) {
try {
const container = document.getElementById(`block-${index}`) ||
document.getElementById(`chunk-${index}`) ||
document.querySelector(`.chunk-pair[data-chunk-index="${index}"]`);
if (!container) return false;
// 旧版一次性渲染,存在 .align-flex 即视为已加载
if (container.querySelector('.align-flex')) return true;
// 新版需触发懒加载
if (window.ChunkCompareOptimizer && window.ChunkCompareOptimizer.instance && typeof window.ChunkCompareOptimizer.instance.loadFullChunk === 'function') {
window.ChunkCompareOptimizer.instance.loadFullChunk(index);
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (container.querySelector('.align-flex')) return true;
await wait(80);
}
} catch {}
return false;
}
blockCopyButtons.forEach(btn => {
btn.onclick = function() {
const blockIndex = this.dataset.block;
const rawBlockContent = window[`blockRawContent_${blockIndex}`];
const currentMode = window[`showMode_block_${blockIndex}`] || 'both'; // 获取当前模式
if (rawBlockContent && Array.isArray(rawBlockContent)) {
let textToCopy = "";
let alertMessage = "";
if (currentMode === 'ocr') {
rawBlockContent.forEach(pair => {
// 如果原文在左pair[0]是原文如果译文在左pair[0]是译文
const ocrText = isOriginalFirstInChunkCompare ? (pair && pair[0]) : (pair && pair[1]);
if (ocrText) textToCopy += ocrText + "\n\n";
});
textToCopy = textToCopy.trim();
alertMessage = `${parseInt(blockIndex) + 1} 块的 原文 已复制!`;
} else if (currentMode === 'trans') {
rawBlockContent.forEach(pair => {
// 如果原文在左pair[1]是译文如果译文在左pair[1]是原文
const transText = isOriginalFirstInChunkCompare ? (pair && pair[1]) : (pair && pair[0]);
if (transText) textToCopy += transText + "\n\n";
});
textToCopy = textToCopy.trim();
alertMessage = `${parseInt(blockIndex) + 1} 块的 译文 已复制!`;
} else { // mode === 'both'
rawBlockContent.forEach(pair => {
const ocrText = isOriginalFirstInChunkCompare ? (pair && pair[0]) : (pair && pair[1]);
const transText = isOriginalFirstInChunkCompare ? (pair && pair[1]) : (pair && pair[0]);
if (ocrText) textToCopy += "原文:\n" + ocrText + "\n\n";
if (transText) textToCopy += "译文:\n" + transText + "\n\n";
});
textToCopy = textToCopy.trim();
alertMessage = `${parseInt(blockIndex) + 1} 块的 原文和译文 已复制!`;
}
if (textToCopy) {
navigator.clipboard.writeText(textToCopy)
.then(() => alert(alertMessage))
.catch(err => {
console.error('复制失败:', err);
alert('复制失败,请查看控制台。');
});
} else {
alert('没有内容可复制。');
}
}
};
});
blockStructCopyButtons.forEach(btn => {
btn.onclick = function() {
const blockIndex = this.dataset.block;
const type = this.dataset.type; // 'ocr' or 'trans'
const structIdx = parseInt(this.dataset.idx);
const rawBlockContent = window[`blockRawContent_${blockIndex}`];
if (rawBlockContent && rawBlockContent[structIdx]) {
const textToCopy = (type === 'ocr') ? rawBlockContent[structIdx][0] : rawBlockContent[structIdx][1];
navigator.clipboard.writeText(textToCopy)
.then(() => alert(`${parseInt(blockIndex) + 1} 块的 ${type === 'ocr' ? '原文' : '译文'} (结构 ${structIdx + 1}) 已复制!`))
.catch(err => console.error('复制失败:', err));
}
};
});
blockNavButtons.forEach(btn => {
btn.onclick = function() {
const blockIndex = parseInt(this.dataset.block);
const direction = this.dataset.dir;
let targetIndex = direction === 'prev' ? blockIndex - 1 : blockIndex + 1;
const targetElement = document.getElementById(`block-${targetIndex}`);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 可选:添加高亮效果
targetElement.classList.add('block-highlight');
setTimeout(() => targetElement.classList.remove('block-highlight'), 1500);
}
};
});
// 性能监控:记录分块对比渲染时间
const renderEndTime = performance.now();
console.log(`[性能] 分块对比渲染完成,总块数: ${data.ocrChunks.length},耗时: ${(renderEndTime - window.chunkCompareStartTime || 0).toFixed(2)}ms`);
// 内存优化:清理旧的缓存(如果太多的话)
if (window.chunkParseCache && Object.keys(window.chunkParseCache).length > 100) {
console.log('[性能] 清理部分解析缓存以释放内存');
// 超长文档更激进地清理缓存
const MAX_CACHE_ITEMS = (window.disableEqualizeForLargeDoc ? 5 : 50);
const cacheKeys = Object.keys(window.chunkParseCache);
const keysToDelete = cacheKeys.slice(0, cacheKeys.length - MAX_CACHE_ITEMS);
keysToDelete.forEach(key => delete window.chunkParseCache[key]);
}
}, 0);
} // end fallback (no optimizer)
} else {
html = '<h3>分块对比</h3><p>此记录没有有效的分块对比数据。</p>';
if (!data.ocrChunks || !data.translatedChunks) {
html += '<p>原因:缺少分块数据 (ocrChunks or translatedChunks missing)。</p>';
} else if (data.ocrChunks.length !== data.translatedChunks.length) {
html += `<p>原因:原文块数量 (${data.ocrChunks.length}) 与译文块数量 (${data.translatedChunks.length}) 不匹配。</p>`;
} else {
html += '<p>原因:分块数据为空。</p>';
}
}
}
document.getElementById('tabContent').innerHTML = html;
// 分批渲染逻辑(仅对 OCR 和翻译标签页生效)
if(tab === 'ocr' || tab === 'translation') {
const contentText = tab === 'ocr' ? (data.ocr || '') : (data.translation || '');
const contentContainer = document.getElementById(contentContainerId);
const batchSize = 30; // 每批渲染的段落数,可调整
const tokens = marked.lexer(contentText).filter(token => ['paragraph','heading','code','table','blockquote','list','html','hr'].includes(token.type));
window.currentBlockTokensForCopy[tab] = tokens;
// 性能优化禁用预标注inline span 注入),统一改为渲染完成后由 applyBlockAnnotations 处理
// 这样可避免在长文本上进行大量正则匹配与 DOM 拼接。
const customRenderer = createCustomMarkdownRenderer([], tab, MarkdownProcessor.renderWithKatexFailback);
// Define segmentInBatches here, so it's in scope for renderBatch's callback
function segmentInBatches(containerElement, batchSize = 10, delay = 50, onDone) {
const blocks = Array.from(containerElement.children).filter(node => node.nodeType === Node.ELEMENT_NODE);
let i = 0;
function runBatch() {
const end = Math.min(i + batchSize, blocks.length);
for (; i < end; i++) {
const el = blocks[i];
el.dataset.blockIndex = String(i);
if (window.SubBlockSegmenter && typeof window.SubBlockSegmenter.segment === 'function') {
// 强制分段,保证英文/短段也有子块,便于精确高亮
window.SubBlockSegmenter.segment(el, i, true);
} else {
console.error("SubBlockSegmenter.segment is not available.");
}
}
if (i < blocks.length) {
setTimeout(runBatch, delay);
} else {
// 所有父块的子块分割完成
onDone && onDone();
}
}
runBatch();
}
function renderBatch(startIdx, onDoneAllBatchesCallback) { // Added onDoneAllBatchesCallback parameter
const fragment = document.createDocumentFragment();
for(let i=startIdx;i<Math.min(tokens.length, startIdx+batchSize);i++){
const tokenRaw = tokens[i].raw || '';
// 检测:如果 token 类型是 paragraph 但包含表格语法,强制作为表格处理
const hasTableSyntax = /\|(:?-+:?\|)+/.test(tokenRaw);
if (tokens[i].type === 'paragraph' && hasTableSyntax) {
console.log('[renderBatch] 检测到 paragraph token 包含表格语法,强制作为表格处理');
tokens[i].type = 'table'; // 强制改为 table 类型
}
// 优先使用 AST 处理器(支持压缩表格修复)
let htmlStr;
if (typeof MarkdownIntegration !== 'undefined' && MarkdownIntegration.smartRender) {
htmlStr = MarkdownIntegration.smartRender(tokenRaw, data.images, customRenderer, contentIdentifier);
} else if (typeof MarkdownProcessorAST !== 'undefined' && MarkdownProcessorAST.render) {
htmlStr = MarkdownProcessorAST.render(tokenRaw, data.images);
} else {
// 降级到旧版
htmlStr = MarkdownProcessor.renderWithKatexFailback(MarkdownProcessor.safeMarkdown(tokenRaw, data.images), customRenderer);
}
// 后验检查:如果渲染后仍然是 <p> 但包含表格 Markdown尝试直接渲染表格
if (hasTableSyntax && htmlStr.trim().startsWith('<p')) {
console.warn('[renderBatch] 渲染后仍然是 <p>,尝试直接提取并渲染表格部分');
// 提取 <p> 中的文本内容
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlStr;
const pElement = tempDiv.querySelector('p');
if (pElement && pElement.textContent.includes('|')) {
const tableMarkdown = pElement.textContent;
// 重新渲染表格
if (typeof MarkdownProcessorAST !== 'undefined' && MarkdownProcessorAST.render) {
htmlStr = MarkdownProcessorAST.render(tableMarkdown, data.images);
console.log('[renderBatch] 重新渲染表格成功');
}
}
}
const wrap = document.createElement('div');
wrap.innerHTML = htmlStr;
while(wrap.firstChild) fragment.appendChild(wrap.firstChild);
}
contentContainer.appendChild(fragment);
if(startIdx+batchSize<tokens.length) {
setTimeout(()=>renderBatch(startIdx+batchSize, onDoneAllBatchesCallback),0); // Pass callback along
} else {
// 所有批次渲染完成
if(tab==='ocr') console.timeEnd('[性能] OCR分批渲染');
if(tab==='translation') console.timeEnd('[性能] 翻译分批渲染');
// Use requestAnimationFrame to allow browser to paint/layout before further DOM manipulation
requestAnimationFrame(() => {
console.log(`[showTab - ${tab}] RAF triggered after all batches rendered.`);
const currentTabContentWrapper = document.getElementById(contentContainerId);
if (currentTabContentWrapper) {
adjustLongHeadingsToParagraphs(currentTabContentWrapper);
// 兜底修复:将相对图片 src 替换为 data URL若前置替换未命中
try {
const imgs = (window.data && Array.isArray(window.data.images)) ? window.data.images : [];
if (imgs && imgs.length > 0) {
const map = new Map();
imgs.forEach((im, idx) => {
const id = (im.id || '').toString();
const name = (im.name || id || `img-${idx+1}.jpg`).toString();
const base = id || name;
const keys = [
base,
name,
`images/${base}`,
`images/${name}`,
base.replace(/\.[^.]+$/, ''),
name.replace(/\.[^.]+$/, '')
];
const dataUri = (im.data && im.data.startsWith && im.data.startsWith('data:')) ? im.data : `data:image/jpeg;base64,${im.data || ''}`;
keys.forEach(k => { if (k) map.set(k, dataUri); });
});
currentTabContentWrapper.querySelectorAll('img').forEach(imgEl => {
const src = imgEl.getAttribute('src') || '';
if (!src || /^data:|^https?:|^\/\//i.test(src)) return;
const clean = src.split('?')[0].split('#')[0].replace(/^\.\//, '');
const nameOnly = clean.split('/').pop();
const candidates = [clean, nameOnly, `images/${nameOnly}`];
for (const key of candidates) {
if (map.has(key)) {
imgEl.setAttribute('src', map.get(key));
break;
}
}
});
}
} catch (e) { console.warn('[showTab] fix images fallback failed:', e); }
}
// Now, call segmentInBatches on the fully rendered content
activeContentElement = document.getElementById(contentContainerId); // Re-affirm activeContentElement
if (activeContentElement) {
segmentInBatches(activeContentElement, 10, 50, () => {
// This is the onDone callback for segmentInBatches
// All segmentation is complete, now apply annotations and listeners
// 后处理:渲染表格和其他地方的纯文本公式(异步版本,不阻塞主线程)
console.log('[showTab] 开始后处理公式(异步)');
console.time('[性能] 公式渲染');
// 优先使用异步 Worker 版本(页面显示时)
if (typeof FormulaPostProcessorAsync !== 'undefined') {
FormulaPostProcessorAsync.processFormulasInElement(activeContentElement, {
useWorker: true, // 页面显示时使用 Worker避免阻塞主线程
onComplete: () => {
console.timeEnd('[性能] 公式渲染');
console.log('[showTab] 公式渲染完成');
}
});
} else if (typeof FormulaPostProcessor !== 'undefined' && FormulaPostProcessor.processFormulasInElement) {
// 回退到同步版本(如果异步版本未加载)
console.warn('[showTab] 异步公式处理器不可用,使用同步版本');
FormulaPostProcessor.processFormulasInElement(activeContentElement);
console.timeEnd('[性能] 公式渲染');
}
if (data && data.annotations && typeof window.applyBlockAnnotations === 'function') {
window.applyBlockAnnotations(activeContentElement, data.annotations, contentIdentifier);
}
// Finally, update Dock stats and TOC as content and structure are finalized
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function') {
console.log(`[showTab - ${tab}] OCR/Translation: segmentInBatches done, forcing Dock stats update.`);
window.DockLogic.updateStats(window.data, currentVisibleTabId);
}
if (typeof window.refreshTocList === 'function') {
console.log(`[showTab - ${tab}] OCR/Translation: segmentInBatches done, forcing TOC refresh.`);
window.refreshTocList();
}
// ========== 渲染完成,解锁 ==========='
renderingTab = null;
// ====================================
// ========== 内容加载完成 =============
window.contentReady = true;
console.log('[DEBUG] window.contentReady = true (after OCR/Translation segmentInBatches)');
// Phase 2.3: 初始化批注系统 DOM 缓存
if (window.AnnotationDOMCache) {
window.AnnotationDOMCache.init();
}
// ====================================
});
} else {
// ========== 渲染完成,解锁 ==========='
renderingTab = null;
// ====================================
// ========== 内容加载完成 =============
window.contentReady = true;
console.log('[DEBUG] window.contentReady = true (after OCR/Translation segmentInBatches, no activeContentElement)');
// Phase 2.3: 初始化批注系统 DOM 缓存
if (window.AnnotationDOMCache) {
window.AnnotationDOMCache.init();
}
// ====================================
}
}); // End of requestAnimationFrame
}
}
// Start rendering batches and provide a callback for when all are done
renderBatch(0, () => {
// This callback is executed after all batches for OCR/Translation are rendered
console.log(`[showTab - ${tab}] All batches rendered. Proceeding with DOM processing.`);
// Use requestAnimationFrame to allow browser to paint/layout before further DOM manipulation
requestAnimationFrame(() => {
console.log(`[showTab - ${tab}] RAF triggered after all batches rendered.`);
const currentTabContentWrapper = document.getElementById(contentContainerId);
if (currentTabContentWrapper) {
adjustLongHeadingsToParagraphs(currentTabContentWrapper);
}
// Now, call segmentInBatches on the fully rendered content
activeContentElement = document.getElementById(contentContainerId); // Re-affirm activeContentElement
if (activeContentElement) {
segmentInBatches(activeContentElement, 10, 50, () => {
// This is the onDone callback for segmentInBatches
// All segmentation is complete, now apply annotations and listeners
// 后处理:渲染表格和其他地方的纯文本公式(异步版本,不阻塞主线程)
console.log('[showTab] 开始后处理公式(异步)');
console.time('[性能] 公式渲染');
// 优先使用异步 Worker 版本(页面显示时)
if (typeof FormulaPostProcessorAsync !== 'undefined') {
FormulaPostProcessorAsync.processFormulasInElement(activeContentElement, {
useWorker: true, // 页面显示时使用 Worker避免阻塞主线程
onComplete: () => {
console.timeEnd('[性能] 公式渲染');
console.log('[showTab] 公式渲染完成');
}
});
} else if (typeof FormulaPostProcessor !== 'undefined' && FormulaPostProcessor.processFormulasInElement) {
// 回退到同步版本(如果异步版本未加载)
console.warn('[showTab] 异步公式处理器不可用,使用同步版本');
FormulaPostProcessor.processFormulasInElement(activeContentElement);
console.timeEnd('[性能] 公式渲染');
}
if (data && data.annotations && typeof window.applyBlockAnnotations === 'function') {
window.applyBlockAnnotations(activeContentElement, data.annotations, contentIdentifier);
}
// Finally, update Dock stats and TOC as content and structure are finalized
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function') {
console.log(`[showTab - ${tab}] OCR/Translation: segmentInBatches done, forcing Dock stats update.`);
window.DockLogic.updateStats(window.data, currentVisibleTabId);
}
if (typeof window.refreshTocList === 'function') {
console.log(`[showTab - ${tab}] OCR/Translation: segmentInBatches done, forcing TOC refresh.`);
window.refreshTocList();
}
// ========== 渲染完成,解锁 ==========='
renderingTab = null;
// ====================================
// ========== 内容加载完成 =============
window.contentReady = true;
console.log('[DEBUG] window.contentReady = true (after OCR/Translation segmentInBatches)');
// Phase 2.3: 初始化批注系统 DOM 缓存
if (window.AnnotationDOMCache) {
window.AnnotationDOMCache.init();
}
try {
// 通知其他模块(例如参考文献管理器)内容已渲染
document.dispatchEvent(new CustomEvent('contentRendered', { detail: { tab } }));
} catch (e) { /* no-op */ }
// ====================================
});
} else {
// ========== 渲染完成,解锁 ==========='
renderingTab = null;
// ====================================
// ========== 内容加载完成 =============
window.contentReady = true;
console.log('[DEBUG] window.contentReady = true (after OCR/Translation segmentInBatches, no activeContentElement)');
// Phase 2.3: 初始化批注系统 DOM 缓存
if (window.AnnotationDOMCache) {
window.AnnotationDOMCache.init();
}
try {
// 通知其他模块(例如参考文献管理器)内容已渲染
document.dispatchEvent(new CustomEvent('contentRendered', { detail: { tab } }));
} catch (e) { /* no-op */ }
// ====================================
}
}); // End of requestAnimationFrame
});
}
// NEW: Adjust long headings. This should be called AFTER innerHTML is set
// and BEFORE refreshTocList is called.
// MOVED: adjustLongHeadingsToParagraphs is now called after renderBatch completes for OCR/Translation
// const tabContentElement = document.getElementById('tabContent');
// if (tabContentElement) {
// adjustLongHeadingsToParagraphs(tabContentElement);
// }
// MOVED: refreshTocList is now called later for OCR/Translation
// if (typeof window.refreshTocList === 'function') {
// window.refreshTocList(); // 更新TOC
// }
// Update reading progress when tab changes and content is rendered - CALLING DOCK_LOGIC
if (window.DockLogic && typeof window.DockLogic.forceUpdateReadingProgress === 'function') {
window.DockLogic.forceUpdateReadingProgress();
}
// 如果是分块对比视图,并且按钮存在,则绑定事件
if (tab === 'chunk-compare') {
const swapBtn = document.getElementById('swap-chunks-btn');
if (swapBtn) {
swapBtn.onclick = function() {
isOriginalFirstInChunkCompare = !isOriginalFirstInChunkCompare;
showTab('chunk-compare'); // 重新渲染分块对比视图
};
}
}
// After tab content is updated, refresh chatbot UI if it's open
if (window.isChatbotOpen && typeof window.ChatbotUI !== 'undefined' && typeof window.ChatbotUI.updateChatbotUI === 'function') {
window.ChatbotUI.updateChatbotUI();
}
// 应用高亮和批注
// MOVED: The logic for applying annotations and adding listeners for OCR/Translation
// is now inside the callback chain starting from renderBatch a few lines above.
/*
if ((tab === 'ocr' || tab === 'translation') && contentContainerId) {
activeContentElement = document.getElementById(contentContainerId);
if (activeContentElement) {
// 分批异步分割子块,避免一次性阻塞
function segmentInBatches(containerElement, batchSize = 10, delay = 50, onDone) {
const blocks = Array.from(containerElement.children).filter(node => node.nodeType === Node.ELEMENT_NODE);
let i = 0;
function runBatch() {
const end = Math.min(i + batchSize, blocks.length);
for (; i < end; i++) {
const el = blocks[i];
el.dataset.blockIndex = String(i);
if (window.SubBlockSegmenter && typeof window.SubBlockSegmenter.segment === 'function') {
// 强制分段,保证英文/短段也有子块,便于精确高亮
window.SubBlockSegmenter.segment(el, i, true);
} else {
console.error("SubBlockSegmenter.segment is not available.");
}
}
if (i < blocks.length) {
setTimeout(runBatch, delay);
} else {
// 所有父块的子块分割完成
onDone && onDone();
}
}
runBatch();
}
segmentInBatches(activeContentElement, 10, 50, () => {
// 所有分割完成后,应用批注和监听器
if (data && data.annotations && typeof window.applyBlockAnnotations === 'function') {
window.applyBlockAnnotations(activeContentElement, data.annotations, window.globalCurrentContentIdentifier);
}
// **** 新增在所有子块分割和批注应用完成后再次更新Dock统计 ****
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function') {
console.log(`[showTab - ${tab}] OCR/Translation content and annotations processed, forcing Dock stats update.`);
window.DockLogic.updateStats(window.data, currentVisibleTabId);
}
// TOC refresh will be handled later
});
}
} else if (tab === 'chunk-compare') {
*/
// The chunk-compare logic for annotations and Dock stats remains, as it has its own processing path.
// We only moved the OCR/Translation specific part.
if (tab === 'chunk-compare') { // This is the original start of the else if block
// 对于分块对比视图,为每个原文和译文块单独处理
setTimeout(() => { //确保DOM更新完毕
// 清理工具:移除区域末尾的换行/空白/空段落,避免不可见换行导致高度不齐
function trimTrailingBreaks(area) {
if (!area) return;
try {
function isWhitespaceText(n) {
if (!n || n.nodeType !== Node.TEXT_NODE) return false;
let t = n.textContent || '';
t = t.replace(/\u00A0/g, ' '); // NBSP → space
t = t.replace(/[\u200B-\u200D\uFEFF]/g, ''); // zero-width
return /^\s*$/.test(t);
}
function isEmptyElement(n) {
if (!n || n.nodeType !== Node.ELEMENT_NODE) return false;
// 没有可见文本与可见子节点(如图片/表格)
const hasMedia = n.querySelector('img, table, video, svg');
let txt = (n.textContent || '');
txt = txt.replace(/\u00A0/g, ' ');
txt = txt.replace(/[\u200B-\u200D\uFEFF]/g, '');
txt = txt.replace(/\s+/g, '');
return !hasMedia && txt.length === 0;
}
function removeTrailingIn(el) {
let node = el && el.lastChild;
// 先清理内部子节点的末尾 <br> 与空白
if (node && node.nodeType === Node.ELEMENT_NODE) {
while (node.lastChild && (node.lastChild.nodeType === Node.ELEMENT_NODE && node.lastChild.tagName.toLowerCase() === 'br' || isWhitespaceText(node.lastChild))) {
node.removeChild(node.lastChild);
}
}
// 再清理当前容器的末尾
while (node) {
if (isWhitespaceText(node)) {
const prev = node.previousSibling; el.removeChild(node); node = prev; continue;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
if (tag === 'br') { const prev = node.previousSibling; el.removeChild(node); node = prev; continue; }
// 清理元素内末尾 <br> 以及纯空元素
while (node.lastChild && (node.lastChild.nodeType === Node.ELEMENT_NODE && node.lastChild.tagName.toLowerCase() === 'br' || isWhitespaceText(node.lastChild))) {
node.removeChild(node.lastChild);
}
if (isEmptyElement(node)) { const prev = node.previousSibling; el.removeChild(node); node = prev; continue; }
}
break;
}
}
removeTrailingIn(area);
} catch (e) { /* ignore */ }
}
const ocrContentAreas = document.querySelectorAll('.chunk-compare-container .align-block-ocr .align-content.markdown-body');
const transContentAreas = document.querySelectorAll('.chunk-compare-container .align-block-trans .align-content.markdown-body');
let areasProcessed = 0;
const totalAreasToProcess = ocrContentAreas.length + transContentAreas.length;
function singleAreaProcessed() {
areasProcessed++;
if (areasProcessed === totalAreasToProcess) {
// 所有分块对比区域处理完毕后更新Dock统计和TOC
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function') {
console.log(`[showTab - ${tab}] Chunk-compare: all areas processed, forcing Dock stats update.`);
window.DockLogic.updateStats(window.data, currentVisibleTabId);
}
if (typeof window.refreshTocList === 'function') {
console.log(`[showTab - ${tab}] Chunk-compare: all areas processed, forcing TOC refresh.`);
window.refreshTocList();
}
}
}
function processContentAreaAsync(area, isOcrArea, callback) { // Renamed for clarity
if (!area.id) {
area.id = 'chunk-content-' + _page_generateUUID();
}
// 渲染完成后立即清理尾部换行/空白
trimTrailingBreaks(area);
const effectiveContentIdentifier = isOriginalFirstInChunkCompare ? (isOcrArea ? 'ocr' : 'translation') : (isOcrArea ? 'translation' : 'ocr');
const blockElements = Array.from(area.children).filter(node => node.nodeType === Node.ELEMENT_NODE);
let i = 0;
const batchSize = 5;
function runChunkSubBatch() {
const end = Math.min(i + batchSize, blockElements.length);
for (; i < end; i++) {
const element = blockElements[i];
element.dataset.blockIndex = String(i);
if (typeof window.SubBlockSegmenter !== 'undefined' && typeof window.SubBlockSegmenter.segment === 'function') {
window.SubBlockSegmenter.segment(element, i);
} else {
console.error("SubBlockSegmenter.segment is not available for chunk processing.");
}
}
if (i < blockElements.length) {
setTimeout(runChunkSubBatch, 20);
} else {
// 后处理:渲染表格和其他地方的纯文本公式
if (typeof FormulaPostProcessor !== 'undefined' && FormulaPostProcessor.processFormulasInElement) {
FormulaPostProcessor.processFormulasInElement(area);
}
if (data && data.annotations && typeof window.applyBlockAnnotations === 'function') {
const annotationsToApply = (currentVisibleTabId === 'chunk-compare') ? [] : data.annotations;
window.applyBlockAnnotations(area, annotationsToApply, effectiveContentIdentifier);
}
callback(); // Signal completion for this area
}
}
runChunkSubBatch();
}
if (totalAreasToProcess === 0) { // Handle case with no content areas
if (window.DockLogic && typeof window.DockLogic.updateStats === 'function') {
console.log(`[showTab - ${tab}] Chunk-compare has no content areas, forcing Dock stats update.`);
window.DockLogic.updateStats(window.data, currentVisibleTabId);
}
// TOC refresh will be handled later -> Actually, should be called here too if no areas.
if (typeof window.refreshTocList === 'function') {
console.log(`[showTab - ${tab}] Chunk-compare has no content areas, forcing TOC refresh.`);
window.refreshTocList();
}
} else {
ocrContentAreas.forEach(area => processContentAreaAsync(area, true, singleAreaProcessed));
transContentAreas.forEach(area => processContentAreaAsync(area, false, singleAreaProcessed));
}
}, 0);
// ========== 渲染完成,解锁 ===========
renderingTab = null;
// ====================================
}
// Attempt to restore scroll position for the current tab
if (docIdForLocalStorage && currentVisibleTabId) {
// 添加模式标识到存储键中
const isImmersive = window.ImmersiveLayout && window.ImmersiveLayout.isActive();
const modePrefix = isImmersive ? 'immersive_' : 'normal_';
const scrollKey = `scrollPos_${modePrefix}${docIdForLocalStorage}_${currentVisibleTabId}`;
const savedScrollTop = localStorage.getItem(scrollKey);
console.log(`[showTab] 尝试恢复滚动位置: ${scrollKey}, 保存的值: ${savedScrollTop}, 沉浸模式: ${isImmersive ? '是' : '否'}`);
if (savedScrollTop !== null && !isNaN(parseInt(savedScrollTop, 10))) {
const scrollableElement = getCurrentScrollableElementForHistoryDetail(); // MODIFIED
if (scrollableElement) {
console.log(`[showTab] 找到可滚动元素:`, {
元素ID: scrollableElement.id || '无ID',
元素类名: scrollableElement.className || '无类名',
元素标签: scrollableElement.tagName,
当前scrollTop: scrollableElement.scrollTop,
将要设置的scrollTop: parseInt(savedScrollTop, 10),
scrollHeight: scrollableElement.scrollHeight,
clientHeight: scrollableElement.clientHeight,
路径: getElementPath(scrollableElement)
});
// 使用多次尝试确保滚动位置被正确设置
const scrollTopToSet = parseInt(savedScrollTop, 10);
let attemptCount = 0;
function attemptToSetScroll() {
if (currentVisibleTabId !== tab) {
console.log(`[showTab] 标签已切换,取消恢复滚动位置`);
return;
}
attemptCount++;
console.log(`[showTab] 第${attemptCount}次尝试设置滚动位置: ${scrollTopToSet}`);
scrollableElement.scrollTop = scrollTopToSet;
// 检查是否成功设置
setTimeout(() => {
const currentScrollTop = scrollableElement.scrollTop;
const difference = Math.abs(scrollTopToSet - currentScrollTop);
console.log(`[showTab] 设置后检查: 预期=${scrollTopToSet}, 实际=${currentScrollTop}, 差值=${difference}`);
// 如果差异大于阈值且尝试次数小于最大次数,则重试
if (difference > 5 && attemptCount < 8) {
console.warn(`[showTab] 警告: 滚动位置设置可能未生效! 将在300ms后重试...`);
// 等待布局稳定scrollHeight 两次相等)后再重试
let checks = 0;
let lastHeight = scrollableElement.scrollHeight;
const interval = setInterval(() => {
const h = scrollableElement.scrollHeight;
if (h === lastHeight || checks > 5) {
clearInterval(interval);
requestAnimationFrame(() => setTimeout(attemptToSetScroll, 50));
} else {
lastHeight = h;
checks++;
}
}, 80);
} else if (difference > 5) {
console.warn(`[showTab] 警告: 滚动位置设置失败,已达到最大尝试次数`);
} else {
console.log(`[showTab] 滚动位置设置成功!`);
}
}, 50);
}
// 使用requestAnimationFrame确保DOM已更新
requestAnimationFrame(() => {
if(currentVisibleTabId === tab) { // Ensure tab hasn't changed during async operation
attemptToSetScroll();
} else {
console.log(`[showTab] 标签已切换,取消恢复滚动位置`);
}
});
} else {
console.warn(`[showTab] 未找到可滚动元素,无法恢复滚动位置`);
}
} else {
console.log(`[showTab] 没有保存的滚动位置或值无效: ${savedScrollTop}`);
}
} else {
console.log(`[showTab] 缺少必要参数: docId=${docIdForLocalStorage}, tabId=${currentVisibleTabId}`);
}
// Call updateReadingProgress after potential scroll restoration and content rendering - CALLING DOCK_LOGIC
// 延迟调用,确保滚动位置恢复后再更新阅读进度
setTimeout(() => {
if (window.DockLogic && typeof window.DockLogic.forceUpdateReadingProgress === 'function') {
console.log("[showTab] 延迟调用 forceUpdateReadingProgress 更新阅读进度");
window.DockLogic.forceUpdateReadingProgress();
}
}, 300);
// 新增:每次渲染后都绑定 scroll 事件到正确容器
if (window.DockLogic && typeof window.DockLogic.bindScrollForCurrentScrollable === 'function') {
window.DockLogic.bindScrollForCurrentScrollable();
}
// 重新绑定滚动事件以保存滚动位置
if (typeof bindScrollForSavePosition === 'function') {
bindScrollForSavePosition();
}
// 性能测试断点 - 总渲染结束
console.timeEnd('[性能] showTab_总渲染');
}