mirror of
https://github.com/saicaca/fuwari.git
synced 2026-01-11 14:52:52 +01:00
251 lines
8.5 KiB
Plaintext
251 lines
8.5 KiB
Plaintext
---
|
|
import type { MarkdownHeading } from 'astro';
|
|
import { siteConfig } from "../../config";
|
|
|
|
interface Props {
|
|
class?: string
|
|
headings: MarkdownHeading[]
|
|
}
|
|
|
|
let { headings = [] } = Astro.props;
|
|
|
|
let minDepth = 10;
|
|
for (const heading of headings) {
|
|
minDepth = Math.min(minDepth, heading.depth);
|
|
}
|
|
|
|
const className = Astro.props.class
|
|
|
|
const removeTailingHash = (text: string) => {
|
|
let lastIndexOfHash = text.lastIndexOf('#');
|
|
if (lastIndexOfHash != text.length - 1) {
|
|
return text;
|
|
}
|
|
|
|
return text.substring(0, lastIndexOfHash);
|
|
}
|
|
|
|
let heading1Count = 1;
|
|
|
|
const maxLevel = siteConfig.toc.depth;
|
|
---
|
|
<table-of-contents class:list={[className, "group"]}>
|
|
{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
|
|
">
|
|
<div class:list={["transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold",
|
|
{
|
|
"bg-[var(--toc-badge-bg)] text-[var(--btn-content)]": heading.depth == minDepth,
|
|
"ml-4": heading.depth == minDepth + 1,
|
|
"ml-8": heading.depth == minDepth + 2,
|
|
}
|
|
]}
|
|
>
|
|
{heading.depth == minDepth && heading1Count++}
|
|
{heading.depth == minDepth + 1 && <div class="transition w-2 h-2 rounded-[0.1875rem] bg-[var(--toc-badge-bg)]"></div>}
|
|
{heading.depth == minDepth + 2 && <div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10"></div>}
|
|
</div>
|
|
<div class:list={["transition text-sm", {
|
|
"text-50": heading.depth == minDepth || heading.depth == minDepth + 1,
|
|
"text-30": heading.depth == minDepth + 2,
|
|
}]}>{removeTailingHash(heading.text)}</div>
|
|
</a>
|
|
)}
|
|
<div id="active-indicator" class="-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 h-16 rounded-xl transition-all
|
|
group-hover:bg-transparent border-2 border-[var(--toc-btn-hover)] group-hover:border-[var(--toc-btn-active)] border-dashed"></div>
|
|
</table-of-contents>
|
|
|
|
|
|
<script>
|
|
class TableOfContents extends HTMLElement {
|
|
tocEl: HTMLElement | null = null;
|
|
visibleClass = "visible";
|
|
observer: IntersectionObserver;
|
|
anchorNavTarget: HTMLElement | null = null;
|
|
headingIdxMap = new Map<string, number>();
|
|
headings: HTMLElement[] = [];
|
|
sections: HTMLElement[] = [];
|
|
tocEntries: HTMLAnchorElement[] = [];
|
|
active: boolean[] = [];
|
|
activeIndicator: 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 idx = id ? this.headingIdxMap.get(id) : undefined;
|
|
|
|
if (entry.isIntersecting && this.anchorNavTarget == entry.target)
|
|
this.anchorNavTarget = null;
|
|
|
|
if (idx != undefined)
|
|
this.active[idx] = entry.isIntersecting;
|
|
});
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!document.querySelector(`#toc .${this.visibleClass}`)) {
|
|
this.fallback();
|
|
}
|
|
this.toggleActiveHeading();
|
|
this.scrollToActiveHeading();
|
|
});
|
|
});
|
|
};
|
|
|
|
toggleActiveHeading = () => {
|
|
let i = this.active.length - 1;
|
|
let min = this.active.length - 1, max = 0;
|
|
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--;
|
|
}
|
|
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 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 - 32;
|
|
else
|
|
top = bottommost.offsetTop - tocHeight * 0.8;
|
|
|
|
this.tocEl.scrollTo({
|
|
top,
|
|
left: 0,
|
|
behavior: "smooth",
|
|
});
|
|
};
|
|
|
|
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 break;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
this.toggleActiveHeading();
|
|
})
|
|
};
|
|
|
|
markActiveHeading = (idx: number)=> {
|
|
this.active[idx] = true;
|
|
}
|
|
|
|
handleAnchorClick = (event: Event) => {
|
|
const anchor = event
|
|
.composedPath()
|
|
.find((element) => element instanceof HTMLAnchorElement);
|
|
|
|
if (anchor) {
|
|
const id = anchor.hash?.substring(1);
|
|
const idx = this.headingIdxMap.get(id);
|
|
if (idx !== undefined) {
|
|
this.anchorNavTarget = this.headings[idx];
|
|
} else {
|
|
this.anchorNavTarget = 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.activeIndicator = document.getElementById("active-indicator");
|
|
|
|
this.sections = Array.from(
|
|
document.querySelectorAll("section")
|
|
);
|
|
|
|
this.tocEntries = Array.from(
|
|
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
|
|
);
|
|
|
|
this.headings = new Array(this.tocEntries.length);
|
|
for (let i = 0; i < this.tocEntries.length; i++) {
|
|
const id = this.tocEntries[i].hash?.substring(1);
|
|
const section = document.getElementById(id)?.parentElement;
|
|
if (section instanceof HTMLElement) {
|
|
this.headings[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.scrollToActiveHeading();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.sections.forEach((section) =>
|
|
this.observer.unobserve(section)
|
|
);
|
|
this.observer.disconnect();
|
|
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
|
|
}
|
|
}
|
|
|
|
customElements.define("table-of-contents", TableOfContents);
|
|
|
|
</script> |