From cbe0f2dc593096e27fe57e4c6d44d71317d271d2 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 24 Oct 2026 01:03:36 +0800 Subject: [PATCH] =?UTF-8?q?3.2.18:=20=E6=B8=85=E6=98=8E=E6=8C=89=E8=8A=82?= =?UTF-8?q?=E6=B0=94=E6=8D=A2=E7=AE=97=E3=80=81=E4=B8=BB=E9=A2=98=E5=86=85?= =?UTF-8?q?=20[TOC]=20=E7=9B=AE=E5=BD=95=E3=80=81=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=8C=BA=E6=9B=BF=E4=BB=A3=20wpDiscuz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三项功能: 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 返回的新评论没有回复按钮);
  • 被
    包着,嵌套回复错位。 现在统一到 yang_comment_html() 单一渲染函数,列表与 AJAX 只差一个 is-new 类。同时修掉三处真实缺陷: · 评论项用 flex 会把
      当成第三个 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(真实文章渲染目录 + 真实评论数据渲染)。 --- assets/css/comments.css | 893 ++++++++++++++++++++++++++++++++++++++ assets/css/toc.css | 303 +++++++++++++ assets/js/comments.js | 418 ++++++++++++++++++ assets/js/comments.min.js | 1 - assets/js/toc.js | 206 +++++++++ comments.php | 273 +++++++++--- functions.php | 9 + inc/theme-article.php | 119 +---- inc/theme-comments.php | 761 ++++++++++++++++++++++++++++++++ inc/theme-mail.php | 65 ++- inc/theme-mourning.php | 201 ++++++++- inc/theme-options.php | 46 +- inc/theme-toc.php | 472 ++++++++++++++++++++ style.css | 2 +- 14 files changed, 3577 insertions(+), 192 deletions(-) create mode 100644 assets/css/comments.css create mode 100644 assets/css/toc.css create mode 100644 assets/js/comments.js delete mode 100644 assets/js/comments.min.js create mode 100644 assets/js/toc.js create mode 100644 inc/theme-comments.php create mode 100644 inc/theme-toc.php diff --git a/assets/css/comments.css b/assets/css/comments.css new file mode 100644 index 0000000..da3c1e0 --- /dev/null +++ b/assets/css/comments.css @@ -0,0 +1,893 @@ +/*! + * 评论区样式 + * Yongquan Yang + * + * ── 为什么整块重写,而不是在旧样式上改 ────────────────────── + * 旧样式的骨架是「头像 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 输出的是 + *
    1. + *
      …
      + *
      …
      + *
        …子评论…
      ← 也是 li 的直接子元素 + *
    2. + * 若 li 用 display:flex,那个
        就成了**第三个 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 过滤器插入的 ) */ +.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; + } +} \ No newline at end of file diff --git a/assets/css/toc.css b/assets/css/toc.css new file mode 100644 index 0000000..0a84cd3 --- /dev/null +++ b/assets/css/toc.css @@ -0,0 +1,303 @@ +/*! + * 文章目录(TOC)样式 + * Yongquan Yang + * + * ── 设计原则 ──────────────────────────────────────────────── + * 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; +} + +/*
        的默认三角标记去掉,用自己的图标 */ +.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; + } +} \ No newline at end of file diff --git a/assets/js/comments.js b/assets/js/comments.js new file mode 100644 index 0000000..ce63cc7 --- /dev/null +++ b/assets/js/comments.js @@ -0,0 +1,418 @@ +/*! + * 评论区交互 + * Yongquan Yang + * + * ── 旧版(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('
          '+t+"
        ")` + * 直接用 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 处理,这里不重复 + +}()) \ No newline at end of file diff --git a/assets/js/comments.min.js b/assets/js/comments.min.js deleted file mode 100644 index 25d7469..0000000 --- a/assets/js/comments.min.js +++ /dev/null @@ -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('
          '+t+"
        "):"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)}}}); \ No newline at end of file diff --git a/assets/js/toc.js b/assets/js/toc.js new file mode 100644 index 0000000..86045fd --- /dev/null +++ b/assets/js/toc.js @@ -0,0 +1,206 @@ +/*! + * 文章目录(TOC)交互 + * Yongquan Yang + * + * ── 设计原则:没有 JS 也要能用 ──────────────────────────────── + * 目录的**核心功能是跳转**,那由 + 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) +}()) \ No newline at end of file diff --git a/comments.php b/comments.php index 459a653..0eca1f3 100644 --- a/comments.php +++ b/comments.php @@ -1,82 +1,239 @@ **。原来 `
        ` 里直接放 + * `wp_list_comments()` 输出的 `
      1. `,标签层级非法。浏览器会 + * 自行"修复"结构,而嵌套回复(`
          `)在这种 + * 被修复过的树里容易错位 —— 这是"评论看起来乱"的一个真实来源。 + * + * 2. **回复状态条**。点「回复」时除了把表单挪过去,还在表单上方 + * 显示「正在回复 @某人 / 取消」。旧实现只有表单被挪动这一条线索, + * 用户在长评论区里挪动后常常不知道自己到底在回复谁。 + * + * 3. **表单加 nonce**。AJAX 提交走 nonce 校验,没有它任何第三方 + * 页面都能借访客的浏览器刷评论。 + * + * 4. **提交按钮的禁用态由 JS 控制**,模板里给出 `.is-loading` 的样式钩子。 + * * @author Yongquan Yang * @license MIT License - * @version 2020.03.14 */ if (isset($_SERVER['SCRIPT_FILENAME']) && 'comments.php' == basename($_SERVER['SCRIPT_FILENAME'])) { die(); } + require get_template_directory() . '/pages/page-smilies.php'; -if (comments_open()) {?> + +$yang_post_id = get_the_ID(); +$yang_comment_count = (int) get_comments_number($yang_post_id); +$yang_max_depth = (int) get_option('thread_comments_depth', 5); +if ($yang_max_depth <= 0) { + $yang_max_depth = 5; +} +$yang_threaded = (bool) get_option('thread_comments'); +$yang_has_comments = ($yang_comment_count > 0); + +/* 评论是否分页:分页时首屏只给「加载更多」,不一次性铺全部 */ +$yang_paged = (bool) get_option('page_comments'); +$yang_per_page = (int) get_option('comments_per_page', 20); +if ($yang_per_page <= 0) { + $yang_per_page = 20; +} +?>
          -

          -
          - + + - -
          - -
          - 登录 之后才可以评论', 'kratos'), wp_login_url(get_permalink()));?> + + + +

          + + + +

          + + + + + 而不是
          :
        • 的父元素必须是列表容器。 + 加 reversed/start 会打乱视觉顺序,所以只用普通
            。 */ + ?> +
              + 'comment', + 'callback' => 'comment_callbacks', + 'style' => 'ol', + 'max_depth' => $yang_threaded ? $yang_max_depth : 1, + 'avatar_size' => 48, + 'per_page' => $yang_paged ? $yang_per_page : 0, + 'page' => 1, + )); + ?> +
            + + $yang_per_page; + ?> +
            > + +
            + + + +

            + + + + + + + +
            +
            - -
            -
            - -
            -
            -
            -
            - + + + +
            +

            + + 依赖 JS 拦截,没 JS 时会跳到 + ?replytocom= 的地址,行为很怪。这里用 button。 */ + ?> + +

            + + + + + + + + +
            +
            + + +
            - -
            -
            -