mirror of
https://github.com/saicaca/fuwari.git
synced 2026-01-11 14:52:52 +01:00
feat: improve the style of the TOC component
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user