feat: 将目录(TOC)移至侧边栏并优化滚动高亮逻辑

- 重构TOC组件,移至SideBar中显示
- 改进IntersectionObserver实现,优化章节高亮行为
- 添加toc国际化翻译(中/英/繁)
- 更新Swup容器配置,支持文章页侧边栏刷新
- 移除原MainGridLayout中的独立TOC容器
This commit is contained in:
2026-03-22 12:16:51 +08:00
parent b7ebf3bef7
commit cbf35beeb8
9 changed files with 261 additions and 257 deletions

View File

@@ -1,22 +1,38 @@
---
import type { MarkdownHeading } from "astro";
import { siteConfig } from "@/config";
import Categories from "./Categories.astro";
import Profile from "./Profile.astro";
import Tag from "./Tags.astro";
import TOC from "./TOC.astro";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const className = Astro.props.class;
const { class: className, headings } = Astro.props;
const currentPagePath = Astro.url.pathname;
const isPostPage = currentPagePath.startsWith("/posts/");
---
<div id="sidebar" class:list={[className, "w-full"]}>
<div class="flex flex-col w-full gap-4 mb-4">
<Profile></Profile>
</div>
{
!isPostPage && (
<div class="flex flex-col w-full gap-4 mb-4">
<Profile />
</div>
)
}
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
<Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
<Tag class="onload-animation" style="animation-delay: 200ms"></Tag>
{
siteConfig.toc.enable && headings && headings.length > 0 && (
<div id="toc-wrapper" class="onload-animation card-base" style="animation-delay: 250ms">
<TOC headings={headings} />
</div>
)
}
<Categories class="onload-animation" style="animation-delay: 150ms" />
<Tag class="onload-animation" style="animation-delay: 200ms" />
</div>
</div>

View File

