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(真实文章渲染目录 + 真实评论数据渲染)。
This commit is contained in:
2026-10-24 01:03:36 +08:00
parent 4beb09307c
commit cbe0f2dc59
14 changed files with 3577 additions and 192 deletions
+206
View File
@@ -0,0 +1,206 @@
/*!
* 文章目录(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)
}())