2026-10-24 01:03:36 +08:00
|
|
|
|
/*!
|
|
|
|
|
|
* 文章目录(TOC)交互
|
|
|
|
|
|
* Yongquan Yang <yangyq.net>
|
|
|
|
|
|
*
|
|
|
|
|
|
* ── 设计原则:没有 JS 也要能用 ────────────────────────────────
|
|
|
|
|
|
* 目录的**核心功能是跳转**,那由 <a href="#id"> + CSS 的
|
2026-10-25 12:18:52 +08:00
|
|
|
|
* scroll-margin-top 完成,完全不需要 JS。这个文件只做三件锦上添花的事:
|
2026-10-24 01:03:36 +08:00
|
|
|
|
* ① 平滑滚动(不支持的浏览器直接跳,不影响功能)
|
|
|
|
|
|
* ② 高亮当前阅读位置的条目
|
2026-10-25 12:18:52 +08:00
|
|
|
|
* ③ 侧边目录面板的展开 / 收起(默认状态由 HTML class 决定,
|
|
|
|
|
|
* JS 不参与"初始长什么样",只负责切换)
|
2026-10-24 01:03:36 +08:00
|
|
|
|
* 所以这里所有增强都必须能安全失败 —— 任何异常都不该让跳转失效。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么不用 jQuery:主题里虽然有 jQuery,但这两件事原生 API 足够,
|
|
|
|
|
|
* 且这个文件能在主题脚本之前/之后加载都不出错。
|
|
|
|
|
|
*/
|
|
|
|
|
|
; (function () {
|
|
|
|
|
|
'use strict'
|
|
|
|
|
|
|
|
|
|
|
|
/* 固定导航栏的高度 + 呼吸空间。
|
|
|
|
|
|
与 toc.css 里的 scroll-margin-top 保持一致 ——
|
|
|
|
|
|
那边管"原生跳转",这边管"JS 平滑滚动",两者必须同步,
|
|
|
|
|
|
否则点目录和直接访问 #hash 会落到不同位置。 */
|
|
|
|
|
|
function navOffset () {
|
|
|
|
|
|
var off = 84
|
|
|
|
|
|
var nav = document.querySelector('.k-nav')
|
|
|
|
|
|
// 管理工具栏存在时补上它的高度(否则锚点被工具栏盖住)
|
|
|
|
|
|
if (document.body && document.body.classList.contains('admin-bar')) {
|
|
|
|
|
|
off += 32
|
|
|
|
|
|
}
|
|
|
|
|
|
// 导航栏若没有固定在顶部(小屏展开状态),就不必让位
|
|
|
|
|
|
if (nav) {
|
|
|
|
|
|
var st = window.getComputedStyle(nav)
|
|
|
|
|
|
if (st.position !== 'fixed' && st.position !== 'absolute') {
|
|
|
|
|
|
off = 16
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return off
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 平滑滚动到某个元素。
|
|
|
|
|
|
不用 element.scrollIntoView() 的原因:它在部分浏览器/滚动容器里
|
|
|
|
|
|
行为不一致,且难以精确控制顶部偏移。手算 scrollTop 更可控。 */
|
|
|
|
|
|
function scrollToEl (el) {
|
|
|
|
|
|
if (!el) return false
|
|
|
|
|
|
var top = el.getBoundingClientRect().top + window.pageYOffset - navOffset()
|
|
|
|
|
|
if (top < 0) top = 0
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.scrollTo({ top: top, behavior: 'smooth' })
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 老浏览器不认 options 对象
|
|
|
|
|
|
window.scrollTo(0, top)
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 解码 hash 里的 id。
|
|
|
|
|
|
中文 id 在地址栏里会被编码成 %E7%AC%AC...,
|
|
|
|
|
|
getElementById 需要解码后的原文。 */
|
|
|
|
|
|
function decodeHash (h) {
|
|
|
|
|
|
if (!h) return ''
|
|
|
|
|
|
var s = h.charAt(0) === '#' ? h.slice(1) : h
|
|
|
|
|
|
try {
|
|
|
|
|
|
return decodeURIComponent(s)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findTarget (hash) {
|
|
|
|
|
|
var id = decodeHash(hash)
|
|
|
|
|
|
if (!id) return null
|
|
|
|
|
|
// 优先按 id 精确查;中文、空格等特殊字符用属性选择器兜底
|
|
|
|
|
|
var el = document.getElementById(id)
|
|
|
|
|
|
if (el) return el
|
|
|
|
|
|
try {
|
|
|
|
|
|
return document.querySelector('[id="' + id.replace(/"/g, '\\"') + '"]')
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── ① 点击目录项:平滑滚动 ────────────────────────────── */
|
|
|
|
|
|
function bindClick () {
|
|
|
|
|
|
document.addEventListener('click', function (ev) {
|
|
|
|
|
|
var a = ev.target.closest ? ev.target.closest('.yang-toc-link') : null
|
|
|
|
|
|
if (!a) return
|
|
|
|
|
|
|
|
|
|
|
|
var href = a.getAttribute('href') || ''
|
|
|
|
|
|
if (href.charAt(0) !== '#') return
|
|
|
|
|
|
|
|
|
|
|
|
var el = findTarget(href)
|
|
|
|
|
|
// 找不到目标就别拦默认行为,让浏览器自己处理(可能跳到别处)
|
|
|
|
|
|
if (!el) return
|
|
|
|
|
|
|
|
|
|
|
|
ev.preventDefault()
|
|
|
|
|
|
scrollToEl(el)
|
|
|
|
|
|
|
|
|
|
|
|
/* 更新地址栏但不新增历史记录:用 replaceState 而不是
|
|
|
|
|
|
pushState。理由:点目录属于"页内跳转",按后退应该回到
|
|
|
|
|
|
上一篇文章,而不是在本文的各个锚点之间倒退。 */
|
|
|
|
|
|
try {
|
|
|
|
|
|
history.replaceState(null, '', href)
|
|
|
|
|
|
} catch (e) { }
|
|
|
|
|
|
|
|
|
|
|
|
// 目标闪一下,告诉用户"跳到这里了"
|
|
|
|
|
|
el.classList.remove('yang-toc-flash')
|
|
|
|
|
|
// 强制重排,保证连续点击同一项时动画能重播
|
|
|
|
|
|
void el.offsetWidth
|
|
|
|
|
|
el.classList.add('yang-toc-flash')
|
|
|
|
|
|
setTimeout(function () {
|
|
|
|
|
|
el.classList.remove('yang-toc-flash')
|
|
|
|
|
|
}, 1200)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── ② 滚动时高亮当前章节 ──────────────────────────────── */
|
|
|
|
|
|
function bindScrollSpy () {
|
2026-10-25 12:18:52 +08:00
|
|
|
|
/* 目录可能在两处:正文里的 .yang-toc,或侧边的 .yang-toc-rail-panel。
|
|
|
|
|
|
两处不会同时出现(正文有手写标记时不出侧栏),所以取第一个找到的
|
|
|
|
|
|
就够;用逗号选择器而不是两个 if,是为了将来加第三种形态时
|
|
|
|
|
|
不用再改这里。 */
|
|
|
|
|
|
var toc = document.querySelector('.yang-toc, .yang-toc-rail-panel')
|
2026-10-24 01:03:36 +08:00
|
|
|
|
if (!toc) return
|
|
|
|
|
|
|
|
|
|
|
|
var links = [].slice.call(toc.querySelectorAll('.yang-toc-link'))
|
|
|
|
|
|
if (!links.length) return
|
|
|
|
|
|
|
|
|
|
|
|
// 建立「目标元素 → 目录链接」的映射
|
|
|
|
|
|
var pairs = []
|
|
|
|
|
|
links.forEach(function (a) {
|
|
|
|
|
|
var el = findTarget(a.getAttribute('href') || '')
|
|
|
|
|
|
if (el) pairs.push({ link: a, el: el })
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!pairs.length) return
|
|
|
|
|
|
|
|
|
|
|
|
var active = null
|
|
|
|
|
|
|
|
|
|
|
|
function update () {
|
|
|
|
|
|
/* 判据:视口顶部往下 navOffset()+24px 处,最后一个已经滚过
|
|
|
|
|
|
这个位置的标题,就是"当前正在读的章节"。
|
|
|
|
|
|
加 24px 是为了让标题刚进入视野下沿时就切过去,而不是
|
|
|
|
|
|
等它顶到最上面才切 —— 后者会明显晚一拍。 */
|
|
|
|
|
|
var line = navOffset() + 24
|
|
|
|
|
|
var cur = null
|
|
|
|
|
|
for (var i = 0; i < pairs.length; i++) {
|
|
|
|
|
|
var r = pairs[i].el.getBoundingClientRect()
|
|
|
|
|
|
if (r.top <= line) {
|
|
|
|
|
|
cur = pairs[i]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 标题是有序的,第一个没滚过的后面的都不用看了
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 滚到页面最底部时,强制选最后一项(否则最后一个短章节永远高亮不到)
|
|
|
|
|
|
if ((window.innerHeight + window.pageYOffset) >= document.body.scrollHeight - 2) {
|
|
|
|
|
|
cur = pairs[pairs.length - 1]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cur === active) return
|
|
|
|
|
|
if (active) active.link.classList.remove('is-active')
|
|
|
|
|
|
active = cur
|
|
|
|
|
|
if (active) active.link.classList.add('is-active')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 节流:滚动事件每帧都可能触发,用 rAF 合并,
|
|
|
|
|
|
避免在低端设备上拖慢滚动。 */
|
|
|
|
|
|
var ticking = false
|
|
|
|
|
|
function onScroll () {
|
|
|
|
|
|
if (ticking) return
|
|
|
|
|
|
ticking = true
|
|
|
|
|
|
window.requestAnimationFrame(function () {
|
|
|
|
|
|
update()
|
|
|
|
|
|
ticking = false
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
|
|
|
|
window.addEventListener('resize', onScroll)
|
|
|
|
|
|
update()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-25 12:18:52 +08:00
|
|
|
|
/* ── ③ 侧边目录:展开 / 收起 ─────────────────────────────
|
|
|
|
|
|
* 目录内容本身(链接、href、锚点)全部由 PHP 输出,浏览器不点也能
|
|
|
|
|
|
* 跳(每个 <a> 都是真链接)。这个函数只负责"把面板滑出来"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 默认展开/收起的状态**已经写在 HTML 的 class 上**(is-open),
|
|
|
|
|
|
* 所以 JS 挂了也只是不能切换,不会把"默认显示"变成"什么都没有"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function bindRail () {
|
|
|
|
|
|
var rails = document.querySelectorAll('.yang-toc-rail')
|
|
|
|
|
|
if (!rails.length) return
|
|
|
|
|
|
|
|
|
|
|
|
[].forEach.call(rails, function (rail) {
|
|
|
|
|
|
var tab = rail.querySelector('.yang-toc-rail-tab')
|
|
|
|
|
|
var panel = rail.querySelector('.yang-toc-rail-panel')
|
|
|
|
|
|
var closeBtn = rail.querySelector('.yang-toc-rail-close')
|
|
|
|
|
|
if (!tab || !panel) return
|
|
|
|
|
|
if (rail.getAttribute('data-bound') === '1') return
|
|
|
|
|
|
rail.setAttribute('data-bound', '1')
|
|
|
|
|
|
|
|
|
|
|
|
function isOpen () {
|
|
|
|
|
|
return rail.classList.contains('is-open')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setOpen (open) {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
rail.classList.add('is-open')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rail.classList.remove('is-open')
|
|
|
|
|
|
}
|
|
|
|
|
|
tab.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
|
|
|
|
rail.setAttribute('data-state', open ? 'open' : 'closed')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tab.addEventListener('click', function (ev) {
|
|
|
|
|
|
ev.preventDefault()
|
|
|
|
|
|
setOpen(!isOpen())
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/* 点 × 收起后把焦点还给标签 —— 否则焦点留在被隐藏的面板里,
|
|
|
|
|
|
键盘用户会"走丢"(Tab 下一个跑到页面开头)。 */
|
|
|
|
|
|
if (closeBtn) {
|
|
|
|
|
|
closeBtn.addEventListener('click', function () {
|
|
|
|
|
|
setOpen(false)
|
|
|
|
|
|
tab.focus()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 点了目录项:宽屏上留着面板方便继续翻(用户多半还要点下一节),
|
|
|
|
|
|
窄屏上它正好盖在刚跳到的正文上,必须收起来。 */
|
|
|
|
|
|
panel.addEventListener('click', function (ev) {
|
|
|
|
|
|
var a = ev.target.closest ? ev.target.closest('.yang-toc-link') : null
|
|
|
|
|
|
if (!a) return
|
|
|
|
|
|
if (window.innerWidth < 1200) {
|
|
|
|
|
|
setOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', function (ev) {
|
|
|
|
|
|
// Esc 收起。只处理 Esc,别拦别的键(会和浏览器快捷键打架)
|
|
|
|
|
|
if ((ev.key === 'Escape' || ev.key === 'Esc') && isOpen()) {
|
|
|
|
|
|
setOpen(false)
|
|
|
|
|
|
tab.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/* 点面板外面收起。用 document 上的冒泡而不是遮罩层:
|
|
|
|
|
|
加一层半透明遮罩会挡住正文,目录只是辅助导航,不该锁住页面。 */
|
|
|
|
|
|
document.addEventListener('click', function (ev) {
|
|
|
|
|
|
if (!isOpen()) return
|
|
|
|
|
|
if (rail.contains(ev.target)) return
|
|
|
|
|
|
setOpen(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── ④ 带 #hash 直接进入页面时,校正落点 ──────────────────
|
2026-10-24 01:03:36 +08:00
|
|
|
|
* 浏览器原生的锚点跳转发生在页面加载早期,那时图片还没加载完、
|
|
|
|
|
|
* CSS 也可能没应用完,落点常常偏上或偏下(尤其是有图片的页面)。
|
|
|
|
|
|
* 这里在 load 之后再做一次校正。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 只在"真的带 hash"时执行,避免正常浏览被莫名滚动。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function fixHashOnLoad () {
|
|
|
|
|
|
if (!window.location.hash) return
|
|
|
|
|
|
var el = findTarget(window.location.hash)
|
|
|
|
|
|
if (!el) return
|
|
|
|
|
|
// 用户已经自己滚动过了就别硬拉回去
|
|
|
|
|
|
if (window.pageYOffset > 40) return
|
|
|
|
|
|
scrollToEl(el)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function init () {
|
|
|
|
|
|
bindClick()
|
|
|
|
|
|
bindScrollSpy()
|
2026-10-25 12:18:52 +08:00
|
|
|
|
bindRail()
|
2026-10-24 01:03:36 +08:00
|
|
|
|
fixHashOnLoad()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', init)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
init()
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('load', fixHashOnLoad)
|
|
|
|
|
|
}())
|