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
+893
View File
@@ -0,0 +1,893 @@
/*!
* 评论区样式
* Yongquan Yang <yangyq.net>
*
* ── 为什么整块重写,而不是在旧样式上改 ──────────────────────
* 旧样式的骨架是「头像 float:left + 右侧 margin-left 挤出来」,
* 这个结构在三个地方会坏:
* · 长昵称/长网址会把头部撑破(float 不定宽容器里没有约束);
* · 嵌套回复靠 .children { padding-left:0 } 实现,等于**没有缩进**,
* 三层以后完全看不出谁回谁;
* · 头像 50px 写死在 img 上,但没有 flex 居中,头像和第一行文字
* 基线对不齐(看起来"文字比头像低一点")。
*
* 所以改用 flex 两列布局:头像固定宽、主体 flex:1,
* 并把嵌套缩进做成明确的层级视觉(左侧引导线)。
*/
/*--------------------------------------------------------------
1. 区块与标题
--------------------------------------------------------------*/
.k-main .details .comments {
margin-top: 16px;
padding: 20px 22px 22px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.k-main .details .post-2 .comments {
box-shadow: none;
}
.comments-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #f0f0f0;
}
.comments-head .title {
margin: 0;
color: #1f2328;
font-weight: 600;
font-size: 19px;
line-height: 1.4;
}
.comments-jump-form {
flex: 0 0 auto;
padding: 5px 13px;
border: 1px solid #d9e4f5;
border-radius: 3px;
background-color: #f4f8ff;
color: #0b74d1;
font-size: 13px;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.comments-jump-form:hover {
border-color: #0b74d1;
background-color: #e8f1ff;
color: #0b74d1;
text-decoration: none;
}
.comments-closed,
.comments-empty {
margin: 20px 0 4px;
padding: 14px 16px;
border-radius: 3px;
background-color: #fafbfc;
color: #8a9099;
font-size: 13.5px;
text-align: center;
}
/*--------------------------------------------------------------
2. 评论列表
--------------------------------------------------------------*/
.yang-comment-list,
.yang-children {
margin: 0;
padding: 0;
list-style: none;
}
.yang-comment-list {
margin-top: 4px;
}
/* 顶层评论之间用细分隔线;嵌套评论之间不用,靠缩进区分 */
.yang-comment-list > .yang-comment {
border-bottom: 1px solid #f5f6f8;
}
.yang-comment-list > .yang-comment:last-child {
border-bottom: 0;
}
/*--------------------------------------------------------------
3. 单条评论
--------------------------------------------------------------*/
/* 单条评论的布局:头像列 + 主体列
*
* ⚠️ 这里**必须是 grid,不能是 flex**。
* 原因:wp_list_comments 输出的是
* <li class="yang-comment">
* <div class="yang-comment-avatar">…</div>
* <div class="yang-comment-main">…</div>
* <ol class="yang-children">…子评论…</ol> ← 也是 li 的直接子元素
* </li>
* 若 li 用 display:flex,那个 <ol> 就成了**第三个 flex 项**,
* 被排到主体右边的同一行里 —— 表现为"子评论跑到父评论右侧"、
* 父评论正文被挤成一条、页面出现横向溢出。这是真实渲染量出来的,
* 光看代码几乎想不到(flex 项的位置不体现在 li 自己的属性上)。
*
* grid 用两列:第 1 列 auto(由头像定宽),第 2 列 minmax(0,1fr)
* (minmax 的下界 0 是关键,否则长英文/长 URL 会把列撑宽)。
* 子评论用 grid-column: 1 / -1 独占下一整行。 */
.yang-comment {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 12px;
align-items: start;
padding: 16px 0;
list-style: none;
}
/* 子评论容器独占整行,排在头像与主体下方 */
.yang-comment > .yang-children {
grid-column: 1 / -1;
}
/* 头像列:固定宽度,不参与伸缩 —— 否则长评论把它挤扁 */
.yang-comment-avatar {
flex: 0 0 auto;
width: 42px;
line-height: 0;
}
.yang-comment-avatar img,
.yang-comment-avatar-img {
display: block;
width: 42px;
height: 42px;
border: 1px solid #eaeaea;
border-radius: 50%;
object-fit: cover;
background-color: #f5f5f5;
}
/* 没有头像时的首字占位 */
.yang-comment-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
background-color: #eef2f7;
color: #7c8794;
font-size: 17px;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
}
/* 主体列 */
.yang-comment-main {
flex: 1 1 auto;
min-width: 0; /* 关键:让内部的 text-overflow / 换行能生效 */
}
.yang-comment-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.yang-comment-author {
color: #252525;
font-weight: 600;
font-style: normal;
font-size: 14px;
line-height: 1.5;
/* 超长昵称截断,别把时间挤到下一行去 */
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yang-comment-author a {
color: #252525;
text-decoration: none;
}
.yang-comment-author a:hover {
color: #00a2ff;
}
.yang-comment-time {
flex: 0 0 auto;
color: #a4abb5;
font-size: 12.5px;
line-height: 1.5;
}
/* 徽标:作者 / 待审核 */
.yang-comment-badge {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 3px;
background-color: #eaf3ff;
color: #0b74d1;
font-size: 11.5px;
font-weight: 400;
line-height: 1.5;
}
.yang-comment-badge.is-pending {
background-color: #fff6e5;
color: #b27412;
}
/* 待审核的整条评论淡化,一眼区分 */
.yang-comment.is-pending .yang-comment-content {
opacity: 0.72;
}
/* 评论正文 */
.yang-comment-content {
color: #3c414a;
font-size: 14px;
line-height: 1.78;
word-wrap: break-word;
overflow-wrap: break-word;
}
.yang-comment-content > p:first-child {
margin-top: 0;
}
.yang-comment-content > p:last-child {
margin-bottom: 0;
}
.yang-comment-content p {
margin: 0 0 8px;
}
/* @某人 的提及高亮(由 comment_add_at 过滤器插入的 <span>) */
.yang-comment-content > span:first-child {
color: #0b74d1;
font-weight: 500;
}
/* 评论里的图片:限制最大宽度,别撑破容器 */
.yang-comment-content img {
max-width: 100%;
height: auto;
}
/* 引用块 */
.yang-comment-content blockquote {
margin: 8px 0;
padding: 6px 12px;
border-left: 3px solid #e2e6ee;
background-color: #fafbfc;
color: #6b7280;
font-size: 13.5px;
}
.yang-comment-content code {
padding: 1px 5px;
border-radius: 3px;
background-color: #f4f5f7;
color: #c7254e;
font-size: 13px;
}
.yang-comment-content pre {
overflow-x: auto;
padding: 10px 12px;
border-radius: 3px;
background-color: #f6f7f9;
font-size: 13px;
}
/* 表情图片与正文基线对齐 */
.yang-comment-content img.wp-smiley {
display: inline-block;
margin: 0 1px;
width: auto;
max-height: 1.4em;
vertical-align: text-bottom;
}
/*--------------------------------------------------------------
4. 操作按钮
--------------------------------------------------------------*/
.yang-comment-actions {
display: flex;
align-items: center;
gap: 14px;
margin-top: 6px;
}
.yang-comment-reply,
.yang-comment-edit {
padding: 0;
border: 0;
background: none;
color: #8a9099;
font-size: 13px;
line-height: 1.6;
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease;
}
.yang-comment-reply:hover,
.yang-comment-edit:hover {
color: #00a2ff;
text-decoration: none;
}
.yang-comment-reply .kicon {
margin-right: 3px;
font-size: 12px;
}
/* 已进入"回复这条"状态时,按钮藏起来(由 JS 加 class) */
.yang-comment-reply.is-hidden {
visibility: hidden;
}
/*--------------------------------------------------------------
5. 嵌套回复
--------------------------------------------------------------*/
.yang-children {
margin-top: 2px;
padding-left: 20px;
/* 左侧引导线:让"谁回谁"在视觉上有明确归属 */
border-left: 2px solid #f0f2f5;
}
.yang-children > .yang-comment {
padding: 11px 0;
}
/* 深层的头像小一号,靠尺寸建立层级感 */
.yang-children .yang-children .yang-comment-avatar,
.yang-children .yang-children .yang-comment-avatar img,
.yang-children .yang-children .yang-comment-avatar-ph {
width: 34px;
}
.yang-children .yang-children .yang-comment-avatar {
width: 34px;
}
.yang-children .yang-children .yang-comment-avatar img {
height: 34px;
}
.yang-children .yang-children .yang-comment-avatar-ph {
height: 34px;
font-size: 14px;
}
/* 第三层以后不再继续缩进 —— 否则窄屏上正文只剩一条竖线 */
.yang-children .yang-children .yang-children {
padding-left: 8px;
}
/* 新提交的评论:淡入 + 短暂底色,让用户看到自己刚发的那条 */
@keyframes yang-comment-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: none;
}
}
.yang-comment.is-new {
animation: yang-comment-in 0.28s ease;
background-color: #fffdf5;
}
/*--------------------------------------------------------------
6. 加载更多
--------------------------------------------------------------*/
.comments-more {
padding: 16px 0 4px;
text-align: center;
}
.comments-more[hidden] {
display: none;
}
.comments-more-btn {
padding: 8px 26px;
border: 1px solid #e2e6ee;
border-radius: 3px;
background-color: #fff;
color: #5a616b;
font-size: 13.5px;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease;
}
.comments-more-btn:hover:not(:disabled) {
border-color: #00a2ff;
background-color: #f5fbff;
color: #00a2ff;
}
.comments-more-btn:disabled {
opacity: 0.6;
cursor: default;
}
/*--------------------------------------------------------------
7. 评论表单
--------------------------------------------------------------*/
.comment-respond {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
/* 表单被"搬"进某条评论时,去掉那条顶部分隔线,视觉上更连贯 */
.yang-children > #respond {
margin-top: 10px;
padding-top: 12px;
border-top: 1px dashed #e8ebf0;
}
.comment-reply-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin: 0 0 14px;
color: #1f2328;
font-weight: 600;
font-size: 16px;
line-height: 1.5;
}
.comment-cancel-reply {
padding: 3px 10px;
border: 1px solid #e4e8ee;
border-radius: 3px;
background-color: #f7f8fa;
color: #6b7280;
font-weight: 400;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
}
.comment-cancel-reply[hidden] {
display: none;
}
.comment-cancel-reply:hover {
border-color: #f0b4b4;
background-color: #fff5f5;
color: #d9534f;
}
/* 「正在回复 @某人」提示条 */
.comment-reply-hint {
margin: 0 0 12px;
padding: 7px 12px;
border-left: 3px solid #00a2ff;
border-radius: 2px;
background-color: #f2f8ff;
color: #4a6b8a;
font-size: 13px;
line-height: 1.6;
}
.comment-reply-hint[hidden] {
display: none;
}
/* 昵称/邮箱/网址:三列,窄屏自动堆叠 */
.comment-info {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.comment-field {
position: relative;
flex: 1 1 180px;
min-width: 0;
}
.comment-field-icon {
position: absolute;
left: 9px;
top: 50%;
transform: translateY(-50%);
color: #b6bcc6;
font-size: 14px;
line-height: 1;
pointer-events: none;
}
.comment-input {
display: block;
width: 100%;
padding: 9px 12px;
border: 1px solid #e6e9ef;
border-radius: 3px;
background-color: #fcfcfd;
color: #303133;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
-webkit-appearance: none;
}
.comment-field .comment-input {
padding-left: 32px;
}
.comment-input::placeholder {
color: #b0b6bf;
}
.comment-input:focus {
outline: none;
border-color: #00a2ff;
background-color: #fff;
box-shadow: 0 0 0 3px rgba(0, 162, 255, 0.1);
}
.comment-input:disabled,
.comment-input[readonly] {
background-color: #f7f8fa;
color: #8a9099;
}
/* textarea 容器 */
.comment-textarea {
position: relative;
border: 1px solid #e6e9ef;
border-radius: 3px;
background-color: #fcfcfd;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.comment-textarea:focus-within {
border-color: #00a2ff;
box-shadow: 0 0 0 3px rgba(0, 162, 255, 0.1);
}
.comment-textarea .comment-input {
min-height: 132px;
padding: 11px 13px;
border: 0;
border-radius: 0;
background: none;
resize: vertical;
box-shadow: none;
}
.comment-textarea .comment-input:focus {
box-shadow: none;
}
/* 工具条 */
.comment-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 10px;
border-top: 1px solid #f0f2f6;
background-color: #fafbfc;
}
.comment-bar-left,
.comment-bar-right {
display: flex;
align-items: center;
gap: 8px;
}
.comment-bar-left {
position: relative;
}
.comment-smile-btn {
padding: 4px 7px;
border: 0;
border-radius: 3px;
background: none;
color: #8a9099;
font-size: 17px;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.comment-smile-btn:hover {
background-color: #eef2f7;
color: #00a2ff;
}
/* 表情面板:3 行 8 列 */
.comment-smile-panel {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
width: 264px;
padding: 8px;
border: 1px solid #e6e9ef;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
}
.comment-smile-panel[hidden] {
display: none;
}
.comment-smile-panel a {
float: none;
display: block;
margin: 2px;
padding: 3px;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 3px;
background-color: #f7f8fa;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.comment-smile-panel a:hover {
border-color: #b9dcff;
background-color: #eaf4ff;
}
.comment-smile-panel img {
display: block;
width: 20px;
height: 20px;
}
/* 状态提示 */
.comment-status {
color: #8a9099;
font-size: 12.5px;
line-height: 1.6;
transition: color 0.15s ease;
}
.comment-status.is-ok {
color: #2f9e44;
}
.comment-status.is-err {
color: #d9534f;
}
/* 提交按钮 */
.comment-submit {
padding: 8px 22px;
border: 0;
border-radius: 3px;
background-color: #00a2ff;
color: #fff;
font-size: 14px;
line-height: 1.5;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.15s ease, opacity 0.15s ease;
}
.comment-submit:hover:not(:disabled) {
background-color: #0090e6;
}
.comment-submit:disabled,
.comment-submit.is-loading {
opacity: 0.62;
cursor: default;
}
/* honeypot:移出视口而不是 display:none(display:none 的字段部分爬虫不填) */
.comment-hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
/* 只需登录才能评论的提示 */
.comments-login-required {
margin: 0;
padding: 18px;
border: 1px solid #e5e5e5;
border-radius: 4px;
background-color: #fafbfc;
color: #6b7280;
font-size: 13.5px;
text-align: center;
}
.comments-login-required a {
color: #00a2ff;
text-decoration: none;
}
/*--------------------------------------------------------------
8. 无障碍:屏幕阅读器专用文本
--------------------------------------------------------------*/
.k-main .details .comments .sr-only {
position: absolute;
overflow: hidden;
clip: rect(0, 0, 0, 0);
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: -1px;
white-space: nowrap;
}
/*--------------------------------------------------------------
9. 响应式
--------------------------------------------------------------*/
@media screen and (max-width: 768px) {
.k-main .details .comments {
padding: 16px 14px 18px;
}
.comments-head {
flex-wrap: wrap;
gap: 8px;
}
.comments-head .title {
font-size: 17px;
}
.yang-comment {
/* 只收窄列间距;写 gap 会同时给 grid 的 row-gap 赋值,
导致"主体"与"子评论"之间多出一道空隙 */
column-gap: 10px;
padding: 13px 0;
}
.yang-comment-avatar,
.yang-comment-avatar img,
.yang-comment-avatar-img,
.yang-comment-avatar-ph {
width: 36px;
}
.yang-comment-avatar img,
.yang-comment-avatar-img,
.yang-comment-avatar-ph {
height: 36px;
}
/* 窄屏缩进收窄:原来 20px 在三层后正文只剩很窄一条 */
.yang-children {
padding-left: 11px;
}
.yang-children .yang-children {
padding-left: 8px;
}
.yang-children .yang-children .yang-children {
padding-left: 0;
border-left: 0;
}
.comment-info {
flex-direction: column;
gap: 8px;
}
.comment-field {
flex: 1 1 auto;
}
.comment-bar {
flex-wrap: wrap;
gap: 8px;
}
.comment-bar-right {
flex: 1 1 100%;
justify-content: space-between;
}
.comment-status {
flex: 1 1 auto;
min-width: 0;
}
.comment-smile-panel {
width: 240px;
}
}
@media screen and (max-width: 400px) {
.yang-comment-avatar,
.yang-comment-avatar img,
.yang-comment-avatar-img,
.yang-comment-avatar-ph {
width: 32px;
}
.yang-comment-avatar img,
.yang-comment-avatar-img,
.yang-comment-avatar-ph {
height: 32px;
}
/* ⚠️ 必须一并覆盖深层头像。
上面的基础规则里有 `.yang-children .yang-children .yang-comment-avatar
{ width: 34px }`,它选择器特异性 (0,3,0) 高于这里的 `.yang-comment-avatar`
(0,1,0) —— 媒体查询**不增加**特异性。所以只写这一条的话,窄屏下
深层头像仍是 34px,反而比顶层的 32px 更大,层级关系是反的。 */
.yang-children .yang-children .yang-comment-avatar,
.yang-children .yang-children .yang-comment-avatar img,
.yang-children .yang-children .yang-comment-avatar-ph {
width: 26px;
}
.yang-children .yang-children .yang-comment-avatar img,
.yang-children .yang-children .yang-comment-avatar-ph {
height: 26px;
font-size: 12px;
}
.comment-smile-panel {
width: 216px;
}
}
/*--------------------------------------------------------------
10. 打印:评论不需要按钮
--------------------------------------------------------------*/
@media print {
.comments-jump-form,
.yang-comment-actions,
.comments-more,
.comment-respond {
display: none !important;
}
.yang-comment-content {
color: #000;
}
}
+303
View File
@@ -0,0 +1,303 @@
/*!
* 文章目录(TOC)样式
* Yongquan Yang <yangyq.net>
*
* ── 设计原则 ────────────────────────────────────────────────
* 1. **不抢正文的视觉**:目录是辅助导航,不是内容。所以用浅灰背景、
* 细边框、小字号,让它在页面上"退后一步"。
* 2. **用主题主色**:强调色统一为 #00a2ff,与主题其他强调元素一致。
* 注意主题**没有**定义 CSS 变量(主色是各处硬编码的),所以这里
* 直接写字面值,不要臆造 var(--kratos-primary) 之类的变量 —— 那会
* 静默失效,目录永远是默认灰色,且看不出是哪里错了。
* 3. **移动端友好**:窄屏下缩进减小、字号略小,但不折叠成下拉 ——
* 目录本来就是给长文用的,手机上更需要它。
*/
.yang-toc {
margin: 24px 0;
border: 1px solid #ebeef5;
border-left: 3px solid #00a2ff;
border-radius: 3px;
background-color: #fafbfc;
font-size: 14px;
line-height: 1.6;
}
/* <details> 的默认三角标记去掉,用自己的图标 */
.yang-toc > summary {
list-style: none;
cursor: pointer;
user-select: none;
}
.yang-toc > summary::-webkit-details-marker {
display: none;
}
.yang-toc > summary::marker {
content: "";
}
.yang-toc-head {
display: flex;
align-items: center;
padding: 11px 16px;
color: #303133;
font-weight: 600;
font-size: 15px;
transition: color 0.15s ease;
}
.yang-toc-head:hover {
color: #00a2ff;
}
/* 折叠箭头:用 CSS 画的三角,不依赖图标字体或 SVG 文件 */
.yang-toc > summary .yang-toc-title::before {
content: "";
display: inline-block;
width: 0;
height: 0;
margin-right: 8px;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 6px solid currentColor;
vertical-align: middle;
transition: transform 0.18s ease;
}
/* 展开时箭头朝下(旋转 90°) */
.yang-toc[open] > summary .yang-toc-title::before {
transform: rotate(90deg);
}
/* 不可折叠时(toggle=0)不显示箭头 */
.yang-toc:not(details) .yang-toc-head .yang-toc-title::before {
display: none;
}
.yang-toc-title {
flex: 0 0 auto;
}
/* 条目数徽标 */
.yang-toc-count {
flex: 0 0 auto;
margin-left: 8px;
padding: 1px 7px;
border-radius: 9px;
background-color: #eef1f6;
color: #909399;
font-weight: 400;
font-size: 12px;
line-height: 1.5;
}
.yang-toc-body {
padding: 0 16px 12px;
}
.yang-toc-list {
margin: 0;
padding-left: 0;
list-style: none;
}
/* 二级以上的列表缩进,并加一条引导线 */
.yang-toc-list .yang-toc-list {
margin-left: 9px;
padding-left: 14px;
border-left: 1px dashed #e2e6ee;
}
.yang-toc-item {
margin: 0;
padding: 0;
list-style: none;
}
.yang-toc-link {
display: flex;
align-items: baseline;
padding: 4px 6px;
border-radius: 3px;
color: #4a4f57;
text-decoration: none;
transition: color 0.15s ease, background-color 0.15s ease;
}
.yang-toc-link:hover,
.yang-toc-link:focus {
background-color: #eef4ff;
color: #00a2ff;
text-decoration: none;
}
/* 序号 */
.yang-toc-num {
flex: 0 0 auto;
min-width: 1.9em;
margin-right: 5px;
color: #a8abb2;
font-variant-numeric: tabular-nums;
font-size: 13px;
}
.yang-toc-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
/* 层级视觉差异:越深越淡、字号越小 */
.yang-toc-lv3 > .yang-toc-link {
color: #6b7280;
font-size: 13.5px;
}
.yang-toc-lv4 > .yang-toc-link,
.yang-toc-lv5 > .yang-toc-link,
.yang-toc-lv6 > .yang-toc-link {
color: #8b9099;
font-size: 13px;
}
/* 当前阅读位置的条目高亮(JS 加 .is-active) */
.yang-toc-link.is-active {
background-color: #eef4ff;
color: #00a2ff;
font-weight: 600;
}
/* ── 标题锚点:点击跳转后被固定导航栏挡住的问题 ────────────────
* 主题的 .k-nav 是 fixed-top,滚动后固定约 67px 高。
* 纯 CSS 方案是给目标元素加 scroll-margin-top —— 浏览器原生支持,
* 且不依赖 JS(用 #anchor 直接访问、前进后退、外部链接进来都生效)。
*
* 为什么不用 JS 算偏移:JS 方案只在"点了目录"的情况下有效,
* 从外部带 #hash 进来、或按浏览器后退回到锚点时都不会触发。
* scroll-margin-top 是声明式的,所有情况自动生效。
*
* 67px 是导航栏高度;再加 16px 呼吸空间。管理工具栏(admin bar)
* 存在时再往上加,否则锚点会被工具栏盖住。
*/
.yang-toc-target,
.k-main .details .content h1[id],
.k-main .details .content h2[id],
.k-main .details .content h3[id],
.k-main .details .content h4[id],
.k-main .details .content h5[id],
.k-main .details .content h6[id] {
scroll-margin-top: 84px;
}
body.admin-bar .k-main .details .content h1[id],
body.admin-bar .k-main .details .content h2[id],
body.admin-bar .k-main .details .content h3[id],
body.admin-bar .k-main .details .content h4[id],
body.admin-bar .k-main .details .content h5[id],
body.admin-bar .k-main .details .content h6[id] {
scroll-margin-top: 116px;
}
/* 滚动到锚点时轻微闪一下,让用户确认"跳到这里了" */
@keyframes yang-toc-flash {
0%,
100% {
background-color: transparent;
}
30% {
background-color: #fffbe6;
}
}
.yang-toc-flash {
animation: yang-toc-flash 1.1s ease;
}
/* ── 窄屏 ── */
@media screen and (max-width: 768px) {
.yang-toc {
margin: 18px 0;
font-size: 13.5px;
}
.yang-toc-head {
padding: 10px 13px;
font-size: 14px;
}
.yang-toc-body {
padding: 0 10px 10px;
}
.yang-toc-list .yang-toc-list {
margin-left: 4px;
padding-left: 10px;
}
.yang-toc-link {
padding: 5px 5px;
}
.yang-toc-lv3 > .yang-toc-link {
font-size: 13px;
}
}
/* ── 打印:目录展开、去背景、不折行 ────────────────────────── */
@media print {
.yang-toc {
border: 1px solid #ccc;
background: none;
page-break-inside: avoid;
}
.yang-toc-count {
display: none;
}
.yang-toc-link {
color: #000;
}
}
/* ── 暗色主题适配 ──────────────────────────────────────────
* 主题没有内置暗色模式,但用户可能在「自定义 CSS」里加
* prefers-color-scheme。这里跟随同一个媒体查询,避免目录
* 在暗色下变成一块刺眼的白板。
*/
@media (prefers-color-scheme: dark) {
html.yang-dark .yang-toc {
border-color: #303640;
border-left-color: #00a2ff;
background-color: #1d2026;
}
html.yang-dark .yang-toc-head {
color: #d5d9e0;
}
html.yang-dark .yang-toc-count {
background-color: #2a2f38;
color: #8b929d;
}
html.yang-dark .yang-toc-link {
color: #b6bcc6;
}
html.yang-dark .yang-toc-link:hover,
html.yang-dark .yang-toc-link.is-active {
background-color: #252b34;
color: #00a2ff;
}
html.yang-dark .yang-toc-list .yang-toc-list {
border-left-color: #333944;
}
html.yang-dark .yang-toc-num {
color: #767d88;
}
}
+418
View File
@@ -0,0 +1,418 @@
/*!
* 评论区交互
* Yongquan Yang <yangyq.net>
*
* ── 旧版(comments.min.js)的四个真实缺陷,逐条对应修掉的点 ────
*
* 1. **错误提示直接把后端原文弹出来**
* `layer.msg(e.responseText)` —— 后端返回什么就弹什么。
* 旧后端出错时 `echo` 的是 WP_Error 的 message(可能是英文、
* 可能带 HTML、可能是空串)。用户看到的是 "ERROR: please type
* a comment" 或一片空白。→ 现在后端统一返回 { ok, message },
* 前端只读 message,空就填兜底文案。
*
* 2. **不防重复提交**
* `beforeSend` 只弹了「评论正在提交中」,按钮仍可点。
* 网络慢时连点三次就是三条重复评论(后端没做 flood 时)。
* → 现在提交期间禁用按钮并置 is-loading,finally 里恢复。
*
* 3. **新评论插错位置会丢嵌套**
* 旧代码 `e("#comment-"+c).append('<ul class="children">'+t+"</ul>")`
* 直接用 jQuery 包一层。若该父评论**已有** children 容器
* (已经被回复过一次),就会变成 children 里再套一个 children,
* 层级越滚越深。→ 现在先查是否已有 children 容器,有就往里追加。
*
* 4. **提交成功后不更新评论数**
* → 现在更新页面上的评论计数(若存在对应元素)。
*
* ── 设计约束 ────────────────────────────────────────────────
* · 不依赖 jQuery、不依赖 layer。主题虽然都加载了,但这两者版本
* 随主题走,出错时整块功能会一起哑掉。原生 fetch 足够。
* · 不用 fetch 的流式读法:要读响应头和 body,用 Promise 链更直观。
* · 任何异常都必须**看得见**。静默 catch 是这类"点了没反应"问题的根源。
*/
; (function () {
'use strict'
var CFG = window.yangComment || {}
var I18N = CFG.i18n || {}
function $(sel, root) { return (root || document).querySelector(sel) }
function $$(sel, root) { return [].slice.call((root || document).querySelectorAll(sel)) }
var form = $('#commentform')
if (!form) return // 没表单(评论关闭/需登录)就什么都不做
var listEl = $('#yang-comment-list')
var statusEl = $('#yang-comment-status')
var submitBtn = $('#submit', form)
var textarea = $('#comment', form)
var replyHint = $('#yang-reply-hint')
var replyTitle = $('#yang-reply-title-text')
var cancelBtn = $('#yang-cancel-reply')
var moreWrap = $('#yang-comments-more')
var moreBtn = moreWrap ? $('.comments-more-btn', moreWrap) : null
// 当前回复的目标(0 表示顶层评论)
var replyTo = 0
var submitting = false
/* ── 状态提示 ──────────────────────────────────────────────
* 用一段内联文本而不是弹窗。type 决定配色(ok / err)。
* aria-live=polite 已在模板里设好,屏幕阅读器会读出来。 */
function setStatus (msg, type) {
if (!statusEl) return
statusEl.textContent = msg || ''
statusEl.className = 'comment-status' + (type ? ' is-' + type : '')
}
/* ── 提交态开关 ────────────────────────────────────────────
* 只改按钮和 textarea 的可交互性,不动表单数据。 */
function setSubmitting (on) {
submitting = on
if (submitBtn) {
submitBtn.disabled = on
submitBtn.classList[on ? 'add' : 'remove']('is-loading')
submitBtn.textContent = on ? (I18N.submitting || '提交中…') : (I18N.submit || '提交评论')
}
if (textarea) textarea.readOnly = on
}
/* ── 回复模式切换 ──────────────────────────────────────────
* 点「回复」→ 把表单搬到该评论下方 + 更新标题 + 显示取消按钮。
* 取消 → 表单搬回原位置 + 清状态。
*
* 为什么要记住"原位置":表单本来在 #respond 里,被搬走后
* #respond 变成空壳。取消时若直接 appendChild 回去,
* 会和评论区里的其它元素顺序错乱 —— 所以留一个占位锚点。 */
var originalAnchor = document.createComment('yang-respond-anchor')
var respondBox = $('#respond')
if (respondBox && respondBox.parentNode) {
respondBox.parentNode.insertBefore(originalAnchor, respondBox.nextSibling)
}
function enterReplyMode (commentId, author) {
replyTo = commentId
var li = document.getElementById('comment-' + commentId)
if (li && respondBox) {
/* 插到该评论的 children 容器里(没有就建一个)。
这样表单在视觉上"长"在被回复评论的内部,
比挪到列表末尾更符合直觉。 */
var children = li.querySelector(':scope > .yang-children')
if (!children) {
children = document.createElement('ol')
children.className = 'yang-children'
li.appendChild(children)
}
children.insertBefore(respondBox, children.firstChild)
}
if (replyTitle) {
replyTitle.textContent = (I18N.replyTo || '回复') + ' @' + (author || '')
}
if (replyHint) {
replyHint.textContent = (I18N.replyTo || '回复') + ' @' + (author || '') + ' '
replyHint.hidden = false
}
if (cancelBtn) cancelBtn.hidden = false
// 隐藏被回复评论上的「回复」按钮,避免重复点击
$$('.yang-comment-reply', li || document).forEach(function (b) {
b.classList.add('is-hidden')
})
var parentInput = $( '#comment_parent', form)
if (parentInput) parentInput.value = String(commentId)
// 聚焦输入框并滚过去
if (textarea) {
textarea.focus()
try {
var top = respondBox.getBoundingClientRect().top + window.pageYOffset - 90
window.scrollTo({ top: top, behavior: 'smooth' })
} catch (e) { window.scrollTo(0, respondBox.offsetTop - 90) }
}
}
function exitReplyMode () {
replyTo = 0
var parentInput = $('#comment_parent', form)
if (parentInput) parentInput.value = '0'
if (replyTitle) replyTitle.textContent = I18N.submit ? '发表评论' : '发表评论'
if (replyHint) {
replyHint.textContent = ''
replyHint.hidden = true
}
if (cancelBtn) cancelBtn.hidden = true
$$('.yang-comment-reply.is-hidden').forEach(function (b) {
b.classList.remove('is-hidden')
})
// 表单搬回原位
if (respondBox && originalAnchor && originalAnchor.parentNode) {
originalAnchor.parentNode.insertBefore(respondBox, originalAnchor.nextSibling)
}
setStatus('', '')
}
/* ── 事件:回复 / 取消 ─────────────────────────────────── */
document.addEventListener('click', function (ev) {
var btn = ev.target.closest ? ev.target.closest('.yang-comment-reply') : null
if (btn) {
ev.preventDefault()
enterReplyMode(
parseInt(btn.getAttribute('data-comment-id'), 10) || 0,
btn.getAttribute('data-author') || ''
)
return
}
// 点到「取消回复」的另一种入口:评论里的取消链接
if (ev.target.closest && ev.target.closest('#yang-cancel-reply')) {
ev.preventDefault()
exitReplyMode()
}
})
/* ── 表情面板 ──────────────────────────────────────────────
* 点表情按钮 → 把对应代码插到光标处(不是简单追加到末尾)。
* 旧实现是 `myField.value += tag`,光标本在中间也会跑到最后,
* 用户想在某句话后面插表情就得重新点光标。 */
var smileBtn = $('#yang-smile-btn')
var smilePanel = $('#yang-smile-panel')
if (smileBtn && smilePanel) {
smileBtn.addEventListener('click', function (ev) {
ev.stopPropagation()
var open = !smilePanel.hidden
smilePanel.hidden = open
smileBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
})
document.addEventListener('click', function (ev) {
if (smilePanel.hidden) return
if (smilePanel.contains(ev.target) || smileBtn.contains(ev.target)) return
smilePanel.hidden = true
smileBtn.setAttribute('aria-expanded', 'false')
})
// 面板里的表情链接是 javascript:grin('...'),这里拦下来自己插
smilePanel.addEventListener('click', function (ev) {
var a = ev.target.closest ? ev.target.closest('a') : null
if (!a) return
var href = a.getAttribute('href') || ''
var m = href.match(/grin\('([^']+)'\)/)
if (!m) return
ev.preventDefault()
insertAtCursor(textarea, ' ' + m[1] + ' ')
smilePanel.hidden = true
smileBtn.setAttribute('aria-expanded', 'false')
})
}
function insertAtCursor (el, text) {
if (!el) return
el.focus()
// 现代浏览器
if (typeof el.setSelectionRange === 'function') {
var s = el.selectionStart || 0
var e = el.selectionEnd || 0
var v = el.value
el.value = v.slice(0, s) + text + v.slice(e)
var pos = s + text.length
el.setSelectionRange(pos, pos)
} else {
el.value += text
}
}
/* ── 提交 ──────────────────────────────────────────────────
* 用 fetch + FormData,直接把整个表单发过去(含 nonce、comment_post_ID、
* comment_parent)。这样服务端 wp_handle_comment_submission 拿到的
* 字段与普通 POST 完全一致,不需要为 AJAX 单独走一套。 */
form.addEventListener('submit', function (ev) {
ev.preventDefault()
if (submitting) return // 双击保护
// 本地前置校验:空内容不发请求(省一次往返,也避免后端 500)
if (textarea && textarea.value.trim() === '') {
setStatus(I18N.emptyComment || '评论内容不能为空。', 'err')
textarea.focus()
return
}
// honeypot:填了就是机器人,直接假装成功,不给它反馈
var hp = $('#yang_hp', form)
if (hp && hp.value !== '') {
setStatus('', '')
return
}
var fd = new FormData(form)
fd.append('action', 'ajax_comment')
setSubmitting(true)
setStatus(I18N.submitting || '提交中…', '')
fetch(CFG.ajaxUrl, {
method: 'POST',
body: fd,
credentials: 'same-origin'
// 注意:不要设 Content-Type,让浏览器自己带 multipart 边界
}).then(function (res) {
/* 关键:即使 HTTP 状态是 4xx/5xx,后端也会返回 JSON。
所以先尝试解析 JSON,解析失败才当网络/服务端异常处理。
旧实现用 jQuery 的 error 回调,拿不到结构化数据,
只能把 responseText 原文弹出来。 */
return res.json().catch(function () {
return { ok: false, message: I18N.networkErr || '网络异常,请稍后重试。' }
})
}).then(function (data) {
if (!data || !data.ok) {
var msg = (data && data.message) ? data.message : (I18N.networkErr || '提交失败,请稍后重试。')
setStatus(msg, 'err')
// 后端指明字段就聚焦过去
if (data && data.field) {
var f = document.getElementById(data.field)
if (f) f.focus()
}
return
}
insertComment(data)
// 清空输入(保留昵称邮箱)
if (textarea) textarea.value = ''
/* 顺序很重要:exitReplyMode() 内部最后会 setStatus('','')
清空状态条。若先设成功提示再退出回复模式,提示会被立刻
擦掉 —— 用户提交成功却看不到任何反馈,正是"点了没反应"
那种体验(真实浏览器里量出来的,单看代码不容易发现)。
所以必须先退出、后设提示。 */
exitReplyMode()
setStatus(data.message || '评论发表成功。', 'ok')
}).catch(function () {
setStatus(I18N.networkErr || '网络异常,请稍后重试。', 'err')
}).then(function () {
setSubmitting(false)
})
})
/* ── 插入新评论 ────────────────────────────────────────────
* 四个分支,对应四种真实情况:
* a. 回复某条评论,且该评论已有 children 容器 → 追加进去
* b. 回复某条评论,还没有 children 容器 → 新建一个
* c. 顶层评论,站点排序 asc → 追加到列表末尾
* d. 顶层评论,站点排序 desc → 插到列表最前
*
* 旧实现只有 a 的一个错误版本(无条件新建 children,导致套娃)
* 和 c/d。b 的情况是它没考虑到的(第一次回复时)。 */
function insertComment (data) {
if (!listEl) return
var tpl = document.createElement('template')
tpl.innerHTML = (data.html || '').trim()
var node = tpl.content.firstElementChild
if (!node) return
var parentId = parseInt(data.parent_id, 10) || 0
if (parentId > 0) {
var parentLi = document.getElementById('comment-' + parentId)
if (parentLi) {
var children = parentLi.querySelector(':scope > .yang-children')
if (!children) {
children = document.createElement('ol')
children.className = 'yang-children'
parentLi.appendChild(children)
}
children.appendChild(node)
} else {
// 父评论不在页面上(分页/已删除)→ 退化成顶层追加,别丢评论
listEl.appendChild(node)
}
} else {
var order = (CFG.order || 'asc').toLowerCase()
if (order === 'desc') {
listEl.insertBefore(node, listEl.firstChild)
} else {
listEl.appendChild(node)
}
}
// 高亮一下,让用户看到自己的评论在哪
node.classList.add('is-new')
setTimeout(function () { node.classList.remove('is-new') }, 2400)
// 更新评论计数(页面上出现「N 条评论」的地方都更新)
updateCount(data.count, data.count_text)
}
function updateCount (n, text) {
if (typeof n !== 'number') return
var label = text || (n + ' 条评论')
// single.php 里的 meta 区
$$('.article .header .meta span').forEach(function (sp) {
if (/条评论$/.test(sp.textContent.trim()) || /^\d+$/.test(sp.textContent.trim())) {
if (/条评论/.test(sp.textContent) || sp.textContent.trim() === '0') {
sp.textContent = label
}
}
})
// 评论区标题
var h = $('.comments-head .title')
if (h) h.textContent = '评论(' + n + ')'
}
/* ── 加载更多 ────────────────────────────────────────────── */
if (moreBtn) {
moreBtn.addEventListener('click', function () {
if (moreBtn.disabled) return
moreBtn.disabled = true
var orig = moreBtn.textContent
moreBtn.textContent = I18N.loading || '加载中…'
var page = parseInt(moreBtn.getAttribute('data-page'), 10) || 2
var fd = new FormData()
fd.append('action', 'yang_load_comments')
fd.append('post_id', String(CFG.postId || 0))
fd.append('page', String(page))
fetch(CFG.ajaxUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.json() })
.then(function (data) {
if (!data || !data.ok) {
moreBtn.textContent = orig
moreBtn.disabled = false
setStatus((data && data.message) || (I18N.networkErr || '加载失败。'), 'err')
return
}
if (data.html && listEl) {
var tpl = document.createElement('template')
tpl.innerHTML = data.html
while (tpl.content.firstChild) {
listEl.appendChild(tpl.content.firstChild)
}
}
if (data.has_more) {
moreBtn.setAttribute('data-page', String(data.next_page))
moreBtn.textContent = orig
moreBtn.disabled = false
} else {
if (moreWrap) moreWrap.hidden = true
setStatus(I18N.noMore || '没有更多评论了', '')
}
})
.catch(function () {
moreBtn.textContent = orig
moreBtn.disabled = false
setStatus(I18N.networkErr || '网络异常,请稍后重试。', 'err')
})
})
}
/* ── 新评论进入视口时轻微高亮 ──────────────────────────── */
// 已由 CSS 的 .is-new 处理,这里不重复
}())
-1
View File
@@ -1 +0,0 @@
jQuery(document).ready(function(e){var t=e("#cancel-comment-reply-link"),n=t.text(),o="list";e(document).on("submit","#commentform",function(){return e.ajax({url:ajaxcomment.ajax_url,data:e(this).serialize()+"&action=ajax_comment",type:e(this).attr("method"),beforeSend:function(){layer.msg("评论正在提交中")},error:function(e){layer.msg(e.responseText)},success:function(t){e("textarea").each(function(){this.value=""});var n=addComment,r=n.I("cancel-comment-reply-link"),m=n.I("wp-temp-form-div"),a=n.I(n.respondId),c=(n.I("comment_post_ID").value,n.I("comment_parent").value);"0"!=c?e("#comment-"+c).append('<ul class="children">'+t+"</ul>"):"asc"==ajaxcomment.order?e("."+o).append(t):e("."+o).prepend(t),layer.msg("评论提交成功"),r.style.display="none",r.onclick=null,n.I("comment_parent").value="0",m&&a&&(m.parentNode.insertBefore(a,m),m.parentNode.removeChild(m))}}),!1}),addComment={moveForm:function(o,r,m){var a,c=this,l=c.I(o),p=c.I(m),d=c.I("cancel-comment-reply-link"),i=c.I("comment_parent");c.I("comment_post_ID");t.text(n),c.respondId=m,c.I("wp-temp-form-div")||(a=document.createElement("div"),a.id="wp-temp-form-div",a.style.display="none",p.parentNode.insertBefore(a,p)),l?l.parentNode.insertBefore(p,l.nextSibling):(temp=c.I("wp-temp-form-div"),c.I("comment_parent").value="0",temp.parentNode.insertBefore(p,temp),temp.parentNode.removeChild(temp)),e("body").animate({scrollTop:e("#respond").offset().top-150},400),i.value=r,d.style.display="",d.onclick=function(){var e=addComment,t=e.I("wp-temp-form-div"),n=e.I(e.respondId);return e.I("comment_parent").value="0",t&&n&&(t.parentNode.insertBefore(n,t),t.parentNode.removeChild(t)),this.style.display="none",this.onclick=null,!1};try{c.I("comment").focus()}catch(e){}return!1},I:function(e){return document.getElementById(e)},createButterbar:function(e){layer.msg(e)}}});
+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)
}())