1460 lines
58 KiB
JavaScript
1460 lines
58 KiB
JavaScript
/**
|
||
* @namespace TocFeature
|
||
* @description 管理页面右侧浮动的目录 (Table of Contents) 功能。
|
||
* 包括TOC按钮的点击事件、TOC悬浮窗的显示/隐藏、
|
||
* 以及动态生成TOC列表项。
|
||
*/
|
||
(function TocFeature(){
|
||
const tocBtn = document.getElementById('toc-float-btn');
|
||
const tocPopup = document.getElementById('toc-popup');
|
||
const tocList = document.getElementById('toc-list');
|
||
const tocCloseBtn = document.getElementById('toc-popup-close-btn');
|
||
|
||
// 添加 TOC 模式切换按钮容器,改为标签页形式
|
||
let tocModeSelector = document.createElement('div');
|
||
tocModeSelector.className = 'toc-mode-selector';
|
||
tocModeSelector.innerHTML = `
|
||
<button class="toc-mode-btn active" data-mode="both">双语</button>
|
||
<button class="toc-mode-btn" data-mode="ocr">原文</button>
|
||
<button class="toc-mode-btn" data-mode="translation">译文</button>
|
||
`;
|
||
|
||
// 当前 TOC 显示模式:both, ocr, translation
|
||
let currentTocMode = 'both';
|
||
|
||
// 将模式选择器插入到 TOC 弹窗头部下方
|
||
if (tocPopup) {
|
||
const tocHeader = tocPopup.querySelector('#toc-popup-header');
|
||
if (tocHeader) {
|
||
tocHeader.parentNode.insertBefore(tocModeSelector, tocHeader.nextSibling);
|
||
}
|
||
}
|
||
|
||
// 绑定模式切换按钮事件
|
||
tocModeSelector.querySelectorAll('.toc-mode-btn').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const mode = this.dataset.mode;
|
||
currentTocMode = mode;
|
||
|
||
// 更新按钮状态
|
||
tocModeSelector.querySelectorAll('.toc-mode-btn').forEach(b => {
|
||
b.classList.remove('active');
|
||
});
|
||
this.classList.add('active');
|
||
|
||
// 重新渲染 TOC 列表
|
||
renderTocList();
|
||
});
|
||
});
|
||
|
||
// 添加底部控制区域(合并展开目录按钮和全部展开/折叠按钮)
|
||
const tocControls = document.createElement('div');
|
||
tocControls.className = 'toc-controls';
|
||
tocControls.innerHTML = `
|
||
<button class="toc-control-btn" id="toc-expand-btn" title="展开目录" aria-label="展开目录">
|
||
<i class="fa-solid fa-angles-right"></i>
|
||
</button>
|
||
<button class="toc-control-btn" id="toc-expand-all" title="全部展开" aria-label="全部展开">
|
||
<i class="fa-solid fa-angle-double-down"></i>
|
||
</button>
|
||
<button class="toc-control-btn" id="toc-collapse-all" title="全部折叠" aria-label="全部折叠">
|
||
<i class="fa-solid fa-angle-double-up"></i>
|
||
</button>
|
||
`;
|
||
|
||
// 将控制区域添加到TOC弹窗
|
||
if (tocPopup) {
|
||
tocPopup.appendChild(tocControls);
|
||
}
|
||
|
||
/**
|
||
* @const {Object<string, string>} tocMap
|
||
* @description 用于TOC中标题的中文到英文的简单映射表。
|
||
*/
|
||
const tocMap = {
|
||
'历史详情': 'History Detail',
|
||
'OCR内容': 'OCR Content',
|
||
'仅OCR': 'OCR Only',
|
||
'翻译内容': 'Translation',
|
||
'仅翻译': 'Translation Only',
|
||
'分块对比': 'Chunk Compare',
|
||
};
|
||
/**
|
||
* @type {Array<HTMLElement>}
|
||
* @description 存储TOC列表项对应的页面内标题DOM元素。
|
||
*/
|
||
let tocNodes = []; // 存储目录对应的标题DOM元素
|
||
|
||
/**
|
||
* 判断两个文本是否相似
|
||
* @param {string} text1 - 第一个文本
|
||
* @param {string} text2 - 第二个文本
|
||
* @returns {boolean} 是否相似
|
||
*/
|
||
function areTextsSimilar(text1, text2) {
|
||
// 如果两个字符串长度差距大于2,认为不相似
|
||
if (Math.abs(text1.length - text2.length) > 2) {
|
||
return false;
|
||
}
|
||
|
||
// 简单的模糊相似度判断
|
||
let similarity = 0;
|
||
const minLength = Math.min(text1.length, text2.length);
|
||
|
||
for (let i = 0; i < minLength; i++) {
|
||
if (text1[i] === text2[i]) {
|
||
similarity++;
|
||
}
|
||
}
|
||
|
||
const similarityRatio = similarity / minLength;
|
||
return similarityRatio > 0.8; // 相似度大于80%认为是相似的
|
||
}
|
||
|
||
/**
|
||
* 智能截断长文本
|
||
* @param {string} text - 要截断的文本
|
||
* @returns {string} 截断后的文本
|
||
*/
|
||
function truncateText(text) {
|
||
// 如果文本不超过35个字符,直接返回
|
||
if (text.length <= 35) {
|
||
return text;
|
||
}
|
||
|
||
// 检查文本是否是图表标题(以"图"或"表"开头,后跟数字)
|
||
const isChartTitle = /^(图|表)\s*\d+\.?\s*/.test(text);
|
||
|
||
// 如果是图表标题,优先在第一个句号或逗号处截断
|
||
if (isChartTitle) {
|
||
const dotIndex = text.indexOf('。');
|
||
const commaIndex = text.indexOf(',');
|
||
// 也检查英文句号和逗号
|
||
const enDotIndex = text.indexOf('.');
|
||
const enCommaIndex = text.indexOf(',');
|
||
|
||
// 找到第一个有效的截断标点位置
|
||
let firstCutIndex = -1;
|
||
// 优先使用句号,其次使用逗号
|
||
if (dotIndex !== -1) {
|
||
firstCutIndex = dotIndex;
|
||
} else if (enDotIndex !== -1) {
|
||
// 确保英文句号不是数字的一部分
|
||
const charBeforeDot = text.charAt(enDotIndex-1);
|
||
const charAfterDot = text.charAt(enDotIndex+1);
|
||
// 如果句号前后都是数字,可能是小数点,继续检查其他标点
|
||
if (!(/\d/.test(charBeforeDot) && /\d/.test(charAfterDot))) {
|
||
firstCutIndex = enDotIndex;
|
||
}
|
||
}
|
||
|
||
// 如果没有找到句号,尝试使用逗号
|
||
if (firstCutIndex === -1) {
|
||
if (commaIndex !== -1) {
|
||
firstCutIndex = commaIndex;
|
||
} else if (enCommaIndex !== -1) {
|
||
firstCutIndex = enCommaIndex;
|
||
}
|
||
}
|
||
|
||
// 如果仍然没有找到有效的截断点,使用所有标点中最早的一个
|
||
if (firstCutIndex === -1) {
|
||
const allPunctIndices = [dotIndex, commaIndex, enDotIndex, enCommaIndex].filter(idx => idx !== -1);
|
||
if (allPunctIndices.length > 0) {
|
||
firstCutIndex = Math.min(...allPunctIndices);
|
||
}
|
||
}
|
||
|
||
// 确保标点不是图表编号的一部分(如"图5."中的句号)
|
||
if (firstCutIndex > 5) {
|
||
// 特殊处理英文句号,可能是小数点
|
||
if (firstCutIndex === enDotIndex) {
|
||
const charBeforeDot = text.charAt(firstCutIndex-1);
|
||
const charAfterDot = text.charAt(firstCutIndex+1);
|
||
|
||
// 如果句号前后都是数字,这可能是编号的一部分
|
||
if (/\d/.test(charBeforeDot) && /\d/.test(charAfterDot)) {
|
||
// 继续寻找下一个标点,同样优先使用句号
|
||
const nextText = text.substring(firstCutIndex + 1);
|
||
const nextDotIndex = nextText.indexOf('。');
|
||
const nextEnDotIndex = nextText.indexOf('.');
|
||
|
||
// 优先检查中文句号
|
||
if (nextDotIndex !== -1) {
|
||
return text.substring(0, firstCutIndex + 1 + nextDotIndex + 1);
|
||
}
|
||
// 再检查英文句号
|
||
else if (nextEnDotIndex !== -1) {
|
||
// 确保这个英文句号不是小数点
|
||
const nextCharBefore = firstCutIndex + 1 + nextEnDotIndex - 1 < text.length ?
|
||
text.charAt(firstCutIndex + 1 + nextEnDotIndex - 1) : '';
|
||
const nextCharAfter = firstCutIndex + 1 + nextEnDotIndex + 1 < text.length ?
|
||
text.charAt(firstCutIndex + 1 + nextEnDotIndex + 1) : '';
|
||
|
||
if (!(/\d/.test(nextCharBefore) && /\d/.test(nextCharAfter))) {
|
||
return text.substring(0, firstCutIndex + 1 + nextEnDotIndex + 1);
|
||
}
|
||
}
|
||
|
||
// 如果没有找到句号,检查逗号
|
||
const nextCommaIndex = nextText.indexOf(',');
|
||
const nextEnCommaIndex = nextText.indexOf(',');
|
||
|
||
if (nextCommaIndex !== -1) {
|
||
return text.substring(0, firstCutIndex + 1 + nextCommaIndex + 1);
|
||
} else if (nextEnCommaIndex !== -1) {
|
||
return text.substring(0, firstCutIndex + 1 + nextEnCommaIndex + 1);
|
||
}
|
||
|
||
// 如果都没找到,使用所有下一个标点中最早的一个
|
||
const nextPunctIndices = [nextDotIndex, nextCommaIndex, nextEnDotIndex, nextEnCommaIndex].filter(idx => idx !== -1);
|
||
if (nextPunctIndices.length > 0) {
|
||
const nextCutIndex = Math.min(...nextPunctIndices);
|
||
return text.substring(0, firstCutIndex + 1 + nextCutIndex + 1);
|
||
}
|
||
} else {
|
||
return text.substring(0, firstCutIndex + 1);
|
||
}
|
||
} else {
|
||
return text.substring(0, firstCutIndex + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 优先使用中文句号作为截断点
|
||
const chineseDotIndex = text.substring(0, 35).indexOf('。');
|
||
if (chineseDotIndex !== -1) {
|
||
return text.substring(0, chineseDotIndex + 1);
|
||
}
|
||
|
||
// 其次使用英文句号(确保不是小数点)
|
||
const englishDotIndex = text.substring(0, 35).indexOf('.');
|
||
if (englishDotIndex !== -1 && englishDotIndex > 0) {
|
||
const charBeforeDot = text.charAt(englishDotIndex - 1);
|
||
const charAfterDot = englishDotIndex + 1 < text.length ? text.charAt(englishDotIndex + 1) : '';
|
||
|
||
// 如果不是小数点,使用英文句号截断
|
||
if (!(/\d/.test(charBeforeDot) && /\d/.test(charAfterDot))) {
|
||
return text.substring(0, englishDotIndex + 1);
|
||
}
|
||
}
|
||
|
||
// 再使用中文逗号
|
||
const chineseCommaIndex = text.substring(0, 35).indexOf(',');
|
||
if (chineseCommaIndex !== -1) {
|
||
return text.substring(0, chineseCommaIndex + 1);
|
||
}
|
||
|
||
// 最后使用英文逗号
|
||
const englishCommaIndex = text.substring(0, 35).indexOf(',');
|
||
if (englishCommaIndex !== -1) {
|
||
return text.substring(0, englishCommaIndex + 1);
|
||
}
|
||
|
||
// 如果以上都没找到,使用其他中文标点
|
||
const otherChinesePunctuationRegex = /[;:!?]/;
|
||
const otherChinesePunctuationMatch = text.substring(0, 35).match(otherChinesePunctuationRegex);
|
||
if (otherChinesePunctuationMatch) {
|
||
return text.substring(0, otherChinesePunctuationMatch.index + 1);
|
||
}
|
||
|
||
// 如果中文标点都没有,尝试使用其他英文标点
|
||
const otherEnglishPunctuationRegex = /[;:!?]/;
|
||
const otherEnglishPunctuationMatch = text.substring(0, 35).match(otherEnglishPunctuationRegex);
|
||
if (otherEnglishPunctuationMatch) {
|
||
return text.substring(0, otherEnglishPunctuationMatch.index + 1);
|
||
}
|
||
|
||
// 如果都没有找到合适的标点符号,截取前32个字符加省略号
|
||
return text.substring(0, 32) + "...";
|
||
}
|
||
|
||
/**
|
||
* 在TOC导航时,如果目标章节与当前视口距离较远,显示一个临时的加载/导航效果。
|
||
* @param {string} sectionName - 正在导航到的章节名称。
|
||
*/
|
||
function showTemporaryLoadingEffect(sectionName) {
|
||
let effectDiv = document.getElementById('toc-loading-effect');
|
||
const mainContainer = document.querySelector('.container');
|
||
|
||
if (!effectDiv) {
|
||
effectDiv = document.createElement('div');
|
||
effectDiv.id = 'toc-loading-effect';
|
||
// 使用CSS类而不是直接设置样式
|
||
effectDiv.className = 'loading-effect';
|
||
document.body.appendChild(effectDiv);
|
||
}
|
||
|
||
if (mainContainer) {
|
||
// 使用CSS类而不是直接设置样式
|
||
mainContainer.classList.add('content-blurred');
|
||
}
|
||
|
||
// 确保截断显示的章节名
|
||
const truncatedSectionName = truncateText(sectionName);
|
||
effectDiv.textContent = `正在前往: ${truncatedSectionName}`;
|
||
|
||
// 使用CSS类管理可见性
|
||
requestAnimationFrame(() => {
|
||
effectDiv.classList.add('loading-effect-visible');
|
||
});
|
||
|
||
setTimeout(() => {
|
||
effectDiv.classList.remove('loading-effect-visible');
|
||
if (mainContainer) {
|
||
mainContainer.classList.remove('content-blurred');
|
||
}
|
||
}, 1500); // 效果持续时间
|
||
}
|
||
|
||
/**
|
||
* 切换TOC项的折叠状态
|
||
* @param {HTMLElement} toggleBtn - 折叠/展开按钮元素
|
||
* @param {HTMLElement} childrenContainer - 子项容器元素
|
||
*/
|
||
function toggleTocItem(toggleBtn, childrenContainer) {
|
||
const isCollapsed = toggleBtn.classList.contains('collapsed');
|
||
|
||
if (isCollapsed) {
|
||
// 展开
|
||
toggleBtn.classList.remove('collapsed');
|
||
childrenContainer.classList.remove('collapsed');
|
||
|
||
// 设置高度以实现动画效果
|
||
const originalHeight = childrenContainer.scrollHeight;
|
||
childrenContainer.style.height = '0';
|
||
|
||
// 触发回流
|
||
childrenContainer.offsetHeight;
|
||
|
||
childrenContainer.style.height = originalHeight + 'px';
|
||
|
||
// 延迟后移除固定高度,允许自动调整
|
||
setTimeout(() => {
|
||
childrenContainer.style.height = 'auto';
|
||
}, 300);
|
||
} else {
|
||
// 折叠
|
||
toggleBtn.classList.add('collapsed');
|
||
|
||
// 先设置当前高度,然后过渡到0
|
||
childrenContainer.style.height = childrenContainer.scrollHeight + 'px';
|
||
|
||
// 强制回流
|
||
childrenContainer.offsetHeight;
|
||
|
||
childrenContainer.style.height = '0';
|
||
childrenContainer.classList.add('collapsed');
|
||
}
|
||
}
|
||
|
||
// 打开/关闭悬浮窗
|
||
tocBtn.onclick = function() {
|
||
const isOpen = tocPopup.classList.contains('toc-popup-visible');
|
||
if (isOpen) {
|
||
// 使用CSS类管理状态
|
||
tocPopup.classList.remove('toc-popup-visible');
|
||
tocPopup.classList.add('toc-popup-hiding');
|
||
setTimeout(() => {
|
||
tocPopup.classList.remove('toc-popup-hiding');
|
||
tocPopup.classList.add('toc-popup-hidden');
|
||
}, 200);
|
||
} else {
|
||
// 检查当前显示的Tab是否为分块对比
|
||
updateTocModeSelectorVisibility();
|
||
renderTocList(); // 每次打开时重新渲染,确保内容最新
|
||
tocPopup.classList.remove('toc-popup-hidden', 'toc-popup-hiding');
|
||
tocPopup.classList.add('toc-popup-visible');
|
||
}
|
||
};
|
||
|
||
// 关闭悬浮窗按钮
|
||
tocCloseBtn.onclick = function() {
|
||
tocPopup.classList.remove('toc-popup-visible');
|
||
tocPopup.classList.add('toc-popup-hiding');
|
||
setTimeout(() => {
|
||
tocPopup.classList.remove('toc-popup-hiding');
|
||
tocPopup.classList.add('toc-popup-hidden');
|
||
}, 200);
|
||
};
|
||
|
||
/**
|
||
* 更新TOC模式选择器的可见性,仅在分块对比模式下显示
|
||
*/
|
||
function updateTocModeSelectorVisibility() {
|
||
// 获取当前显示的Tab内容
|
||
const visibleTab = document.querySelector('.tab-btn.active');
|
||
const currentTabId = visibleTab ? visibleTab.id : null;
|
||
|
||
// 判断当前是否在分块对比模式
|
||
const isChunkCompareMode = currentTabId === 'tab-chunk-compare';
|
||
|
||
// 仅在分块对比模式下显示模式选择器
|
||
if (isChunkCompareMode) {
|
||
tocModeSelector.style.display = 'flex';
|
||
} else {
|
||
tocModeSelector.style.display = 'none';
|
||
// 如果不是分块对比模式,强制使用both模式
|
||
if (currentTocMode !== 'both') {
|
||
currentTocMode = 'both';
|
||
// 更新按钮状态
|
||
tocModeSelector.querySelectorAll('.toc-mode-btn').forEach(b => {
|
||
b.classList.remove('active');
|
||
});
|
||
tocModeSelector.querySelector('[data-mode="both"]').classList.add('active');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成并渲染TOC目录列表。
|
||
* - 清空现有列表。
|
||
* - 从 `.container` 中查找所有 `h1`, `h2`, `h3`, `h4`, `h5`, `h6` 元素作为TOC条目。
|
||
* - 为每个标题元素生成一个列表项,包含其文本和可选的英文翻译(来自 `tocMap`)。
|
||
* - 列表项链接到对应标题的ID,点击时平滑滚动到该标题,并根据距离触发加载效果。
|
||
* - 存储标题DOM节点到 `tocNodes` 数组。
|
||
* - 新增:构建层级结构并支持折叠/展开功能。
|
||
* - 新增:智能识别标题格式,根据标题格式自动调整层级。
|
||
*/
|
||
function renderTocList() {
|
||
tocList.innerHTML = '';
|
||
tocNodes = []; // 每次渲染时清空并重新填充
|
||
const container = document.querySelector('.container');
|
||
if (!container) return;
|
||
|
||
let potentialHeadings = [];
|
||
// 1. 获取标准的 Hx 标题和被转换的长标题
|
||
container.querySelectorAll('h1, h2:not(#fileName), h3, h4, h5, h6, p.converted-from-heading').forEach(h => {
|
||
potentialHeadings.push(h);
|
||
});
|
||
|
||
// 2. 获取可能是图表标题的 P 标签
|
||
// 正则表达式:匹配 "图/表/Figure/Table" + 空格 + 数字/字母/./- + 单词边界 (确保是独立编号)
|
||
const captionRegex = /^(图|表|Figure|Table)\s*[\d\w.-]+\b/i;
|
||
container.querySelectorAll('p').forEach(p => {
|
||
const text = p.textContent.trim();
|
||
if (captionRegex.test(text)) {
|
||
// 标记为图表标题,以便后续处理和样式化
|
||
p.dataset.isCaptionToc = "true";
|
||
// 同时设置一个标志,表示是图表标题
|
||
p.dataset.isChartCaption = "true";
|
||
potentialHeadings.push(p);
|
||
}
|
||
});
|
||
|
||
// 3. 按文档顺序对所有潜在标题进行排序
|
||
potentialHeadings.sort((a, b) => {
|
||
if (a === b) return 0;
|
||
const position = a.compareDocumentPosition(b);
|
||
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||
return -1; // a 在 b 之前
|
||
} else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
||
return 1; // a 在 b 之后
|
||
}
|
||
return 0; // 通常不应发生,除非元素不在同一文档树或存在包含关系
|
||
});
|
||
|
||
const headingElements = potentialHeadings;
|
||
|
||
// 根据当前模式过滤标题
|
||
let filteredHeadings = [];
|
||
if (currentTocMode === 'both') {
|
||
filteredHeadings = headingElements;
|
||
} else {
|
||
// 获取当前显示的Tab内容
|
||
const visibleTab = document.querySelector('.tab-btn.active');
|
||
const currentTabId = visibleTab ? visibleTab.id : null;
|
||
|
||
// 判断当前是否在分块对比模式
|
||
const isChunkCompareMode = currentTabId === 'tab-chunk-compare';
|
||
|
||
if (isChunkCompareMode) {
|
||
// 在分块对比模式下,根据currentTocMode筛选标题
|
||
if (currentTocMode === 'ocr') {
|
||
// 筛选左侧原文块的标题
|
||
filteredHeadings = Array.from(headingElements).filter(el => {
|
||
const closestAlignBlock = el.closest('.align-block-ocr');
|
||
return closestAlignBlock !== null;
|
||
});
|
||
} else if (currentTocMode === 'translation') {
|
||
// 筛选右侧译文块的标题
|
||
filteredHeadings = Array.from(headingElements).filter(el => {
|
||
const closestAlignBlock = el.closest('.align-block-trans');
|
||
return closestAlignBlock !== null;
|
||
});
|
||
}
|
||
} else {
|
||
// 非分块对比模式下,检查当前显示的是否是与所选模式匹配的标签页
|
||
if ((currentTabId === 'tab-ocr' && currentTocMode === 'ocr') ||
|
||
(currentTabId === 'tab-translation' && currentTocMode === 'translation')) {
|
||
filteredHeadings = headingElements;
|
||
} else {
|
||
// 如果当前标签页与所选模式不匹配,显示一个提示
|
||
const li = document.createElement('li');
|
||
li.className = 'toc-info';
|
||
li.textContent = `请切换到${currentTocMode === 'ocr' ? '原文' : '译文'}标签页查看对应目录`;
|
||
tocList.appendChild(li);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建一个层级结构对象
|
||
const tocStructure = {
|
||
root: true,
|
||
children: []
|
||
};
|
||
|
||
// 存储上一个处理过的TOC项,用于比较和合并
|
||
let previousTocItem = null;
|
||
let previousHeadingLevel = 0;
|
||
let currentPath = [tocStructure]; // 当前路径,从根开始
|
||
|
||
// 结构化标题格式的正则表达式
|
||
const chapterPattern = /^第[一二三四五六七八九十百千]+[章节篇部]/; // 匹配"第一章"、"第二节"等
|
||
const numericPattern = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/; // 匹配"1"、"1.1"、"1.1.1"等
|
||
const romanPattern = /^([IVX]+)(?:\.([IVX]+))?(?:\.([IVX]+))?/i; // 匹配罗马数字标题
|
||
const letterPattern = /^([A-Za-z])(?:\.([A-Za-z]))?(?:\.([A-Za-z]))?/; // 匹配字母标题如"A"、"A.1"
|
||
|
||
// 新的正则表达式,增强匹配能力
|
||
const spacedNumericPattern = /^(\d+)\.?\s+(\d+(?:\.?\d+)*)\s+/; // 匹配"3. 1.1 xxxx"和"4. 5 xxxx"格式
|
||
|
||
// 新增:多种列表项匹配模式
|
||
const bulletListPattern = /^[•\*\-]\s+/; // 匹配"• xxx"、"* xxx"、"- xxx"等无序列表
|
||
const numberedListPattern = /^(\d+)(?:[\.、]|\s*[\(\(])\s*/; // 匹配"1. xxx"、"2、xxx"、"3) xxx"、"4(xxx"等
|
||
const chineseNumberedListPattern = /^[一二三四五六七八九十]+[\.、]/; // 匹配"一、xxx"等中文编号
|
||
const alphaListPattern = /^[((]?([a-zA-Z])[\))\.、]\s*/; // 匹配"(a) xxx"、"(A) xxx"、"a. xxx"、"A、xxx"等
|
||
const specialSymbolListPattern = /^[((]?[\*\#\+\-][\))]\s*/; // 匹配"(*) xxx"、"(#) xxx"等特殊符号列表
|
||
|
||
// 论文特殊章节标题模式
|
||
const specialSectionPattern = /^(摘要|Abstract|引言|Introduction|参考文献|References|附录|Appendix|致谢|Acknowledgements|结论|Conclusion|讨论|Discussion|实验|Experiment|方法|Methods|材料|Materials)/i;
|
||
|
||
// 智能层级管理对象
|
||
let levelManager = {
|
||
// 结构化前缀到层级的映射
|
||
prefixMapping: {},
|
||
// 当前处理到的章节编号
|
||
currentChapter: null,
|
||
currentSection: null,
|
||
currentSubsection: null,
|
||
|
||
// 新增属性,用于跟踪更复杂的上下文
|
||
lastStructureType: null, // 上一个结构化标题的类型
|
||
lastStructureLevel: 0, // 上一个结构化标题的层级
|
||
lastNumericPrefix: null, // 上一个数字前缀,如"1.5"
|
||
lastSimpleNumber: null, // 上一个简单数字,如"2."中的"2"
|
||
inSimpleList: false, // 是否在简单数字列表中(如"1. 2. 3.")
|
||
simpleListParentLevel: 0, // 简单数字列表的父级层级
|
||
|
||
// 分析标题文本,提取结构化信息
|
||
analyzeHeading: function(text) {
|
||
let structureInfo = {
|
||
type: 'normal', // 默认为普通标题
|
||
level: null, // 结构化层级
|
||
prefix: '', // 标题前缀
|
||
content: text, // 标题内容
|
||
isSimpleNumbered: false // 是否是简单数字编号(如"1. 2. 3.")
|
||
};
|
||
|
||
// 检查是否为论文特殊章节标题
|
||
const specialSectionMatch = text.match(specialSectionPattern);
|
||
if (specialSectionMatch) {
|
||
this.lastStructureType = 'special';
|
||
this.lastStructureLevel = 1;
|
||
this.inSimpleList = false;
|
||
|
||
structureInfo.type = 'special';
|
||
structureInfo.level = 1; // 特殊章节通常是顶级
|
||
structureInfo.prefix = specialSectionMatch[0];
|
||
structureInfo.content = text;
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查是否为章节标题(如"第一章")
|
||
const chapterMatch = text.match(chapterPattern);
|
||
if (chapterMatch) {
|
||
this.lastStructureType = 'chapter';
|
||
this.lastStructureLevel = 1;
|
||
this.inSimpleList = false;
|
||
|
||
structureInfo.type = 'chapter';
|
||
structureInfo.level = 1; // 章节一般是顶级
|
||
structureInfo.prefix = chapterMatch[0];
|
||
structureInfo.content = text.substring(chapterMatch[0].length).trim();
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查是否为带空格的多级编号格式(如"3. 1.1 xxxx"或"4. 5 xxxx")
|
||
const spacedNumericMatch = text.match(spacedNumericPattern);
|
||
if (spacedNumericMatch) {
|
||
// 提取主要数字部分
|
||
const mainNumber = spacedNumericMatch[1];
|
||
const subNumbers = spacedNumericMatch[2];
|
||
|
||
// 检查子编号是否已经包含点号,如果没有,需要添加
|
||
let formattedSubNumbers = subNumbers;
|
||
if (!subNumbers.includes('.')) {
|
||
formattedSubNumbers = subNumbers; // 如"4. 5 xxxx"中的"5"
|
||
}
|
||
|
||
// 组合成标准格式,确保格式正确
|
||
const combinedNumber = mainNumber + "." + formattedSubNumbers.replace(/\s+/g, '');
|
||
|
||
// 计算层级(根据点的数量+1)
|
||
const dots = (combinedNumber.match(/\./g) || []).length;
|
||
|
||
// 检查是否可能是章节的子节
|
||
let isSubSection = false;
|
||
if (this.lastStructureType === 'chapter' && this.currentChapter === mainNumber) {
|
||
isSubSection = true;
|
||
}
|
||
|
||
// 设置适当的层级
|
||
let level = dots + 1;
|
||
if (isSubSection) {
|
||
// 如果是章节的子节,层级应该是章节层级+1
|
||
level = this.lastStructureLevel + 1;
|
||
}
|
||
|
||
// 更新状态
|
||
this.lastStructureType = 'numeric';
|
||
this.lastStructureLevel = level;
|
||
this.lastNumericPrefix = combinedNumber;
|
||
this.inSimpleList = false;
|
||
|
||
structureInfo.type = 'numeric';
|
||
structureInfo.level = level;
|
||
structureInfo.prefix = spacedNumericMatch[0]; // 保留原始前缀,包括空格
|
||
structureInfo.originalPrefix = combinedNumber; // 保存标准化的前缀
|
||
structureInfo.content = text.substring(spacedNumericMatch[0].length).trim();
|
||
structureInfo.isSubSection = isSubSection; // 标记是否为章节的子节
|
||
|
||
// 更新当前处理到的章节编号
|
||
const numParts = combinedNumber.split('.');
|
||
if (numParts.length > 0) this.currentChapter = numParts[0];
|
||
if (numParts.length > 1) this.currentSection = numParts[1];
|
||
if (numParts.length > 2) this.currentSubsection = numParts[2];
|
||
|
||
return structureInfo;
|
||
}
|
||
|
||
// 新增:检查各种列表项格式
|
||
// 无序列表项
|
||
const bulletMatch = text.match(bulletListPattern);
|
||
if (bulletMatch) {
|
||
// 无序列表通常是当前层级的子层级
|
||
const parentLevel = this.lastStructureLevel || 1;
|
||
|
||
this.inSimpleList = true;
|
||
this.simpleListParentLevel = parentLevel;
|
||
|
||
structureInfo.type = 'bullet-list';
|
||
structureInfo.level = parentLevel + 1;
|
||
structureInfo.prefix = bulletMatch[0];
|
||
structureInfo.content = text.substring(bulletMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查是否为各种数字编号列表项(如"1. "、"2、"、"3) "等)
|
||
const numberedMatch = text.match(numberedListPattern);
|
||
if (numberedMatch) {
|
||
const number = numberedMatch[1];
|
||
const parentLevel = this.lastStructureLevel || 1;
|
||
|
||
// 判断是否是简单数字列表的开始或延续
|
||
if (!this.inSimpleList) {
|
||
// 如果前一个标题是结构化的,那么这个简单数字可能是其子项
|
||
if (this.lastStructureType && this.lastStructureType !== 'bullet-list') {
|
||
// 设置为简单数字列表模式
|
||
this.inSimpleList = true;
|
||
this.simpleListParentLevel = parentLevel;
|
||
this.lastSimpleNumber = number;
|
||
|
||
// 层级为父级层级+1
|
||
structureInfo.level = parentLevel + 1;
|
||
structureInfo.type = 'simple-numbered';
|
||
structureInfo.prefix = numberedMatch[0];
|
||
structureInfo.content = text.substring(numberedMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
} else {
|
||
// 已经在简单数字列表中,继续使用相同的层级
|
||
this.lastSimpleNumber = number;
|
||
|
||
structureInfo.level = this.simpleListParentLevel + 1;
|
||
structureInfo.type = 'simple-numbered';
|
||
structureInfo.prefix = numberedMatch[0];
|
||
structureInfo.content = text.substring(numberedMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
}
|
||
|
||
// 检查中文数字编号列表项(如"一、")
|
||
const chineseNumberedMatch = text.match(chineseNumberedListPattern);
|
||
if (chineseNumberedMatch) {
|
||
const parentLevel = this.lastStructureLevel || 1;
|
||
|
||
this.inSimpleList = true;
|
||
this.simpleListParentLevel = parentLevel;
|
||
|
||
structureInfo.type = 'chinese-numbered';
|
||
structureInfo.level = parentLevel + 1;
|
||
structureInfo.prefix = chineseNumberedMatch[0];
|
||
structureInfo.content = text.substring(chineseNumberedMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查字母编号列表项(如"(a) "、"A. ")
|
||
const alphaMatch = text.match(alphaListPattern);
|
||
if (alphaMatch) {
|
||
const parentLevel = this.lastStructureLevel || 1;
|
||
|
||
this.inSimpleList = true;
|
||
this.simpleListParentLevel = parentLevel;
|
||
|
||
structureInfo.type = 'alpha-list';
|
||
structureInfo.level = parentLevel + 1;
|
||
structureInfo.prefix = alphaMatch[0];
|
||
structureInfo.content = text.substring(alphaMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查特殊符号列表项(如"(*) ")
|
||
const specialSymbolMatch = text.match(specialSymbolListPattern);
|
||
if (specialSymbolMatch) {
|
||
const parentLevel = this.lastStructureLevel || 1;
|
||
|
||
this.inSimpleList = true;
|
||
this.simpleListParentLevel = parentLevel;
|
||
|
||
structureInfo.type = 'special-symbol-list';
|
||
structureInfo.level = parentLevel + 1;
|
||
structureInfo.prefix = specialSymbolMatch[0];
|
||
structureInfo.content = text.substring(specialSymbolMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查是否为简单数字列表项(如"1. "、"2. ",不包含子编号)
|
||
const simpleNumberMatch = text.match(/^(\d+)\.\s+/);
|
||
if (simpleNumberMatch) {
|
||
const number = simpleNumberMatch[1];
|
||
|
||
// 判断是否是简单数字列表的开始或延续
|
||
if (!this.inSimpleList) {
|
||
// 如果前一个标题是结构化的(如"1.5"),那么这个简单数字可能是其子项
|
||
if (this.lastStructureType === 'numeric' && this.lastNumericPrefix) {
|
||
// 设置为简单数字列表模式
|
||
this.inSimpleList = true;
|
||
this.simpleListParentLevel = this.lastStructureLevel;
|
||
this.lastSimpleNumber = number;
|
||
|
||
// 层级为父级层级+1
|
||
structureInfo.level = this.simpleListParentLevel + 1;
|
||
structureInfo.type = 'simple-numbered';
|
||
structureInfo.prefix = simpleNumberMatch[0];
|
||
structureInfo.content = text.substring(simpleNumberMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
} else {
|
||
// 已经在简单数字列表中,继续使用相同的层级
|
||
this.lastSimpleNumber = number;
|
||
|
||
structureInfo.level = this.simpleListParentLevel + 1;
|
||
structureInfo.type = 'simple-numbered';
|
||
structureInfo.prefix = simpleNumberMatch[0];
|
||
structureInfo.content = text.substring(simpleNumberMatch[0].length).trim();
|
||
structureInfo.isSimpleNumbered = true;
|
||
return structureInfo;
|
||
}
|
||
}
|
||
|
||
// 检查是否为数字编号标题(如"1.1")
|
||
const numericMatch = text.match(numericPattern);
|
||
if (numericMatch) {
|
||
// 计算层级(根据实际匹配到的数字段数)
|
||
let level = 0;
|
||
for (let i = 1; i < numericMatch.length; i++) {
|
||
if (numericMatch[i]) {
|
||
level++;
|
||
}
|
||
}
|
||
|
||
// 获取当前编号的主部分(如"1.5"中的"1")
|
||
const mainNumber = numericMatch[1];
|
||
|
||
// 如果之前在简单数字列表中,但现在遇到了正式的结构化编号
|
||
// 例如从"1. 2. 3."列表跳转到"1.6"
|
||
if (this.inSimpleList) {
|
||
// 检查当前编号是否是与父级编号相同的系列
|
||
// 例如,如果父级是"1.5",那么当前"1.6"应该是同级的
|
||
if (this.lastNumericPrefix && this.lastNumericPrefix.startsWith(mainNumber + '.')) {
|
||
// 退出简单数字列表模式
|
||
this.inSimpleList = false;
|
||
}
|
||
}
|
||
|
||
// 更新状态
|
||
this.lastStructureType = 'numeric';
|
||
this.lastStructureLevel = level;
|
||
this.lastNumericPrefix = numericMatch[0];
|
||
|
||
structureInfo.type = 'numeric';
|
||
structureInfo.level = level;
|
||
structureInfo.prefix = numericMatch[0];
|
||
structureInfo.content = text.substring(numericMatch[0].length).trim();
|
||
|
||
// 更新当前处理到的章节编号
|
||
if (level === 1) {
|
||
this.currentChapter = numericMatch[1];
|
||
this.currentSection = null;
|
||
this.currentSubsection = null;
|
||
} else if (level === 2) {
|
||
this.currentSection = numericMatch[2];
|
||
this.currentSubsection = null;
|
||
} else if (level === 3) {
|
||
this.currentSubsection = numericMatch[3];
|
||
}
|
||
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查是否为罗马数字标题
|
||
const romanMatch = text.match(romanPattern);
|
||
if (romanMatch) {
|
||
// 类似处理逻辑...
|
||
this.lastStructureType = 'roman';
|
||
this.inSimpleList = false;
|
||
|
||
let dots = 0;
|
||
for (let i = 1; i < romanMatch.length; i++) {
|
||
if (romanMatch[i]) dots++;
|
||
}
|
||
this.lastStructureLevel = dots + 1;
|
||
|
||
structureInfo.type = 'roman';
|
||
structureInfo.level = dots + 1;
|
||
structureInfo.prefix = romanMatch[0];
|
||
structureInfo.content = text.substring(romanMatch[0].length).trim();
|
||
return structureInfo;
|
||
}
|
||
|
||
// 检查是否为字母标题
|
||
const letterMatch = text.match(letterPattern);
|
||
if (letterMatch) {
|
||
// 类似处理逻辑...
|
||
this.lastStructureType = 'letter';
|
||
this.inSimpleList = false;
|
||
|
||
let dots = 0;
|
||
for (let i = 1; i < letterMatch.length; i++) {
|
||
if (letterMatch[i]) dots++;
|
||
}
|
||
this.lastStructureLevel = dots + 1;
|
||
|
||
structureInfo.type = 'letter';
|
||
structureInfo.level = dots + 1;
|
||
structureInfo.prefix = letterMatch[0];
|
||
structureInfo.content = text.substring(letterMatch[0].length).trim();
|
||
return structureInfo;
|
||
}
|
||
|
||
// 无法识别结构,重置简单列表状态
|
||
this.inSimpleList = false;
|
||
|
||
// 无法识别结构,返回默认值
|
||
return structureInfo;
|
||
},
|
||
|
||
// 根据标题文本和标签确定层级
|
||
determineLevel: function(text, tagName) {
|
||
// 首先分析标题结构
|
||
const structureInfo = this.analyzeHeading(text);
|
||
|
||
// 如果能识别结构化层级,则使用识别的层级
|
||
if (structureInfo.level !== null) {
|
||
return {
|
||
level: structureInfo.level,
|
||
structureInfo: structureInfo
|
||
};
|
||
}
|
||
|
||
// 无法识别结构,则根据标签名确定层级
|
||
let headingLevel = 0;
|
||
if (tagName.match(/^h[1-6]$/)) {
|
||
headingLevel = parseInt(tagName.substring(1));
|
||
} else {
|
||
headingLevel = 3; // 默认级别
|
||
}
|
||
|
||
return {
|
||
level: headingLevel,
|
||
structureInfo: structureInfo
|
||
};
|
||
}
|
||
};
|
||
|
||
filteredHeadings.forEach((nodeEl, idx) => {
|
||
// 补丁1:强制给没有 id 的标题分配唯一 id
|
||
if (!nodeEl.id) nodeEl.id = 'toc-auto-' + idx;
|
||
tocNodes.push(nodeEl); // 存储DOM节点
|
||
|
||
let zh = nodeEl.textContent.trim();
|
||
|
||
// 过滤掉 "原文块" 或 "译文块" 标题
|
||
if (zh.includes('原文块') || zh.includes('译文块')) {
|
||
return;
|
||
}
|
||
|
||
// 应用智能截断
|
||
let displayText = truncateText(zh);
|
||
let en = tocMap[zh]; // 获取英文翻译
|
||
|
||
// 检查与前一个TOC项是否相似,如果相似则合并
|
||
if (previousTocItem && areTextsSimilar(previousTocItem, zh)) {
|
||
// 不创建新的TOC项,而是更新前一个的引用
|
||
const lastItem = currentPath[currentPath.length - 1].children[currentPath[currentPath.length - 1].children.length - 1];
|
||
if (lastItem) {
|
||
lastItem.additionalTargetId = nodeEl.id;
|
||
}
|
||
return; // 跳过创建新的TOC项
|
||
}
|
||
|
||
// 记录当前项以供下一次比较
|
||
previousTocItem = zh;
|
||
|
||
// 确定标题级别
|
||
let headingLevel = 0;
|
||
let nodeTagName = nodeEl.tagName.toLowerCase();
|
||
let isChartCaption = nodeEl.dataset.isChartCaption === "true";
|
||
|
||
if (nodeEl.classList.contains('converted-from-heading') && nodeEl.dataset.originalTag) {
|
||
nodeTagName = nodeEl.dataset.originalTag; // 使用原始标签名决定TOC层级
|
||
}
|
||
|
||
// 图表标题处理逻辑
|
||
if (nodeEl.dataset.isCaptionToc === "true") {
|
||
// 图表标题默认为其父章节的下一级,先使用默认值
|
||
headingLevel = 4;
|
||
isChartCaption = true;
|
||
} else {
|
||
// 使用结构化识别确定层级
|
||
const { level, structureInfo } = levelManager.determineLevel(zh, nodeTagName);
|
||
headingLevel = level;
|
||
|
||
// 如果标题有结构化前缀,保存原始文本用于显示
|
||
if (structureInfo.prefix) {
|
||
nodeEl.dataset.structuredPrefix = structureInfo.prefix;
|
||
// 保存结构信息
|
||
nodeEl.dataset.structureType = structureInfo.type;
|
||
}
|
||
}
|
||
|
||
// 如果是图表标题,应用特殊的截断逻辑
|
||
if (isChartCaption) {
|
||
// 首先识别图表标题的前缀部分(如"图5.")
|
||
const titlePrefixMatch = zh.match(/^(图|表)\s*\d+\.?\s*/);
|
||
const titlePrefix = titlePrefixMatch ? titlePrefixMatch[0] : '';
|
||
const contentStart = titlePrefix.length;
|
||
|
||
// 在图表标题内容部分查找标点符号作为截断点
|
||
const dotIndex = zh.indexOf('。', contentStart);
|
||
const commaIndex = zh.indexOf(',', contentStart);
|
||
|
||
// 找到最近的中文标点
|
||
let firstPunctIndex = -1;
|
||
if (dotIndex !== -1 && commaIndex !== -1) {
|
||
firstPunctIndex = Math.min(dotIndex, commaIndex);
|
||
} else if (dotIndex !== -1) {
|
||
firstPunctIndex = dotIndex;
|
||
} else if (commaIndex !== -1) {
|
||
firstPunctIndex = commaIndex;
|
||
}
|
||
|
||
if (firstPunctIndex !== -1) {
|
||
// 找到了中文标点,在此处截断
|
||
displayText = zh.substring(0, firstPunctIndex + 1);
|
||
} else {
|
||
// 尝试查找英文标点
|
||
const enDotIndex = zh.indexOf('.', contentStart);
|
||
const enCommaIndex = zh.indexOf(',', contentStart);
|
||
|
||
let firstEnPunctIndex = -1;
|
||
if (enDotIndex !== -1 && enCommaIndex !== -1) {
|
||
firstEnPunctIndex = Math.min(enDotIndex, enCommaIndex);
|
||
} else if (enDotIndex !== -1) {
|
||
firstEnPunctIndex = enDotIndex;
|
||
} else if (enCommaIndex !== -1) {
|
||
firstEnPunctIndex = enCommaIndex;
|
||
}
|
||
|
||
// 确保句号不是数字后的小数点(如:图5.1中的点)
|
||
if (firstEnPunctIndex === enDotIndex && firstEnPunctIndex !== -1) {
|
||
let validDotIndex = firstEnPunctIndex;
|
||
while (validDotIndex !== -1) {
|
||
const charBeforeDot = zh.charAt(validDotIndex - 1);
|
||
const charAfterDot = zh.charAt(validDotIndex + 1);
|
||
|
||
// 如果句号前是数字,后也是数字,那么这可能是小数点,继续寻找下一个句号
|
||
if (/\d/.test(charBeforeDot) && /\d/.test(charAfterDot)) {
|
||
validDotIndex = zh.indexOf('.', validDotIndex + 1);
|
||
} else {
|
||
// 找到了有效的句号
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (validDotIndex !== -1) {
|
||
displayText = zh.substring(0, validDotIndex + 1);
|
||
} else if (enCommaIndex !== -1) {
|
||
// 如果没有有效的句号但有逗号,使用逗号截断
|
||
displayText = zh.substring(0, enCommaIndex + 1);
|
||
}
|
||
} else if (firstEnPunctIndex !== -1) {
|
||
displayText = zh.substring(0, firstEnPunctIndex + 1);
|
||
}
|
||
}
|
||
|
||
// 图表标题特殊处理:将其归属到当前层级的下一级
|
||
// 计算其应该属于的层级
|
||
headingLevel = previousHeadingLevel + 1;
|
||
// 限制最大层级,防止层级过深
|
||
if (headingLevel > 6) headingLevel = 6;
|
||
}
|
||
|
||
// 新增:读取真实的 data-block-index
|
||
let realBlockIndex = nodeEl.dataset.blockIndex ? parseInt(nodeEl.dataset.blockIndex, 10) : null;
|
||
|
||
// 根据标题级别调整当前路径
|
||
if (headingLevel > previousHeadingLevel) {
|
||
// 进入更深层级
|
||
// 确保有父节点
|
||
if (currentPath[currentPath.length - 1].children.length === 0) {
|
||
// 如果当前路径的最后一个节点没有子节点,添加一个占位节点
|
||
// 获取当前文件名作为未命名章节的替代文本
|
||
const fileNameElement = document.getElementById('fileName');
|
||
const fileName = fileNameElement ? fileNameElement.textContent : '未命名章节';
|
||
|
||
const placeholderItem = {
|
||
id: 'placeholder-' + idx,
|
||
text: fileName,
|
||
level: previousHeadingLevel,
|
||
children: []
|
||
};
|
||
currentPath[currentPath.length - 1].children.push(placeholderItem);
|
||
}
|
||
// 将最后一个子节点作为新的当前节点
|
||
currentPath.push(currentPath[currentPath.length - 1].children[currentPath[currentPath.length - 1].children.length - 1]);
|
||
} else if (headingLevel < previousHeadingLevel) {
|
||
// 返回上层
|
||
const levelsToGoUp = previousHeadingLevel - headingLevel;
|
||
for (let i = 0; i < levelsToGoUp && currentPath.length > 1; i++) {
|
||
currentPath.pop();
|
||
}
|
||
}
|
||
|
||
// 创建新的TOC项
|
||
const tocItem = {
|
||
id: nodeEl.id,
|
||
text: displayText,
|
||
originalText: zh,
|
||
translation: en,
|
||
level: headingLevel,
|
||
children: [],
|
||
isChartCaption: isChartCaption,
|
||
structuredPrefix: nodeEl.dataset.structuredPrefix || null,
|
||
structureType: nodeEl.dataset.structureType || null,
|
||
structureInfo: levelManager.analyzeHeading(zh),
|
||
blockIndex: realBlockIndex // 新增,真实内容流 blockIndex
|
||
};
|
||
|
||
// 将TOC项添加到当前路径的最后一个节点
|
||
currentPath[currentPath.length - 1].children.push(tocItem);
|
||
previousHeadingLevel = headingLevel;
|
||
});
|
||
|
||
// === 在原有 TOC 节点生成后,补充 blockIndex/startBlockIndex/endBlockIndex 字段 ===
|
||
function parseBlockIndexFromId(id) {
|
||
if (!id) return null;
|
||
let match = id.match(/block-(\d+)/);
|
||
if (match) return parseInt(match[1], 10);
|
||
match = id.match(/toc-anchor-(\d+)/);
|
||
if (match) return parseInt(match[1], 10);
|
||
match = id.match(/toc-auto-(\d+)/);
|
||
if (match) return parseInt(match[1], 10);
|
||
match = id.match(/auto-hx-(\d+)/); // 新增支持 auto-hx-数字
|
||
if (match) return parseInt(match[1], 10);
|
||
return null;
|
||
}
|
||
function supplementBlockIndexRecursive(nodes) {
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
let node = nodes[i];
|
||
node.blockIndex = parseBlockIndexFromId(node.id);
|
||
node.startBlockIndex = node.blockIndex;
|
||
if (i < nodes.length - 1) {
|
||
node.endBlockIndex = nodes[i + 1].blockIndex !== null ? nodes[i + 1].blockIndex - 1 : null;
|
||
} else {
|
||
node.endBlockIndex = null;
|
||
}
|
||
if (node.children && node.children.length > 0) {
|
||
supplementBlockIndexRecursive(node.children);
|
||
}
|
||
if (node.blockIndex == null && node.el && node.el.dataset && node.el.dataset.blockIndex) {
|
||
node.blockIndex = parseInt(node.el.dataset.blockIndex, 10);
|
||
}
|
||
}
|
||
}
|
||
if (tocStructure && tocStructure.children && tocStructure.children.length > 0) {
|
||
supplementBlockIndexRecursive(tocStructure.children);
|
||
}
|
||
|
||
// 递归构建TOC HTML
|
||
function buildTocHtml(items, parentElement) {
|
||
items.forEach(item => {
|
||
// 跳过占位符和空标题
|
||
// 1. 空标题
|
||
// 2. "未命名章节"占位符
|
||
// 3. placeholder- 开头的ID(占位符标识)
|
||
// 4. undefined/null 文本
|
||
if (!item.text ||
|
||
item.text === '未命名章节' ||
|
||
item.text === 'undefined' ||
|
||
item.text === 'null' ||
|
||
(item.id && item.id.indexOf('placeholder-') === 0)) {
|
||
return;
|
||
}
|
||
|
||
const li = document.createElement('li');
|
||
const hasChildren = item.children && item.children.length > 0;
|
||
|
||
// 设置CSS类
|
||
if (item.level) {
|
||
// 只基于item属性判断
|
||
if (item.isCaption || item.isChartCaption) {
|
||
li.className = 'toc-caption';
|
||
} else {
|
||
li.className = `toc-h${item.level}`;
|
||
}
|
||
|
||
// 检查是否为简单数字列表项或带空格的多级编号标题
|
||
const isSimpleNumbered = item.structureInfo && item.structureInfo.isSimpleNumbered;
|
||
const hasSpacedNumeric = item.structureInfo && item.structureInfo.originalPrefix;
|
||
const structureType = item.structureInfo && item.structureInfo.type;
|
||
|
||
// 如果有结构化前缀、是简单数字列表项或带空格的多级编号,添加结构化样式类
|
||
if (item.structuredPrefix || isSimpleNumbered || hasSpacedNumeric ||
|
||
(structureType && structureType !== 'normal')) {
|
||
li.classList.add(`toc-structured`);
|
||
|
||
// 对于简单数字列表项,使用特殊样式类
|
||
if (isSimpleNumbered) {
|
||
li.classList.add('toc-simple-numbered');
|
||
li.classList.add(`toc-structure-simple-numbered`);
|
||
}
|
||
// 对于带空格的多级编号,使用标准数字编号样式
|
||
else if (hasSpacedNumeric) {
|
||
li.classList.add(`toc-structure-numeric`);
|
||
li.classList.add('toc-spaced-numeric');
|
||
}
|
||
// 处理各种新增的列表项类型
|
||
else if (structureType) {
|
||
li.classList.add(`toc-structure-${structureType}`);
|
||
}
|
||
else {
|
||
li.classList.add(`toc-structure-${item.structureType || 'normal'}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasChildren) {
|
||
li.classList.add('has-children');
|
||
}
|
||
|
||
// 构建链接HTML
|
||
let linkHTML = '';
|
||
|
||
if (hasChildren) {
|
||
linkHTML += `<span class="toc-toggle">▼</span>`;
|
||
}
|
||
|
||
// 包装文本内容在一个span中,以便更好地控制多行显示
|
||
linkHTML += `<span class="toc-text">`;
|
||
|
||
// 如果有结构化前缀、是简单数字列表项或带空格的多级编号,特殊显示前缀
|
||
if (item.structuredPrefix || (item.structureInfo && item.structureInfo.prefix)) {
|
||
// 对于带空格的多级编号,优先使用标准化的前缀
|
||
let prefix = item.structuredPrefix || item.structureInfo.prefix;
|
||
if (item.structureInfo && item.structureInfo.originalPrefix) {
|
||
prefix = item.structureInfo.originalPrefix; // 使用标准化的格式,如"3.1.1"
|
||
}
|
||
linkHTML += `<span class="toc-prefix">${prefix}</span> `;
|
||
}
|
||
|
||
// 如果是图表标题,添加特殊图标
|
||
if (item.isChartCaption) {
|
||
// 根据图表类型显示不同图标
|
||
const isTable = item.originalText && item.originalText.trim().startsWith('表');
|
||
const icon = isTable ? '📊' : '📈';
|
||
linkHTML += `<span class="toc-chart-icon">${icon}</span> `;
|
||
}
|
||
|
||
// 显示主要文本内容
|
||
let displayText = item.text;
|
||
|
||
// 如果有结构化前缀,从显示文本中移除
|
||
if (item.structuredPrefix && displayText.indexOf(item.structuredPrefix) === 0) {
|
||
displayText = displayText.replace(item.structuredPrefix, '').trim();
|
||
}
|
||
// 如果是简单数字列表项,从显示文本中移除前缀
|
||
else if (item.structureInfo && item.structureInfo.prefix &&
|
||
displayText.indexOf(item.structureInfo.prefix) === 0) {
|
||
displayText = displayText.replace(item.structureInfo.prefix, '').trim();
|
||
}
|
||
|
||
// 将文本内容包装在span中,确保正确显示
|
||
linkHTML += `<span class="toc-content">${displayText}</span>`;
|
||
|
||
if (item.translation && item.translation !== item.originalText) {
|
||
linkHTML += ` <span class="toc-en-translation">/ ${item.translation}</span>`;
|
||
}
|
||
|
||
linkHTML += `</span>`;
|
||
|
||
const link = document.createElement('a');
|
||
link.href = `#${item.id}`;
|
||
link.innerHTML = linkHTML;
|
||
if (item.originalText) {
|
||
link.dataset.originalText = item.originalText;
|
||
}
|
||
|
||
if (item.additionalTargetId) {
|
||
link.dataset.additionalTargetId = item.additionalTargetId;
|
||
}
|
||
|
||
// 添加点击事件
|
||
link.onclick = function(e) {
|
||
e.preventDefault();
|
||
const targetElement = document.getElementById(item.id);
|
||
if (targetElement) {
|
||
const clickedNodeIndex = tocNodes.findIndex(n => n.id === item.id);
|
||
let currentTopNodeIndex = 0;
|
||
|
||
if (tocNodes.length > 0) {
|
||
let minPositiveTop = Infinity;
|
||
let foundPositive = false;
|
||
for (let i = 0; i < tocNodes.length; i++) {
|
||
const rect = tocNodes[i].getBoundingClientRect();
|
||
if (rect.top >= 0 && rect.top < minPositiveTop) {
|
||
minPositiveTop = rect.top;
|
||
currentTopNodeIndex = i;
|
||
foundPositive = true;
|
||
}
|
||
}
|
||
if (!foundPositive) {
|
||
let maxNegativeTop = -Infinity;
|
||
let foundNegative = false;
|
||
for (let i = 0; i < tocNodes.length; i++) {
|
||
const rect = tocNodes[i].getBoundingClientRect();
|
||
if (rect.top < 0 && rect.top > maxNegativeTop) {
|
||
maxNegativeTop = rect.top;
|
||
currentTopNodeIndex = i;
|
||
foundNegative = true;
|
||
}
|
||
}
|
||
if (!foundNegative && tocNodes.length > 0) {
|
||
currentTopNodeIndex = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
const indexDifference = Math.abs(clickedNodeIndex - currentTopNodeIndex);
|
||
|
||
if (indexDifference >= 6) {
|
||
// 使用原始文本而非截断后的文本显示加载效果
|
||
const originalText = this.dataset.originalText || item.originalText;
|
||
showTemporaryLoadingEffect(originalText || "目标章节");
|
||
}
|
||
|
||
// 修复:在沉浸模式下使用自定义滚动逻辑,避免布局偏移
|
||
if (window.ImmersiveLayout && window.ImmersiveLayout.isActive()) {
|
||
// 沉浸模式下使用自定义滚动定位
|
||
const scrollContainer = document.querySelector('#immersive-main-content-area .tab-content');
|
||
if (scrollContainer && scrollContainer.style.overflowY === 'auto') {
|
||
// 计算目标元素相对于滚动容器的位置
|
||
const containerRect = scrollContainer.getBoundingClientRect();
|
||
const targetRect = targetElement.getBoundingClientRect();
|
||
const currentScrollTop = scrollContainer.scrollTop;
|
||
|
||
// 计算目标位置(将元素置于容器中心)
|
||
const targetScrollTop = currentScrollTop + targetRect.top - containerRect.top - (containerRect.height / 2) + (targetRect.height / 2);
|
||
|
||
// 平滑滚动到目标位置
|
||
scrollContainer.scrollTo({
|
||
top: Math.max(0, targetScrollTop),
|
||
behavior: 'smooth'
|
||
});
|
||
} else {
|
||
// 备用方案:使用原生scrollIntoView
|
||
targetElement.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'nearest'
|
||
});
|
||
}
|
||
} else {
|
||
// 普通模式下使用原生scrollIntoView
|
||
targetElement.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'nearest'
|
||
});
|
||
}
|
||
|
||
// 添加临时高亮效果
|
||
targetElement.classList.add('toc-target-highlight');
|
||
|
||
// 3秒后移除高亮效果
|
||
setTimeout(() => {
|
||
targetElement.classList.remove('toc-target-highlight');
|
||
}, 3000);
|
||
|
||
// 检查是否有额外的目标节点
|
||
const additionalTargetId = this.dataset.additionalTargetId;
|
||
if (additionalTargetId) {
|
||
const additionalTarget = document.getElementById(additionalTargetId);
|
||
if (additionalTarget) {
|
||
// 也为额外目标添加高亮效果
|
||
additionalTarget.classList.add('toc-target-highlight');
|
||
setTimeout(() => {
|
||
additionalTarget.classList.remove('toc-target-highlight');
|
||
}, 3000);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
li.appendChild(link);
|
||
|
||
// 如果有子项,创建子项容器
|
||
if (hasChildren) {
|
||
const childrenContainer = document.createElement('ul');
|
||
childrenContainer.className = 'toc-children';
|
||
buildTocHtml(item.children, childrenContainer);
|
||
li.appendChild(childrenContainer);
|
||
|
||
// 为折叠按钮添加点击事件,确保事件正确处理
|
||
const toggleBtn = li.querySelector('.toc-toggle');
|
||
if (toggleBtn) {
|
||
// 移除现有的事件监听器(如果有)
|
||
toggleBtn.replaceWith(toggleBtn.cloneNode(true));
|
||
|
||
// 重新获取按钮并添加事件
|
||
const newToggleBtn = li.querySelector('.toc-toggle');
|
||
newToggleBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation(); // 阻止事件冒泡
|
||
e.preventDefault(); // 防止链接被点击
|
||
|
||
// 获取子项容器
|
||
const childContainer = this.closest('li').querySelector('.toc-children');
|
||
if (childContainer) {
|
||
toggleTocItem(this, childContainer);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
parentElement.appendChild(li);
|
||
});
|
||
}
|
||
|
||
// 构建TOC HTML
|
||
buildTocHtml(tocStructure.children, tocList);
|
||
|
||
window.getCurrentTocStructure = function() {
|
||
return tocStructure;
|
||
};
|
||
|
||
// Expose getTocNodes to window
|
||
window.getTocNodes = function() {
|
||
return tocNodes;
|
||
};
|
||
}
|
||
|
||
// 点击页面其他地方关闭目录 (可选,如果需要请取消注释)
|
||
/*
|
||
document.addEventListener('click', function(event) {
|
||
if (tocPopup.classList.contains('toc-popup-visible') &&
|
||
!tocPopup.contains(event.target) &&
|
||
!tocBtn.contains(event.target)) {
|
||
tocPopup.classList.remove('toc-popup-visible');
|
||
tocPopup.classList.add('toc-popup-hiding');
|
||
setTimeout(() => {
|
||
tocPopup.classList.remove('toc-popup-hiding');
|
||
tocPopup.classList.add('toc-popup-hidden');
|
||
}, 200);
|
||
}
|
||
});
|
||
*/
|
||
window.refreshTocList = function() {
|
||
updateTocModeSelectorVisibility();
|
||
renderTocList();
|
||
};
|
||
|
||
// 初始化TOC界面
|
||
updateTocModeSelectorVisibility();
|
||
|
||
// 监听标签页切换事件,当标签页切换时更新TOC模式选择器可见性
|
||
document.querySelectorAll('.tab-btn').forEach(tab => {
|
||
tab.addEventListener('click', updateTocModeSelectorVisibility);
|
||
});
|
||
|
||
// 展开/收起TOC的功能
|
||
document.getElementById('toc-expand-btn').addEventListener('click', function() {
|
||
const isExpanded = tocPopup.classList.contains('toc-expanded');
|
||
const icon = this.querySelector('i');
|
||
if (isExpanded) {
|
||
tocPopup.classList.remove('toc-expanded');
|
||
icon.classList.remove('fa-angles-left');
|
||
icon.classList.add('fa-angles-right');
|
||
this.title = '展开目录';
|
||
} else {
|
||
tocPopup.classList.add('toc-expanded');
|
||
icon.classList.remove('fa-angles-right');
|
||
icon.classList.add('fa-angles-left');
|
||
this.title = '收起目录';
|
||
}
|
||
});
|
||
|
||
// 全部展开功能
|
||
document.getElementById('toc-expand-all').addEventListener('click', function() {
|
||
const allToggleButtons = tocList.querySelectorAll('.toc-toggle.collapsed');
|
||
allToggleButtons.forEach(btn => {
|
||
const childrenContainer = btn.closest('li').querySelector('.toc-children');
|
||
if (childrenContainer) {
|
||
toggleTocItem(btn, childrenContainer);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 全部折叠功能
|
||
document.getElementById('toc-collapse-all').addEventListener('click', function() {
|
||
const allToggleButtons = tocList.querySelectorAll('.toc-toggle:not(.collapsed)');
|
||
allToggleButtons.forEach(btn => {
|
||
const childrenContainer = btn.closest('li').querySelector('.toc-children');
|
||
if (childrenContainer) {
|
||
toggleTocItem(btn, childrenContainer);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 目前的结构中,TocFeature 是一个IIFE,它会立即执行。
|
||
// 它将 refreshTocList 函数暴露到 window 对象。
|
||
// 在 history_detail.html 中,showTab 函数会调用 window.refreshTocList()。
|
||
// 因此,只要 toc_logic.js 在调用 showTab 的主脚本之前加载,这个设置就应该能工作。
|
||
})(); |