diff --git a/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md new file mode 100644 index 0000000..ea3c93c --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md @@ -0,0 +1,594 @@ +# CBZ 章节化连续阅读(锁章 + 预读)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 漫画一章内所有图视为连续整章内容,默认滚动锁在章末(只能按钮切章),可开连读跨章,并预渲染其后 N 章(0–3,默认 2)使切章零白屏;移除 连读/整页/适高 三档模式。 + +**Architecture:** 纯前端改动。`lib/virt.ts` 新增两个纯函数(章节窗口计算)供单测全覆盖;`CbzReader.tsx` 重构为「章节窗口 + 屏翻页 + 隐藏预读层」,`PageHeights` 虚拟列表模型不动。无后端/API 改动。 + +**Tech Stack:** React 18 + TypeScript + Tailwind(rd-* 类)、vitest、vite。 + +Spec: `docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md` + +## Global Constraints + +- 分支 `feat/ui-redesign`(当前已在此分支,勿切 master 提交)。 +- 测试/构建在容器内跑:`docker exec -w /app booklib-web-1 npm run -s check`(= tsc --noEmit + vitest + vite build);单文件测试 `docker exec -w /app booklib-web-1 npx vitest run test/virt.test.ts`。 +- localStorage 键:`cbz-continuous`("1"/"0",默认关)、`cbz-prefetch`(0–3 整数,非法回退 2);旧键 `cbz-mode` 废弃不再读写。 +- 用户可见变更必须记 `docs/CHANGELOG_web.md`(英中各一行、相邻行、不同条目间空行),不重复进 `docs/CHANGELOG.md`。 +- 全书页号语义不变:进度/书签 locator `{page}`、滑条、页码 HUD 均为全书页,不改后端。 +- 容器宽上限 `MAX_W = 720` 保留;扁平包(`chapters` 为 null 或长度 < 2)= 整本一章。 + +--- + +### Task 1: `lib/virt.ts` 章节窗口纯函数(TDD) + +**Files:** +- Modify: `frontend/src/lib/virt.ts`(文件末尾追加) +- Test: `frontend/test/virt.test.ts`(文件末尾追加) + +**Interfaces:** +- Consumes: 无(新代码)。 +- Produces: + - `chapterIndexAt(chs: { start: number }[] | null, page: number): number` — 页所在章下标;无章/单章恒 0;page 小于首章 start 归 0。 + - `chapterWin(chs: { start: number }[] | null, count: number, ci: number, continuous: boolean, prefetch: number): ChapterWindow`,`interface ChapterWindow { start: number; end: number; mountEnd: number }`(start 含、end 不含;连读/扁平 → 全书;锁章 → 本章窗口,mountEnd 含 prefetch 章)。Task 2 按此签名消费。 + +- [ ] **Step 1: 写失败测试**(追加到 `frontend/test/virt.test.ts` 末尾) + +```ts +import { chapterIndexAt, chapterWin } from "../src/lib/virt"; // 与文件顶部 import 合并 + +const CH4 = [{ start: 0 }, { start: 10 }, { start: 20 }, { start: 30 }]; + +it("chapterIndexAt:无章/单章恒 0;页号归章;首章 start 之前的散页归 0", () => { + expect(chapterIndexAt(null, 7)).toBe(0); + expect(chapterIndexAt([{ start: 0 }], 7)).toBe(0); + expect(chapterIndexAt(CH4, 0)).toBe(0); + expect(chapterIndexAt(CH4, 9)).toBe(0); + expect(chapterIndexAt(CH4, 10)).toBe(1); + expect(chapterIndexAt(CH4, 999)).toBe(3); + expect(chapterIndexAt([{ start: 5 }, { start: 10 }], 3)).toBe(0); // 根散页 +}); + +it("chapterWin:扁平/连读 = 全书窗口", () => { + expect(chapterWin(null, 40, 0, false, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 }); + expect(chapterWin(CH4, 40, 2, true, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 }); +}); + +it("chapterWin:锁章窗口 = 本章;mountEnd 按预读章数延伸、夹到 count", () => { + expect(chapterWin(CH4, 40, 1, false, 0)).toEqual({ start: 10, end: 20, mountEnd: 20 }); + expect(chapterWin(CH4, 40, 1, false, 1)).toEqual({ start: 10, end: 20, mountEnd: 30 }); + expect(chapterWin(CH4, 40, 1, false, 2)).toEqual({ start: 10, end: 20, mountEnd: 40 }); + expect(chapterWin(CH4, 40, 1, false, 3)).toEqual({ start: 10, end: 20, mountEnd: 40 }); // 越界夹住 + expect(chapterWin(CH4, 40, 3, false, 2)).toEqual({ start: 30, end: 40, mountEnd: 40 }); // 末章 +}); + +it("chapterWin:ci 越界夹边;首章窗口含首章 start 之前的散页;prefetch 负数按 0", () => { + expect(chapterWin(CH4, 40, 9, false, 1)).toEqual({ start: 30, end: 40, mountEnd: 40 }); + expect(chapterWin(CH4, 40, -2, false, 1)).toEqual({ start: 0, end: 10, mountEnd: 20 }); + expect(chapterWin([{ start: 5 }, { start: 10 }], 20, 0, false, 0)).toEqual({ start: 0, end: 10, mountEnd: 10 }); + expect(chapterWin(CH4, 40, 1, false, -1)).toEqual({ start: 10, end: 20, mountEnd: 20 }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `docker exec -w /app booklib-web-1 npx vitest run test/virt.test.ts` +Expected: FAIL —「chapterIndexAt is not exported」/ import 解析错误。 + +- [ ] **Step 3: 实现**(追加到 `frontend/src/lib/virt.ts` 末尾) + +```ts +/** 页号 → 所在章下标;无章(扁平包或单组)恒 0;首章 start 之前的散页归首章。 */ +export function chapterIndexAt(chs: { start: number }[] | null, page: number): number { + if (!chs || chs.length < 2) return 0; + return chs.reduce((acc, c, i) => (c.start <= page ? i : acc), 0); +} + +export interface ChapterWindow { + start: number; // 窗口首页(含)——滚动/挂载下界 + end: number; // 窗口末页(不含)——锁章时的滚动边界 + mountEnd: number; // 挂载上界(含预读章),恒 ≤ count 且 ≥ end +} + +/** 阅读窗口:连读或扁平包 = 全书;锁章 = 本章滚动、本章 + prefetch 章挂载。 */ +export function chapterWin( + chs: { start: number }[] | null, + count: number, + ci: number, + continuous: boolean, + prefetch: number, +): ChapterWindow { + if (!chs || chs.length < 2 || continuous) return { start: 0, end: count, mountEnd: count }; + const c = Math.max(0, Math.min(ci, chs.length - 1)); + const end = chs[c + 1]?.start ?? count; + return { + start: c === 0 ? 0 : chs[c].start, + end, + mountEnd: chs[c + 1 + Math.max(0, prefetch)]?.start ?? count, + }; +} +``` + +- [ ] **Step 4: 跑测试确认通过(含既有 virt 用例回归)** + +Run: `docker exec -w /app booklib-web-1 npx vitest run test/virt.test.ts` +Expected: 全部 PASS(既有 4 条 + 新增 4 条)。 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/virt.ts frontend/test/virt.test.ts +git commit -m "feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests" +``` + +--- + +### Task 2: CbzReader 重构为章节窗口模型 + +**Files:** +- Modify: `frontend/src/readers/CbzReader.tsx`(整文件替换,代码见 Step 1) +- Modify: `docs/CHANGELOG_web.md`(Step 3) +- Test: 无新增组件测试(既有约定:reader UI 走 `npm run check` + 真实浏览器点验) + +**Interfaces:** +- Consumes: Task 1 的 `chapterIndexAt` / `chapterWin`;既有 `PageHeights`、`fetchObjectUrl`、`useProgressSaver`、`useBookmarks`、`api.pageCount`(`{count, chapters: {title,start}[] | null}`)、`chrome`(ReaderProps)。 +- Produces: 重写后的默认导出 `CbzReader(props: ReaderProps)`;删除 `PageMode/MODES/MODE_LABEL/useStoredMode/fitH` 等全部旧模式符号(无外部引用者)。 + +- [ ] **Step 1: 用以下完整内容替换 `frontend/src/readers/CbzReader.tsx`** + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { api, formatPageUrl } from "../api/client"; +import { btn } from "../components/ui"; +import { useBookmarks } from "../components/Bookmarks"; +import { fetchObjectUrl } from "../lib/authImage"; +import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; +import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt"; + +const MAX_W = 720; +const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅) + +function useStoredBool(key: string, def: boolean) { + const [v, setV] = useState(() => { + const s = localStorage.getItem(key); + return s === null ? def : s === "1"; + }); + useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]); + return [v, setV] as const; +} + +function useStoredPrefetch() { + const [n, setN] = useState(() => { + const v = Number(localStorage.getItem("cbz-prefetch")); + return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2; + }); + useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]); + return [n, setN] as const; +} + +function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) { + const [src, setSrc] = useState(""); + const [failed, setFailed] = useState(false); + const [nonce, setNonce] = useState(0); + useEffect(() => { + let dead = false; + setSrc(""); + setFailed(false); + fetchObjectUrl(url) + .then((s) => !dead && setSrc(s)) + .catch(() => !dead && setFailed(true)); + return () => { + dead = true; + }; + }, [url, nonce]); + if (failed) + return ( +
+ 本页加载失败 + +
+ ); + if (!src) + return
; + return ( + { + const el = e.currentTarget; + if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width); + }} + /> + ); +} + +export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) { + const countQ = useQuery({ + queryKey: ["pages", book.id], + queryFn: () => api.pageCount(book.pages_url ?? ""), + enabled: !!book.pages_url, + retry: 0, + }); + const count = countQ.data?.count ?? 0; + const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章 + const [toc, setToc] = useState(false); + const activeRow = useRef(null); + const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); + const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) }); + const boxRef = useRef(null); + const [width, setWidth] = useState(480); + const [vh, setVh] = useState(600); + const [top, setTop] = useState(0); + const [scrub, setScrub] = useState(null); // 页滑条拖拽中的临时值 + const [, bump] = useState(0); + const restored = useRef(false); + const scrollRaf = useRef(0); + const idle = useRef>(undefined); + const curRef = useRef(0); + const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop + const shiftRaf = useRef(0); + const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定) + const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章 + const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3 + const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步 + const pending = useRef(null); // 跨章跳页:切窗后下一帧落位 + + const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]); + const w = useMemo( + () => chapterWin(chapters, count, ci, continuous, prefetch), + [chapters, count, ci, continuous, prefetch], + ); + const locked = !!chapters && chapters.length >= 2 && !continuous; + + // 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点) + const roRef = useRef(null); + function attachBox(el: HTMLDivElement | null) { + boxRef.current = el; + roRef.current?.disconnect(); + roRef.current = null; + if (!el) return; + const ro = new ResizeObserver(() => { + setWidth(Math.min(el.clientWidth, MAX_W)); + setVh(el.clientHeight); + }); + ro.observe(el); + roRef.current = ro; + } + + // 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径 + useEffect(() => { + if (restored.current || !count || !boxRef.current) return; + restored.current = true; + const p = Number(initialLocator?.page); + if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false); + }, [count, initialLocator, ph]); + + // 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效 + useEffect(() => { + if (pending.current === null) return; + const el = boxRef.current; + if (!el) return; + const t = pending.current; + pending.current = null; + el.scrollTop = ph.offset(t); + }, [ci, ph]); + + // 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴 + function onScroll() { + if (scrollRaf.current) return; + scrollRaf.current = requestAnimationFrame(() => { + scrollRaf.current = 0; + const el = boxRef.current; + if (!el) return; + const t = el.scrollTop; + setTop(t); + if (count) { + curRef.current = ph.pageAt(t + vh / 2); + if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮) + saver.report({ page: curRef.current }, (curRef.current + 1) / count); + } + clearTimeout(idle.current); + idle.current = setTimeout(() => { + for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++) + fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); + }, 250); + }); + } + + useEffect( + () => () => { + cancelAnimationFrame(scrollRaf.current); + cancelAnimationFrame(shiftRaf.current); + clearTimeout(idle.current); + }, + [], + ); + + function applyShift(d: number) { + shift.current += d; + if (shiftRaf.current) return; + shiftRaf.current = requestAnimationFrame(() => { + shiftRaf.current = 0; + const el = boxRef.current; + if (el && shift.current) el.scrollTop += shift.current; + shift.current = 0; + }); + } + + const cur = ph.pageAt(top + vh / 2); + + useEffect(() => { + if (toc) activeRow.current?.scrollIntoView({ block: "center" }); + }, [toc]); + + function scrollToPage(i: number, smooth: boolean) { + const el = boxRef.current; + if (!el) return; + el.scrollTo({ top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))), behavior: smooth ? "smooth" : "auto" }); + } + + // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径) + function goPage(i: number, smooth = true) { + const t = Math.max(0, Math.min(count - 1, i)); + chrome.show(); + if (locked && (t < w.start || t >= w.end)) { + setCi(chapterIndexAt(chapters, t)); + pending.current = t; + return; + } + scrollToPage(t, smooth); + } + + // 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去 + function turnScreen(d: number) { + boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" }); + } + + // 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区) + function onZoneClick(e: MouseEvent) { + if ((e.target as HTMLElement).closest("button,input,nav,a")) return; + if (window.getSelection()?.toString()) return; + const r = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - r.left) / r.width; + if (x < 0.28) turnScreen(-1); + else if (x > 0.72) turnScreen(1); + else chrome.toggle(); + } + + // pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态 + if (countQ.isPending && !!book.pages_url) + return
页索引加载中…
; + if (countQ.isError || !book.page_url_fmt) + return ( +
+ 无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"} +
+ ); + + const fmt: string = book.page_url_fmt; + const [a0, b0] = ph.range(top, vh, 2); + const a = Math.max(a0, w.start); + const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层 + const contentH = locked ? ph.offset(w.end) + vh : ph.total(); + + function measure(i: number, h: number) { + const el = boxRef.current; + const topY = el ? el.scrollTop : 0; + const oldEst = ph.estHeight; + const topOfI = ph.offset(i); + const d = ph.set(i, h); + if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动 + // 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架 + if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) { + const n = ph.setEst(h, topY); + if (el && n) applyShift(n * (h - oldEst)); + ratio.current = h / width; + } + bump((x) => x + 1); + } + + return ( +
{ + if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); + else return; + e.preventDefault(); + }} + > +
+
+ {Array.from({ length: Math.max(0, b - a) }, (_, k) => { + const i = a + k; + return ( +
+ measure(i, h)} /> +
+ ); + })} + {locked && ( +
+ {w.end >= count ? ( + — 全书完 — + ) : ( + + )} +
+ )} + {locked && w.mountEnd > w.end && ( + // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) + + )} +
+
+ {chrome.on ? ( +
+
+ { + const v = Number(e.target.value); + setScrub(v); + goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿 + }} + onPointerUp={() => setScrub(null)} + onPointerCancel={() => setScrub(null)} + onBlur={() => setScrub(null)} + className="rd-range" + aria-label="页码跳转" + /> + + {(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}% + +
+
+ + {locked && ( + + )} + {bm.btn} + {chapters && ( + <> + + + {ci + 1}/{chapters.length} + + + + + )} +
+
+ ) : ( +
+ + {cur + 1}/{count} + + {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% +
+ )} + {toc && chapters && ( + <> + + ); +} +``` + +- [ ] **Step 2: 静态检查 + 单测全绿** + +Run: `docker exec -w /app booklib-web-1 npm run -s check` +Expected: tsc 无错误、vitest 全绿、vite build 成功。若 tsc 报 `chapters!` 收窄或 unused import,就地修正后重跑。 + +- [ ] **Step 3: 追加 changelog**(`docs/CHANGELOG_web.md`,插入到 `### Added / 新增` 之后的**最上方**,与下一条之间留一个空行;英中文案见 spec《文档 / Docs》节,内容如下) + +```markdown +- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone. +- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。 +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/readers/CbzReader.tsx docs/CHANGELOG_web.md +git commit -m "feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes" +``` + +--- + +### Task 3: 浏览器验收(真实运行环境点验) + +**Files:** +- 无新文件;发现问题就地回改 Task 1/2 的文件并补 commit。 + +**Interfaces:** +- Consumes: Task 2 产物;dev 栈(`deploy/docker-compose.dev.yml`,web 热更新无需重建)。 + +- [ ] **Step 1: 确认 dev 栈在跑**(`docker compose -f deploy/docker-compose.dev.yml ps`;未跑则 `up -d`),打开 `http://localhost:5173`(或 compose 映射端口),登录。 +- [ ] **Step 2: 分章包锁章验收**——打开一本按目录分章的漫画:向下滚到本章底部出现「本章完 · 下一章」卡片且继续滚不进入下一章;点卡片/下一章秒开无白屏(预读 2 章生效);「预读」按钮循环 0–3 且刷新后记忆。 +- [ ] **Step 3: 连读验收**——点「连读」变「连读·开」:滚动可直接滑过章界进下章,目录计数随滚动走;刷新页面开关状态保持。 +- [ ] **Step 4: 定位类入口验收**——拖滑条到别章页 = 跳章成功;书签 seek 跳他章;重进书恢复到他章原位;点按左右缘/←→ 翻一屏(非翻一张图)。 +- [ ] **Step 5: 扁平包回归**——无目录的漫画:工具栏无连读/预读按钮、正常滚动到底只有全书末尾(无章末卡片);图片加载失败重试可用。 +- [ ] **Step 6: 移动端手感抽查**——窄屏(DevTools 手机模拟)滚到章底回弹正常、无逐帧跳动;console 无 React 报错。 +- [ ] **Step 7: 全量回归收尾**——`docker exec -w /app booklib-web-1 npm run -s check` 绿;有修复则 `git add -A && git commit -m "fix(cbz): <具体现象>"`。 + +--- + +## Self-Review 记录 + +1. **Spec 覆盖**:模式移除✓(Task 2 删除三档)、锁章滚动边界✓(contentH=章末+卡片)、翻屏✓(turnScreen)、章末卡片✓、滑条/书签/目录跨章=显式跳章✓(goPage 统一入口)、预读隐藏层✓(mountEnd + overflow:hidden 层)、步进器/持久化✓、扁平包✓(chapterWin 全书 + 按钮隐藏)、恢复进度✓(goPage 路径)、量高学习保留✓(measure)、changelog✓。 +2. **占位符**:无 TBD/similar-to;测试与实现代码完整。 +3. **类型一致**:Task 1 导出 `chapterIndexAt(chs,page)` / `chapterWin(chs,count,ci,continuous,prefetch)` 与 Task 2 import 一致;`ChapterWindow` 字段名与 Task 2 使用一致。