TOC 说明
layouts/_partials/toc/toc.html 增加
1<script>2class TableOfContents extends HTMLElement {3 constructor() {4 super();5 this.tocEl = null;6 this.visibleClass = "visible";7 this.observer = new IntersectionObserver(8 this.markVisibleSection, { threshold: 0 }9 );10 this.anchorNavTarget = null;11 this.headingIdxMap = new Map();12 this.headings = [];13 this.sections = [];14 this.tocEntries = [];15 this.active = [];16 this.activeIndicator = null;17 }18 19 markVisibleSection = (entries) => {20 entries.forEach((entry) => {21 const id = entry.target.children[0]?.getAttribute("id");22 const idx = id ? this.headingIdxMap.get(id) : undefined;23 if (idx != undefined)24 this.active[idx] = entry.isIntersecting;25 26 if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)27 this.anchorNavTarget = null;28 });29 30 if (!this.active.includes(true))31 this.fallback();32 this.update();33 };34 35 toggleActiveHeading = () => {36 let i = this.active.length - 1;37 let min = this.active.length - 1, max = -1;38 while (i >= 0 && !this.active[i]) {39 this.tocEntries[i].classList.remove(this.visibleClass);40 i--;41 }42 while (i >= 0 && this.active[i]) {43 this.tocEntries[i].classList.add(this.visibleClass);44 min = Math.min(min, i);45 max = Math.max(max, i);46 i--;47 }48 while (i >= 0) {49 this.tocEntries[i].classList.remove(this.visibleClass);50 i--;51 }52 if (min > max) {53 this.activeIndicator?.setAttribute("style", `opacity: 0`);54 } else {55 let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;56 let scrollOffset = this.tocEl?.scrollTop || 0;57 let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;58 let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;59 this.activeIndicator?.setAttribute("style", `top: ${top}px; height: ${bottom - top}px`);60 }61 };62 63 scrollToActiveHeading = () => {64 if (this.anchorNavTarget || !this.tocEl) return;65 const activeHeading =66 document.querySelectorAll(`#toc .${this.visibleClass}`);67 if (!activeHeading.length) return;68 69 const topmost = activeHeading[0];70 const bottommost = activeHeading[activeHeading.length - 1];71 const tocHeight = this.tocEl.clientHeight;72 73 let top;74 if (bottommost.getBoundingClientRect().bottom -75 topmost.getBoundingClientRect().top < 0.9 * tocHeight)76 top = topmost.offsetTop - 32;77 else78 top = bottommost.offsetTop - tocHeight * 0.8;79 80 this.tocEl.scrollTo({81 top,82 left: 0,83 behavior: "smooth",84 });85 };86 87 update = () => {88 requestAnimationFrame(() => {89 this.toggleActiveHeading();90 this.scrollToActiveHeading();91 });92 };93 94 fallback = () => {95 if (!this.sections.length) return;96 97 for (let i = 0; i < this.sections.length; i++) {98 let offsetTop = this.sections[i].getBoundingClientRect().top;99 let offsetBottom = this.sections[i].getBoundingClientRect().bottom;100 101 if (this.isInRange(offsetTop, 0, window.innerHeight)102 || this.isInRange(offsetBottom, 0, window.innerHeight)103 || (offsetTop < 0 && offsetBottom > window.innerHeight)) {104 this.markActiveHeading(i);105 }106 else if (offsetTop > window.innerHeight) break;107 }108 };109 110 markActiveHeading = (idx) => {111 this.active[idx] = true;112 };113 114 handleAnchorClick = (event) => {115 const anchor = event116 .composedPath()117 .find((element) => element instanceof HTMLAnchorElement);118 119 if (anchor) {120 const id = decodeURIComponent(anchor.hash?.substring(1));121 const idx = this.headingIdxMap.get(id);122 if (idx !== undefined) {123 this.anchorNavTarget = this.headings[idx];124 } else {125 this.anchorNavTarget = null;126 }127 }128 };129 130 isInRange(value, min, max) {131 return min < value && value < max;132 }133 134 connectedCallback() {135 const element = document.querySelector('.prose');136 if (element) {137 element.addEventListener('animationend', () => {138 this.init();139 }, { once: true });140 } else {141 console.debug('Animation element not found');142 }143 }144 145 init() {146 this.tocEl = document.getElementById("toc-inner-wrapper");147 148 if (!this.tocEl) return;149 150 this.tocEl.addEventListener("click", this.handleAnchorClick, {151 capture: true,152 });153 154 this.activeIndicator = document.getElementById("active-indicator");155 156 this.tocEntries = Array.from(157 document.querySelectorAll("#toc a[href^='#']")158 );159 160 if (this.tocEntries.length === 0) return;161 162 this.sections = new Array(this.tocEntries.length);163 this.headings = new Array(this.tocEntries.length);164 for (let i = 0; i < this.tocEntries.length; i++) {165 const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));166 const heading = document.getElementById(id);167 const section = heading?.parentElement;168 if (heading instanceof HTMLElement && section instanceof HTMLElement) {169 this.headings[i] = heading;170 this.sections[i] = section;171 this.headingIdxMap.set(id, i);172 }173 }174 this.active = new Array(this.tocEntries.length).fill(false);175 176 this.sections.forEach((section) =>177 this.observer.observe(section)178 );179 180 this.fallback();181 this.update();182 }183 184 disconnectedCallback() {185 this.sections.forEach((section) =>186 this.observer.unobserve(section)187 );188 this.observer.disconnect();189 this.tocEl?.removeEventListener("click", this.handleAnchorClick);190 }191}192 193if (!customElements.get("table-of-contents")) {194 customElements.define("table-of-contents", TableOfContents);195}196</script>
此外还需要生成 section。也就是remarkSectionize 的功能
1 remarkPlugins: [2 remarkMath,3 remarkReadingTime,4 remarkExcerpt,5 remarkGithubAdmonitionsToDirectives,6 remarkDirective,7 remarkSectionize,8 parseDirectiveNode,9 ],
layouts/_markup/render-heading.html 增加
1{{- /* 使用 Scratch 存储上一个标题级别 */ -}}2{{- $prevHeading := .Page.Scratch.Get "prevHeading" -}}3{{- $currentHeading := .Level -}}4 5{{- /* 章节逻辑:6 1. 当不是第一个标题时,关闭上一个章节7 2. 打开新章节8 3. 渲染当前标题9 4. 将当前标题存入 Scratch,供下一次调用10*/ -}}11 12{{- /* 1. 如果不是第一个标题,就关闭上一个章节 */ -}}13{{- if $prevHeading }}14</section>15{{- end }}16 17{{- /* 2. 打开新章节,关键 id 必须与标题自身的锚点相同,确保滚动监听 */ -}}18{{/* <section id="heading-{{ .Anchor }}" data-level="{{ .Level }}" class="content-section"> */}}19<section>20 21{{- /* 3. 渲染标题本身 */ -}}22<h{{ .Level }} id="{{ .Anchor }}">23 {{ .Text }}24</h{{ .Level }}>25 26{{- /* 4. 更新 Scratch,记录当前标题级别 */ -}}27{{- .Page.Scratch.Set "prevHeading" $currentHeading -}}
然后 layouts/posts/single.html layouts/page.html 增加
1 {{ .Content }}2 {{ if .Scratch.Get "prevHeading" }}3 </section>4 {{ end }}