/*! * 文章目录(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) }())