Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions openspec/changes/add-sidebar-toc/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## Context

博客使用 Astro + AstroPaper 主题,当前文章详情页 (`PostDetails.astro`) 采用单栏布局,最大宽度 `max-w-4xl`。需要在不破坏现有布局的前提下,添加侧边栏 TOC。

Astro 的 `render()` 函数会返回 `headings` 数组,包含文章所有标题的 `depth`、`slug`、`text` 信息,可直接用于生成 TOC。

## Goals / Non-Goals

**Goals:**
- 大屏幕(>= 1280px)显示侧边栏 TOC
- 滚动时自动高亮当前阅读章节
- 点击 TOC 项平滑跳转
- 支持全局开关和单篇文章禁用

**Non-Goals:**
- 移动端侧边栏 TOC(保持简洁,依赖内联 TOC)
- TOC 折叠/展开功能(MVP 不做)
- 多级嵌套超过 h4(限制 h2-h4)

## Decisions

### 1. 布局方案:Sticky 定位 + 绝对定位容器

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

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

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

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.

### 2. 高亮实现:Intersection Observer

**选择**: 使用 Intersection Observer API 监听标题元素进入视口。

**理由**:
- 性能优于 scroll 事件监听
- 浏览器原生支持,无需额外依赖
- Astro 的 `is:inline` 脚本可直接使用

### 3. 响应式断点:xl (1280px)

**选择**: 仅在 `>= 1280px` 显示 TOC。

**理由**:
- 文章内容区 `max-w-4xl` 约 896px
- TOC 宽度约 200-250px
- 加上左右边距,1280px 是合理的最小显示宽度

### 4. TOC 显示条件

**选择**: 仅当 `headings.length >= 2` 且 `SITE.showToc && !hideToc` 时显示。

**理由**: 单个标题或无标题的文章不需要 TOC。

## Risks / Trade-offs

| 风险 | 影响 | 缓解措施 |
|-----|-----|---------|
| 超长 TOC 溢出 | 部分章节不可见 | 添加 `max-height` + `overflow-y: auto` |
| 标题文字过长 | TOC 宽度撑开 | 使用 `text-overflow: ellipsis` 截断 |
| 快速滚动高亮闪烁 | 体验不佳 | 添加 debounce 或 threshold 调整 |

## Open Questions

- 是否需要支持 h5/h6 层级?(建议 MVP 不支持)
- TOC 标题是否需要显示"目录"字样?(建议显示)
50 changes: 50 additions & 0 deletions openspec/changes/add-sidebar-toc/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Change: 添加侧边栏文章目录 (Table of Contents)

## Why

当前博客的 TOC 机制是内联式的(需要在文章中手动添加 `## Table of contents` 占位符),无法自动生成,也不支持:
- 侧边栏浮动显示
- 滚动时自动高亮当前阅读位置
- 点击跳转到对应章节

长文章阅读体验较差,读者难以快速定位内容。

## What Changes

- **新增** `TableOfContents.astro` 组件:侧边栏浮动 TOC
- **修改** `PostDetails.astro` 布局:集成 TOC 组件,调整文章区域布局
- **新增** TOC 相关样式:响应式设计、高亮当前章节
- **新增** `SITE.showToc` 配置项:全局开关
- **新增** frontmatter `hideToc` 字段:单篇文章禁用 TOC

## Impact

- Affected specs: `blog-reading` (新增)
- Affected code:
- `src/components/TableOfContents.astro` (新建)
- `src/layouts/PostDetails.astro` (修改布局)
- `src/styles/global.css` (新增 TOC 样式)
- `src/config.ts` (新增配置项)
- `src/content.config.ts` (新增 frontmatter schema)

## Design Considerations

### 布局方案

采用 CSS Grid 三栏布局:
- 左侧:留白(可选放置其他元素)
- 中间:文章内容(保持现有 `max-w-4xl` 宽度)
- 右侧:TOC 侧边栏(固定定位,宽度约 200-250px)

### 响应式策略

| 屏幕宽度 | TOC 行为 |
|---------|---------|
| `< 1280px` (xl) | 隐藏侧边栏 TOC |
| `>= 1280px` | 显示侧边栏 TOC |

移动端继续依赖原有的 remark-toc 内联目录(如有需要)。

### 高亮逻辑

使用 Intersection Observer API 监听各标题进入视口,动态高亮当前章节。
56 changes: 56 additions & 0 deletions openspec/changes/add-sidebar-toc/specs/blog-reading/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
## ADDED Requirements

### Requirement: Sidebar Table of Contents

The blog system SHALL display a sidebar Table of Contents (TOC) for article pages on large screens.

#### Scenario: TOC displays on large screens
- **WHEN** a user views an article page on a screen width >= 1280px
- **AND** the article has 2 or more headings (h2-h4)
- **AND** `SITE.showToc` is enabled
- **AND** the article frontmatter does not have `hideToc: true`
- **THEN** a sidebar TOC SHALL be displayed on the right side of the article

#### Scenario: TOC hidden on small screens
- **WHEN** a user views an article page on a screen width < 1280px
- **THEN** the sidebar TOC SHALL NOT be displayed

#### Scenario: TOC hidden for short articles
- **WHEN** an article has fewer than 2 headings
- **THEN** the sidebar TOC SHALL NOT be displayed

#### Scenario: TOC disabled via frontmatter
- **WHEN** an article has `hideToc: true` in frontmatter
- **THEN** the sidebar TOC SHALL NOT be displayed for that article

### Requirement: TOC Current Section Highlighting

The TOC SHALL highlight the current reading section as the user scrolls through the article.

