From 6f51d50a58db25f669263154fa1620249c028e6b Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 25 Oct 2026 12:18:52 +0800 Subject: [PATCH] =?UTF-8?q?3.2.30:=20=E6=96=87=E7=AB=A0=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E8=BF=9B=E5=90=8E=E5=8F=B0=EF=BC=88=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=9B=AE=E5=BD=95=E3=80=8D=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=EF=BC=89=E2=80=94=E2=80=94=20=E4=BD=8D=E7=BD=AE/?= =?UTF-8?q?=E6=8F=92=E5=85=A5=E7=82=B9/=E9=BB=98=E8=AE=A4=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E5=8F=AF=E9=85=8D=EF=BC=8C=E6=89=8B=E5=86=99=20[TOC]?= =?UTF-8?q?=20=E6=B0=B8=E8=BF=9C=E4=BC=98=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台「主题设置」新增第 5 个标签页「自动目录」,7 个选项: 目录位置 正文中间 / 页面左侧(浮动)/ 页面右侧(浮动) 添加方式 手动(只认 [TOC])/ 自动 自动插入位置 文章开头 / 第一个 H1、H2 之前 / 文章结尾 默认状态 默认显示 / 默认隐藏 另外:目录标题、收录层级(H1~H4)、是否显示序号 设计要点: - 插入方式拆成 toc_mode(manual/auto) x toc_anchor(start/first/end) 两个正交 字段,不合成一个四值下拉:侧栏模式下插入位置毫无意义,合并会产生歧义。 - 手写 [TOC] 绝对优先:yang_toc_plan() 里 $has_marker 最先返回,inline_at ='marker' 且 rail=false —— 否则同一篇会出现两份目录,老文章升级后突然多 出一个目录。这条是刻意守住的。 - 纯函数 + 薄壳分层:normalize_settings / plan / inject / build_list 全部不查库, 单进程可喂几十种组合;settings() 只负责读库 + 调纯函数 + static 缓存。 -
是唯一可靠的「默认收起」手段:展开态不接受 CSS 覆盖,只能 由 open 属性决定,所以隐藏时必须不输出该属性。 - 侧栏收起态必须 translateX(±100%) 完全移出视口:只做 opacity 会让透明面板 留在原位吃掉正文点击(探针 elementFromPoint 才抓得到)。 - yang_toc_rail_ready() 开关:侧栏在 wp_footer 输出,那时正文可能没走 the_content(缓存插件/OEmbed),标题没 id ⇒ 侧栏链接全跳不动却看起来正常。 宁可不出侧栏。 - 后台联动隐藏「插入位置」不用 jQuery show()/hide():会把 .section 的行内 display 写成 block,悄悄回退 3.2.28 的 display:flow-root BFC 修复。改用 class + display:none!important。 验证(全部在本轮重跑): _test_toc.php 53/0 旧 TOC 行为回归 _toc330_logic.php 88/0 逻辑判据 _toc330_falsify 13/13 注入全变红且红对地方 _geo330_rail.js 73/0 侧栏真实几何(左/右 x 展开/收起 x 宽窄屏) _geo330_falsify 8/8 _guard330.php 46/0 后台控件与运行时默认值双向一致 _guard330_falsify 8/8 _admin330_tab_check 36/0 新标签页几何 + 联动 + 载入态 _admin330_falsify 8/8 _admin328_check 13/0 3.2.28 布局回归 发布:只发 zip + readme + update.json 到 theme-update/,不动服务器主题目录, 用户在 WP 后台自行升级(沿用 3.2.24 起的策略)。 --- assets/css/toc.css | 248 +++++++ assets/js/toc.js | 88 ++- .../css/optionsframework.css | 10 + inc/options-framework/js/options-custom.js | 16 + inc/theme-options.php | 108 ++- inc/theme-toc.php | 679 +++++++++++++++--- style.css | 2 +- 7 files changed, 1060 insertions(+), 91 deletions(-) diff --git a/assets/css/toc.css b/assets/css/toc.css index 0a84cd3..741206c 100644 --- a/assets/css/toc.css +++ b/assets/css/toc.css @@ -245,6 +245,214 @@ body.admin-bar .k-main .details .content h6[id] { } } +/* ── 正文内嵌槽(自动插入时用) ────────────────────────────── + * 自动插入的目录外面包一层 slot。加这层不是为了好看,是为了让 + * 「目录在哪」这件事在 DOM 里有痕迹:插入位置变了、或者根本没插进去, + * 看 slot 的 class / 位置就能判断,不用去猜正文里的
是谁输出的。 + * + * 边距放在 slot 上、把 .yang-toc 自身的 margin 归零: + * 两层都带 margin 时外边距会叠加,插在开头/结尾的那几种位置尤其难看 + * (开头会顶出一块空白)。 + */ +.yang-toc-slot { + margin: 24px 0; +} + +.yang-toc-slot > .yang-toc { + margin: 0; +} + +/* ── 侧边浮动目录 ───────────────────────────────────────────── + * 结构:贴边的一条细标签 + 一块可滑出的面板。 + * + * 关键几何约束(容易被后来的改动破坏): + * · 容器是 0 宽的定位锚点,不可见、不吃点击(pointer-events:none); + * 真正可点的只有标签和面板。 + * · 收起态面板必须**完全**移出视口(translateX(±100%)), + * 不能只做透明度。留一点"露头"看着舒服,但它会盖住正文列边缘, + * 窄屏上正好压掉正文的第一个字 —— 那种问题肉眼很难定位。 + * · z-index 低于导航(Bootstrap navbar 是 1030), + * 万一两者在高度上相遇,导航仍然压在上面。 + */ +.yang-toc-rail { + position: fixed; + top: 0; + bottom: 0; + width: 0; + z-index: 1000; + pointer-events: none; +} + +.yang-toc-rail-left { + left: 0; +} + +.yang-toc-rail-right { + right: 0; +} + +.yang-toc-rail > * { + pointer-events: auto; +} + +/* 贴边标签:竖排文字,主色底 */ +.yang-toc-rail-tab { + position: absolute; + top: 50%; + width: 34px; + padding: 14px 0; + border: 0; + border-radius: 0 6px 6px 0; + background-color: #00a2ff; + color: #fff; + box-shadow: 1px 0 6px rgba(0, 0, 0, 0.12); + cursor: pointer; + font-size: 13px; + line-height: 1; + transform: translateY(-50%); + transition: opacity 0.18s ease, background-color 0.18s ease; +} + +.yang-toc-rail-left .yang-toc-rail-tab { + left: 0; +} + +.yang-toc-rail-right .yang-toc-rail-tab { + right: 0; + border-radius: 6px 0 0 6px; + box-shadow: -1px 0 6px rgba(0, 0, 0, 0.12); +} + +.yang-toc-rail-tab:hover, +.yang-toc-rail-tab:focus { + background-color: #0089d8; +} + +.yang-toc-rail-tab-text { + display: block; + letter-spacing: 3px; + writing-mode: vertical-rl; + text-orientation: upright; +} + +/* 展开时标签让位给面板(面板已经贴边盖住它了,留着只是碍眼) */ +.yang-toc-rail.is-open .yang-toc-rail-tab { + opacity: 0; + pointer-events: none; +} + +/* 面板 */ +.yang-toc-rail-panel { + position: absolute; + top: 50%; + display: flex; + flex-direction: column; + width: 288px; + max-height: 72vh; + border: 1px solid #ebeef5; + background-color: #fff; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.14); + font-size: 14px; + line-height: 1.6; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, transform 0.28s ease, visibility 0s linear 0.28s; +} + +.yang-toc-rail-left .yang-toc-rail-panel { + left: 0; + border-left: 3px solid #00a2ff; + border-radius: 0 4px 4px 0; + transform: translate(-100%, -50%); +} + +.yang-toc-rail-right .yang-toc-rail-panel { + right: 0; + border-right: 3px solid #00a2ff; + border-radius: 4px 0 0 4px; + transform: translate(100%, -50%); +} + +.yang-toc-rail.is-open .yang-toc-rail-panel { + opacity: 1; + visibility: visible; + transform: translate(0, -50%); + transition: opacity 0.2s ease, transform 0.28s ease, visibility 0s; +} + +.yang-toc-rail-head { + display: flex; + flex: 0 0 auto; + align-items: center; + padding: 12px 12px 8px 14px; + border-bottom: 1px solid #f3f5f8; +} + +.yang-toc-rail-title { + color: #303133; + font-weight: 600; + font-size: 15px; +} + +.yang-toc-rail-close { + flex: 0 0 auto; + width: 24px; + height: 24px; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 3px; + background: none; + color: #909399; + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.yang-toc-rail-close:hover, +.yang-toc-rail-close:focus { + background-color: #f2f4f8; + color: #00a2ff; +} + +.yang-toc-rail-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 10px 12px 12px; + overscroll-behavior: contain; +} + +/* ── 窄屏:面板按视口宽度收,标签再窄一点 ── */ +@media screen and (max-width: 768px) { + .yang-toc-rail-panel { + width: min(78vw, 300px); + max-height: 70vh; + } + + .yang-toc-rail-tab { + width: 30px; + padding: 12px 0; + font-size: 12px; + } + + .yang-toc-rail-head { + padding: 10px 10px 8px 12px; + } + + .yang-toc-rail-body { + padding: 8px 10px 10px; + } +} + +/* 动效敏感的用户:不要滑出动画,直接显隐 */ +@media (prefers-reduced-motion: reduce) { + .yang-toc-rail-panel, + .yang-toc-rail.is-open .yang-toc-rail-panel, + .yang-toc-rail-tab { + transition: none; + } +} + /* ── 打印:目录展开、去背景、不折行 ────────────────────────── */ @media print { .yang-toc { @@ -260,6 +468,11 @@ body.admin-bar .k-main .details .content h6[id] { .yang-toc-link { color: #000; } + + /* 侧栏是屏幕上的快捷入口,纸上没有意义,还会盖住正文 */ + .yang-toc-rail { + display: none; + } } /* ── 暗色主题适配 ────────────────────────────────────────── @@ -300,4 +513,39 @@ body.admin-bar .k-main .details .content h6[id] { html.yang-dark .yang-toc-num { color: #767d88; } + + /* 侧边目录:面板底色/边框/文字跟内嵌目录同一套暗色值, + 否则暗色站点上会凭空出现一块白板。标签本身是主色底白字, + 两种模式下都不用改。 */ + html.yang-dark .yang-toc-rail-panel { + border-color: #303640; + background-color: #1d2026; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); + } + + html.yang-dark .yang-toc-rail-left .yang-toc-rail-panel { + border-left-color: #00a2ff; + } + + html.yang-dark .yang-toc-rail-right .yang-toc-rail-panel { + border-right-color: #00a2ff; + } + + html.yang-dark .yang-toc-rail-head { + border-bottom-color: #2a2f38; + } + + html.yang-dark .yang-toc-rail-title { + color: #d5d9e0; + } + + html.yang-dark .yang-toc-rail-close { + color: #8b929d; + } + + html.yang-dark .yang-toc-rail-close:hover, + html.yang-dark .yang-toc-rail-close:focus { + background-color: #252b34; + color: #00a2ff; + } } \ No newline at end of file diff --git a/assets/js/toc.js b/assets/js/toc.js index 86045fd..e348894 100644 --- a/assets/js/toc.js +++ b/assets/js/toc.js @@ -4,9 +4,11 @@ * * ── 设计原则:没有 JS 也要能用 ──────────────────────────────── * 目录的**核心功能是跳转**,那由 + CSS 的 - * scroll-margin-top 完成,完全不需要 JS。这个文件只做两件锦上添花的事: + * scroll-margin-top 完成,完全不需要 JS。这个文件只做三件锦上添花的事: * ① 平滑滚动(不支持的浏览器直接跳,不影响功能) * ② 高亮当前阅读位置的条目 + * ③ 侧边目录面板的展开 / 收起(默认状态由 HTML class 决定, + * JS 不参与"初始长什么样",只负责切换) * 所以这里所有增强都必须能安全失败 —— 任何异常都不该让跳转失效。 * * 为什么不用 jQuery:主题里虽然有 jQuery,但这两件事原生 API 足够, @@ -114,7 +116,11 @@ /* ── ② 滚动时高亮当前章节 ──────────────────────────────── */ function bindScrollSpy () { - var toc = document.querySelector('.yang-toc') + /* 目录可能在两处:正文里的 .yang-toc,或侧边的 .yang-toc-rail-panel。 + 两处不会同时出现(正文有手写标记时不出侧栏),所以取第一个找到的 + 就够;用逗号选择器而不是两个 if,是为了将来加第三种形态时 + 不用再改这里。 */ + var toc = document.querySelector('.yang-toc, .yang-toc-rail-panel') if (!toc) return var links = [].slice.call(toc.querySelectorAll('.yang-toc-link')) @@ -175,7 +181,82 @@ update() } - /* ── ③ 带 #hash 直接进入页面时,校正落点 ────────────────── + /* ── ③ 侧边目录:展开 / 收起 ───────────────────────────── + * 目录内容本身(链接、href、锚点)全部由 PHP 输出,浏览器不点也能 + * 跳(每个 都是真链接)。这个函数只负责"把面板滑出来"。 + * + * 默认展开/收起的状态**已经写在 HTML 的 class 上**(is-open), + * 所以 JS 挂了也只是不能切换,不会把"默认显示"变成"什么都没有"。 + */ + function bindRail () { + var rails = document.querySelectorAll('.yang-toc-rail') + if (!rails.length) return + + [].forEach.call(rails, function (rail) { + var tab = rail.querySelector('.yang-toc-rail-tab') + var panel = rail.querySelector('.yang-toc-rail-panel') + var closeBtn = rail.querySelector('.yang-toc-rail-close') + if (!tab || !panel) return + if (rail.getAttribute('data-bound') === '1') return + rail.setAttribute('data-bound', '1') + + function isOpen () { + return rail.classList.contains('is-open') + } + + function setOpen (open) { + if (open) { + rail.classList.add('is-open') + } else { + rail.classList.remove('is-open') + } + tab.setAttribute('aria-expanded', open ? 'true' : 'false') + rail.setAttribute('data-state', open ? 'open' : 'closed') + } + + tab.addEventListener('click', function (ev) { + ev.preventDefault() + setOpen(!isOpen()) + }) + + /* 点 × 收起后把焦点还给标签 —— 否则焦点留在被隐藏的面板里, + 键盘用户会"走丢"(Tab 下一个跑到页面开头)。 */ + if (closeBtn) { + closeBtn.addEventListener('click', function () { + setOpen(false) + tab.focus() + }) + } + + /* 点了目录项:宽屏上留着面板方便继续翻(用户多半还要点下一节), + 窄屏上它正好盖在刚跳到的正文上,必须收起来。 */ + panel.addEventListener('click', function (ev) { + var a = ev.target.closest ? ev.target.closest('.yang-toc-link') : null + if (!a) return + if (window.innerWidth < 1200) { + setOpen(false) + } + }) + + document.addEventListener('keydown', function (ev) { + // Esc 收起。只处理 Esc,别拦别的键(会和浏览器快捷键打架) + if ((ev.key === 'Escape' || ev.key === 'Esc') && isOpen()) { + setOpen(false) + tab.focus() + } + }) + + /* 点面板外面收起。用 document 上的冒泡而不是遮罩层: + 加一层半透明遮罩会挡住正文,目录只是辅助导航,不该锁住页面。 */ + document.addEventListener('click', function (ev) { + if (!isOpen()) return + if (rail.contains(ev.target)) return + setOpen(false) + }) + }) + } + + /* ── ④ 带 #hash 直接进入页面时,校正落点 ────────────────── * 浏览器原生的锚点跳转发生在页面加载早期,那时图片还没加载完、 * CSS 也可能没应用完,落点常常偏上或偏下(尤其是有图片的页面)。 * 这里在 load 之后再做一次校正。 @@ -194,6 +275,7 @@ function init () { bindClick() bindScrollSpy() + bindRail() fixHashOnLoad() } diff --git a/inc/options-framework/css/optionsframework.css b/inc/options-framework/css/optionsframework.css index 4f5be59..a920e0d 100644 --- a/inc/options-framework/css/optionsframework.css +++ b/inc/options-framework/css/optionsframework.css @@ -492,6 +492,16 @@ margin: 0 0 10px; } +/* 条件字段(如「自动插入位置」只在目录位置=正文中间时才有意义) + * + * 用 !important 的 display:none 而不是 jQuery 的 show()/hide(),原因见 + * js/options-custom.js 里 yq_toggle_toc_anchor():.section 的 display 是 + * flow-root,jQuery 会把行内 display 写成 block,等于回退 3.2.28 的修复。 + * 这里只隐藏,不改变其它布局属性。 */ +#optionsframework .section.yq-cond-hide { + display: none !important; +} + /* 3. 提交栏:与分组标题同一套留白 */ #optionsframework-submit { padding: 12px 18px; diff --git a/inc/options-framework/js/options-custom.js b/inc/options-framework/js/options-custom.js index 5d3e7bc..961b49a 100644 --- a/inc/options-framework/js/options-custom.js +++ b/inc/options-framework/js/options-custom.js @@ -100,6 +100,22 @@ jQuery(document).ready(function ($) { jQuery('#section-top_describe').show() } + // 自动目录:只有「目录位置 = 正文中间」时,插入位置才有意义。 + // 侧栏模式(左/右)下它完全无效 —— 若还留着这个下拉框,用户会以为 + // 「选了文章结尾却没插进正文」是个 bug。 + // + // 用 class 切换而不是 fadeIn/fadeOut:.section 的 display 是 flow-root + // (3.2.28 靠它建独立 BFC,避免被左侧浮动标签列的 clear:both 拉长)。 + // jQuery 的 show()/hide() 会写行内 display,把那套布局打回 block, + // 等于悄悄回退 3.2.28 的修复。 + function yq_toggle_toc_anchor () { + var center = jQuery('#toc_place').val() === 'center' + jQuery('#section-toc_anchor').toggleClass('yq-cond-hide', !center) + } + + jQuery('#toc_place').on('change', yq_toggle_toc_anchor) + yq_toggle_toc_anchor() + // Loads the color pickers $('.of-color').wpColorPicker() diff --git a/inc/theme-options.php b/inc/theme-options.php index a0f2caa..7fd15fc 100644 --- a/inc/theme-options.php +++ b/inc/theme-options.php @@ -381,7 +381,103 @@ function kratos_options() ); /* ══════════════════════════════════════════════════════════ - * 五、站长信息 —— 文章页页脚那张名片 + * 五、自动目录 —— 把长文切成能跳的章节 + * + * 与「文章配置」分开成独立标签页,是刻意的:目录的选项有六项, + * 塞进文章配置里会把「页面布局 / 打赏」这些一眼要看到的东西挤下去。 + * + * ⚠️ 这里的 std 必须与 inc/theme-toc.php 的 yang_toc_defaults() 一致。 + * 前者只服务「后台预填」和「恢复默认」,运行时一律走 + * kratos_option($id, 默认值) —— 两处不一致会出现 + * 「页面照 A 渲染、后台框里显示 B」的错位,而且极难发现。 + * ══════════════════════════════════════════════════════════ */ + $options[] = array( + 'name' => __('自动目录', 'kratos'), + 'type' => 'heading', + ); + + $options[] = array( + 'name' => __('目录位置', 'kratos'), + 'desc' => __('正文中间:目录是正文的一部分,按下面的「自动插入位置」插进去。
页面左侧 / 右侧:目录变成贴在页面边缘的浮动面板,点边上的小标签滑出,正文里不放任何东西(「自动插入位置」对它无效)。窄屏(手机)上也是从边缘滑出的抽屉,不会挤占正文。', 'kratos'), + 'id' => 'toc_place', + 'std' => 'center', + 'type' => 'select', + 'options' => array( + 'center' => __('正文中间', 'kratos'), + 'left' => __('页面左侧(浮动)', 'kratos'), + 'right' => __('页面右侧(浮动)', 'kratos'), + ), + ); + + $options[] = array( + 'name' => __('添加方式', 'kratos'), + 'desc' => __('手动:不自动加,只有正文里手写了 [TOC] 的地方才显示(老文章的行为,升级后不会突然多出目录)。
自动:由主题自己加,不用改任何文章。', 'kratos'), + 'id' => 'toc_mode', + 'std' => 'manual', + 'type' => 'select', + 'options' => array( + 'manual' => __('手动(只在写 [TOC] 的地方显示)', 'kratos'), + 'auto' => __('自动添加', 'kratos'), + ), + ); + + $options[] = array( + 'name' => __('自动插入位置', 'kratos'), + 'desc' => __('只在「添加方式 = 自动」且「目录位置 = 正文中间」时起作用。
第一个标题之前取正文里最先出现的 h1 或 h2 —— 文章大标题在正文之外,不会被算进去;一个 h1/h2 都没有时退回插在文章开头。', 'kratos'), + 'id' => 'toc_anchor', + 'std' => 'first', + 'type' => 'select', + 'options' => array( + 'start' => __('文章开头', 'kratos'), + 'first' => __('第一个标题之前(H1 / H2)', 'kratos'), + 'end' => __('文章结尾', 'kratos'), + ), + ); + + $options[] = array( + 'name' => __('默认状态', 'kratos'), + 'desc' => __('默认显示:进来就能看到全部条目。默认隐藏:先只露出标题栏(或侧边的标签),读者自己点开。较长或标题很多的文章建议默认隐藏,否则目录会占掉一屏。
注:这里只管「默认长什么样」,读者点开后仍然可以正常使用;正文里手写的 [TOC] 也可以用 open="0" 单独指定。', 'kratos'), + 'id' => 'toc_default', + 'std' => 'show', + 'type' => 'select', + 'options' => array( + 'show' => __('默认显示', 'kratos'), + 'hide' => __('默认隐藏', 'kratos'), + ), + ); + + $options[] = array( + 'name' => __('目录标题', 'kratos'), + 'desc' => __('显示在目录顶部的那行字。留空则用「目录」。', 'kratos'), + 'id' => 'toc_title', + 'std' => __('目录', 'kratos'), + 'type' => 'text', + ); + + $options[] = array( + 'name' => __('收录标题级别', 'kratos'), + 'desc' => __('勾选要进目录的标题级别。正文里的 h1 是作者自己写的章节标题(文章大标题在正文之外,不会被收进来),所以要用 h1 分章的文章也建议勾上 h1。
h4 以下通常是细节,勾上会让目录变得很长。', 'kratos'), + 'id' => 'toc_levels', + 'std' => array('1' => 1, '2' => 1, '3' => 1, '4' => 0), + 'type' => 'multicheck', + 'options' => array( + '1' => array('name' => 'H1', 'note' => __('一级标题', 'kratos')), + '2' => array('name' => 'H2', 'note' => __('二级标题', 'kratos')), + '3' => array('name' => 'H3', 'note' => __('三级标题', 'kratos')), + '4' => array('name' => 'H4', 'note' => __('四级标题', 'kratos')), + ), + ); + + $options[] = array( + 'name' => __('显示序号', 'kratos'), + 'desc' => __('在条目前面加 1 / 1.1 / 1.1.1 这样的序号。', 'kratos'), + 'id' => 'toc_numbering', + 'std' => '0', + 'type' => 'checkbox', + ); + + /* ══════════════════════════════════════════════════════════ + * 六、站长信息 —— 文章页页脚那张名片 * ══════════════════════════════════════════════════════════ */ $options[] = array( 'name' => __('站长信息', 'kratos'), @@ -411,7 +507,7 @@ function kratos_options() ); /* ══════════════════════════════════════════════════════════ - * 六、页脚配置 —— 页面最底下那几行 + * 七、页脚配置 —— 页面最底下那几行 * ══════════════════════════════════════════════════════════ */ $options[] = array( 'name' => __('页脚配置', 'kratos'), @@ -465,7 +561,7 @@ function kratos_options() ); /* ══════════════════════════════════════════════════════════ - * 七、哀悼模式 —— 整站级功能,与首页无关 + * 八、哀悼模式 —— 整站级功能,与首页无关 * * 这一组原来错放在「首页配置」下面。它影响的是整站配色 * (首页、文章页、说明页都变黑白),放首页下面会让人以为 @@ -560,7 +656,7 @@ function kratos_options() ); /* ══════════════════════════════════════════════════════════ - * 八、收录与统计 —— 给搜索引擎看的 + * 九、收录与统计 —— 给搜索引擎看的 * ══════════════════════════════════════════════════════════ */ $options[] = array( 'name' => __('收录与统计', 'kratos'), @@ -618,7 +714,7 @@ function kratos_options() ); /* ══════════════════════════════════════════════════════════ - * 九、邮件通知 —— SMTP 与评论提醒 + * 十、邮件通知 —— SMTP 与评论提醒 * ══════════════════════════════════════════════════════════ */ $options[] = array( 'name' => __('邮件通知', 'kratos'), @@ -716,7 +812,7 @@ function kratos_options() ); /* ══════════════════════════════════════════════════════════ - * 十、自定义代码 —— 兜底用,放最后 + * 十一、自定义代码 —— 兜底用,放最后 * * 3.2.29 起这两项默认都为空:站点自己的通用样式 * (段落缩进、h2 蓝条、列表缩进)已并入 assets/css/site.css, diff --git a/inc/theme-toc.php b/inc/theme-toc.php index 6b5666a..0064861 100644 --- a/inc/theme-toc.php +++ b/inc/theme-toc.php @@ -2,21 +2,35 @@ /** * 文章目录(TOC) * - * ── 用法 ────────────────────────────────────────────────────── - * 在文章内容的任意位置写一行: + * ── 两种用法 ────────────────────────────────────────────────── + * ① 手动:在文章内容的任意位置写一行 * - * [TOC] + * [TOC] * - * 这个位置就会出现本文的目录,点条目跳到对应标题。 + * 这个位置就会出现本文的目录,点条目跳到对应标题。 + * 可调参数(都可省略): * - * 可调参数(都可省略): + * [TOC title="本文目录" levels="1,2,3" numbering="1" toggle="0" open="0"] * - * [TOC title="本文目录" levels="1,2,3" numbering="1" toggle="0"] + * · title 目录标题,默认「目录」 + * · levels 收录哪几级标题,逗号分隔,默认 "1,2,3" + * · numbering 1 = 条目前加 1 / 1.1 这样的序号,默认 0 + * · toggle 1 = 标题可点击折叠(
),默认 1 + * · open 1 = 默认展开,0 = 默认收起,默认 1 * - * · title 目录标题,默认「目录」 - * · levels 收录哪几级标题,逗号分隔,默认 "1,2,3" - * · numbering 1 = 条目前加 1 / 1.1 这样的序号,默认 0 - * · toggle 1 = 标题可点击折叠,默认 1 + * 手写标记里写了的参数优先于后台设置,没写的才用后台设置。 + * + * ② 自动:后台「主题设置 → 自动目录」里选好,正文不用写任何东西。 + * · 目录位置:正文中间 / 页面左侧(浮动)/ 页面右侧(浮动) + * · 添加方式:手动 / 自动 + * · 自动插入位置(只对「正文中间」有效):文章开头 / 第一个 H1、H2 之前 / 文章结尾 + * · 默认状态:默认显示 / 默认隐藏 + * · 另外可配目录标题、收录层级、是否显示序号 + * + * ── 手写永远优先 ────────────────────────────────────────────── + * 只要正文里有 [TOC],就**只**按作者写的位置渲染,不再自动插第二份、 + * 也不出侧栏。理由:作者明确表过态的地方,机器不该再替他做一次决定; + * 否则老文章升级后会突然多出一个目录,位置还未必是他要的。 * * ── 为什么默认收 h1(曾经错设成 "2,3")────────────────────── * 原先的理由是「h1 是文章标题本身,不该进目录」。这个理由是**假的**: @@ -27,7 +41,7 @@ * 一旦正文用 h1 划分章节(很常见:从 Markdown 或别的编辑器粘过来, * 或原本的 ## 被降级),默认 "2,3" 会把这些一级章节整段丢掉, * 于是二级标题集体失去父级、目录变成平铺 —— 层级信息全错。 - * 所以默认跟 h2/h3 一起收 h1,最多到 h3(h4 以下通常是细节,不进目录)。 + * 所以默认跟 h2/h3 一起收 h1,最多到 h4(h5 以下通常是细节,不进目录)。 * * ── 为什么在 the_content 优先级 9 处理,而不是只靠短代码 ────── * 目录能点,前提是「标题有 id」且「目录链接指向那个 id」。这两件事 @@ -40,7 +54,7 @@ * * 所以在优先级 9(早于 wpautop 和 do_shortcode)一次做完: * ① 给每个 h1~h6 补 id(已有 id 的沿用,不动别人的锚点) - * ② 把 [TOC] 直接替换成目录 HTML + * ② 把 [TOC] 直接替换成目录 HTML,或按后台设置插到指定位置 * * 放在
里是刻意的:wpautop 会把「块级元素」两边的

剥掉, * 所以不会出现

…

这种非法嵌套。 @@ -49,6 +63,12 @@ * 调用 [TOC](小工具、页面构建器),短代码那条路也能用,且复用 * 同一个渲染函数,不会出现两套行为。 * + * ── 侧栏目录为什么在 wp_footer 输出 ────────────────────────── + * 侧栏是 position:fixed 的独立元素,不属于正文流。挂在正文里会让 + * wpautop、各种内容过滤器和「正文首块」样式都去处理一个本不属于 + * 正文的盒子,而且一旦有人调 the_content(相关文章、摘要),就会 + * 跟着重复输出。放在页面末尾、只认「当前请求就是这篇文章」,最稳。 + * * @author Yongquan Yang * @license MIT License */ @@ -57,6 +77,250 @@ if (!defined('ABSPATH')) { exit; } +/** + * 后台选项的运行时默认值(唯一事实来源) + * + * ⚠️ 只改这里不够:后台表单的 std(inc/theme-options.php)是给 + * 「后台预填」和「恢复默认」用的,两者必须一致,否则会出现 + * 「页面照 A 渲染、后台框里显示 B」的错位。改默认值时两处一起改。 + * + * @return array + */ +function yang_toc_defaults() +{ + return array( + 'place' => 'center', // center | left | right + 'mode' => 'manual', // manual | auto + 'anchor' => 'first', // start | first | end(只对 center 有效) + 'default' => 'show', // show | hide + 'title' => __('目录', 'kratos'), + 'levels' => array('1' => 1, '2' => 1, '3' => 1, '4' => 0), + 'numbering' => 0, + ); +} + +/** + * 把「收录层级」的多种存法归一成 array(1..6 => true) + * + * 三种输入都可能出现: + * · 库里的 multicheck 值:array('1'=>1,'2'=>1,'3'=>1,'4'=>0) + * · 短代码属性:"2,3" + * · 什么都没配:null / '' + * + * 全空时不返回空数组,而是回落到 1,2,3 —— 「一个级别都没勾」和 + * 「目录不显示」在用户心里不是一回事:前者多半是误操作,后者应该 + * 用「插入位置=手动且不写标记」表达。这里宁可退回可见的默认。 + * + * @param mixed $raw + * @return array + */ +function yang_toc_normalize_levels($raw) +{ + $levels = array(); + + if (is_array($raw)) { + foreach ($raw as $k => $v) { + if (empty($v)) { + continue; + } + $n = (int) $k; + if ($n >= 1 && $n <= 6) { + $levels[$n] = true; + } + } + } elseif (is_string($raw)) { + foreach (preg_split('/[\s,,、]+/u', $raw) as $one) { + if ($one === '') { + continue; + } + $n = (int) $one; + if ($n >= 1 && $n <= 6) { + $levels[$n] = true; + } + } + } + + if (empty($levels)) { + $levels = array(1 => true, 2 => true, 3 => true); + } + + return $levels; +} + +/** + * 把「原始配置」归一化成可用的设置(纯函数,不查库) + * + * 为什么单独拆出来:这里要判的坏值很多(下拉框被写坏、导入配置带了 + * 别的值、老版本残留的键),而真正的判断逻辑与 WordPress 无关。 + * 拆成纯函数之后,测试可以直接喂几十种组合,不用动数据库、 + * 也不用为了测试给「读设置」那层加后门。 + * + * 非法值一律回落到默认值:后台的下拉框理论上不会送来非法值,但 + * 配置可能被「恢复默认」「导入配置」「手改数据库」写坏。这里不抛错、 + * 不报警,只是让页面别因为一个坏字符串变成空白正文。 + * + * @param array $raw 原始键值(可能缺键、可能类型不对) + * @return array + */ +function yang_toc_normalize_settings($raw) +{ + $d = yang_toc_defaults(); + if (!is_array($raw)) { + $raw = array(); + } + + $pick = function ($key, $allowed) use ($raw, $d) { + $v = array_key_exists($key, $raw) ? $raw[$key] : $d[$key]; + return in_array($v, $allowed, true) ? $v : $d[$key]; + }; + + $s = array(); + $s['place'] = $pick('place', array('center', 'left', 'right')); + $s['mode'] = $pick('mode', array('manual', 'auto')); + $s['anchor'] = $pick('anchor', array('start', 'first', 'end')); + $s['default'] = $pick('default', array('show', 'hide')); + + $title = array_key_exists('title', $raw) ? $raw['title'] : $d['title']; + $title = is_string($title) ? trim(wp_strip_all_tags($title)) : ''; + $s['title'] = $title !== '' ? $title : $d['title']; + + $s['levels'] = yang_toc_normalize_levels( + array_key_exists('levels', $raw) ? $raw['levels'] : $d['levels'] + ); + + // checkbox 存的是 '1' / true / false,统一成 0/1 + $s['numbering'] = !empty($raw['numbering']) ? 1 : 0; + + return $s; +} + +/** + * 当前设置(从库里读一次,本请求内缓存) + * + * @return array + */ +function yang_toc_settings() +{ + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $raw = array(); + foreach (array('place', 'mode', 'anchor', 'default', 'title', 'levels', 'numbering') as $k) { + $raw[$k] = kratos_option('toc_' . $k, null); + } + // 库里没有的键用 null 占位,normalize 时按「未配置」回落默认值 + foreach ($raw as $k => $v) { + if ($v === null) { + unset($raw[$k]); + } + } + + $cache = yang_toc_normalize_settings($raw); + return $cache; +} + +/** + * 把设置翻译成渲染参数(与短代码属性同名同格式) + * + * 这样「后台设置」和「手写属性」能直接 array_merge,不需要两套 + * 参数体系 —— 两套体系是同类功能最经典的 bug 来源:改了后台不生效、 + * 或者手写参数被后台悄悄覆盖。 + * + * @param array $s 设置(不传则取当前设置) + * @return array + */ +function yang_toc_atts_from_settings($s = null) +{ + if (!is_array($s)) { + $s = yang_toc_settings(); + } + + $levels = array(); + $map = is_array($s['levels']) ? $s['levels'] : yang_toc_normalize_levels($s['levels']); + ksort($map); + foreach ($map as $k => $v) { + if (!empty($v)) { + $levels[] = $k; + } + } + + return array( + 'title' => $s['title'], + 'levels' => implode(',', $levels), + 'numbering' => !empty($s['numbering']) ? '1' : '0', + 'toggle' => '1', + 'open' => $s['default'] === 'show' ? '1' : '0', + ); +} + +/** + * 正文里是否有手写的 [TOC] 标记 + * + * @param string $content + * @return bool + */ +function yang_toc_has_marker($content) +{ + return is_string($content) && stripos($content, '[toc') !== false; +} + +/** + * 本次请求该怎么办 + * + * 返回: + * inline_at null 正文里不插目录 + * 'marker' 按作者写的 [TOC] 位置插 + * 'start' 文章开头 + * 'first' 第一个 H1/H2 之前 + * 'end' 文章结尾 + * rail bool 是否输出侧边浮动目录 + * side 'left'/'right' 侧栏挂哪边 + * settings array 归一化设置 + * + * 规则(顺序即优先级): + * ① 正文有 [TOC] ⇒ 只在标记处渲染,不出侧栏、不自动插; + * ② 添加方式 = 手动 ⇒ 什么都不自动做; + * ③ 目录位置 = 中间 ⇒ 按「自动插入位置」插进正文; + * ④ 目录位置 = 左/右 ⇒ 出侧栏,正文不动(插入位置对侧栏无效)。 + * + * @param bool $has_marker + * @param array $s 设置(不传则取当前设置) + * @return array + */ +function yang_toc_plan($has_marker = false, $s = null) +{ + if (!is_array($s)) { + $s = yang_toc_settings(); + } + + $plan = array( + 'inline_at' => null, + 'rail' => false, + 'side' => 'left', + 'settings' => $s, + ); + + if ($has_marker) { + $plan['inline_at'] = 'marker'; + return $plan; + } + + if ($s['mode'] === 'manual') { + return $plan; + } + + if ($s['place'] === 'center') { + $plan['inline_at'] = $s['anchor']; + } else { + $plan['rail'] = true; + $plan['side'] = ($s['place'] === 'right') ? 'right' : 'left'; + } + + return $plan; +} + /** * 把标题文本变成锚点 id * @@ -192,42 +456,25 @@ function yang_toc_parse_atts($raw) } /** - * 把条目数组渲染成目录 HTML + * 把条目数组渲染成
'; - } - $o[] = ''; + $o[] = ''; + for ($k = $prev_depth; $k > 0; $k--) { + $o[] = ''; } + $o[] = ''; - $list = implode('', $o); + return array('list' => implode('', $o), 'picked' => count($picked)); +} + +/** + * 把条目数组渲染成目录 HTML(完整盒子,用于正文内嵌) + * + * @param array $items 条目(含 level/text/id) + * @param array $atts 解析后的属性 + * @return string 空串表示不该输出(没有可用标题) + */ +function yang_toc_render($items, $atts = array()) +{ + $built = yang_toc_build_list($items, $atts); + if ($built['list'] === '') { + return ''; + } + $list = $built['list']; + $count = $built['picked']; + + // ── 参数归一 ── + $title = isset($atts['title']) && trim($atts['title']) !== '' + ? trim($atts['title']) + : __('目录', 'kratos'); + + $toggle = !isset($atts['toggle']) || (string) $atts['toggle'] === '1'; + $open = !isset($atts['open']) || (string) $atts['open'] === '1'; /* 折叠用
:原生元素,不需要 JS 就能工作。 JS 被禁用 / 加载失败时,目录仍然可以展开收起 —— - 比用自定义 div + class 切换更抗故障。 */ + 比用自定义 div + class 切换更抗故障。 + + 默认收起时不写 open 属性(details 的展开态只由 open 决定, + 不认 CSS)——「默认隐藏」靠的就是这一点。 */ if ($toggle) { - $html = '
' + $html = '
' . '' . '' . esc_html($title) . '' - . '' . count($picked) . '' + . '' . $count . '' . '' . '