810 words
2 min
TOC 说明
0001-01-01
0001-01-01

TOC 说明

layouts/_partials/toc/toc.html 增加

<script>
class TableOfContents extends HTMLElement {
    constructor() {
        super();
        this.tocEl = null;
        this.visibleClass = "visible";
        this.observer = new IntersectionObserver(
            this.markVisibleSection, { threshold: 0 }
        );
        this.anchorNavTarget = null;
        this.headingIdxMap = new Map();
        this.headings = [];
        this.sections = [];
        this.tocEntries = [];
        this.active = [];
        this.activeIndicator = null;
    }

    markVisibleSection = (entries) => {
        entries.forEach((entry) => {
            const id = entry.target.children[0]?.getAttribute("id");
            const idx = id ? this.headingIdxMap.get(id) : undefined;
            if (idx != undefined)
                this.active[idx] = entry.isIntersecting;

            if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
                this.anchorNavTarget = null;
        });

        if (!this.active.includes(true))
            this.fallback();
        this.update();
    };

    toggleActiveHeading = () => {
        let i = this.active.length - 1;
        let min = this.active.length - 1, max = -1;
        while (i >= 0 && !this.active[i]) {
            this.tocEntries[i].classList.remove(this.visibleClass);
            i--;
        }
        while (i >= 0 && this.active[i]) {
            this.tocEntries[i].classList.add(this.visibleClass);
            min = Math.min(min, i);
            max = Math.max(max, i);
            i--;
        }
        while (i >= 0) {
            this.tocEntries[i].classList.remove(this.visibleClass);
            i--;
        }
        if (min > max) {
            this.activeIndicator?.setAttribute("style", `opacity: 0`);
        } else {
            let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;
            let scrollOffset = this.tocEl?.scrollTop || 0;
            let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;
            let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;
            this.activeIndicator?.setAttribute("style", `top: ${top}px; height: ${bottom - top}px`);
        }
    };

    scrollToActiveHeading = () => {
        if (this.anchorNavTarget || !this.tocEl) return;
        const activeHeading =
            document.querySelectorAll(`#toc .${this.visibleClass}`);
        if (!activeHeading.length) return;

        const topmost = activeHeading[0];
        const bottommost = activeHeading[activeHeading.length - 1];
        const tocHeight = this.tocEl.clientHeight;

        let top;
        if (bottommost.getBoundingClientRect().bottom -
            topmost.getBoundingClientRect().top < 0.9 * tocHeight)
            top = topmost.offsetTop - 32;
        else
            top = bottommost.offsetTop - tocHeight * 0.8;

        this.tocEl.scrollTo({
            top,
            left: 0,
            behavior: "smooth",
        });
    };

    update = () => {
        requestAnimationFrame(() => {
            this.toggleActiveHeading();
            this.scrollToActiveHeading();
        });
    };

    fallback = () => {
        if (!this.sections.length) return;

        for (let i = 0; i < this.sections.length; i++) {
            let offsetTop = this.sections[i].getBoundingClientRect().top;
            let offsetBottom = this.sections[i].getBoundingClientRect().bottom;

            if (this.isInRange(offsetTop, 0, window.innerHeight)
                || this.isInRange(offsetBottom, 0, window.innerHeight)
                || (offsetTop < 0 && offsetBottom > window.innerHeight)) {                    
                this.markActiveHeading(i);
            }
            else if (offsetTop > window.innerHeight) break;
        }
    };

    markActiveHeading = (idx) => {
        this.active[idx] = true;
    };

    handleAnchorClick = (event) => {
        const anchor = event
            .composedPath()
            .find((element) => element instanceof HTMLAnchorElement);

        if (anchor) {
            const id = decodeURIComponent(anchor.hash?.substring(1));
            const idx = this.headingIdxMap.get(id);
            if (idx !== undefined) {
                this.anchorNavTarget = this.headings[idx];
            } else {
                this.anchorNavTarget = null;
            }
        }
    };

    isInRange(value, min, max) {
        return min < value && value < max;
    }

    connectedCallback() {
        const element = document.querySelector('.prose');
        if (element) {
            element.addEventListener('animationend', () => {
                this.init();
            }, { once: true });
        } else {
            console.debug('Animation element not found');
        }
    }

    init() {
        this.tocEl = document.getElementById("toc-inner-wrapper");

        if (!this.tocEl) return;

        this.tocEl.addEventListener("click", this.handleAnchorClick, {
            capture: true,
        });

        this.activeIndicator = document.getElementById("active-indicator");

        this.tocEntries = Array.from(
            document.querySelectorAll("#toc a[href^='#']")
        );

        if (this.tocEntries.length === 0) return;

        this.sections = new Array(this.tocEntries.length);
        this.headings = new Array(this.tocEntries.length);
        for (let i = 0; i < this.tocEntries.length; i++) {
            const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
            const heading = document.getElementById(id);
            const section = heading?.parentElement;
            if (heading instanceof HTMLElement && section instanceof HTMLElement) {
                this.headings[i] = heading;
                this.sections[i] = section;
                this.headingIdxMap.set(id, i);
            }
        }
        this.active = new Array(this.tocEntries.length).fill(false);

        this.sections.forEach((section) =>
            this.observer.observe(section)
        );

        this.fallback();
        this.update();
    }

    disconnectedCallback() {
        this.sections.forEach((section) =>
            this.observer.unobserve(section)
        );
        this.observer.disconnect();
        this.tocEl?.removeEventListener("click", this.handleAnchorClick);
    }
}

if (!customElements.get("table-of-contents")) {
    customElements.define("table-of-contents", TableOfContents);
}
</script>

此外还需要生成 section。也就是remarkSectionize 的功能

		remarkPlugins: [
			remarkMath,
			remarkReadingTime,
			remarkExcerpt,
			remarkGithubAdmonitionsToDirectives,
			remarkDirective,
			remarkSectionize,
			parseDirectiveNode,
		],

layouts/_markup/render-heading.html 增加

{{- /* 使用 Scratch 存储上一个标题级别 */ -}}
{{- $prevHeading := .Page.Scratch.Get "prevHeading" -}}
{{- $currentHeading := .Level -}}

{{- /* 章节逻辑:
      1. 当不是第一个标题时,关闭上一个章节
      2. 打开新章节
      3. 渲染当前标题
      4. 将当前标题存入 Scratch,供下一次调用
*/ -}}

{{- /* 1. 如果不是第一个标题,就关闭上一个章节 */ -}}
{{- if $prevHeading }}
</section>
{{- end }}

{{- /* 2. 打开新章节,关键 id 必须与标题自身的锚点相同,确保滚动监听 */ -}}
{{/*  <section id="heading-{{ .Anchor }}" data-level="{{ .Level }}" class="content-section">  */}}
<section>

{{- /* 3. 渲染标题本身 */ -}}
<h{{ .Level }} id="{{ .Anchor }}">
    {{ .Text }}
</h{{ .Level }}>

{{- /* 4. 更新 Scratch,记录当前标题级别 */ -}}
{{- .Page.Scratch.Set "prevHeading" $currentHeading -}}

然后 layouts/posts/single.html layouts/page.html 增加

      {{ .Content }}
      {{ if .Scratch.Get "prevHeading" }}
      </section>
      {{ end }}
TOC 说明
https://kkbt0.github.io/Hugo-Landscape/posts/toc/
Author
恐咖兵糖
Published at
0001-01-01
License
CC BY-NC-SA 4.0