feat: 添加侧边栏文章目录 (TOC) - #27
Conversation
- proposal.md: 变更说明和影响范围 - design.md: 技术设计方案 - tasks.md: 实施任务清单 - specs/blog-reading/spec.md: 需求规格 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
功能实现: - 新增 TableOfContents.astro 组件 - 支持 h2-h4 层级目录 - Intersection Observer 实现滚动高亮 - 点击平滑跳转到对应章节 配置选项: - SITE.showToc: 全局开关 - hideToc: 单篇文章禁用 样式特性: - 固定定位侧边栏 - 响应式显示(>= 1280px) - accent 颜色高亮当前章节 - 层级缩进 + 文字截断 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a sidebar Table of Contents (TOC) feature for blog articles, automatically displaying a navigation sidebar on large screens (≥ 1280px) with scroll-based highlighting and smooth click navigation.
Changes:
- Adds TableOfContents.astro component with IntersectionObserver-based scroll tracking
- Integrates TOC into PostDetails layout with conditional rendering logic
- Implements responsive CSS styling with fixed positioning for TOC sidebar
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/TableOfContents.astro | New TOC component with scroll highlighting using IntersectionObserver |
| src/layouts/PostDetails.astro | Adds TOC integration with conditional rendering based on config and heading count |
| src/styles/global.css | Adds TOC sidebar styles with fixed positioning and responsive breakpoints |
| src/config.ts | Adds global showToc configuration flag |
| src/content.config.ts | Adds hideToc schema field for per-article control |
| openspec/changes/add-sidebar-toc/*.md | Documentation and specification files for the feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const observer = new IntersectionObserver( | ||
| entries => { | ||
| // 找到第一个进入视口的标题 | ||
| const visibleEntry = entries.find(entry => entry.isIntersecting); | ||
|
|
||
| if (visibleEntry) { | ||
| const id = visibleEntry.target.id; | ||
| setActiveLink(id); | ||
| } else { | ||
| // 如果没有标题在视口中,找最近的一个已经滚过的标题 | ||
| const scrollTop = window.scrollY; | ||
| let closestHeading = null; | ||
| let closestDistance = Infinity; | ||
|
|
||
| headingElements.forEach(heading => { | ||
| if (!heading) return; | ||
| const rect = heading.getBoundingClientRect(); | ||
| const headingTop = rect.top + scrollTop; | ||
|
|
||
| if (headingTop <= scrollTop + 100) { | ||
| const distance = scrollTop - headingTop; | ||
| if (distance < closestDistance) { | ||
| closestDistance = distance; | ||
| closestHeading = heading; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| if (closestHeading) { | ||
| setActiveLink(closestHeading.id); | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| rootMargin: "-80px 0px -80% 0px", | ||
| threshold: 0, | ||
| } | ||
| ); |
There was a problem hiding this comment.
Incomplete cleanup logic: when 'astro:after-swap' fires and initTocHighlight() runs again, a new IntersectionObserver is created without disconnecting the previous one. This happens because the observer variable is local to the function and the cleanup only happens on 'astro:before-swap'. The observer should be stored in a higher scope or the cleanup should happen at the start of initTocHighlight() before creating a new observer.
| --- | ||
|
|
||
| <nav class="toc-sidebar" aria-label="目录"> | ||
| <h2 class="toc-title">目录</h2> |
There was a problem hiding this comment.
Accessibility issue: the h2 element with class 'toc-title' should use aria-hidden="true" or be changed to a div, since the nav element already has aria-label="目录". Having both a visible h2 and an aria-label on the nav can cause confusion for screen reader users as the heading creates a landmark that duplicates the navigation landmark's label.
| <h2 class="toc-title">目录</h2> | |
| <h2 class="toc-title" aria-hidden="true">目录</h2> |
| } else { | ||
| // 如果没有标题在视口中,找最近的一个已经滚过的标题 | ||
| const scrollTop = window.scrollY; | ||
| let closestHeading = null; | ||
| let closestDistance = Infinity; | ||
|
|
||
| headingElements.forEach(heading => { | ||
| if (!heading) return; | ||
| const rect = heading.getBoundingClientRect(); | ||
| const headingTop = rect.top + scrollTop; | ||
|
|
||
| if (headingTop <= scrollTop + 100) { | ||
| const distance = scrollTop - headingTop; | ||
| if (distance < closestDistance) { | ||
| closestDistance = distance; | ||
| closestHeading = heading; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| if (closestHeading) { | ||
| setActiveLink(closestHeading.id); | ||
| } | ||
| } |
There was a problem hiding this comment.
Performance consideration: the IntersectionObserver callback runs on every intersection change and iterates through all headingElements when no entry is intersecting. For articles with many headings, this could cause performance issues during rapid scrolling. Consider debouncing or throttling the scrollTop calculation in the else branch, or optimizing the loop to break early once the closest heading is found if headings are guaranteed to be in DOM order.
| ### 1. 布局方案:Sticky 定位 + 绝对定位容器 | ||
|
|
||
| **选择**: 使用 `position: sticky` 让 TOC 跟随滚动,配合右侧绝对定位容器。 | ||
|
|
||
| **备选方案**: | ||
| - Grid 三栏布局:会影响现有内容区居中逻辑,改动较大 | ||
| - Fixed 定位:需要手动计算滚动位置,且会脱离文档流 | ||
|
|
||
| **理由**: Sticky 定位最简单,只需在文章容器外层包裹一个相对定位的容器,TOC 放在右侧即可。 |
There was a problem hiding this comment.
Documentation inconsistency: the design document states the decision was to use 'position: sticky' for the TOC, but the actual implementation uses 'position: fixed'. The documentation should be updated to reflect the final implementation choice or include a note explaining why the decision was changed from sticky to fixed positioning.
| ### 1. 布局方案:Sticky 定位 + 绝对定位容器 | |
| **选择**: 使用 `position: sticky` 让 TOC 跟随滚动,配合右侧绝对定位容器。 | |
| **备选方案**: | |
| - Grid 三栏布局:会影响现有内容区居中逻辑,改动较大 | |
| - Fixed 定位:需要手动计算滚动位置,且会脱离文档流 | |
| **理由**: Sticky 定位最简单,只需在文章容器外层包裹一个相对定位的容器,TOC 放在右侧即可。 | |
| ### 1. 布局方案:Fixed 定位 + 绝对定位容器(更新后) | |
| **选择**: 最终实现使用 `position: fixed` 将 TOC 固定在右侧,配合绝对/固定定位容器保证在长文和不同页面结构下行为一致(最初方案为 `position: sticky`,后续在实现阶段调整为 fixed)。 | |
| **备选方案(设计阶段)**: | |
| - Grid 三栏布局:会影响现有内容区居中逻辑,改动较大 | |
| - Sticky 定位:在某些页面与页脚/容器高度配合存在边界问题,且不同浏览器表现不完全一致 | |
| **理由**: Fixed 定位在各页面结构下表现更稳定,不依赖外层容器高度计算,避免了 sticky 在滚动到页面底部或复杂布局时的兼容性问题,同时实现上更易于控制 TOC 的起始/结束位置。 |
| document.addEventListener("astro:before-swap", () => { | ||
| observer.disconnect(); | ||
| }); |
There was a problem hiding this comment.
Memory leak: the event listener for 'astro:before-swap' is registered inside initTocHighlight() but never removed. Each time initTocHighlight() runs (on page load and after every page swap), a new listener is added, causing accumulation of duplicate listeners over time. The listener should be registered once outside the function, or the cleanup handler should use a named function that can be properly removed and re-added.
| left: calc(50% + 448px + 2rem); | ||
| } | ||
| } | ||
|
|
||
| @media (min-width: 1536px) { | ||
| .toc-sidebar { | ||
| left: calc(50% + 448px + 3rem); |
There was a problem hiding this comment.
Potential layout issue: the TOC uses fixed positioning with a calculated left position (calc(50% + 448px + 2rem)), which assumes the content is centered. However, if the viewport is not wide enough (e.g., exactly 1280px), the TOC may overflow outside the viewport or be cut off. Consider adding a max constraint or using right positioning instead. For example, at 1280px viewport width: (1280/2) + 448 + 32 = 640 + 480 = 1120px left position, leaving only 160px for the 220px wide TOC.
| left: calc(50% + 448px + 2rem); | |
| } | |
| } | |
| @media (min-width: 1536px) { | |
| .toc-sidebar { | |
| left: calc(50% + 448px + 3rem); | |
| left: min(calc(50% + 448px + 2rem), calc(100vw - 220px - 1rem)); | |
| } | |
| } | |
| @media (min-width: 1536px) { | |
| .toc-sidebar { | |
| left: min(calc(50% + 448px + 3rem), calc(100vw - 250px - 1rem)); |
| const rect = heading.getBoundingClientRect(); | ||
| const headingTop = rect.top + scrollTop; | ||
|
|
||
| if (headingTop <= scrollTop + 100) { |
There was a problem hiding this comment.
Magic number: the value 100 in 'scrollTop + 100' is unexplained. This appears to be an offset threshold but lacks documentation explaining why this specific value was chosen and what it represents (likely the top margin or header height). Consider extracting this as a named constant with a descriptive comment.
| .post-container { | ||
| position: relative; | ||
| } | ||
|
|
There was a problem hiding this comment.
Unnecessary CSS class: the 'post-container' class only sets 'position: relative' but the TOC uses 'position: fixed', which doesn't position relative to this container. The post-container div wrapper doesn't serve a functional purpose with the current fixed positioning approach. Consider removing it or changing the TOC positioning strategy to actually use this container.
| .post-container { | |
| position: relative; | |
| } |
| // 页面加载和切换时初始化 | ||
| initTocHighlight(); | ||
| document.addEventListener("astro:after-swap", initTocHighlight); |
There was a problem hiding this comment.
Inconsistent event listener pattern: this component adds the 'astro:after-swap' listener at the module level (line 124), which is correct. However, looking at similar components like BackToTopButton.astro in the codebase, they handle cleanup by removing and re-adding listeners in a consistent pattern. Consider refactoring to match the established pattern in the codebase for consistency and maintainability.
Summary
SITE.showToc和单篇禁用hideTocChanges
src/components/TableOfContents.astrosrc/layouts/PostDetails.astrosrc/styles/global.csssrc/config.tsshowToc配置src/content.config.tshideTocschemaFeatures
Test plan
hideToc: true文章不显示 TOC🤖 Generated with Claude Code