-
-
Notifications
You must be signed in to change notification settings - Fork 16
feat: 添加侧边栏文章目录 (TOC) #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 放在右侧即可。 | ||
|
|
||
| ### 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 标题是否需要显示"目录"字样?(建议显示) | ||
| 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 监听各标题进入视口,动态高亮当前章节。 |
| 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) |
| 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 隐藏 |
| 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> | ||||||
|
||||||
| <h2 class="toc-title">目录</h2> | |
| <h2 class="toc-title" aria-hidden="true">目录</h2> |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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
AI
Jan 19, 2026
There was a problem hiding this comment.
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
AI
Jan 19, 2026
There was a problem hiding this comment.
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
AI
Jan 19, 2026
There was a problem hiding this comment.
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
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.