paper-burner/js/ui/toc_logic.js

1460 lines
58 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.

/**
* @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"、"4xxx"等
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 的主脚本之前加载,这个设置就应该能工作。
})();