mirror of
https://github.com/saicaca/fuwari.git
synced 2026-01-11 06:42:53 +01:00
feat: TOC highlight and scroll sync (#216)
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -49,7 +49,7 @@ let heading1Count = 1;
|
||||
|
||||
const maxLevel = siteConfig.toc.depth;
|
||||
---
|
||||
<div class:list={[className]}>
|
||||
<table-of-contents class:list={[className]}>
|
||||
{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
|
||||
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>
|
||||
</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>
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user