feat: improve the style of the TOC component

This commit is contained in:
saicaca
2024-12-06 17:11:00 +08:00
parent e816120045
commit 9ab977fe4c

View File

@@ -9,26 +9,6 @@ interface Props {
let { headings = [] } = Astro.props; let { headings = [] } = Astro.props;
// generate random headings, for testing
// headings = [
// { text: 'Heading 1', depth: 1, slug: 'heading-1' },
// { text: 'Heading 2', depth: 2, slug: 'heading-2' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 2', depth: 2, slug: 'heading-2' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 1', depth: 1, slug: 'heading-1' },
// { text: 'Heading 2', depth: 2, slug: 'heading-2' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 2', depth: 2, slug: 'heading-2' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// { text: 'Heading 3', depth: 3, slug: 'heading-3' },
// ]
let minDepth = 10; let minDepth = 10;
for (const heading of headings) { for (const heading of headings) {
minDepth = Math.min(minDepth, heading.depth); minDepth = Math.min(minDepth, heading.depth);
@@ -49,7 +29,7 @@ let heading1Count = 1;
const maxLevel = siteConfig.toc.depth; const maxLevel = siteConfig.toc.depth;
--- ---
<table-of-contents class:list={[className]}> <table-of-contents class:list={[className, "group"]}>
{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,22 +52,23 @@ const maxLevel = siteConfig.toc.depth;
}]}>{removeTailingHash(heading.text)}</div> }]}>{removeTailingHash(heading.text)}</div>
</a> </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> </table-of-contents>
<script> <script>
interface HeadingPairInterface {
tocHeading: HTMLAnchorElement;
mdHeading: HTMLElement;
}
class TableOfContents extends HTMLElement { class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null; tocEl: HTMLElement | null = null;
headingMap = new Map<string, HeadingPairInterface>();
elementsToObserve: HTMLElement[] = [];
visibleClass = "visible"; visibleClass = "visible";
observer: IntersectionObserver; observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null; anchorNavTarget: HTMLElement | null = null;
headingIdxMap = new Map<string, number>();
headings: HTMLElement[] = [];
sections: HTMLElement[] = [];
tocEntries: HTMLAnchorElement[] = [];
active: boolean[] = [];
activeIndicator: HTMLElement | null = null;
constructor() { constructor() {
super(); super();
@@ -101,34 +82,47 @@ class TableOfContents extends HTMLElement {
entries.forEach((entry) => { entries.forEach((entry) => {
const id = entry.target.children[0]?.getAttribute("id"); const id = entry.target.children[0]?.getAttribute("id");
const pair = id ? this.headingMap.get(id) : undefined; const idx = id ? this.headingIdxMap.get(id) : undefined;
if (entry.isIntersecting && this.anchorNavTarget == entry.target) if (entry.isIntersecting && this.anchorNavTarget == entry.target)
this.anchorNavTarget = null; this.anchorNavTarget = null;
if (pair) if (idx != undefined)
this.toggleActiveHeading(pair, entry.isIntersecting); this.active[idx] = entry.isIntersecting;
}); });
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!document.querySelector(`#toc .${this.visibleClass}`)) { if (!document.querySelector(`#toc .${this.visibleClass}`)) {
this.fallback(); this.fallback();
} }
this.toggleActiveHeading();
this.scrollToActiveHeading(); this.scrollToActiveHeading();
}); });
}); });
}; };
toggleActiveHeading = ( toggleActiveHeading = () => {
headingPair: HeadingPairInterface, let i = this.active.length - 1;
flag: boolean let min = this.active.length - 1, max = 0;
) => { while (i >= 0 && !this.active[i]) {
this.tocEntries[i].classList.remove(this.visibleClass);
headingPair.tocHeading.classList.toggle(this.visibleClass, flag); i--;
headingPair.tocHeading.children[1].classList.toggle( }
"!text-[var(--toc-item-active)]", while (i >= 0 && this.active[i]) {
flag 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 = () => { scrollToActiveHeading = () => {
@@ -148,7 +142,7 @@ class TableOfContents extends HTMLElement {
let top; let top;
if (bottommost.getBoundingClientRect().bottom - if (bottommost.getBoundingClientRect().bottom -
topmost.getBoundingClientRect().top < 0.9 * tocHeight) topmost.getBoundingClientRect().top < 0.9 * tocHeight)
top = topmost.offsetTop - 20; top = topmost.offsetTop - 32;
else else
top = bottommost.offsetTop - tocHeight * 0.8; top = bottommost.offsetTop - tocHeight * 0.8;
@@ -160,48 +154,27 @@ class TableOfContents extends HTMLElement {
}; };
fallback = () => { fallback = () => {
if (!this.headingMap.size) return; if (!this.sections.length) return;
let pairs = []; for (let i = 0; i < this.sections.length; i++) {
let prevOffsetTop = -Infinity; let offsetTop = this.sections[i].getBoundingClientRect().top;
let offsetBottom = this.sections[i].getBoundingClientRect().bottom;
for (const [key, val] of this.headingMap) { if (this.isInRange(offsetTop, 0, window.innerHeight)
let offsetTop = val.mdHeading.getBoundingClientRect().top; || this.isInRange(offsetBottom, 0, window.innerHeight)
|| (offsetTop < 0 && offsetBottom > window.innerHeight)) {
if (this.isInRange(prevOffsetTop, 0, window.innerHeight) this.markActiveHeading(i);
|| (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; else break;
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
pairs.forEach(pair => { this.toggleActiveHeading();
this.toggleActiveHeading(pair, true);
})
}) })
}; };
markActiveHeading = (activeHeadingKey: string)=> { markActiveHeading = (idx: number)=> {
let sectionPairs: HeadingPairInterface[] = []; this.active[idx] = true;
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) => { handleAnchorClick = (event: Event) => {
@@ -211,7 +184,12 @@ class TableOfContents extends HTMLElement {
if (anchor) { if (anchor) {
const id = anchor.hash?.substring(1); const id = anchor.hash?.substring(1);
this.anchorNavTarget = this.headingMap.get(id)?.mdHeading || null; const idx = this.headingIdxMap.get(id);
if (idx !== undefined) {
this.anchorNavTarget = this.headings[idx];
} else {
this.anchorNavTarget = null;
}
} }
}; };
@@ -230,22 +208,28 @@ class TableOfContents extends HTMLElement {
capture: true, capture: true,
}); });
this.elementsToObserve = Array.from( this.activeIndicator = document.getElementById("active-indicator");
this.sections = Array.from(
document.querySelectorAll("section") document.querySelectorAll("section")
); );
const tocItems = Array.from( this.tocEntries = Array.from(
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']") document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
); );
tocItems.forEach((tocHeading) => { this.headings = new Array(this.tocEntries.length);
const id = tocHeading.hash?.substring(1); for (let i = 0; i < this.tocEntries.length; i++) {
const mdHeading = document.getElementById(id)?.parentElement; const id = this.tocEntries[i].hash?.substring(1);
if (mdHeading instanceof HTMLElement) const section = document.getElementById(id)?.parentElement;
this.headingMap.set(id, { tocHeading, mdHeading }); if (section instanceof HTMLElement) {
}); this.headings[i] = section;
this.headingIdxMap.set(id, i);
}
}
this.active = new Array(this.tocEntries.length).fill(false);
this.elementsToObserve.forEach((section) => this.sections.forEach((section) =>
this.observer.observe(section) this.observer.observe(section)
); );
@@ -254,7 +238,7 @@ class TableOfContents extends HTMLElement {
} }
disconnectedCallback() { disconnectedCallback() {
this.elementsToObserve.forEach((section) => this.sections.forEach((section) =>
this.observer.unobserve(section) this.observer.unobserve(section)
); );
this.observer.disconnect(); this.observer.disconnect();