177 lines
4.0 KiB
Plaintext
177 lines
4.0 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);
|
|
text-shadow:
|
|
0.3px 0 0 currentColor,
|
|
-0.3px 0 0 currentColor;
|
|
}
|
|
|
|
.toc-item.is-active {
|
|
position: relative;
|
|
color: var(--text-title-hover);
|
|
text-shadow:
|
|
0.3px 0 0 currentColor,
|
|
-0.3px 0 0 currentColor;
|
|
}
|
|
|
|
.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>
|