feat: TOC highlight and scroll sync (#216)

This commit is contained in:
foxton9
2024-12-06 12:46:10 +08:00
committed by saicaca
parent b604cdf18c
commit e816120045
5 changed files with 241 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ import rehypeSlug from "rehype-slug";
import remarkDirective from "remark-directive"; /* Handle directives */ import remarkDirective from "remark-directive"; /* Handle directives */
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives"; import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import remarkSectionize from "remark-sectionize";
import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs"; import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs";
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"; import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js"; import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
@@ -68,6 +69,7 @@ export default defineConfig({
remarkExcerpt, remarkExcerpt,
remarkGithubAdmonitionsToDirectives, remarkGithubAdmonitionsToDirectives,
remarkDirective, remarkDirective,
remarkSectionize,
parseDirectiveNode, parseDirectiveNode,
], ],
rehypePlugins: [ rehypePlugins: [

View File

@@ -48,6 +48,7 @@
"remark-directive-rehype": "^0.4.2", "remark-directive-rehype": "^0.4.2",
"remark-github-admonitions-to-directives": "^1.0.5", "remark-github-admonitions-to-directives": "^1.0.5",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-sectionize": "^2.0.0",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"stylus": "^0.63.0", "stylus": "^0.63.0",

43
pnpm-lock.yaml generated
View File

@@ -107,6 +107,9 @@ importers:
remark-math: remark-math:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
remark-sectionize:
specifier: ^2.0.0
version: 2.0.0
sanitize-html: sanitize-html:
specifier: ^2.13.1 specifier: ^2.13.1
version: 2.13.1 version: 2.13.1
@@ -4009,6 +4012,9 @@ packages:
remark-rehype@11.1.1: remark-rehype@11.1.1:
resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==}
remark-sectionize@2.0.0:
resolution: {integrity: sha512-B+sCNNQroXybxX5Gwu9xbkjFIgK6vHMwbgPM/CEzQTP2ODxUiBsQRBjoSC6XR+yPOkgHvXV83HWCNA8IZuvJKg==}
remark-smartypants@3.0.2: remark-smartypants@3.0.2:
resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -4500,9 +4506,15 @@ packages:
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} 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: unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@5.2.1:
resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==}
unist-util-is@6.0.0: unist-util-is@6.0.0:
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
@@ -4524,9 +4536,15 @@ packages:
unist-util-visit-children@3.0.0: unist-util-visit-children@3.0.0:
resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} 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: unist-util-visit-parents@6.0.1:
resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==}
unist-util-visit@4.1.2:
resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==}
unist-util-visit@5.0.0: unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
@@ -9381,6 +9399,11 @@ snapshots:
unified: 11.0.5 unified: 11.0.5
vfile: 6.0.3 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: remark-smartypants@3.0.2:
dependencies: dependencies:
retext: 9.0.0 retext: 9.0.0
@@ -10031,11 +10054,20 @@ snapshots:
trough: 2.2.0 trough: 2.2.0
vfile: 6.0.3 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: unist-util-find-after@5.0.0:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
unist-util-is: 6.0.0 unist-util-is: 6.0.0
unist-util-is@5.2.1:
dependencies:
'@types/unist': 2.0.11
unist-util-is@6.0.0: unist-util-is@6.0.0:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -10066,11 +10098,22 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@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: unist-util-visit-parents@6.0.1:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
unist-util-is: 6.0.0 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: unist-util-visit@5.0.0:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3

View File

@@ -49,7 +49,7 @@ let heading1Count = 1;
const maxLevel = siteConfig.toc.depth; const maxLevel = siteConfig.toc.depth;
--- ---
<div class:list={[className]}> <table-of-contents class:list={[className]}>
{headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) => {headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) =>
<a href={`#${heading.slug}`} class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl <a href={`#${heading.slug}`} class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl
hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2 hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2
@@ -72,4 +72,196 @@ const maxLevel = siteConfig.toc.depth;
}]}>{removeTailingHash(heading.text)}</div> }]}>{removeTailingHash(heading.text)}</div>
</a> </a>
)} )}
</div> </table-of-contents>
<script>
interface HeadingPairInterface {
tocHeading: HTMLAnchorElement;
mdHeading: HTMLElement;
}
class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null;
headingMap = new Map<string, HeadingPairInterface>();
elementsToObserve: HTMLElement[] = [];
visibleClass = "visible";
observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null;
constructor() {
super();
this.observer = new IntersectionObserver(
this.markVisibleSection, { threshold: 0 }
);
}
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
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<HTMLDivElement>("#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<HTMLElement>("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<HTMLAnchorElement>("#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);
</script>

View File

@@ -93,4 +93,5 @@ define({
--toc-btn-hover: oklch(0.92 0.015 var(--hue)) oklch(0.22 0.02 var(--hue)) --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-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-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))
}) })