/*! * 文章目录(TOC)交互 * Yongquan Yang * * ── 设计原则:没有 JS 也要能用 ──────────────────────────────── * 目录的**核心功能是跳转**,那由 + CSS 的 * scroll-margin-top 完成,完全不需要 JS。这个文件只做三件锦上添花的事: * ① 平滑滚动(不支持的浏览器直接跳,不影响功能) * ② 高亮当前阅读位置的条目 * ③ 侧边目录面板的展开 / 收起(默认状态由 HTML class 决定, * 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 () { /* 目录可能在两处:正文里的 .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')) 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() } /* ── ③ 侧边目录:展开 / 收起 ───────────────────────────── * 目录内容本身(链接、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 之后再做一次校正。 * * 只在"真的带 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() bindRail() fixHashOnLoad() } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init) } else { init() } window.addEventListener('load', fixHashOnLoad) }())