diff --git a/astro.config.mjs b/astro.config.mjs index c00f4e86..81374932 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -12,6 +12,7 @@ import rehypeSlug from "rehype-slug"; import remarkDirective from "remark-directive"; /* Handle directives */ import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives"; import remarkMath from "remark-math"; +import remarkSectionize from "remark-sectionize"; import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs"; import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"; import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js"; @@ -68,6 +69,7 @@ export default defineConfig({ remarkExcerpt, remarkGithubAdmonitionsToDirectives, remarkDirective, + remarkSectionize, parseDirectiveNode, ], rehypePlugins: [ diff --git a/package.json b/package.json index ffbdb974..2da44884 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "remark-directive-rehype": "^0.4.2", "remark-github-admonitions-to-directives": "^1.0.5", "remark-math": "^6.0.0", + "remark-sectionize": "^2.0.0", "sanitize-html": "^2.13.1", "sharp": "^0.33.5", "stylus": "^0.63.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbd0bcde..2c891564 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: remark-math: specifier: ^6.0.0 version: 6.0.0 + remark-sectionize: + specifier: ^2.0.0 + version: 2.0.0 sanitize-html: specifier: ^2.13.1 version: 2.13.1 @@ -4009,6 +4012,9 @@ packages: remark-rehype@11.1.1: resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} + remark-sectionize@2.0.0: + resolution: {integrity: sha512-B+sCNNQroXybxX5Gwu9xbkjFIgK6vHMwbgPM/CEzQTP2ODxUiBsQRBjoSC6XR+yPOkgHvXV83HWCNA8IZuvJKg==} + remark-smartypants@3.0.2: resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} engines: {node: '>=16.0.0'} @@ -4500,9 +4506,15 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@4.0.1: + resolution: {integrity: sha512-QO/PuPMm2ERxC6vFXEPtmAutOopy5PknD+Oq64gGwxKtk4xwo9Z97t9Av1obPmGU0IyTa6EKYUfTrK2QJS3Ozw==} + unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@5.2.1: + resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} @@ -4524,9 +4536,15 @@ packages: unist-util-visit-children@3.0.0: resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + unist-util-visit-parents@5.1.3: + resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} + unist-util-visit-parents@6.0.1: resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + unist-util-visit@4.1.2: + resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} + unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} @@ -9381,6 +9399,11 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-sectionize@2.0.0: + dependencies: + unist-util-find-after: 4.0.1 + unist-util-visit: 4.1.2 + remark-smartypants@3.0.2: dependencies: retext: 9.0.0 @@ -10031,11 +10054,20 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@4.0.1: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.0 + unist-util-is@5.2.1: + dependencies: + '@types/unist': 2.0.11 + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3 @@ -10066,11 +10098,22 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-visit-parents@5.1.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents@6.0.1: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.0 + unist-util-visit@4.1.2: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + unist-util-visit@5.0.0: dependencies: '@types/unist': 3.0.3 diff --git a/src/components/widget/TOC.astro b/src/components/widget/TOC.astro index 59da887d..6f03738e 100644 --- a/src/components/widget/TOC.astro +++ b/src/components/widget/TOC.astro @@ -49,7 +49,7 @@ let heading1Count = 1; const maxLevel = siteConfig.toc.depth; --- -
+ {headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) => { + requestAnimationFrame(() => { + entries.forEach((entry) => { + const id = entry.target.children[0]?.getAttribute("id"); + + const pair = id ? this.headingMap.get(id) : undefined; + + if (entry.isIntersecting && this.anchorNavTarget == entry.target) + this.anchorNavTarget = null; + + if (pair) + this.toggleActiveHeading(pair, entry.isIntersecting); + }); + + requestAnimationFrame(() => { + if (!document.querySelector(`#toc .${this.visibleClass}`)) { + this.fallback(); + } + this.scrollToActiveHeading(); + }); + }); + }; + + toggleActiveHeading = ( + headingPair: HeadingPairInterface, + flag: boolean + ) => { + + headingPair.tocHeading.classList.toggle(this.visibleClass, flag); + headingPair.tocHeading.children[1].classList.toggle( + "!text-[var(--toc-item-active)]", + flag + ); + }; + + scrollToActiveHeading = () => { + // If the TOC widget can accommodate both the topmost + // and bottommost items, scroll to the topmost item. + // Otherwise, scroll to the bottommost one. + + if (this.anchorNavTarget || !this.tocEl) return; + const activeHeading = + document.querySelectorAll("#toc .visible"); + 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 - 20; + else + top = bottommost.offsetTop - tocHeight * 0.8; + + this.tocEl.scrollTo({ + top, + left: 0, + behavior: "smooth", + }); + }; + + fallback = () => { + if (!this.headingMap.size) return; + + let pairs = []; + let prevOffsetTop = -Infinity; + + for (const [key, val] of this.headingMap) { + let offsetTop = val.mdHeading.getBoundingClientRect().top; + + if (this.isInRange(prevOffsetTop, 0, window.innerHeight) + || (prevOffsetTop < 0 && + this.isInRange(offsetTop, 0, window.innerHeight)) + || (prevOffsetTop < 0 && offsetTop > window.innerHeight)) { + const newPairs = this.markActiveHeading(key); + pairs.push(...newPairs); + prevOffsetTop = offsetTop; + } + else break; + } + + requestAnimationFrame(() => { + pairs.forEach(pair => { + this.toggleActiveHeading(pair, true); + }) + }) + }; + + markActiveHeading = (activeHeadingKey: string)=> { + let sectionPairs: HeadingPairInterface[] = []; + let currentSection = this.headingMap.get(activeHeadingKey) + ?.mdHeading.closest("section"); + + while (currentSection && !currentSection.classList.contains("prose")) { + const id = currentSection.firstElementChild?.id; + const sectionPair = id ? this.headingMap.get(id) : undefined; + + sectionPair && sectionPairs.push(sectionPair); + + currentSection = currentSection.parentElement + ?.closest("section") || null; + } + + return sectionPairs; + } + + handleAnchorClick = (event: Event) => { + const anchor = event + .composedPath() + .find((element) => element instanceof HTMLAnchorElement); + + if (anchor) { + const id = anchor.hash?.substring(1); + this.anchorNavTarget = this.headingMap.get(id)?.mdHeading || null; + } + }; + + isInRange(value: number, min: number, max: number) { + return min < value && value < max; + } + + connectedCallback() { + this.tocEl = document.getElementById( + "toc-inner-wrapper" + ); + + if (!this.tocEl) return; + + this.tocEl.addEventListener("click", this.handleAnchorClick, { + capture: true, + }); + + this.elementsToObserve = Array.from( + document.querySelectorAll("section") + ); + + const tocItems = Array.from( + document.querySelectorAll("#toc a[href^='#']") + ); + + tocItems.forEach((tocHeading) => { + const id = tocHeading.hash?.substring(1); + const mdHeading = document.getElementById(id)?.parentElement; + if (mdHeading instanceof HTMLElement) + this.headingMap.set(id, { tocHeading, mdHeading }); + }); + + this.elementsToObserve.forEach((section) => + this.observer.observe(section) + ); + + this.fallback(); + this.scrollToActiveHeading(); + } + + disconnectedCallback() { + this.elementsToObserve.forEach((section) => + this.observer.unobserve(section) + ); + this.observer.disconnect(); + this.tocEl?.removeEventListener("click", this.handleAnchorClick); + } +} + +customElements.define("table-of-contents", TableOfContents); + + \ No newline at end of file diff --git a/src/styles/variables.styl b/src/styles/variables.styl index 76b82be3..9bccc093 100644 --- a/src/styles/variables.styl +++ b/src/styles/variables.styl @@ -93,4 +93,5 @@ define({ --toc-btn-hover: oklch(0.92 0.015 var(--hue)) oklch(0.22 0.02 var(--hue)) --toc-btn-active: oklch(0.90 0.015 var(--hue)) oklch(0.25 0.02 var(--hue)) --toc-width: calc((100vw - var(--page-width)) / 2 - 1rem) + --toc-item-active: oklch(0.70 0.13 var(--hue)) oklch(0.35 0.07 var(--hue)) })