网站功能:自动目录(Auto TOC)组件介绍
在编写长篇技术文档、博客文章或帮助中心时,一个清晰、可交互的目录能显著提升读者的阅读体验。本文介绍的 Auto TOC 是一个纯前端、零依赖的自动目录生成组件,只需在页面中插入一段 HTML,即可自动提取标题,生成带有折叠、高亮、平滑滚动等功能的智能导航。
现在这个功能已经集成到limitless网站中,并且默认使用。你可以在侧栏看到任何页面下的自动目录,方便快速跳转。
📌 适用场景
| 场景 | 说明 |
|---|---|
| 技术博客/文章 | 长文自动生成侧边或内嵌目录,方便快速跳转 |
| 产品文档/帮助中心 | 多级标题结构一目了然,提升查找效率 |
| 知识库/维基 | 动态内容更新时目录自动同步 |
| 单页应用(SPA) | 监听路由变化或内容更新,自动重建目录 |
| 任何包含多级标题的长页面 | 无需额外插件,轻量集成 |
✨ 效果展示
Auto TOC 会在页面上生成一个可折叠面板,其中包含从 h1 到 h6 自动提取的标题树:
- 层次清晰:依据标题级别(h1~h6)自动缩进,并用左侧边框线表示层级关系。
- 交互丰富:每个有子级的条目均可展开/收起,支持 ▾ / ▸ 按钮切换。
- 当前章节高亮:滚动页面时,当前可视区域对应的目录项会自动高亮,并在目录滚动区保持可见。
- 平滑滚动:点击目录项,页面平滑滚动至目标标题(可配置)。
- 状态记忆:面板的展开/收起状态、各子目录的折叠状态均保存在 Cookie 中,90 天内刷新或回访仍保持一致。
🧠 工作原理
🎯 核心优势与特点
| 特性 | 说明 |
|---|---|
| 零依赖 | 纯原生 JavaScript + CSS,无需任何第三方库。 |
| 高度可配置 | 通过 JSON 配置选择器、偏移量、展开深度、平滑滚动等。 |
| 状态持久化 | 面板折叠状态、各分支展开/收起均保存在 Cookie 中,跨页面保留。 |
| 自动适应动态内容 | 内置 MutationObserver,当页面标题增删改时自动刷新目录。 |
| 响应式设计 | 针对移动端优化字体大小,并随视口高度自动调整目录最大高度。 |
| 可访问性 | 支持 aria-expanded、aria-current 等 WAI-ARIA 属性,键盘操作友好。 |
| 定制样式 | 使用 CSS 变量(--toc-*)轻松修改背景、文字、边框、悬停颜色。 |
| 事件通知 | 面板或分支展开/收起时派发 content_isexpand 自定义事件,方便外部联动(如调整侧边栏)。 |
| 滚动动画 | 目录滚动区采用缓动动画(easeInOutCubic)将高亮项平滑移入视野,不干扰用户手动滚动。 |
📋 用户使用方法
- 展开/收起面板:点击目录顶部的 “收起”/“展开” 按钮,可隐藏或显示整个目录面板。这个功能是带有90天记忆的(依赖cookie)
- 展开/收起子目录:点击每个标题前面的 ▸ / ▾ 按钮,可展开或收起该标题下的所有子标题。
- 快速跳转:点击任意目录项,页面会平滑滚动至对应的标题位置,同时该目录项高亮。
你说得非常对!上一版的“作者使用方法”确实更偏向网站管理员/开发者(如何安装代码、改配置),而真正的内容创作者(写文章的人)根本不需要关心这些。
下面这份是专门写给写作者(博主、编辑、技术文档工程师)的须知,你可以直接替换掉原文中“作者使用方法”和“开发者注意事项”部分,或者单独作为一个章节发布。
📝 文章内容作者使用方法与注意事项
作为写文章的人,你不需要懂任何代码、不需要安装任何东西,只需要正常写文章,这个目录就会自动生成。但为了让目录更清晰、更好用,有几点小技巧值得了解:
1. 目录是“自动长出来”的,不用手动管
你只需要像平常一样使用标题,目录会自动抓取它们并生成列表。你不需要手动写任何 <ul> 或 <li> 目录列表,系统全包了。
对于本站利用wordpress框架的,你正常使用文章标题,最后生成的代码就是<h1>,<h2>标签之类的,可以被自动处理。
2. 目录里显示的文字 = 你的标题原文
目录项的文字直接取自你标题里的文字。所以:
- 尽量让标题简洁、有概括性(例如“安装步骤”比“第一步”更好)。
- 如果你希望标题在正文中长一点、在目录中短一点(或反过来),可以使用 HTML 属性
data-toc-title单独指定目录显示的文字:
<h2 id="step1" data-toc-title="步骤一">第一步:下载并安装软件包(含依赖)</h2>
这样正文显示长标题,目录只显示“步骤一”。
3. 标题层级 = 目录的缩进层级
目录的层级关系完全由你的标题等级决定:
H2是顶层H3是H2的子级H4是H3的子级- 以此类推……
⚠️ 建议不要跳级:比如
H2下面直接写H4,虽然系统能处理(会自动归入最近的上级),但逻辑上会让读者困惑。最好按H2 → H3 → H4的顺序逐级展开。
4. 哪些标题不会出现在目录里?
不是所有标题都会被收录,下面几种情况会被自动忽略:
- 标题位于 导航栏(nav)、页脚(footer) 内部的;
- 标题所在的容器带有
class="no-toc"或data-no-toc属性的; - 标题本身带有
class="no-toc"或data-no-toc属性的; - 标题内容为空(或只有空格)的。
💡 如果你写了某个标题但不想让它出现在目录里(比如文章末尾的“相关推荐”或“版权声明”),可以给它加一个
class="no-toc",例如:<h2 class="no-toc">致谢</h2>
5. 锚点链接(# 跳转)不用你操心
系统会自动为每个标题生成唯一的 id(比如 #toc-section-1),也会保留你手动写的 id。你不需要特意去给标题加锚点,点击目录项就能自动平滑滚动过去。
6. 如果你用可视化编辑器(富文本)
直接在编辑器里选中文字,设置为“标题1”“标题2”……即可。不要把标题放在表格、弹窗或动态加载的评论区里,否则可能不会被扫描到(除非管理员额外配置)。
7. 文章更新后,目录会自动刷新
你修改了文章标题,增删了章节,目录会自动重新生成,不需要手动刷新页面。但如果文章是纯静态 HTML 且没有动态加载,可能需要刷新一下浏览器。
8. 写长文时的最佳实践建议
| 建议 | 原因 |
|---|---|
| 前 2~3 级标题不要太密集 | 滚动高亮功能依赖于标题在视口中的位置,太密集会导致高亮跳动频繁 |
| 每个章节至少写 2~3 段正文 | 方便滚动定位时“偏移量”更准确,读者跳转后不会感觉位置偏了 |
| 标题尽量用陈述句 | 如“配置说明”比“配置”更友好,读者扫目录就能知道内容 |
| 子标题数量超过 5 个时考虑分组 | 避免目录单项列表过长,增加一个中间层 H2 作为分组 |
总结一句话给作者:你只管用好
H2~H6标题写文章,别跳级,别把标题塞进页脚或导航栏,目录就会乖乖听话,自动生成一份漂亮且好用的导航。
🛠️ 网站管理员使用方法
1. 插入 HTML 结构
将代码块放在你希望目录出现的位置(例如侧边栏、文章开头或固定位置),即可自动获取页面的类似于<h1>,<h2>之类的标题。
代码将会在文章末尾提供。
2. 按需修改配置
配置项位于 <script type="application/json" class="auto-toc__config"> 中,各字段含义如下:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
title | string | "目 录" | 目录面板标题 |
contentSelector | string | "main, article, [role='main']" | 扫描标题的内容容器,支持多个选择器 |
headingSelector | string | "h1,h2,h3,h4,h5,h6" | 需要提取的标题标签 |
ignoreSelector | string | "nav, footer, [data-no-toc], .no-toc" | 忽略的容器(标题在其中会被跳过) |
expandDepth | number | 6 | 默认展开的层级深度(1~6) |
offset | number | 80 | 滚动到标题时的偏移量(像素),用于固定导航栏高度补偿 |
smoothScroll | boolean | true | 点击目录项是否启用平滑滚动 |
updateHash | boolean | true | 点击后是否更新 URL 的 hash |
highlightCurrent | boolean | true | 是否高亮当前滚动位置对应的目录项 |
autoExpandActive | boolean | true | 高亮项所在的父级是否自动展开 |
observeChanges | boolean | true | 是否开启 MutationObserver 监听内容变化自动重建 |
panelCollapsed | boolean | false | 初始面板是否折叠(会被 Cookie 覆盖) |
还有一个在代码中出现两次的配置,如果你发现页面高度比较低的时候,目录高度存在异常,看起来不合适,可以尝试设置这个高度的值。下面的例子设置的是保持最小高度为175。
/* 高度自动化 需要设定的位置 1/2 */
max-height: calc(100vh - 175px);
function updateWidgetMaxHeight() {
// 高度自动化 需要设定的位置 2/2
var WIDGET_MAX_HEIGHT_OFFSET = 175;
widget.style.maxHeight =
Math.max(0, window.innerHeight - WIDGET_MAX_HEIGHT_OFFSET) + "px";
}
5. 自定义样式
组件使用 CSS 变量控制主题,你可以在 .auto-toc 块中覆盖:
.auto-toc {
--toc-background: #efefef; /* 背景色 */
--toc-text: #1e1e1e; /* 文字颜色 */
--toc-border: #ccc; /* 边框及分割线颜色 */
--toc-hover: #1e1e1e; /* 悬停/激活时的背景色(文字会反色) */
--toc-transition: background-color 0.3s ease, color 0.3s ease;
}
另外,组件高度默认设为 calc(100vh - 175px),你可以在 .auto-toc 的 max-height 和 JavaScript 中的 WIDGET_MAX_HEIGHT_OFFSET(175)处同步调整。
👨💻 开发者注意事项
1. 标题 ID 自动生成
如果标题没有 id,组件会自动生成 toc-section-{序号} 格式的 ID。若该 ID 已被占用,会自动添加 -2、-3 等后缀,确保唯一。
2. 多容器支持
contentSelector 可指定多个容器(如 main, article),组件会合并所有容器内的标题,并按文档顺序排序。嵌套容器会去重。
3. 忽略规则
除了 ignoreSelector,组件还会自动忽略:
- 位于目录组件自身内部的标题(避免递归)
- 具有
[hidden]或[aria-hidden="true"]的元素内的标题 - 标题文本为空(或仅空白)的标题
4. Cookie 管理
- 面板状态 cookie 名:
auto_toc_panel_collapsed(值true/false) - 分支状态 cookie 名:
auto_toc_branch_state(值为 JSON 对象,键为标题id,值为是否展开) - 有效期 90 天,路径为根目录
/ - 如果不需要持久化,可以在代码中注释掉
writeCookie相关调用
5. 事件监听
组件在面板或分支展开/收起时会派发 content_isexpand 自定义事件到 window,你可以监听它来执行外部逻辑,例如调整布局:
window.addEventListener('content_isexpand', function(e) {
console.log('展开状态:', e.detail.expanded, '来源:', e.detail.source);
// 例如重新计算侧边栏位置
});
事件 detail 包含:
expanded:当前是否展开(true/false)source:"panel"或"branch"targetId:当source为"branch"时,对应标题的id
6. 滚动冲突处理
组件在目录滚动区(.auto-toc__nav)实现了自定义平滑滚动动画,以保证高亮项始终可见。当用户手动滚动目录时,动画会自动取消,避免干扰。
7. 性能考虑
MutationObserver仅在标题相关变化时触发重建,并带有 80ms 防抖。- 滚动高亮使用
requestAnimationFrame节流,避免频繁计算。
8. 兼容性
- 支持所有现代浏览器(Chrome/Firefox/Edge/Safari)。
- 依赖
MutationObserver、CustomEvent、Element.closest等 API,如需兼容 IE11 可引入 polyfill。
9. 二次开发建议
- 如需修改渲染逻辑,可调整
renderTree函数。 - 如需自定义高亮算法,可重写
updateActiveHeading。 - 目录项点击后默认通过
history.replaceState更新 hash,若需改为pushState可自行调整。
📦 完整代码获取
以上所有 HTML、CSS、JavaScript 已整合在本文提供的代码片段中,复制即可使用。你也可以根据上述配置和样式说明自由裁剪。
✅ 总结
Auto TOC 是一个轻量、智能、可定制的自动目录组件,它:
- 省去了手动维护目录的繁琐
- 提供了优秀的交互体验(折叠、高亮、平滑滚动)
- 支持动态内容和状态持久化
- 便于集成和二次开发
无论是个人博客还是企业级文档站点,它都能成为提升阅读效率的得力工具。
代码开源
立即尝试吧! 将它嵌入你的页面,让读者一键直达所需内容。
单个html代码
把下面的这些代码插入到你希望提供目录组件的html位置即可。
<div class="auto-toc" data-auto-toc>
<!-- JSON 配置:可按需修改,JSON 内不能写注释 -->
<script type="application/json" class="auto-toc__config">
{
"title": "目 录",
"contentSelector": "main, article, [role='main']",
"headingSelector": "h1,h2,h3,h4,h5,h6",
"ignoreSelector": "nav, footer, [data-no-toc], .no-toc",
"expandDepth": 6,
"offset": 80,
"smoothScroll": true,
"updateHash": true,
"highlightCurrent": true,
"autoExpandActive": true,
"observeChanges": true,
"panelCollapsed": false
}
</script>
<div class="auto-toc__panel">
<div class="auto-toc__header">
<div class="auto-toc__title" style="font-size: 24px;">目 录</div>
<button class="auto-toc__panel-toggle" type="button" aria-expanded="true">
收起
</button>
</div>
<nav class="auto-toc__nav" aria-label="目 录" aria-live="polite"></nav>
</div>
</div>
<style>
.auto-toc {
--toc-background: #efefef;
--toc-text: #1e1e1e;
--toc-border: #ccc;
--toc-hover: #1e1e1e;
--toc-transition: background-color 0.3s ease, color 0.3s ease;
/* 高度自动化 需要设定的位置 1/2 */
max-height: calc(100vh - 175px);
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
width: 100%;
color: var(--toc-text);
font-family: -apple-system, "PingFang SC", BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.auto-toc *,
.auto-toc *::before,
.auto-toc *::after {
box-sizing: border-box;
}
.auto-toc__panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
width: 100%;
}
.auto-toc__header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 10px 14px;
border-bottom: 1px solid #eee;
}
.auto-toc__title {
overflow: hidden;
font-size: 16px;
font-weight: 700;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-toc__panel-toggle {
flex: 0 0 auto;
margin-left: 12px;
padding: 5px 9px;
border: 1px solid var(--toc-border);
border-radius: 6px;
background: var(--toc-background);
color: var(--toc-text);
font: inherit;
font-size: 13px;
cursor: pointer;
transition: var(--toc-transition);
}
.auto-toc__panel-toggle:hover {
background: var(--toc-hover);
color: var(--toc-background);
}
.auto-toc__nav {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 10px;
scroll-behavior: smooth;
}
.auto-toc__nav[hidden] {
display: none !important;
}
.auto-toc__list {
margin: 0;
padding: 0;
list-style: none;
}
.auto-toc__list .auto-toc__list {
margin: 2px 0 2px 10px;
padding-left: 10px;
border-left: 1px solid var(--toc-border);
}
.auto-toc__item {
margin: 1px 0;
padding: 0;
}
.auto-toc__row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: start;
border-radius: 7px;
transition: var(--toc-transition);
}
.auto-toc__row:hover {
background: var(--toc-hover);
}
.auto-toc__row:hover .auto-toc__link,
.auto-toc__row:hover .auto-toc__branch-toggle {
color: var(--toc-background);
}
.auto-toc__row.is-active-row,
.auto-toc__row.is-active-row:hover {
background: var(--toc-background);
}
.auto-toc__row.is-active-row:hover .auto-toc__link,
.auto-toc__row.is-active-row:hover .auto-toc__branch-toggle,
.auto-toc__link.is-active:hover {
color: var(--toc-text);
}
.auto-toc__branch-toggle,
.auto-toc__branch-spacer {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 32px;
}
.auto-toc__branch-toggle {
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--toc-text);
font: inherit;
font-size: 14px;
cursor: pointer;
transition: var(--toc-transition);
}
.auto-toc__branch-toggle:hover {
background: var(--toc-hover);
color: var(--toc-background);
}
.auto-toc__row.is-active-row:hover .auto-toc__branch-toggle:hover {
background: var(--toc-hover);
color: var(--toc-background);
}
.auto-toc__link {
display: block;
min-width: 0;
padding: 6px 8px 6px 3px;
border-radius: 6px;
color: var(--toc-text);
font-size: 14px;
line-height: 20px;
text-decoration: none;
overflow-wrap: anywhere;
transition: var(--toc-transition);
}
.auto-toc__link:hover {
color: var(--toc-background);
}
.auto-toc__link.is-active {
font-weight: 600;
}
.auto-toc__item.is-collapsed>.auto-toc__list {
display: none;
}
.auto-toc__empty {
padding: 14px 10px;
color: var(--toc-text);
font-size: 14px;
text-align: center;
}
.auto-toc__panel-toggle:focus,
.auto-toc__branch-toggle:focus,
.auto-toc__link:focus {
outline: none;
}
@media (max-width: 640px) {
.auto-toc__link {
font-size: 13px;
}
}
</style>
<script>
(function () {
"use strict";
var PANEL_COOKIE_NAME = "auto_toc_panel_collapsed";
var BRANCH_COOKIE_NAME = "auto_toc_branch_state";
var COOKIE_MAX_AGE_DAYS = 90;
function readCookie(name) {
var prefix = name + "=";
var parts = document.cookie.split(";");
for (var index = 0; index < parts.length; index += 1) {
var part = parts[index].trim();
if (part.indexOf(prefix) === 0) {
return decodeURIComponent(part.slice(prefix.length));
}
}
return null;
}
function writeCookie(name, value) {
document.cookie =
name +
"=" +
encodeURIComponent(value) +
"; max-age=" +
COOKIE_MAX_AGE_DAYS * 24 * 60 * 60 +
"; path=/";
}
function deleteCookie(name) {
document.cookie = name + "=; max-age=0; path=/";
}
var defaultConfig = {
title: "目 录",
contentSelector: "main, article, [role='main']",
headingSelector: "h1,h2,h3,h4,h5,h6",
ignoreSelector: "nav, footer, [data-no-toc], .no-toc",
expandDepth: 6,
offset: 80,
smoothScroll: true,
updateHash: true,
highlightCurrent: true,
autoExpandActive: true,
observeChanges: true,
panelCollapsed: false
};
function bootAutoToc() {
var widgets = document.querySelectorAll(
"[data-auto-toc]:not([data-auto-toc-ready])"
);
widgets.forEach(function (widget) {
initializeWidget(widget);
});
}
function initializeWidget(widget) {
widget.setAttribute("data-auto-toc-ready", "true");
var configElement = widget.querySelector(".auto-toc__config");
var configData = {};
if (configElement) {
try {
configData = JSON.parse(configElement.textContent.trim());
} catch (error) {
console.error("自动目录 JSON 配置格式错误:", error);
}
}
var config = Object.assign({}, defaultConfig, configData);
/*
* 用 cookie 中保存的面板展开/收起状态覆盖配置值,
* 使面板状态在 90 天内保持一致。
*/
var savedPanelCollapsed = readCookie(PANEL_COOKIE_NAME);
if (savedPanelCollapsed !== null) {
config.panelCollapsed = savedPanelCollapsed === "true";
}
var titleElement = widget.querySelector(".auto-toc__title");
var panelButton = widget.querySelector(".auto-toc__panel-toggle");
var navElement = widget.querySelector(".auto-toc__nav");
if (!titleElement || !panelButton || !navElement) {
return;
}
titleElement.textContent = String(config.title || "目 录");
var headingSelector = validateSelector(
config.headingSelector,
defaultConfig.headingSelector
);
var contentSelector = validateSelector(
config.contentSelector,
defaultConfig.contentSelector
);
var ignoreSelector = "";
if (config.ignoreSelector) {
ignoreSelector = validateSelector(
config.ignoreSelector,
defaultConfig.ignoreSelector
);
}
var offset = Number(config.offset);
if (!Number.isFinite(offset)) {
offset = defaultConfig.offset;
}
var expandDepth = Number(config.expandDepth);
if (!Number.isFinite(expandDepth)) {
expandDepth = defaultConfig.expandDepth;
}
var headings = [];
var headingMap = new Map();
var branchState = new Map();
var activeHeadingId = "";
/*
* 从 cookie 恢复各子目录的展开/收起状态。
* cookie 中保存的是一个 JSON 对象,
* 键为标题 id,值为是否展开(true/false)。
*/
function loadBranchState() {
var saved = readCookie(BRANCH_COOKIE_NAME);
if (!saved) {
return;
}
try {
var state = JSON.parse(saved);
Object.keys(state).forEach(function (id) {
branchState.set(id, Boolean(state[id]));
});
} catch (error) {
console.warn("自动目录:子目录状态 cookie 解析失败", error);
}
}
/*
* 将各子目录的展开/收起状态写入 cookie。
* 没有任何手动记录的状态时,删除对应 cookie。
*/
function saveBranchState() {
if (branchState.size === 0) {
deleteCookie(BRANCH_COOKIE_NAME);
return;
}
var state = {};
branchState.forEach(function (expanded, id) {
state[id] = expanded;
});
writeCookie(BRANCH_COOKIE_NAME, JSON.stringify(state));
}
loadBranchState();
var observer = null;
var rebuildTimer = null;
var scrollTicking = false;
var navScrollAnimating = false;
var navScrollAnimationId = 0;
function validateSelector(selector, fallback) {
try {
document.querySelector(String(selector));
return String(selector);
} catch (error) {
console.warn(
"自动目录选择器无效,将使用默认值:",
selector,
error
);
return fallback;
}
}
function getHeadingTitle(heading) {
var customTitle = heading.getAttribute("data-toc-title");
if (customTitle) {
return customTitle.trim();
}
return heading.textContent.replace(/\s+/g, " ").trim();
}
function shouldIgnoreHeading(heading) {
if (widget.contains(heading)) {
return true;
}
if (
heading.closest("[hidden], [aria-hidden='true']")
) {
return true;
}
if (ignoreSelector) {
try {
if (heading.closest(ignoreSelector)) {
return true;
}
} catch (error) {
// 忽略无效排除选择器
}
}
return getHeadingTitle(heading).length === 0;
}
function collectHeadings() {
var roots = [];
try {
roots = Array.from(
document.querySelectorAll(contentSelector)
);
} catch (error) {
roots = [];
}
/*
* 如果页面中找不到 main、article 等内容容器,
* 自动退回到扫描整个 body。
*/
if (roots.length === 0) {
roots = [document.body || document.documentElement];
}
var results = [];
var seen = new Set();
roots.forEach(function (root) {
root.querySelectorAll(headingSelector).forEach(function (heading) {
if (seen.has(heading)) {
return;
}
seen.add(heading);
if (!shouldIgnoreHeading(heading)) {
results.push(heading);
}
});
});
/*
* 多个内容容器可能互相嵌套,因此重新按页面实际顺序排序。
*/
results.sort(function (a, b) {
if (a === b) {
return 0;
}
var position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1;
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1;
}
return 0;
});
return results;
}
function ensureHeadingIds(items) {
var usedIds = new Set();
items.forEach(function (heading, index) {
var id = heading.id;
var existingElement = id
? document.getElementById(id)
: null;
if (
!id ||
usedIds.has(id) ||
(existingElement && existingElement !== heading)
) {
var baseId = id || "toc-section-" + (index + 1);
var candidate = baseId;
var number = 2;
while (
usedIds.has(candidate) ||
(
document.getElementById(candidate) &&
document.getElementById(candidate) !== heading
)
) {
candidate = baseId + "-" + number;
number += 1;
}
id = candidate;
heading.id = id;
}
usedIds.add(id);
});
}
function buildTree(items) {
var root = {
level: 0,
children: []
};
var stack = [root];
items.forEach(function (heading) {
var match = heading.tagName.match(/^H([1-6])$/i);
if (!match) {
return;
}
var node = {
heading: heading,
level: Number(match[1]),
title: getHeadingTitle(heading),
children: []
};
/*
* 遇到同级或更高级标题时返回上一层。
* 即使标题级别跳跃,例如 h2 后直接是 h4,
* 也会自动归入最近的上级标题。
*/
while (
stack.length > 1 &&
stack[stack.length - 1].level >= node.level
) {
stack.pop();
}
stack[stack.length - 1].children.push(node);
stack.push(node);
});
return root.children;
}
function setBranchExpanded(listItem, expanded) {
listItem.classList.toggle("is-collapsed", !expanded);
var row = Array.from(listItem.children).find(function (child) {
return child.classList.contains("auto-toc__row");
});
if (!row) {
return;
}
var button = row.querySelector(".auto-toc__branch-toggle");
if (button) {
button.setAttribute("aria-expanded", String(expanded));
button.textContent = expanded ? "▾" : "▸";
button.title = expanded ? "收起子目录" : "展开子目录";
}
}
function renderTree(nodes, depth) {
var list = document.createElement("ol");
list.className = "auto-toc__list";
nodes.forEach(function (node) {
var listItem = document.createElement("li");
listItem.className = "auto-toc__item";
listItem.dataset.targetId = node.heading.id;
listItem.dataset.headingLevel = String(node.level);
var row = document.createElement("div");
row.className = "auto-toc__row";
if (node.children.length > 0) {
var toggleButton = document.createElement("button");
toggleButton.type = "button";
toggleButton.className = "auto-toc__branch-toggle";
toggleButton.setAttribute("aria-label", "展开或收起子目录");
var expanded = branchState.has(node.heading.id)
? branchState.get(node.heading.id)
: depth <= expandDepth;
toggleButton.setAttribute(
"aria-expanded",
String(expanded)
);
toggleButton.textContent = expanded ? "▾" : "▸";
toggleButton.title = expanded
? "收起子目录"
: "展开子目录";
row.appendChild(toggleButton);
if (!expanded) {
listItem.classList.add("is-collapsed");
}
} else {
var spacer = document.createElement("span");
spacer.className = "auto-toc__branch-spacer";
spacer.setAttribute("aria-hidden", "true");
row.appendChild(spacer);
}
var link = document.createElement("a");
link.className = "auto-toc__link";
link.href = "#" + encodeURIComponent(node.heading.id);
link.dataset.targetId = node.heading.id;
link.textContent = node.title;
row.appendChild(link);
listItem.appendChild(row);
if (node.children.length > 0) {
listItem.appendChild(
renderTree(node.children, depth + 1)
);
}
list.appendChild(listItem);
});
return list;
}
function rebuildDirectory() {
headings = collectHeadings();
ensureHeadingIds(headings);
headingMap = new Map();
headings.forEach(function (heading) {
headingMap.set(heading.id, heading);
});
navElement.textContent = "";
activeHeadingId = "";
if (headings.length === 0) {
var emptyElement = document.createElement("div");
emptyElement.className = "auto-toc__empty";
emptyElement.textContent = "当前区域中没有可用标题";
navElement.appendChild(emptyElement);
return;
}
var tree = buildTree(headings);
navElement.appendChild(renderTree(tree, 1));
updateActiveHeading();
}
/*
* 滚动目录的可滚动区域,使高亮标题保持可见。
* 仅当高亮链接超出 nav 可视范围时才滚动,
* 避免在用户手动浏览目录时产生不必要的滚动。
* 这里使用带非线性缓动(easeInOutCubic)的逐帧动画,
* 滚动过程先慢后快再慢,而不是瞬时跳变。
*/
function scrollActiveIntoView(link) {
var navRect = navElement.getBoundingClientRect();
var linkRect = link.getBoundingClientRect();
var padding = 10;
var targetScrollTop = null;
if (linkRect.top < navRect.top) {
targetScrollTop =
navElement.scrollTop - (navRect.top - linkRect.top + padding);
} else if (linkRect.bottom > navRect.bottom) {
targetScrollTop =
navElement.scrollTop + (linkRect.bottom - navRect.bottom + padding);
}
if (targetScrollTop === null) {
return;
}
var maxScroll = navElement.scrollHeight - navElement.clientHeight;
animateNavScroll(
Math.max(0, Math.min(targetScrollTop, maxScroll))
);
}
/*
* 使用 requestAnimationFrame 逐帧滚动 nav,
* 缓动函数采用 easeInOutCubic(三次缓入缓出)。
* 动画期间临时把内联 scroll-behavior 设为 auto,
* 避免与 CSS 的 smooth 平滑滚动互相干扰。
*/
function animateNavScroll(targetScrollTop) {
// 任何新动画都会取代正在进行的旧动画
navScrollAnimationId += 1;
var animationId = navScrollAnimationId;
var startScrollTop = navElement.scrollTop;
var startTime = null;
var duration = 300;
var distance = targetScrollTop - startScrollTop;
// 逐帧赋值需要即时滚动,暂时覆盖 CSS 的 smooth
navElement.style.scrollBehavior = "auto";
navScrollAnimating = true;
function easeInOutCubic(t) {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function finish() {
if (animationId !== navScrollAnimationId) {
return;
}
navElement.scrollTop = targetScrollTop;
navScrollAnimating = false;
navElement.style.scrollBehavior = "";
}
if (Math.abs(distance) < 0.5) {
finish();
return;
}
function step(timestamp) {
if (animationId !== navScrollAnimationId) {
// 动画已被更新的动画取代,或用户手动取消
return;
}
if (startTime === null) {
startTime = timestamp;
}
var progress = Math.min(
1,
(timestamp - startTime) / duration
);
navElement.scrollTop =
startScrollTop + distance * easeInOutCubic(progress);
if (progress < 1) {
window.requestAnimationFrame(step);
} else {
finish();
}
}
window.requestAnimationFrame(step);
}
/*
* 用户手动滚动目录时,立即取消正在进行的动画,
* 避免动画与用户操作互相打架。
*/
function cancelNavScrollAnimation() {
navScrollAnimationId += 1;
if (navScrollAnimating) {
navScrollAnimating = false;
navElement.style.scrollBehavior = "";
}
}
function markHeadingActive(headingId) {
if (!config.highlightCurrent) {
return;
}
if (
activeHeadingId === headingId &&
navElement.querySelector(".auto-toc__link.is-active")
) {
return;
}
activeHeadingId = headingId;
var currentLink = null;
navElement
.querySelectorAll(".auto-toc__link")
.forEach(function (link) {
var isActive = link.dataset.targetId === headingId;
link.classList.toggle("is-active", isActive);
if (isActive) {
link.setAttribute("aria-current", "location");
currentLink = link;
} else {
link.removeAttribute("aria-current");
}
});
/*
* 为当前激活目录项所在的整行添加状态类,
* 使该行在悬浮时也保持浅色背景和深色字体。
*/
navElement
.querySelectorAll(".auto-toc__row")
.forEach(function (row) {
row.classList.toggle(
"is-active-row",
Boolean(
row.querySelector(".auto-toc__link.is-active")
)
);
});
if (!currentLink) {
return;
}
/*
* 当前标题位于被收起的目录中时,
* 自动展开它的所有父目录。
*/
if (config.autoExpandActive) {
var parentItem = currentLink.closest(".auto-toc__item");
while (parentItem && navElement.contains(parentItem)) {
var wasCollapsed = parentItem.classList.contains(
"is-collapsed"
);
setBranchExpanded(parentItem, true);
/*
* 当前标题位于被收起的子目录中时,该子目录会被自动
* 展开以满足高亮需求,目录高度随之变化。这里同样需要
* 发射一次 content_isexpand 信号,与手动点击子目录
* 折叠按钮时的行为保持一致,方便外部同步布局。
* 仅当子目录原本处于收起状态时才需要通知,避免展开
* 已展开的目录时产生多余的信号。
*/
if (wasCollapsed) {
notifyContentExpanded({
expanded: true,
source: "branch",
targetId: parentItem.dataset.targetId
});
}
var parentList = parentItem.parentElement;
parentItem = parentList
? parentList.closest(".auto-toc__item")
: null;
}
}
/*
* 让目录滚轮区跟随高亮标题,
* 确保当前激活项始终在可视范围内。
*/
scrollActiveIntoView(currentLink);
}
function updateActiveHeading() {
if (!config.highlightCurrent || headings.length === 0) {
return;
}
var probeLine = offset + 10;
var currentHeading = null;
for (var index = 0; index < headings.length; index += 1) {
var heading = headings[index];
if (!heading.isConnected) {
continue;
}
if (heading.getBoundingClientRect().top <= probeLine) {
currentHeading = heading;
} else {
break;
}
}
if (!currentHeading) {
currentHeading = headings.find(function (heading) {
return heading.isConnected;
});
}
if (currentHeading) {
markHeadingActive(currentHeading.id);
}
}
function handleScroll() {
if (scrollTicking) {
return;
}
scrollTicking = true;
window.requestAnimationFrame(function () {
updateActiveHeading();
scrollTicking = false;
});
}
/*
* 在 window 上派发 content_isexpand 自定义事件,
* 供外部代码(例如更新侧边栏位置)监听:
*
* window.addEventListener('content_isexpand',
* debouncedUpdateSidebarPosition);
*
* detail 字段说明:
* - expanded: 是否处于展开状态(true=展开,false=收起)
* - source: 触发来源("panel"=整面板,"branch"=子目录)
* - targetId: 当 source 为 "branch" 时,对应子目录条目的标题 id
*/
function notifyContentExpanded(detail) {
var event;
try {
event = new CustomEvent("content_isexpand", {
detail: detail || {}
});
} catch (error) {
// 兼容不支持 CustomEvent 构造函数的旧浏览器
event = document.createEvent("CustomEvent");
event.initCustomEvent(
"content_isexpand",
false,
false,
detail || {}
);
}
window.dispatchEvent(event);
}
function setPanelCollapsed(collapsed) {
navElement.hidden = collapsed;
panelButton.setAttribute(
"aria-expanded",
String(!collapsed)
);
panelButton.textContent = collapsed ? "展开" : "收起";
}
function updateWidgetMaxHeight() {
// 高度自动化 需要设定的位置 2/2
var WIDGET_MAX_HEIGHT_OFFSET = 175;
widget.style.maxHeight =
Math.max(0, window.innerHeight - WIDGET_MAX_HEIGHT_OFFSET) + "px";
}
/*
* 页面尺寸变化时,重新计算目录控件最大高度并刷新当前激活标题。
* 同时兼容移动端 visualViewport(地址栏伸缩等场景)。
*/
function handleResize() {
updateWidgetMaxHeight();
handleScroll();
}
panelButton.addEventListener("click", function () {
var nextCollapsed = !navElement.hidden;
setPanelCollapsed(nextCollapsed);
writeCookie(PANEL_COOKIE_NAME, String(nextCollapsed));
notifyContentExpanded({
expanded: !nextCollapsed,
source: "panel"
});
});
/*
* 监听用户手动滚动目录的交互方式,
* 一旦用户开始滚动就取消自动动画。
*/
navElement.addEventListener(
"wheel",
cancelNavScrollAnimation,
{ passive: true }
);
navElement.addEventListener(
"touchstart",
cancelNavScrollAnimation,
{ passive: true }
);
navElement.addEventListener(
"mousedown",
cancelNavScrollAnimation,
{ passive: true }
);
navElement.addEventListener("keydown", function (event) {
// 仅在与滚动相关的按键按下时取消动画
var scrollKeys = [
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" "
];
if (scrollKeys.indexOf(event.key) !== -1) {
cancelNavScrollAnimation();
}
});
navElement.addEventListener("click", function (event) {
var target = event.target;
if (!(target instanceof Element)) {
return;
}
var branchButton = target.closest(
".auto-toc__branch-toggle"
);
if (branchButton && navElement.contains(branchButton)) {
var listItem = branchButton.closest(".auto-toc__item");
var currentlyExpanded =
branchButton.getAttribute("aria-expanded") === "true";
var nextExpanded = !currentlyExpanded;
setBranchExpanded(listItem, nextExpanded);
branchState.set(
listItem.dataset.targetId,
nextExpanded
);
saveBranchState();
notifyContentExpanded({
expanded: nextExpanded,
source: "branch",
targetId: listItem.dataset.targetId
});
return;
}
var link = target.closest(".auto-toc__link");
if (!link || !navElement.contains(link)) {
return;
}
var heading = headingMap.get(link.dataset.targetId);
if (!heading) {
return;
}
event.preventDefault();
var targetTop =
window.scrollY +
heading.getBoundingClientRect().top -
offset;
window.scrollTo({
top: Math.max(0, targetTop),
behavior: config.smoothScroll ? "smooth" : "auto"
});
markHeadingActive(heading.id);
if (config.updateHash) {
try {
history.replaceState(
null,
"",
"#" + encodeURIComponent(heading.id)
);
} catch (error) {
// 某些特殊页面环境可能不允许修改地址栏
}
}
});
window.addEventListener("scroll", handleScroll, {
passive: true
});
window.addEventListener("resize", handleResize, {
passive: true
});
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", handleResize);
}
updateWidgetMaxHeight();
rebuildDirectory();
setPanelCollapsed(Boolean(config.panelCollapsed));
/*
* 控件加载完成后派发一次 content_isexpand 事件,
* 让外部监听代码(例如更新侧边栏位置)第一时间
* 获知目录的初始展开状态,与点击面板展开/折叠按钮
* 发送的信号格式保持一致。
*/
notifyContentExpanded({
expanded: !Boolean(config.panelCollapsed),
source: "panel"
});
/*
* 监听页面动态内容。
* 如果框架、AJAX 或编辑器增加/删除标题,目录会自动刷新。
*/
if (config.observeChanges && "MutationObserver" in window) {
observer = new MutationObserver(function (mutations) {
var needsRebuild = mutations.some(function (mutation) {
var target =
mutation.target.nodeType === Node.ELEMENT_NODE
? mutation.target
: mutation.target.parentElement;
if (!target || widget.contains(target)) {
return false;
}
if (mutation.type === "characterData") {
return Boolean(target.closest(headingSelector));
}
if (target.matches && target.matches(headingSelector)) {
return true;
}
var changedNodes = Array.from(mutation.addedNodes).concat(
Array.from(mutation.removedNodes)
);
return changedNodes.some(function (node) {
if (node.nodeType !== Node.ELEMENT_NODE) {
return false;
}
return (
node.matches(headingSelector) ||
Boolean(node.querySelector(headingSelector))
);
});
});
if (!needsRebuild) {
return;
}
window.clearTimeout(rebuildTimer);
rebuildTimer = window.setTimeout(function () {
rebuildDirectory();
}, 80);
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootAutoToc, {
once: true
});
} else {
bootAutoToc();
}
})();
</script>
拆分形式
单个html如果放在动态页面,每次加载可能比较消耗带宽,建议拆分成多个文件。将js和css放在静态文件内,然后html每次动态加载(由于wordpress的限制,可能无法将此静态html放在绝对静态的位置,不过长度较短的情况下问题不大)
拆分后的html:
<div class="auto-toc" data-auto-toc="">
<!-- JSON 配置:可按需修改,JSON 内不能写注释 -->
<script type="application/json" class="auto-toc__config">
{
"title": "目 录",
"contentSelector": "main, article, [role='main']",
"headingSelector": "h1,h2,h3,h4,h5,h6",
"ignoreSelector": "nav, footer, [data-no-toc], .no-toc",
"expandDepth": 6,
"offset": 80,
"smoothScroll": true,
"updateHash": true,
"highlightCurrent": true,
"autoExpandActive": true,
"observeChanges": true,
"panelCollapsed": false
}
</script>
<div class="auto-toc__panel">
<div class="auto-toc__header">
<div class="auto-toc__title" style="font-size: 24px;">目 录</div>
<button class="auto-toc__panel-toggle" type="button" aria-expanded="true">
收起
</button>
</div>
<nav class="auto-toc__nav" aria-label="目 录" aria-live="polite"></nav>
</div>
</div>
<script src="/wp-content/themes/fox009-wisdom/assets/js/autotoc.js"></script>
拆分后的js,需要放置在上面html最后一行指定的路径的位置,如果需要修改,请同时修改html的引用js路径
/* /wp-content/themes/fox009-wisdom/assets/js/autotoc.js */
function () {
"use strict";
var PANEL_COOKIE_NAME = "auto_toc_panel_collapsed";
var BRANCH_COOKIE_NAME = "auto_toc_branch_state";
var COOKIE_MAX_AGE_DAYS = 90;
function readCookie(name) {
var prefix = name + "=";
var parts = document.cookie.split(";");
for (var index = 0; index < parts.length; index += 1) {
var part = parts[index].trim();
if (part.indexOf(prefix) === 0) {
return decodeURIComponent(part.slice(prefix.length));
}
}
return null;
}
function writeCookie(name, value) {
document.cookie =
name +
"=" +
encodeURIComponent(value) +
"; max-age=" +
COOKIE_MAX_AGE_DAYS * 24 * 60 * 60 +
"; path=/";
}
function deleteCookie(name) {
document.cookie = name + "=; max-age=0; path=/";
}
var defaultConfig = {
title: "目 录",
contentSelector: "main, article, [role='main']",
headingSelector: "h1,h2,h3,h4,h5,h6",
ignoreSelector: "nav, footer, [data-no-toc], .no-toc",
expandDepth: 6,
offset: 80,
smoothScroll: true,
updateHash: true,
highlightCurrent: true,
autoExpandActive: true,
observeChanges: true,
panelCollapsed: false
};
function bootAutoToc() {
var widgets = document.querySelectorAll(
"[data-auto-toc]:not([data-auto-toc-ready])"
);
widgets.forEach(function (widget) {
initializeWidget(widget);
});
}
function initializeWidget(widget) {
widget.setAttribute("data-auto-toc-ready", "true");
var configElement = widget.querySelector(".auto-toc__config");
var configData = {};
if (configElement) {
try {
configData = JSON.parse(configElement.textContent.trim());
} catch (error) {
console.error("自动目录 JSON 配置格式错误:", error);
}
}
var config = Object.assign({}, defaultConfig, configData);
/*
* 用 cookie 中保存的面板展开/收起状态覆盖配置值,
* 使面板状态在 90 天内保持一致。
*/
var savedPanelCollapsed = readCookie(PANEL_COOKIE_NAME);
if (savedPanelCollapsed !== null) {
config.panelCollapsed = savedPanelCollapsed === "true";
}
var titleElement = widget.querySelector(".auto-toc__title");
var panelButton = widget.querySelector(".auto-toc__panel-toggle");
var navElement = widget.querySelector(".auto-toc__nav");
if (!titleElement || !panelButton || !navElement) {
return;
}
titleElement.textContent = String(config.title || "目 录");
var headingSelector = validateSelector(
config.headingSelector,
defaultConfig.headingSelector
);
var contentSelector = validateSelector(
config.contentSelector,
defaultConfig.contentSelector
);
var ignoreSelector = "";
if (config.ignoreSelector) {
ignoreSelector = validateSelector(
config.ignoreSelector,
defaultConfig.ignoreSelector
);
}
var offset = Number(config.offset);
if (!Number.isFinite(offset)) {
offset = defaultConfig.offset;
}
var expandDepth = Number(config.expandDepth);
if (!Number.isFinite(expandDepth)) {
expandDepth = defaultConfig.expandDepth;
}
var headings = [];
var headingMap = new Map();
var branchState = new Map();
var activeHeadingId = "";
/*
* 从 cookie 恢复各子目录的展开/收起状态。
* cookie 中保存的是一个 JSON 对象,
* 键为标题 id,值为是否展开(true/false)。
*/
function loadBranchState() {
var saved = readCookie(BRANCH_COOKIE_NAME);
if (!saved) {
return;
}
try {
var state = JSON.parse(saved);
Object.keys(state).forEach(function (id) {
branchState.set(id, Boolean(state[id]));
});
} catch (error) {
console.warn("自动目录:子目录状态 cookie 解析失败", error);
}
}
/*
* 将各子目录的展开/收起状态写入 cookie。
* 没有任何手动记录的状态时,删除对应 cookie。
*/
function saveBranchState() {
if (branchState.size === 0) {
deleteCookie(BRANCH_COOKIE_NAME);
return;
}
var state = {};
branchState.forEach(function (expanded, id) {
state[id] = expanded;
});
writeCookie(BRANCH_COOKIE_NAME, JSON.stringify(state));
}
loadBranchState();
var observer = null;
var rebuildTimer = null;
var scrollTicking = false;
var navScrollAnimating = false;
var navScrollAnimationId = 0;
function validateSelector(selector, fallback) {
try {
document.querySelector(String(selector));
return String(selector);
} catch (error) {
console.warn(
"自动目录选择器无效,将使用默认值:",
selector,
error
);
return fallback;
}
}
function getHeadingTitle(heading) {
var customTitle = heading.getAttribute("data-toc-title");
if (customTitle) {
return customTitle.trim();
}
return heading.textContent.replace(/\s+/g, " ").trim();
}
function shouldIgnoreHeading(heading) {
if (widget.contains(heading)) {
return true;
}
if (
heading.closest("[hidden], [aria-hidden='true']")
) {
return true;
}
if (ignoreSelector) {
try {
if (heading.closest(ignoreSelector)) {
return true;
}
} catch (error) {
// 忽略无效排除选择器
}
}
return getHeadingTitle(heading).length === 0;
}
function collectHeadings() {
var roots = [];
try {
roots = Array.from(
document.querySelectorAll(contentSelector)
);
} catch (error) {
roots = [];
}
/*
* 如果页面中找不到 main、article 等内容容器,
* 自动退回到扫描整个 body。
*/
if (roots.length === 0) {
roots = [document.body || document.documentElement];
}
var results = [];
var seen = new Set();
roots.forEach(function (root) {
root.querySelectorAll(headingSelector).forEach(function (heading) {
if (seen.has(heading)) {
return;
}
seen.add(heading);
if (!shouldIgnoreHeading(heading)) {
results.push(heading);
}
});
});
/*
* 多个内容容器可能互相嵌套,因此重新按页面实际顺序排序。
*/
results.sort(function (a, b) {
if (a === b) {
return 0;
}
var position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1;
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1;
}
return 0;
});
return results;
}
function ensureHeadingIds(items) {
var usedIds = new Set();
items.forEach(function (heading, index) {
var id = heading.id;
var existingElement = id
? document.getElementById(id)
: null;
if (
!id ||
usedIds.has(id) ||
(existingElement && existingElement !== heading)
) {
var baseId = id || "toc-section-" + (index + 1);
var candidate = baseId;
var number = 2;
while (
usedIds.has(candidate) ||
(
document.getElementById(candidate) &&
document.getElementById(candidate) !== heading
)
) {
candidate = baseId + "-" + number;
number += 1;
}
id = candidate;
heading.id = id;
}
usedIds.add(id);
});
}
function buildTree(items) {
var root = {
level: 0,
children: []
};
var stack = [root];
items.forEach(function (heading) {
var match = heading.tagName.match(/^H([1-6])$/i);
if (!match) {
return;
}
var node = {
heading: heading,
level: Number(match[1]),
title: getHeadingTitle(heading),
children: []
};
/*
* 遇到同级或更高级标题时返回上一层。
* 即使标题级别跳跃,例如 h2 后直接是 h4,
* 也会自动归入最近的上级标题。
*/
while (
stack.length > 1 &&
stack[stack.length - 1].level >= node.level
) {
stack.pop();
}
stack[stack.length - 1].children.push(node);
stack.push(node);
});
return root.children;
}
function setBranchExpanded(listItem, expanded) {
listItem.classList.toggle("is-collapsed", !expanded);
var row = Array.from(listItem.children).find(function (child) {
return child.classList.contains("auto-toc__row");
});
if (!row) {
return;
}
var button = row.querySelector(".auto-toc__branch-toggle");
if (button) {
button.setAttribute("aria-expanded", String(expanded));
button.textContent = expanded ? "▾" : "▸";
button.title = expanded ? "收起子目录" : "展开子目录";
}
}
function renderTree(nodes, depth) {
var list = document.createElement("ol");
list.className = "auto-toc__list";
nodes.forEach(function (node) {
var listItem = document.createElement("li");
listItem.className = "auto-toc__item";
listItem.dataset.targetId = node.heading.id;
listItem.dataset.headingLevel = String(node.level);
var row = document.createElement("div");
row.className = "auto-toc__row";
if (node.children.length > 0) {
var toggleButton = document.createElement("button");
toggleButton.type = "button";
toggleButton.className = "auto-toc__branch-toggle";
toggleButton.setAttribute("aria-label", "展开或收起子目录");
var expanded = branchState.has(node.heading.id)
? branchState.get(node.heading.id)
: depth <= expandDepth;
toggleButton.setAttribute(
"aria-expanded",
String(expanded)
);
toggleButton.textContent = expanded ? "▾" : "▸";
toggleButton.title = expanded
? "收起子目录"
: "展开子目录";
row.appendChild(toggleButton);
if (!expanded) {
listItem.classList.add("is-collapsed");
}
} else {
var spacer = document.createElement("span");
spacer.className = "auto-toc__branch-spacer";
spacer.setAttribute("aria-hidden", "true");
row.appendChild(spacer);
}
var link = document.createElement("a");
link.className = "auto-toc__link";
link.href = "#" + encodeURIComponent(node.heading.id);
link.dataset.targetId = node.heading.id;
link.textContent = node.title;
row.appendChild(link);
listItem.appendChild(row);
if (node.children.length > 0) {
listItem.appendChild(
renderTree(node.children, depth + 1)
);
}
list.appendChild(listItem);
});
return list;
}
function rebuildDirectory() {
headings = collectHeadings();
ensureHeadingIds(headings);
headingMap = new Map();
headings.forEach(function (heading) {
headingMap.set(heading.id, heading);
});
navElement.textContent = "";
activeHeadingId = "";
if (headings.length === 0) {
var emptyElement = document.createElement("div");
emptyElement.className = "auto-toc__empty";
emptyElement.textContent = "当前区域中没有可用标题";
navElement.appendChild(emptyElement);
return;
}
var tree = buildTree(headings);
navElement.appendChild(renderTree(tree, 1));
updateActiveHeading();
}
/*
* 滚动目录的可滚动区域,使高亮标题保持可见。
* 仅当高亮链接超出 nav 可视范围时才滚动,
* 避免在用户手动浏览目录时产生不必要的滚动。
* 这里使用带非线性缓动(easeInOutCubic)的逐帧动画,
* 滚动过程先慢后快再慢,而不是瞬时跳变。
*/
function scrollActiveIntoView(link) {
var navRect = navElement.getBoundingClientRect();
var linkRect = link.getBoundingClientRect();
var padding = 10;
var targetScrollTop = null;
if (linkRect.top < navRect.top) {
targetScrollTop =
navElement.scrollTop - (navRect.top - linkRect.top + padding);
} else if (linkRect.bottom > navRect.bottom) {
targetScrollTop =
navElement.scrollTop + (linkRect.bottom - navRect.bottom + padding);
}
if (targetScrollTop === null) {
return;
}
var maxScroll = navElement.scrollHeight - navElement.clientHeight;
animateNavScroll(
Math.max(0, Math.min(targetScrollTop, maxScroll))
);
}
/*
* 使用 requestAnimationFrame 逐帧滚动 nav,
* 缓动函数采用 easeInOutCubic(三次缓入缓出)。
* 动画期间临时把内联 scroll-behavior 设为 auto,
* 避免与 CSS 的 smooth 平滑滚动互相干扰。
*/
function animateNavScroll(targetScrollTop) {
// 任何新动画都会取代正在进行的旧动画
navScrollAnimationId += 1;
var animationId = navScrollAnimationId;
var startScrollTop = navElement.scrollTop;
var startTime = null;
var duration = 300;
var distance = targetScrollTop - startScrollTop;
// 逐帧赋值需要即时滚动,暂时覆盖 CSS 的 smooth
navElement.style.scrollBehavior = "auto";
navScrollAnimating = true;
function easeInOutCubic(t) {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function finish() {
if (animationId !== navScrollAnimationId) {
return;
}
navElement.scrollTop = targetScrollTop;
navScrollAnimating = false;
navElement.style.scrollBehavior = "";
}
if (Math.abs(distance) < 0.5) {
finish();
return;
}
function step(timestamp) {
if (animationId !== navScrollAnimationId) {
// 动画已被更新的动画取代,或用户手动取消
return;
}
if (startTime === null) {
startTime = timestamp;
}
var progress = Math.min(
1,
(timestamp - startTime) / duration
);
navElement.scrollTop =
startScrollTop + distance * easeInOutCubic(progress);
if (progress < 1) {
window.requestAnimationFrame(step);
} else {
finish();
}
}
window.requestAnimationFrame(step);
}
/*
* 用户手动滚动目录时,立即取消正在进行的动画,
* 避免动画与用户操作互相打架。
*/
function cancelNavScrollAnimation() {
navScrollAnimationId += 1;
if (navScrollAnimating) {
navScrollAnimating = false;
navElement.style.scrollBehavior = "";
}
}
function markHeadingActive(headingId) {
if (!config.highlightCurrent) {
return;
}
if (
activeHeadingId === headingId &&
navElement.querySelector(".auto-toc__link.is-active")
) {
return;
}
activeHeadingId = headingId;
var currentLink = null;
navElement
.querySelectorAll(".auto-toc__link")
.forEach(function (link) {
var isActive = link.dataset.targetId === headingId;
link.classList.toggle("is-active", isActive);
if (isActive) {
link.setAttribute("aria-current", "location");
currentLink = link;
} else {
link.removeAttribute("aria-current");
}
});
/*
* 为当前激活目录项所在的整行添加状态类,
* 使该行在悬浮时也保持浅色背景和深色字体。
*/
navElement
.querySelectorAll(".auto-toc__row")
.forEach(function (row) {
row.classList.toggle(
"is-active-row",
Boolean(
row.querySelector(".auto-toc__link.is-active")
)
);
});
if (!currentLink) {
return;
}
/*
* 当前标题位于被收起的目录中时,
* 自动展开它的所有父目录。
*/
if (config.autoExpandActive) {
var parentItem = currentLink.closest(".auto-toc__item");
while (parentItem && navElement.contains(parentItem)) {
var wasCollapsed = parentItem.classList.contains(
"is-collapsed"
);
setBranchExpanded(parentItem, true);
/*
* 当前标题位于被收起的子目录中时,该子目录会被自动
* 展开以满足高亮需求,目录高度随之变化。这里同样需要
* 发射一次 content_isexpand 信号,与手动点击子目录
* 折叠按钮时的行为保持一致,方便外部同步布局。
* 仅当子目录原本处于收起状态时才需要通知,避免展开
* 已展开的目录时产生多余的信号。
*/
if (wasCollapsed) {
notifyContentExpanded({
expanded: true,
source: "branch",
targetId: parentItem.dataset.targetId
});
}
var parentList = parentItem.parentElement;
parentItem = parentList
? parentList.closest(".auto-toc__item")
: null;
}
}
/*
* 让目录滚轮区跟随高亮标题,
* 确保当前激活项始终在可视范围内。
*/
scrollActiveIntoView(currentLink);
}
function updateActiveHeading() {
if (!config.highlightCurrent || headings.length === 0) {
return;
}
var probeLine = offset + 10;
var currentHeading = null;
for (var index = 0; index < headings.length; index += 1) {
var heading = headings[index];
if (!heading.isConnected) {
continue;
}
if (heading.getBoundingClientRect().top <= probeLine) {
currentHeading = heading;
} else {
break;
}
}
if (!currentHeading) {
currentHeading = headings.find(function (heading) {
return heading.isConnected;
});
}
if (currentHeading) {
markHeadingActive(currentHeading.id);
}
}
function handleScroll() {
if (scrollTicking) {
return;
}
scrollTicking = true;
window.requestAnimationFrame(function () {
updateActiveHeading();
scrollTicking = false;
});
}
/*
* 在 window 上派发 content_isexpand 自定义事件,
* 供外部代码(例如更新侧边栏位置)监听:
*
* window.addEventListener('content_isexpand',
* debouncedUpdateSidebarPosition);
*
* detail 字段说明:
* - expanded: 是否处于展开状态(true=展开,false=收起)
* - source: 触发来源("panel"=整面板,"branch"=子目录)
* - targetId: 当 source 为 "branch" 时,对应子目录条目的标题 id
*/
function notifyContentExpanded(detail) {
var event;
try {
event = new CustomEvent("content_isexpand", {
detail: detail || {}
});
} catch (error) {
// 兼容不支持 CustomEvent 构造函数的旧浏览器
event = document.createEvent("CustomEvent");
event.initCustomEvent(
"content_isexpand",
false,
false,
detail || {}
);
}
window.dispatchEvent(event);
}
function setPanelCollapsed(collapsed) {
navElement.hidden = collapsed;
panelButton.setAttribute(
"aria-expanded",
String(!collapsed)
);
panelButton.textContent = collapsed ? "展开" : "收起";
}
function updateWidgetMaxHeight() {
// 高度自动化 需要设定的位置 2/2
var WIDGET_MAX_HEIGHT_OFFSET = 175;
widget.style.maxHeight =
Math.max(0, window.innerHeight - WIDGET_MAX_HEIGHT_OFFSET) + "px";
}
/*
* 页面尺寸变化时,重新计算目录控件最大高度并刷新当前激活标题。
* 同时兼容移动端 visualViewport(地址栏伸缩等场景)。
*/
function handleResize() {
updateWidgetMaxHeight();
handleScroll();
}
panelButton.addEventListener("click", function () {
var nextCollapsed = !navElement.hidden;
setPanelCollapsed(nextCollapsed);
writeCookie(PANEL_COOKIE_NAME, String(nextCollapsed));
notifyContentExpanded({
expanded: !nextCollapsed,
source: "panel"
});
});
/*
* 监听用户手动滚动目录的交互方式,
* 一旦用户开始滚动就取消自动动画。
*/
navElement.addEventListener(
"wheel",
cancelNavScrollAnimation,
{ passive: true }
);
navElement.addEventListener(
"touchstart",
cancelNavScrollAnimation,
{ passive: true }
);
navElement.addEventListener(
"mousedown",
cancelNavScrollAnimation,
{ passive: true }
);
navElement.addEventListener("keydown", function (event) {
// 仅在与滚动相关的按键按下时取消动画
var scrollKeys = [
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" "
];
if (scrollKeys.indexOf(event.key) !== -1) {
cancelNavScrollAnimation();
}
});
navElement.addEventListener("click", function (event) {
var target = event.target;
if (!(target instanceof Element)) {
return;
}
var branchButton = target.closest(
".auto-toc__branch-toggle"
);
if (branchButton && navElement.contains(branchButton)) {
var listItem = branchButton.closest(".auto-toc__item");
var currentlyExpanded =
branchButton.getAttribute("aria-expanded") === "true";
var nextExpanded = !currentlyExpanded;
setBranchExpanded(listItem, nextExpanded);
branchState.set(
listItem.dataset.targetId,
nextExpanded
);
saveBranchState();
notifyContentExpanded({
expanded: nextExpanded,
source: "branch",
targetId: listItem.dataset.targetId
});
return;
}
var link = target.closest(".auto-toc__link");
if (!link || !navElement.contains(link)) {
return;
}
var heading = headingMap.get(link.dataset.targetId);
if (!heading) {
return;
}
event.preventDefault();
var targetTop =
window.scrollY +
heading.getBoundingClientRect().top -
offset;
window.scrollTo({
top: Math.max(0, targetTop),
behavior: config.smoothScroll ? "smooth" : "auto"
});
markHeadingActive(heading.id);
if (config.updateHash) {
try {
history.replaceState(
null,
"",
"#" + encodeURIComponent(heading.id)
);
} catch (error) {
// 某些特殊页面环境可能不允许修改地址栏
}
}
});
window.addEventListener("scroll", handleScroll, {
passive: true
});
window.addEventListener("resize", handleResize, {
passive: true
});
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", handleResize);
}
updateWidgetMaxHeight();
rebuildDirectory();
setPanelCollapsed(Boolean(config.panelCollapsed));
/*
* 控件加载完成后派发一次 content_isexpand 事件,
* 让外部监听代码(例如更新侧边栏位置)第一时间
* 获知目录的初始展开状态,与点击面板展开/折叠按钮
* 发送的信号格式保持一致。
*/
notifyContentExpanded({
expanded: !Boolean(config.panelCollapsed),
source: "panel"
});
/*
* 监听页面动态内容。
* 如果框架、AJAX 或编辑器增加/删除标题,目录会自动刷新。
*/
if (config.observeChanges && "MutationObserver" in window) {
observer = new MutationObserver(function (mutations) {
var needsRebuild = mutations.some(function (mutation) {
var target =
mutation.target.nodeType === Node.ELEMENT_NODE
? mutation.target
: mutation.target.parentElement;
if (!target || widget.contains(target)) {
return false;
}
if (mutation.type === "characterData") {
return Boolean(target.closest(headingSelector));
}
if (target.matches && target.matches(headingSelector)) {
return true;
}
var changedNodes = Array.from(mutation.addedNodes).concat(
Array.from(mutation.removedNodes)
);
return changedNodes.some(function (node) {
if (node.nodeType !== Node.ELEMENT_NODE) {
return false;
}
return (
node.matches(headingSelector) ||
Boolean(node.querySelector(headingSelector))
);
});
});
if (!needsRebuild) {
return;
}
window.clearTimeout(rebuildTimer);
rebuildTimer = window.setTimeout(function () {
rebuildDirectory();
}, 80);
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootAutoToc, {
once: true
});
} else {
bootAutoToc();
}
})();
拆分后的css,可以添加到主题定义的css内,总之页面内能拿到就可以
.auto-toc {
--toc-background: #efefef;
--toc-text: #1e1e1e;
--toc-border: #ccc;
--toc-hover: #1e1e1e;
--toc-transition: background-color 0.3s ease, color 0.3s ease;
/* 高度自动化 需要设定的位置 1/2 */
max-height: calc(100vh - 175px);
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
width: 100%;
color: var(--toc-text);
font-family: -apple-system, "PingFang SC", BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.auto-toc *,
.auto-toc *::before,
.auto-toc *::after {
box-sizing: border-box;
}
.auto-toc__panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
width: 100%;
}
.auto-toc__header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 10px 14px;
border-bottom: 1px solid #eee;
}
.auto-toc__title {
overflow: hidden;
font-size: 16px;
font-weight: 700;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-toc__panel-toggle {
flex: 0 0 auto;
margin-left: 12px;
padding: 5px 9px;
border: 1px solid var(--toc-border);
border-radius: 6px;
background: var(--toc-background);
color: var(--toc-text);
font: inherit;
font-size: 13px;
cursor: pointer;
transition: var(--toc-transition);
}
.auto-toc__panel-toggle:hover {
background: var(--toc-hover);
color: var(--toc-background);
}
.auto-toc__nav {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 10px;
scroll-behavior: smooth;
}
.auto-toc__nav[hidden] {
display: none !important;
}
.auto-toc__list {
margin: 0;
padding: 0;
list-style: none;
}
.auto-toc__list .auto-toc__list {
margin: 2px 0 2px 10px;
padding-left: 10px;
border-left: 1px solid var(--toc-border);
}
.auto-toc__item {
margin: 1px 0;
padding: 0;
}
.auto-toc__row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: start;
border-radius: 7px;
transition: var(--toc-transition);
}
.auto-toc__row:hover {
background: var(--toc-hover);
}
.auto-toc__row:hover .auto-toc__link,
.auto-toc__row:hover .auto-toc__branch-toggle {
color: var(--toc-background);
}
.auto-toc__row.is-active-row,
.auto-toc__row.is-active-row:hover {
background: var(--toc-background);
}
.auto-toc__row.is-active-row:hover .auto-toc__link,
.auto-toc__row.is-active-row:hover .auto-toc__branch-toggle,
.auto-toc__link.is-active:hover {
color: var(--toc-text);
}
.auto-toc__branch-toggle,
.auto-toc__branch-spacer {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 32px;
}
.auto-toc__branch-toggle {
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--toc-text);
font: inherit;
font-size: 14px;
cursor: pointer;
transition: var(--toc-transition);
}
.auto-toc__branch-toggle:hover {
background: var(--toc-hover);
color: var(--toc-background);
}
.auto-toc__row.is-active-row:hover .auto-toc__branch-toggle:hover {
background: var(--toc-hover);
color: var(--toc-background);
}
.auto-toc__link {
display: block;
min-width: 0;
padding: 6px 8px 6px 3px;
border-radius: 6px;
color: var(--toc-text);
font-size: 14px;
line-height: 20px;
text-decoration: none;
overflow-wrap: anywhere;
transition: var(--toc-transition);
}
.auto-toc__link:hover {
color: var(--toc-background);
}
.auto-toc__link.is-active {
font-weight: 600;
}
.auto-toc__item.is-collapsed>.auto-toc__list {
display: none;
}
.auto-toc__empty {
padding: 14px 10px;
color: var(--toc-text);
font-size: 14px;
text-align: center;
}
.auto-toc__panel-toggle:focus,
.auto-toc__branch-toggle:focus,
.auto-toc__link:focus {
outline: none;
}
@media (max-width: 640px) {
.auto-toc__link {
font-size: 13px;
}
}
limitless网站管理员
2026年7月31日
