Files
blog/src/components/Post/Toc.astro
2026-08-28 22:14:41 +08:00

173 lines
3.9 KiB
Plaintext

---
import { type TocNode } from "@/lib/processHtml";
interface Props {
toc: TocNode[];
}
const { toc } = Astro.props;
const lowLevel = Math.min(...toc.map((v) => v.level));
---
<div class="toc-mount">
<div class="toc">
{
toc.map((item) => {
return (
<a
href={`#${item.id}`}
class="toc-item"
style={{ paddingLeft: `${(item.level - lowLevel) * 8}px` }}
>
{item.text}
</a>
);
})
}
</div>
</div>
<style>
.toc-mount {
position: absolute;
top: -20px;
height: 100%;
right: -320px;
width: 280px;
padding: 24px 0;
}
.toc {
position: sticky;
top: 60px;
height: calc(100vh - 120px);
width: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
border-left: var(--border-secondary);
padding-left: 24px;
}
.toc-item {
color: var(--text-primary);
font-size: 14px;
line-height: 20px;
}
.toc-item:hover {
color: var(--text-title-hover);
font-weight: bold;
}
.toc-item.is-active {
position: relative;
color: var(--text-title-hover);
font-weight: bold;
}
.toc-item.is-active::before {
content: "";
position: absolute;
left: -25px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 18px;
border-radius: 2px;
background: currentColor;
}
@media (max-width: 1250px) {
.toc-mount{
display: none;
}
}
</style>
<script>
const links = Array.from(
document.querySelectorAll<HTMLAnchorElement>(".toc-item"),
);
const headings = links
.map((link) => {
const id = link.getAttribute("href")?.slice(1);
return id ? { link, heading: document.getElementById(id) } : null;
})
.filter((x): x is { link: HTMLAnchorElement; heading: HTMLElement } =>
Boolean(x?.heading),
);
if (headings.length > 0) {
const toc = document.querySelector<HTMLElement>(".toc");
const ACTIVE_OFFSET = 100;
let currentId: string | null = null;
let raf = 0;
const scrollActiveIntoView = (link: HTMLAnchorElement) => {
if (!toc) return;
const containerTop = toc.getBoundingClientRect().top;
const linkTop =
link.getBoundingClientRect().top - containerTop + toc.scrollTop;
const linkBottom =
link.getBoundingClientRect().bottom - containerTop + toc.scrollTop;
if (linkTop < toc.scrollTop) {
toc.scrollTop = linkTop;
} else if (linkBottom > toc.scrollTop + toc.clientHeight) {
toc.scrollTop = linkBottom - toc.clientHeight;
}
};
const setActive = (id: string | null) => {
if (id === currentId) return;
currentId = id;
for (const { link, heading } of headings) {
const active = heading.id === id;
link.classList.toggle("is-active", active);
if (active) {
link.setAttribute("aria-current", "location");
scrollActiveIntoView(link);
} else {
link.removeAttribute("aria-current");
}
}
};
const update = () => {
raf = 0;
const doc = document.documentElement;
const atBottom =
window.scrollY + window.innerHeight >= doc.scrollHeight - 4;
let activeId: string | null = null;
if (atBottom) {
activeId = headings[headings.length - 1].heading.id;
} else {
for (const { heading } of headings) {
if (heading.getBoundingClientRect().top <= ACTIVE_OFFSET) {
activeId = heading.id;
} else {
break;
}
}
}
setActive(activeId);
};
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(update);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
update();
}
</script>