diff --git a/openspec/changes/add-sidebar-toc/design.md b/openspec/changes/add-sidebar-toc/design.md new file mode 100644 index 00000000..afff5db0 --- /dev/null +++ b/openspec/changes/add-sidebar-toc/design.md @@ -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 标题是否需要显示"目录"字样?(建议显示) diff --git a/openspec/changes/add-sidebar-toc/proposal.md b/openspec/changes/add-sidebar-toc/proposal.md new file mode 100644 index 00000000..22241bce --- /dev/null +++ b/openspec/changes/add-sidebar-toc/proposal.md @@ -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 监听各标题进入视口,动态高亮当前章节。 diff --git a/openspec/changes/add-sidebar-toc/specs/blog-reading/spec.md b/openspec/changes/add-sidebar-toc/specs/blog-reading/spec.md new file mode 100644 index 00000000..e1928d0b --- /dev/null +++ b/openspec/changes/add-sidebar-toc/specs/blog-reading/spec.md @@ -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) diff --git a/openspec/changes/add-sidebar-toc/tasks.md b/openspec/changes/add-sidebar-toc/tasks.md new file mode 100644 index 00000000..1bc3c24f --- /dev/null +++ b/openspec/changes/add-sidebar-toc/tasks.md @@ -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 隐藏 diff --git a/src/components/TableOfContents.astro b/src/components/TableOfContents.astro new file mode 100644 index 00000000..a8b12f27 --- /dev/null +++ b/src/components/TableOfContents.astro @@ -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); +--- + + + + diff --git a/src/config.ts b/src/config.ts index f138e9a8..e016d429 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ export const SITE = { scheduledPostMargin: 15 * 60 * 1000, // 15 minutes showArchives: true, showBackButton: true, + showToc: true, // 显示侧边栏目录 editPost: { enabled: true, text: "编辑页面", diff --git a/src/content.config.ts b/src/content.config.ts index 6119aa62..919acd11 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), }), }); diff --git a/src/layouts/PostDetails.astro b/src/layouts/PostDetails.astro index 58fe2de3..335e485a 100644 --- a/src/layouts/PostDetails.astro +++ b/src/layouts/PostDetails.astro @@ -9,6 +9,7 @@ import EditPost from "@/components/EditPost.astro"; import ShareLinks from "@/components/ShareLinks.astro"; import BackButton from "@/components/BackButton.astro"; import BackToTopButton from "@/components/BackToTopButton.astro"; +import TableOfContents from "@/components/TableOfContents.astro"; import { getPath } from "@/utils/getPath"; import { slugifyStr } from "@/utils/slugify"; import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg"; @@ -33,9 +34,16 @@ const { timezone, tags, hideEditPost, + hideToc, } = post.data; -const { Content } = await render(post); +const { Content, headings } = await render(post); + +// TOC 显示条件:全局开启 && 文章未禁用 && 标题数 >= 2 +const showToc = + SITE.showToc && + !hideToc && + headings.filter(h => h.depth >= 2 && h.depth <= 4).length >= 2; let ogImageUrl: string | undefined; @@ -85,93 +93,96 @@ const nextPost =
-
-

+
- {title} -

-
- - - -
- { - description && ( -
- - 导读 - -

- {description} -

-
- ) - } -
- -
- -
- - - -
    - {tags.map(tag => )} -
- - - - - -
- - -
- { - prevPost && ( - - -
- 上一篇 -
{prevPost.title}
-
-
- ) - } + {title} + +
+ + + +
{ - nextPost && ( - -
- 下一篇 -
{nextPost.title}
-
- -
+ description && ( +
+ + 导读 + +

+ {description} +

+
) } -
-
+
+ +
+ +
+ + + +
    + {tags.map(tag => )} +
+ + + + + +
+ + +
+ { + prevPost && ( + + +
+ 上一篇 +
{prevPost.title}
+
+
+ ) + } + { + nextPost && ( + +
+ 下一篇 +
{nextPost.title}
+
+ +
+ ) + } +
+ + {showToc && } +