@@ -1,7 +1,10 @@
---
import type { MarkdownHeading } from "astro";
import { siteConfig } from "../../config";
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { url } from "../../utils/url-utils";
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
class?: string;
@@ -31,238 +34,249 @@ let heading1Count = 1;
const maxLevel = siteConfig.toc.depth;
---
{isPostsRoute &&
<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,
}
]}
<table-of-contents>
<WidgetLayout name={i18n(I18nKey.toc)} id="toc" class={className}>
<div class="flex flex-col pb-4">
<div
id="toc-wrapper"
class="onload-animation card-base"
style="animation-delay: 250ms"
>
<div
id="toc-inner-wrapper"
class="overflow-y-auto max-h-[50vh] hide-scrollbar pr-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>}
{
headings
.filter(
(heading) =>
heading.depth < minDepth + maxLevel,
)
.map((heading) => (
<a
href={`#${heading.slug}`}
class="px-2 flex gap-2 relative transition-colors duration-200 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)]" />
)}
{heading.depth == minDepth + 2 && (
<div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10" />
)}
</div>
<div
class:list={[
"transition text-sm",
{
"text-90":
heading.depth == minDepth ||
heading.depth ==
minDepth + 1,
"text-75":
heading.depth ==
minDepth + 2,
},
]}
>
{removeTailingHash(heading.text)}
</div>
</a>
))
}
</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" style="opacity: 0" class:list={[{'hidden': headings.length == 0}, "-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 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>}
</div>
</div>
</WidgetLayout>
</table-of-contents>
<style>
table-of-contents a.visible {
background-color: var(--toc-btn-active) !important;
}
</style>
<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;
class TableOfContents extends HTMLElement {
tocEl: HTMLElement | null = null;
visibleClass = "visible";
observer: IntersectionObserver;
anchorNavTarget: HTMLElement | null = null;
headingIdxMap = new Map<string, number>();
sections: HTMLElement[] = [];
tocEntries: HTMLAnchorElement[] = [];
// active 数组不再需要,我们将直接追踪 activeIndex
activeIndex: number | null = null;
constructor() {
super();
this.observer = new IntersectionObserver(
this.markVisibleSection, { threshold: 0 }
);
};
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const id = entry.target.children[0]?.getAttribute("id");
const idx = id ? this.headingIdxMap.get(id) : undefined;
if (idx != undefined)
this.active[idx] = entry.isIntersecting;
if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
this.anchorNavTarget = null;
});
if (!this.active.includes(true))
this.fallback();
this.update();
};
toggleActiveHeading = () => {
let i = this.active.length - 1;
let min = this.active.length - 1, max = -1;
while (i >= 0 && !this.active[i]) {
this.tocEntries[i].classList.remove(this.visibleClass);
i--;
constructor() {
super();
// 调整 IntersectionObserver 的 rootMargin使其在章节顶部进入视口时就触发
this.observer = new IntersectionObserver(
this.handleIntersection,
{ rootMargin: "0px 0px -80% 0px" }, // 关键调整
);
}
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--;
}
if (min > max) {
this.activeIndicator?.setAttribute("style", `opacity: 0`);
} else {
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.
// --- 核心修改点 1: 新的 IntersectionObserver 回调 ---
handleIntersection = (entries: IntersectionObserverEntry[]) => {
// 当用户点击链接导航时,忽略 IntersectionObserver 的结果,防止冲突
if (this.anchorNavTarget) return;
if (this.anchorNavTarget || !this.tocEl) return;
const activeHeading =
document.querySelectorAll<HTMLDivElement>(`#toc .${this.visibleClass}`);
if (!activeHeading.length) return;
let latestVisibleEntry: IntersectionObserverEntry | undefined;
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",
});
};
update = () => {
requestAnimationFrame(() => {
this.toggleActiveHeading();
// requestAnimationFrame(() => {
this.scrollToActiveHeading();
// });
});
};
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);
// 找到进入视口的、最新的(在文档中位置最靠后)的条目
for (const entry of entries) {
if (entry.isIntersecting) {
latestVisibleEntry = entry;
}
}
else if (offsetTop > window.innerHeight) break;
}
};
markActiveHeading = (idx: number)=> {
this.active[idx] = true;
};
if (latestVisibleEntry) {
const id =
latestVisibleEntry.target.children[0]?.getAttribute("id");
const idx = id ? this.headingIdxMap.get(id) : undefined;
if (idx !== undefined) {
this.setActiveIndex(idx);
}
}
};
handleAnchorClick = (event: Event) => {
const anchor = event
.composedPath()
.find((element) => element instanceof HTMLAnchorElement);
// --- 核心修改点 2: 统一设置活动索引并更新UI ---
setActiveIndex = (index: number | null) => {
// 如果索引没有变化,则不执行任何操作
if (this.activeIndex === index) return;
this.activeIndex = index;
// 更新UI
this.tocEntries.forEach((entry, i) => {
if (i === this.activeIndex) {
entry.classList.add(this.visibleClass);
} else {
entry.classList.remove(this.visibleClass);
}
});
// 只有在非用户点击导航时才自动滚动
if (!this.anchorNavTarget) {
this.scrollToActiveHeading();
}
};
scrollToActiveHeading = () => {
if (this.activeIndex === null || !this.tocEl) return;
const activeEntry = this.tocEntries[this.activeIndex];
if (!activeEntry) return;
const tocHeight = this.tocEl.clientHeight;
const entryTop = activeEntry.offsetTop;
const entryHeight = activeEntry.offsetHeight;
// 检查活动项是否在可视区域内
if (
entryTop < this.tocEl.scrollTop ||
entryTop + entryHeight > this.tocEl.scrollTop + tocHeight
) {
this.tocEl.scrollTo({
top: entryTop - tocHeight / 2 + entryHeight / 2, // 尝试滚动到中间位置
left: 0,
behavior: "smooth",
});
}
};
// --- 核心修改点 3: 处理点击事件 ---
handleAnchorClick = (event: Event) => {
// 找到被点击的 <a> 标签
const anchor = event.currentTarget as HTMLAnchorElement;
if (anchor) {
const id = decodeURIComponent(anchor.hash?.substring(1));
const idx = this.headingIdxMap.get(id);
if (idx !== undefined) {
this.anchorNavTarget = this.headings[idx];
} else {
this.anchorNavTarget = null;
// 标记正在进行锚点导航,以暂时禁用 IntersectionObserver 的更新
const targetHeading = document.getElementById(id);
if (targetHeading) {
this.anchorNavTarget = targetHeading;
// 在导航完成后重置标志
setTimeout(() => {
this.anchorNavTarget = null;
}, 1000); // 1秒后自动清除以防万一
}
// 立即更新高亮
this.setActiveIndex(idx);
}
}
};
};
isInRange(value: number, min: number, max: number) {
return min < value && value < max;
};
connectedCallback() {
// wait for the onload animation to finish, which makes the `getBoundingClientRect` return correct values
const element = document.querySelector('.prose');
if (element) {
element.addEventListener('animationend', () => {
connectedCallback() {
requestAnimationFrame(() => {
this.init();
}, { once: true });
} else {
console.debug('Animation element not found');
});
}
};
init() {
this.tocEl = document.getElementById(
"toc-inner-wrapper"
);
init() {
this.tocEl = document.getElementById("toc-inner-wrapper") || this;
if (!this.tocEl) return;
this.tocEntries = Array.from(
this.querySelectorAll<HTMLAnchorElement>("a[href^='#']"),
);
this.tocEl.addEventListener("click", this.handleAnchorClick, {
capture: true,
});
if (this.tocEntries.length === 0) return;
this.activeIndicator = document.getElementById("active-indicator");
this.sections = new Array(this.tocEntries.length);
for (let i = 0; i < this.tocEntries.length; i++) {
const entry = this.tocEntries[i];
const id = decodeURIComponent(entry.hash?.substring(1));
const heading = document.getElementById(id);
const section = heading?.parentElement;
this.tocEntries = Array.from(
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
);
if (this.tocEntries.length === 0) return;
this.sections = new Array(this.tocEntries.length);
this.headings = new Array(this.tocEntries.length);
for (let i = 0; i < this.tocEntries.length; i++) {
const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
const heading = document.getElementById(id);
const section = heading?.parentElement;
if (heading instanceof HTMLElement && section instanceof HTMLElement) {
this.headings[i] = heading;
this.sections[i] = section;
this.headingIdxMap.set(id, i);
if (
heading instanceof HTMLElement &&
section instanceof HTMLElement
) {
this.sections[i] = section;
this.headingIdxMap.set(id, i);
// 为每个链接单独添加点击事件监听器
entry.addEventListener("click", this.handleAnchorClick);
}
}
this.sections.forEach((section) => this.observer.observe(section));
// 页面加载时,设置第一个标题为活动状态
this.setActiveIndex(0);
}
this.active = new Array(this.tocEntries.length).fill(false);
this.sections.forEach((section) =>
this.observer.observe(section)
);
disconnectedCallback() {
this.sections.forEach((section) =>
this.observer.unobserve(section),
);
this.observer.disconnect();
this.tocEntries.forEach((entry) => {
entry.removeEventListener("click", this.handleAnchorClick);
});
}
}
this.fallback();
this.update();
};
disconnectedCallback() {
this.sections.forEach((section) =>
this.observer.unobserve(section)
);
this.observer.disconnect();
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
};
}
if (!customElements.get("table-of-contents")) {
customElements.define("table-of-contents", TableOfContents);
}
</script>
if (!customElements.get("table-of-contents")) {
customElements.define("table-of-contents", TableOfContents);
}
</script>

View File

@@ -37,6 +37,7 @@ enum I18nKey {
notFoundDesc = "notFoundDesc",
backToHome = "backToHome",
pinned = "pinned",
toc = "toc",
}
export default I18nKey;

View File

@@ -40,4 +40,5 @@ export const en: Translation = {
[Key.notFoundDesc]: "The link is broken, the page has gone missing",
[Key.backToHome]: "Back to Home",
[Key.pinned]: "Pinned",
[Key.toc]: "Toc",
};

View File

@@ -40,4 +40,5 @@ export const zh_CN: Translation = {
[Key.notFoundDesc]: "你访问的链接已断开,页面走丢了",
[Key.backToHome]: "返回首页",
[Key.pinned]: "置顶",
[Key.toc]: "目录",
};

View File

@@ -40,4 +40,5 @@ export const zh_TW: Translation = {
[Key.notFoundDesc]: "你訪問的連結已斷開,頁面走丟了",
[Key.backToHome]: "返回首頁",
[Key.pinned]: "置頂",
[Key.toc]: "目錄",
};

View File

@@ -405,7 +405,19 @@ const setup = () => {
}
})
window.swup.hooks.on('content:replace', initCustomScrollbar)
window.swup.hooks.on('visit:start', (visit: {to: {url: string}}) => {
window.swup.hooks.on('visit:start', (visit: {to: {url: string};from: { url: string };containers: string[]}) => {
const postUrlPattern = "/posts/";
const isToPost = visit.to.url.includes(postUrlPattern);
const isFromPost = visit.from.url.includes(postUrlPattern);
if (isToPost || isFromPost) {
visit.containers = ["main", "#sidebar"]; // 刷新两者
} else {
// 否则(例如,从首页导航到关于页面),
// 仅刷新主内容区域。
visit.containers = ["main"];
}
// change banner height immediately when a link is clicked
const bodyElement = document.querySelector('body')
if (pathsEqual(visit.to.url, url('/'))) {
@@ -419,12 +431,6 @@ const setup = () => {
if (heightExtend) {
heightExtend.classList.remove('hidden')
}
// Hide the TOC while scrolling back to top
let toc = document.getElementById('toc-wrapper');
if (toc) {
toc.classList.add('toc-not-ready')
}
});
window.swup.hooks.on('page:view', () => {
// hide the temp high element when the transition is done
@@ -439,12 +445,6 @@ const setup = () => {
if (heightExtend) {
heightExtend.classList.add('hidden')
}
// Just make the transition looks better
const toc = document.getElementById('toc-wrapper');
if (toc) {
toc.classList.remove('toc-not-ready')
}
}, 200)
});
}
@@ -455,7 +455,6 @@ if (window?.swup?.hooks) {
}
let backToTopBtn = document.getElementById('back-to-top-btn');
let toc = document.getElementById('toc-wrapper');
let navbar = document.getElementById('navbar-wrapper')
function scrollFunction() {
let bannerHeight = window.innerHeight * (BANNER_HEIGHT / 100)
@@ -468,14 +467,6 @@ function scrollFunction() {
}
}
if (bannerEnabled && toc) {
if (document.body.scrollTop > bannerHeight || document.documentElement.scrollTop > bannerHeight) {
toc.classList.remove('toc-hide')
} else {
toc.classList.add('toc-hide')
}
}
if (!bannerEnabled) return
if (navbar) {
const NAVBAR_HEIGHT = 72

View File

@@ -6,7 +6,6 @@ import SideBar from "@components/widget/SideBar.astro";
import type { MarkdownHeading } from "astro";
import { Icon } from "astro-icon/components";
import ImageWrapper from "../components/misc/ImageWrapper.astro";
import TOC from "../components/widget/TOC.astro";
import { siteConfig } from "../config";
import {
BANNER_HEIGHT,
@@ -102,24 +101,4 @@ const mainPanelTop = siteConfig.banner.enable
</div>
</div>
<!-- The things that should be under the banner, only the TOC for now -->
<div class="absolute w-full z-0 hidden 2xl:block">
<div class="relative max-w-[var(--page-width)] mx-auto">
<!-- TOC component -->
{siteConfig.toc.enable && <div id="toc-wrapper" class:list={["hidden lg:block transition absolute top-0 -right-[var(--toc-width)] w-[var(--toc-width)] items-center",
{"toc-hide": siteConfig.banner.enable}]}
>
<div id="toc-inner-wrapper" class="fixed top-14 w-[var(--toc-width)] h-[calc(100vh_-_20rem)] overflow-y-scroll overflow-x-hidden hide-scrollbar">
<div id="toc" class="w-full h-full transition-swup-fade ">
<div class="h-8 w-full"></div>
<TOC headings={headings}></TOC>
<div class="h-8 w-full"></div>
</div>
</div>
</div>}
<!-- #toc needs to exist for Swup to work normally -->
{!siteConfig.toc.enable && <div id="toc"></div>}
</div>
</div>
</Layout>

View File

@@ -65,9 +65,9 @@
@apply opacity-0 pointer-events-none
}
#toc-inner-wrapper {
/* #toc-inner-wrapper {
mask-image: linear-gradient(to bottom, transparent 0%, black 2rem, black calc(100% - 2rem), transparent 100%);
}
} */
.hide-scrollbar {
scrollbar-width: none;