Files
wordpress-kratos-yang/assets/js/toc.js
T
laoyang cbe0f2dc59 3.2.18: 清明按节气换算、主题内 [TOC] 目录、重建评论区替代 wpDiscuz
三项功能:

1. 清明不再用固定 04-04
   清明是节气,日期在 4/4~4/6 之间浮动。硬编码 04-04 实测 1900-2100
   共 201 年里只有 76 年是对的(约 38%)。改为内嵌逐年表换算真实日期,
   超出 1900-2100 返回 null 明确放弃,不退化成近似值。
   兼容 3.2.17 及以前勾过的 '04-04' 键,升级后勾选不会被静默丢弃。

2. 主题内实现 [TOC]
   文章里写 [TOC],该位置输出本文目录,点击跳到对应标题。
   在 the_content 优先级 9 处理(早于 wpautop 10 / do_shortcode 11),
   保证"补 id"与"扫目录"用的是同一份文本,href 与 id 必然对得上。
   嵌套用深度归一化,h1→h3 跳级不会算错层级。
   scroll-margin-top 声明式让位,原生锚点/外部 #hash/后退全都生效。

3. 重建评论区
   旧实现的问题:列表渲染与 AJAX 渲染各写一份 HTML,已经分叉
   (AJAX 返回的新评论没有回复按钮);<li> 被 <div> 包着,嵌套回复错位。
   现在统一到 yang_comment_html() 单一渲染函数,列表与 AJAX 只差一个
   is-new 类。同时修掉三处真实缺陷:
     · 评论项用 flex 会把 <ol class="yang-children"> 当成第三个 flex 项,
       子评论跑到父评论右侧、正文被挤到 156px、页面横向溢出 75px —— 改 grid
     · 成功提示在 exitReplyMode() 之前设置,被其末尾的 setStatus('','')
       擦掉,用户提交成功却看不到反馈 —— 调整顺序
     · 窄屏媒体查询打不过深层选择器(媒体查询不增加特异性),
       嵌套头像反向变大 —— 补深层覆盖

   与 wpDiscuz 解耦:邮件模块不再无条件 return false(那等于永久关掉
   notify_postauthor 且不报错);插件在场时不加载主题评论资源,
   避免双份表单互抢。

验证:PHP 语法 73/73;_static_qingming 36、_test_toc 53、
_test_comments 62、_test_comments_falsify 17、
_toc_geometry 121、_toc_geometry_falsify 9、
_comments_geometry 98、_comments_geometry_falsify 23,全部 0 失败。
线上端到端 29/0(真实文章渲染目录 + 真实评论数据渲染)。
2026-10-24 01:03:36 +08:00

206 lines
6.7 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。这个文件只做两件锦上添花的事:
* ① 平滑滚动(不支持的浏览器直接跳,不影响功能)
* ② 高亮当前阅读位置的条目
* 所以这里所有增强都必须能安全失败 —— 任何异常都不该让跳转失效。
*
* 为什么不用 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 () {
var toc = document.querySelector('.yang-toc')
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()
}
/* ── ③ 带 #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()
fixHashOnLoad()
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
window.addEventListener('load', fixHashOnLoad)
}())