Files
wordpress-kratos-yang/assets/js/toc.js
T
laoyang 6f51d50a58 3.2.30: 文章目录进后台(新增「自动目录」标签页)—— 位置/插入点/默认展开可配,手写 [TOC] 永远优先
后台「主题设置」新增第 5 个标签页「自动目录」,7 个选项:
  目录位置   正文中间 / 页面左侧(浮动)/ 页面右侧(浮动)
  添加方式   手动(只认 [TOC])/ 自动
  自动插入位置  文章开头 / 第一个 H1、H2 之前 / 文章结尾
  默认状态   默认显示 / 默认隐藏
  另外:目录标题、收录层级(H1~H4)、是否显示序号

设计要点:
- 插入方式拆成 toc_mode(manual/auto) x toc_anchor(start/first/end) 两个正交
  字段,不合成一个四值下拉:侧栏模式下插入位置毫无意义,合并会产生歧义。
- 手写 [TOC] 绝对优先:yang_toc_plan() 里 $has_marker 最先返回,inline_at
  ='marker' 且 rail=false —— 否则同一篇会出现两份目录,老文章升级后突然多
  出一个目录。这条是刻意守住的。
- 纯函数 + 薄壳分层:normalize_settings / plan / inject / build_list 全部不查库,
  单进程可喂几十种组合;settings() 只负责读库 + 调纯函数 + static 缓存。
- <details open> 是唯一可靠的「默认收起」手段:展开态不接受 CSS 覆盖,只能
  由 open 属性决定,所以隐藏时必须不输出该属性。
- 侧栏收起态必须 translateX(±100%) 完全移出视口:只做 opacity 会让透明面板
  留在原位吃掉正文点击(探针 elementFromPoint 才抓得到)。
- yang_toc_rail_ready() 开关:侧栏在 wp_footer 输出,那时正文可能没走
  the_content(缓存插件/OEmbed),标题没 id ⇒ 侧栏链接全跳不动却看起来正常。
  宁可不出侧栏。
- 后台联动隐藏「插入位置」不用 jQuery show()/hide():会把 .section 的行内
  display 写成 block,悄悄回退 3.2.28 的 display:flow-root BFC 修复。改用
  class + display:none!important。

验证(全部在本轮重跑):
  _test_toc.php       53/0   旧 TOC 行为回归
  _toc330_logic.php   88/0   逻辑判据
  _toc330_falsify     13/13  注入全变红且红对地方
  _geo330_rail.js     73/0   侧栏真实几何(左/右 x 展开/收起 x 宽窄屏)
  _geo330_falsify     8/8
  _guard330.php       46/0   后台控件与运行时默认值双向一致
  _guard330_falsify   8/8
  _admin330_tab_check 36/0   新标签页几何 + 联动 + 载入态
  _admin330_falsify   8/8
  _admin328_check     13/0   3.2.28 布局回归

发布:只发 zip + readme + update.json 到 theme-update/,不动服务器主题目录,
用户在 WP 后台自行升级(沿用 3.2.24 起的策略)。
2026-10-25 12:18:52 +08:00

288 lines
9.8 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.
/*!
* 文章目录(TOC)交互
* Yongquan Yang <yangyq.net>
*
* ── 设计原则:没有 JS 也要能用 ────────────────────────────────
* 目录的**核心功能是跳转**,那由 <a href="#id"> + CSS 的
* scroll-margin-top 完成,完全不需要 JS。这个文件只做三件锦上添花的事:
* ① 平滑滚动(不支持的浏览器直接跳,不影响功能)
* ② 高亮当前阅读位置的条目
* ③ 侧边目录面板的展开 / 收起(默认状态由 HTML class 决定,
* JS 不参与"初始长什么样",只负责切换)
* 所以这里所有增强都必须能安全失败 —— 任何异常都不该让跳转失效。
*
* 为什么不用 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 () {
/* 目录可能在两处:正文里的 .yang-toc,或侧边的 .yang-toc-rail-panel。
两处不会同时出现(正文有手写标记时不出侧栏),所以取第一个找到的
就够;用逗号选择器而不是两个 if,是为了将来加第三种形态时
不用再改这里。 */
var toc = document.querySelector('.yang-toc, .yang-toc-rail-panel')
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()
}
/* ── ③ 侧边目录:展开 / 收起 ─────────────────────────────
* 目录内容本身(链接、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 直接进入页面时,校正落点 ──────────────────
* 浏览器原生的锚点跳转发生在页面加载早期,那时图片还没加载完、
* 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()
bindRail()
fixHashOnLoad()
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
window.addEventListener('load', fixHashOnLoad)
}())