散篇与系列章节共用同一套进度基础设施。差别集中在进度键和几块系列专用 UI。
进度键怎么定§
| 内容 | ArticleShell / history 的键 |
|---|---|
| 散篇 | 文章路径名,如 001-mdx-code-blocks |
| 系列章节 | series/{小册}/{章节},如 series/inkstack-source/02-architecture-and-dirs |
键由 lib/series.ts 在建索引时写入 chapter.progressKey,章节页原样传给 ArticleShell。两边共用 Zustand 的 history 字典,但不互相覆盖。
store.ts:三份状态,只持久化一份§
history: Record<string, number>; // 路径键 → 0~1,写入 localStorage
fraction: number; // 当前页滚动比例,不持久化
activeHeading: string | null; // 当前节标题 id,不持久化persist 配置里:
- 存储键:
inkstack-reading-v1 partialize: (s) => ({ history: s.history })——只保存历史进度,避免把瞬时的fraction写盘造成抖动或错误恢复
setFraction / setProgress 都做了量化与现值判断:变化太小就返回旧 state,减少无意义的重渲染。
progress-bar.tsx:为什么不用 React 每帧 setState§
滚动时用 requestAnimationFrame 循环:
- 目标进度 =
scrollY / (scrollHeight - innerHeight) - 当前显示值向目标做
* 0.14的阻尼逼近 - 直接改
fillRef的transform: scaleX(...),避免每帧触发 React 协调 - 仅当显示值变化超过约
0.0024时,才useReading.getState().setFraction,供侧栏百分比等使用
散篇页与系列章节页都挂同一个 ProgressBar。
article-shell.tsx:盯标题 + 存进度 + 继续阅读§
- IntersectionObserver:观察
article内带 id 的 h2/h3/h4,rootMargin设成上边留页眉、下边留 70%,用「当前视口里最靠上的可见标题」作为activeHeading - 进度快照:约每 900ms 以及
beforeunload时,把滚动比例写入history[slug] - 继续阅读:若本地已有进度且大于约 3%,延迟显示芯片;点击则
scrollTo到对应比例
系列章节只要把 slug 换成 progressKey,上述逻辑无需分支。
路径:components/reading/article-shell.tsx。
reading-path.tsx:本章标题目录§
大屏右侧(散篇)或系列三栏的右栏,都用 ReadingPathRail;小屏用 ReadingPathFab。
要点回顾:
- 按标题深度错开「轨道」,换道用三次贝塞尔
- 描边与圆点在约 300ms 内插值
- 路由切换时短暂钉住顶部项,避免旧进度串高亮
系列页右栏与散篇右栏是同一组件;左栏「本册目录」才是系列专用(SeriesChapterRail)。
系列与列表上的进度展示§
reading-chip.tsx§
统一的「已读 xx%」芯片(variant="card" | "chapter")。挂在:
- 散篇卡片(
components/posts/post-card.tsx) - 小册首页目录(
SeriesToc)
按 progressKey 读 history,用 mounted 门闩避免水合不一致。
series-chapter-strip-client.tsx§
窄屏横向章节条。只依赖 @/lib/series-path(不碰 fs)。挂载或换章后,把当前章芯片 scrollIntoView({ inline: "center" })。
home-reading-strip.tsx§
首页「继续阅读」同时扫描散篇 slug 与系列 progressKey,取进度最高的一条;系列章会带上小册名作为上下文。
产品动机与动效取舍的更细讨论,见散篇《阅读进度》专题。下一章收束:站点壳、样式,以及改功能时的文件索引(含系列全表)。