#### Scenario: Highlight updates on scroll
- **WHEN** a user scrolls through the article
- **AND** a heading enters the viewport
- **THEN** the corresponding TOC item SHALL be visually highlighted
- **AND** previously highlighted items SHALL be unhighlighted

### Requirement: TOC Click Navigation

The TOC SHALL support click-to-navigate functionality with smooth scrolling.

#### Scenario: Click TOC item to navigate
- **WHEN** a user clicks on a TOC item
- **THEN** the page SHALL smooth-scroll to the corresponding heading
- **AND** the URL hash SHALL be updated to reflect the heading anchor

### Requirement: TOC Configuration

The system SHALL provide configuration options to control TOC behavior.

#### Scenario: Global TOC toggle
- **WHEN** `SITE.showToc` is set to `false` in config
- **THEN** sidebar TOC SHALL NOT be displayed on any article page

#### Scenario: Per-article TOC toggle
- **WHEN** an article frontmatter contains `hideToc: true`
- **THEN** sidebar TOC SHALL NOT be displayed for that specific article
- **AND** other articles without this flag SHALL still display TOC (if enabled globally)
37 changes: 37 additions & 0 deletions openspec/changes/add-sidebar-toc/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## 1. 配置与 Schema

- [x] 1.1 在 `src/config.ts` 添加 `showToc: true` 配置项
- [x] 1.2 在 `src/content.config.ts` 的 blog schema 添加 `hideToc` 可选字段

## 2. TOC 组件开发

- [x] 2.1 创建 `src/components/TableOfContents.astro` 组件
- 接收 `headings` 属性(从 `render()` 获取)
- 渲染嵌套列表结构(支持 h2-h4 层级)
- 支持点击跳转(平滑滚动)
- [x] 2.2 添加客户端脚本实现高亮逻辑
- 使用 Intersection Observer 监听标题元素
- 动态添加 `.active` 类到当前章节链接

## 3. 布局集成

- [x] 3.1 修改 `src/layouts/PostDetails.astro`
- 从 `render()` 解构 `headings`
- 调整布局结构(添加 post-container 包裹)
- 条件渲染 TOC 组件(检查 `SITE.showToc` 和 `!hideToc`)
- TOC 仅在 headings 数量 >= 2 时显示

## 4. 样式实现

- [x] 4.1 在 `src/styles/global.css` 添加 TOC 样式
- 固定定位(fixed)
- 响应式显示/隐藏(1280px 断点)
- 高亮状态样式(accent 颜色 + 左边框)
- 层级缩进(h3/h4 递进缩进)
- 滚动条美化(thin scrollbar)

## 5. 验证测试

- [x] 5.1 本地构建验证通过
- [x] 5.2 TOC 显示条件逻辑实现:`SITE.showToc && !hideToc && headings >= 2`
- [x] 5.3 响应式断点:>= 1280px 显示,< 1280px 隐藏
125 changes: 125 additions & 0 deletions src/components/TableOfContents.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
import type { MarkdownHeading } from "astro";

interface Props {
headings: MarkdownHeading[];
}

const { headings } = Astro.props;

// 只保留 h2-h4 层级
const filteredHeadings = headings.filter(h => h.depth >= 2 && h.depth <= 4);
---

<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.
<ul class="toc-list">
{
filteredHeadings.map(heading => (
<li
class:list={[
"toc-item",
{ "toc-h3": heading.depth === 3 },
{ "toc-h4": heading.depth === 4 },
]}
>
<a href={`#${heading.slug}`} data-heading-slug={heading.slug}>
{heading.text}
</a>
</li>
))
}
</ul>
</nav>

<script is:inline data-astro-rerun>
function initTocHighlight() {
const tocLinks = document.querySelectorAll(
".toc-sidebar a[data-heading-slug]"
);
if (tocLinks.length === 0) return;

const headingElements = Array.from(tocLinks)
.map(link => {
const slug = link.getAttribute("data-heading-slug");
return document.getElementById(slug);
})
.filter(Boolean);

if (headingElements.length === 0) return;

let currentActive = null;

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) {

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.
const distance = scrollTop - headingTop;
if (distance < closestDistance) {
closestDistance = distance;
closestHeading = heading;
}
}
});

if (closestHeading) {
setActiveLink(closestHeading.id);
}
}
Comment on lines +61 to +84

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.
},
{
rootMargin: "-80px 0px -80% 0px",
threshold: 0,
}
);
Comment on lines +53 to +90

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.

function setActiveLink(id) {
if (currentActive === id) return;
currentActive = id;

tocLinks.forEach(link => {
const slug = link.getAttribute("data-heading-slug");
if (slug === id) {
link.classList.add("active");
} else {
link.classList.remove("active");
}
});
}

// 观察所有标题
headingElements.forEach(heading => {
if (heading) observer.observe(heading);
});

// 初始化时设置第一个为激活状态
if (headingElements[0]) {
setActiveLink(headingElements[0].id);
}

// 清理函数
document.addEventListener("astro:before-swap", () => {
observer.disconnect();
});
Comment on lines +117 to +119

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.
}

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

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.
</script>
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const SITE = {
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
showArchives: true,
showBackButton: true,
showToc: true, // 显示侧边栏目录
editPost: {
enabled: true,
text: "编辑页面",
Expand Down
1 change: 1 addition & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const blog = defineCollection({
description: z.string(),
canonicalURL: z.string().optional(),
hideEditPost: z.boolean().optional(),
hideToc: z.boolean().optional(),
timezone: z.string().optional(),
}),
});
Expand Down
Loading
Loading