Skip to content

feat: 添加侧边栏文章目录 (TOC) - #27

Merged
xkcoding merged 2 commits into
masterfrom
feature/add-sidebar-toc
Jan 19, 2026
Merged

xkcoding merged 2 commits into
masterfrom
feature/add-sidebar-toc

Conversation

@xkcoding

Copy link
Copy Markdown
Owner

Summary

  • 新增侧边栏 TOC 组件,大屏幕(>= 1280px)自动显示
  • 滚动时自动高亮当前阅读章节
  • 支持全局开关 SITE.showToc 和单篇禁用 hideToc

Changes

文件 变更
src/components/TableOfContents.astro 新增 TOC 组件
src/layouts/PostDetails.astro 集成 TOC 组件
src/styles/global.css 添加 TOC 样式
src/config.ts 添加 showToc 配置
src/content.config.ts 添加 hideToc schema

Features

  • Intersection Observer 实现滚动高亮
  • h2-h4 层级缩进显示
  • 点击平滑跳转
  • 响应式隐藏(< 1280px)
  • 标题 < 2 时自动隐藏

Test plan

  • 长文章 TOC 显示正确
  • 滚动高亮功能正常
  • 点击跳转平滑
  • 小屏幕 TOC 隐藏
  • hideToc: true 文章不显示 TOC

🤖 Generated with Claude Code

xkcoding and others added 2 commits January 19, 2026 17:01
- 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>
Copilot AI review requested due to automatic review settings January 19, 2026 09:10
@xkcoding
xkcoding merged commit a6b7709 into master Jan 19, 2026
5 checks passed
@xkcoding
xkcoding deleted the feature/add-sidebar-toc branch January 19, 2026 09:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +53 to +90
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,
}
);

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
---

<nav class="toc-sidebar" aria-label="目录">
<h2 class="toc-title">目录</h2>

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<h2 class="toc-title">目录</h2>
<h2 class="toc-title" aria-hidden="true">目录</h2>

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +84
} 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);
}
}

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +30
### 1. 布局方案:Sticky 定位 + 绝对定位容器

**选择**: 使用 `position: sticky` 让 TOC 跟随滚动,配合右侧绝对定位容器。

**备选方案**:
- Grid 三栏布局:会影响现有内容区居中逻辑,改动较大
- Fixed 定位:需要手动计算滚动位置,且会脱离文档流

**理由**: Sticky 定位最简单,只需在文章容器外层包裹一个相对定位的容器,TOC 放在右侧即可。

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
### 1. 布局方案:Sticky 定位 + 绝对定位容器
**选择**: 使用 `position: sticky` TOC 跟随滚动,配合右侧绝对定位容器
**备选方案**:
- Grid 三栏布局:会影响现有内容区居中逻辑,改动较大
- Fixed 定位:需要手动计算滚动位置,且会脱离文档流
**理由**: Sticky 定位最简单,只需在文章容器外层包裹一个相对定位的容器,TOC 放在右侧即可
### 1. 布局方案:Fixed 定位 + 绝对定位容器(更新后)
**选择**: 最终实现使用 `position: fixed` TOC 固定在右侧,配合绝对/固定定位容器保证在长文和不同页面结构下行为一致(最初方案为 `position: sticky`,后续在实现阶段调整为 fixed)
**备选方案(设计阶段)**:
- Grid 三栏布局:会影响现有内容区居中逻辑,改动较大
- Sticky 定位:在某些页面与页脚/容器高度配合存在边界问题,且不同浏览器表现不完全一致
**理由**: Fixed 定位在各页面结构下表现更稳定,不依赖外层容器高度计算,避免了 sticky 在滚动到页面底部或复杂布局时的兼容性问题,同时实现上更易于控制 TOC 的起始/结束位置

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +119
document.addEventListener("astro:before-swap", () => {
observer.disconnect();
});

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/styles/global.css
Comment on lines +94 to +100
left: calc(50% + 448px + 2rem);
}
}

@media (min-width: 1536px) {
.toc-sidebar {
left: calc(50% + 448px + 3rem);

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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));

Copilot uses AI. Check for mistakes.
const rect = heading.getBoundingClientRect();
const headingTop = rect.top + scrollTop;

if (headingTop <= scrollTop + 100) {

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/styles/global.css
Comment on lines +73 to +76
.post-container {
position: relative;
}

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.post-container {
position: relative;
}

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +124
// 页面加载和切换时初始化
initTocHighlight();
document.addEventListener("astro:after-swap", initTocHighlight);

Copilot AI Jan 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants