Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5dacc0a77 | ||
|
|
60c4ccf26f | ||
|
|
f63b639f64 | ||
|
|
cd251b940c | ||
|
|
bba82e5529 | ||
|
|
497dc16604 | ||
|
|
37ddff89a5 | ||
|
|
a39ddf7b57 |
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- 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 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
|
||||
|
||||
- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
|
||||
- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
|
||||
本页加载失败
|
||||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!src)
|
||||
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{ width }}
|
||||
className="block"
|
||||
onLoad={(e) => {
|
||||
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<HTMLButtonElement>(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<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(480);
|
||||
const [vh, setVh] = useState(600);
|
||||
const [top, setTop] = useState(0);
|
||||
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
|
||||
const [, bump] = useState(0);
|
||||
const restored = useRef(false);
|
||||
const scrollRaf = useRef(0);
|
||||
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(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<number | null>(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<ResizeObserver | null>(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<HTMLDivElement>) {
|
||||
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 <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
|
||||
if (countQ.isError || !book.page_url_fmt)
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
||||
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
|
||||
className="relative h-full"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
|
||||
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
|
||||
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
|
||||
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
|
||||
const i = a + k;
|
||||
return (
|
||||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
|
||||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{locked && (
|
||||
<div
|
||||
className="absolute left-0 grid w-full place-items-center"
|
||||
style={{ top: ph.offset(w.end), height: vh }}
|
||||
>
|
||||
{w.end >= count ? (
|
||||
<span className="text-sm text-stone-500">— 全书完 —</span>
|
||||
) : (
|
||||
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
||||
本章完 · 下一章 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{locked && w.mountEnd > w.end && (
|
||||
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
|
||||
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
|
||||
const i = w.end + k;
|
||||
return (
|
||||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
|
||||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{chrome.on ? (
|
||||
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, count - 1)}
|
||||
value={scrub ?? cur}
|
||||
onChange={(e) => {
|
||||
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="页码跳转"
|
||||
/>
|
||||
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
|
||||
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
||||
<button
|
||||
className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
|
||||
aria-pressed={continuous}
|
||||
onClick={() => setContinuous(!continuous)}
|
||||
>
|
||||
连读{continuous ? "·开" : "·关"}
|
||||
</button>
|
||||
{locked && (
|
||||
<button
|
||||
className="rd-btn"
|
||||
aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
|
||||
onClick={() => setPrefetch((p) => (p + 1) % 4)}
|
||||
>
|
||||
预读 {prefetch} 章
|
||||
</button>
|
||||
)}
|
||||
{bm.btn}
|
||||
{chapters && (
|
||||
<>
|
||||
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
|
||||
目录
|
||||
</button>
|
||||
<span className="text-xs tabular-nums opacity-60">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
|
||||
← 上一章
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn"
|
||||
disabled={ci === chapters.length - 1}
|
||||
onClick={() => goPage(chapters[ci + 1].start)}
|
||||
>
|
||||
下一章 →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
|
||||
<span>
|
||||
{cur + 1}/{count}
|
||||
</span>
|
||||
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{toc && chapters && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
|
||||
<nav
|
||||
id="cbz-toc"
|
||||
aria-label="章节目录"
|
||||
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
|
||||
>
|
||||
<ul className="space-y-0.5">
|
||||
{chapters.map((c, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
ref={i === ci ? activeRow : undefined}
|
||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||
onClick={() => {
|
||||
goPage(c.start);
|
||||
setToc(false);
|
||||
}}
|
||||
>
|
||||
{i + 1}. {c.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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 使用一致。
|
||||
@@ -0,0 +1,73 @@
|
||||
# CBZ 章节化连续阅读(锁章 + 预读)设计 / CBZ chapter-scoped continuous reading design
|
||||
|
||||
日期 2026-09-08。分支 `feat/ui-redesign`。状态:已获用户批准(会话内确认)。
|
||||
|
||||
## 目标 / Goal
|
||||
|
||||
漫画(CBZ)阅读以「章」为单位:一章内无论多少图都视为一段连续的整章内容,
|
||||
默认不能靠上下滑动越过章界进入下一章(只能点「下一章」),可开「连读」让滑动贯穿章节。
|
||||
去掉现有 连读/整页/适高 三档概念。锁章时预渲染(挂载+解码)其后 N 章(用户可配置,默认 2),
|
||||
切章零白屏。
|
||||
CBZ reading is chapter-scoped: images within a chapter flow continuously; by default scrolling cannot cross a chapter boundary (only the 下一章 button can); an optional 连读 mode lets scrolling flow into following chapters. The three-mode cycle (连读/整页/适高) is removed. The next N chapters (user-configurable, default 2) are pre-rendered while locked so chapter switches are instant.
|
||||
|
||||
## 决策记录 / Decisions
|
||||
|
||||
- 章节内显示 = 按容器宽度(≤720px)连续滚动、无 snap、无适高一屏一页。用户选定(方案 A)。
|
||||
- 翻页手势/按键 = 章内翻一屏(`scrollBy ±vh`),不再按图跳页。用户选定。
|
||||
- 采用「按章节作用域渲染」(方案 A),否决:全书渲染+滚动钳制(手感生硬、浪费加载)、
|
||||
一章一挂载(书签/进度/量高学习跨章迁移复杂、切章闪烁)。
|
||||
- 预读实现为隐藏层挂载解码(不增加可滚动高度),不违反锁章语义。用户要求「可预渲染下两章、可配置」。
|
||||
- 进度滑条/页码/书签/服务端进度均仍为全书页号,locator 形状 `{page}` 不变,无后端/API 改动。
|
||||
- 扁平包(chapters 为 null)= 整本一章:章按钮与连读开关隐藏,锁章/预读无意义,行为即纯连续滚动。
|
||||
|
||||
## 前端 / Frontend(全部改动在 `frontend/src`,纯前端)
|
||||
|
||||
### 状态与持久化
|
||||
|
||||
- 删除 `PageMode`/`MODES`/`MODE_LABEL`/`useStoredMode` 及 `cbz-mode` 键;PageImg 去掉 `fitH` 分支;
|
||||
滚动盒去掉 `snap-y snap-proximity`。
|
||||
- `localStorage("cbz-continuous")`:连读开关,默认关(锁章)。
|
||||
- `localStorage("cbz-prefetch")`:预读章数 0–3,默认 2。工具栏步进器按钮循环 0→1→2→3→0。
|
||||
|
||||
### 区间几何
|
||||
|
||||
- 当前章 `ci` 由滚动位置(现 `cur` 所在页 → 章)推出,章起点 `chapters[ci].start`。
|
||||
- `PageHeights` 仍覆盖全书 `count` 页(复用现有量高/估高/`applyShift` 补偿逻辑)。
|
||||
- 挂载上限 `mountEnd`:锁章 = 第 `ci+1+prefetch` 章起点(越界取 count);连读 = count。
|
||||
- 滚动终点 `scrollEnd`:锁章 = `ph.offset(章末) + 章末卡片高`;连读 = 全书末尾。
|
||||
- 超出 `scrollEnd` 的预读页渲染进一个 `height:0; overflow:hidden` 的绝对定位隐藏层
|
||||
(子页仍按 `ph.offset(i)` 定位、`aria-hidden`):blob 经 `fetchObjectUrl` 缓存、`<img>` 挂载即解码,
|
||||
但不参与滚动几何。切章后这些页落入可见区间即直接上屏。
|
||||
- 纯计算抽为 `lib/virt.ts` 导出 helper(如 `chapterRange(chapters, count, ci, continuous, prefetch)`
|
||||
→ `{mountStart, mountEnd, scrollEndPage}`),vitest 覆盖。
|
||||
|
||||
### 交互
|
||||
|
||||
- 点按左右侧区/←→/PageUp·Down:`scrollBy(±vh, smooth)`;锁章时滚动高度天然停在章末卡片。
|
||||
- 点中间仍唤出工具栏(不变)。
|
||||
- 章末卡片:锁章时本章末尾一屏「本章完 · 下一章 →」(最终章显示「全书完」),点卡片=切章。
|
||||
- 切章入口(上一章/下一章按钮、目录行、书签 seek、章末卡片、滑条拖入他章区间):
|
||||
设 `ci` 并 `jump(page)`;滑条跨章视为显式定位,不算「滑动越界」。
|
||||
- 恢复进度:由 `initialLocator.page` 反推所在章后定位。
|
||||
- 闲时「停滚 250ms 预取后 5 页」保留。
|
||||
|
||||
## 错误处理 / Errors
|
||||
|
||||
- 无新增失败面:页数接口、鉴权取图、失败重试(「本页加载失败」)均沿用。
|
||||
- `cbz-prefetch` 值非法(手改 localStorage)→ 回退默认 2;`cbz-continuous` 非 `"1"` → 关。
|
||||
|
||||
## 测试 / Tests
|
||||
|
||||
- `lib/virt.ts` helper 表驱动单测:锁/连读 × 预读 0–3 × 首章/末章/单章扁平包 × 边界页;
|
||||
断言 mountEnd/scrollEnd 不越过滚动锁、预读不超过 count。
|
||||
- 现有 `virt` 用例保持绿;`npm run check` 绿。
|
||||
- 真实浏览器点验:锁章滚不到下章、开连读可滑过、预读下切章无白屏、扁平包无章 UI。
|
||||
|
||||
## 文档 / Docs
|
||||
|
||||
- `docs/CHANGELOG_web.md` 新增条目(英中各一行),不重复进 `docs/CHANGELOG.md`。
|
||||
|
||||
## 不做 / Non-goals
|
||||
|
||||
- 后端章节结构改动、章内页号重映射、PDF/EPUB/TXT 阅读器改动、横翻 RTL 模式、
|
||||
长按拖拽越章「窥视」下一章。
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM node:22 AS dev
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json web/package-lock.json ./
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY frontend/package.json web/package-lock.json ./
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
@@ -76,3 +76,33 @@ export class PageHeights {
|
||||
return [a, b];
|
||||
}
|
||||
}
|
||||
|
||||
/** 页号 → 所在章下标;无章(扁平包或单组)恒 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,35 +5,32 @@ import { btn } from "../components/ui";
|
||||
import { useBookmarks } from "../components/Bookmarks";
|
||||
import { fetchObjectUrl } from "../lib/authImage";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
import { PageHeights } from "../lib/virt";
|
||||
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
|
||||
|
||||
const MAX_W = 720;
|
||||
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
||||
|
||||
type PageMode = "scroll" | "snap" | "fit"; // 连读 / 整页(适宽) / 适高(一页一屏)
|
||||
const MODES: PageMode[] = ["scroll", "snap", "fit"];
|
||||
const MODE_LABEL: Record<PageMode, string> = { scroll: "连读", snap: "整页", fit: "适高" };
|
||||
|
||||
function useStoredMode() {
|
||||
const [mode, setMode] = useState<PageMode>(() => {
|
||||
const v = localStorage.getItem("cbz-mode");
|
||||
return MODES.includes(v as PageMode) ? (v as PageMode) : "scroll";
|
||||
function useStoredBool(key: string, def: boolean) {
|
||||
const [v, setV] = useState(() => {
|
||||
const s = localStorage.getItem(key);
|
||||
return s === null ? def : s === "1";
|
||||
});
|
||||
useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]);
|
||||
return [mode, setMode] as const;
|
||||
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
|
||||
return [v, setV] as const;
|
||||
}
|
||||
|
||||
function PageImg({
|
||||
url,
|
||||
width,
|
||||
fitH,
|
||||
onLoaded,
|
||||
}: {
|
||||
url: string;
|
||||
width: number;
|
||||
fitH: boolean;
|
||||
onLoaded: (h: number) => void;
|
||||
}) {
|
||||
function useStoredPrefetch() {
|
||||
const [n, setN] = useState(() => {
|
||||
const s = localStorage.getItem("cbz-prefetch");
|
||||
if (s === null || s === "") return 2; // 键缺失/空串 ≠ 0:新用户默认 2
|
||||
const v = Number(s);
|
||||
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);
|
||||
@@ -58,11 +55,7 @@ function PageImg({
|
||||
</div>
|
||||
);
|
||||
if (!src)
|
||||
return (
|
||||
<div className="animate-pulse bg-stone-800" style={{ width, height: fitH ? "100%" : width * EST_RATIO }} />
|
||||
);
|
||||
if (fitH)
|
||||
return <img src={src} alt="" className="mx-auto block h-full w-full object-contain" loading="eager" />;
|
||||
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
@@ -85,11 +78,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
retry: 0,
|
||||
});
|
||||
const count = countQ.data?.count ?? 0;
|
||||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
|
||||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
|
||||
const [toc, setToc] = useState(false);
|
||||
const activeRow = useRef<HTMLButtonElement>(null);
|
||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => jump(Number(l.page) || 0) });
|
||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(480);
|
||||
const [vh, setVh] = useState(600);
|
||||
@@ -103,14 +96,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
|
||||
const shiftRaf = useRef(0);
|
||||
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
||||
const [mode, setMode] = useStoredMode();
|
||||
const fitH = mode === "fit";
|
||||
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
|
||||
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
|
||||
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
|
||||
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
|
||||
|
||||
// vh 只在适高模式参与几何;滚动模式里地址栏收展改 vh 不得重建(会丢全部实测页高→页间错位+重挂图请求风暴)
|
||||
const ph = useMemo(
|
||||
() => new PageHeights(count, fitH ? vh : width * ratio.current),
|
||||
[count, width, fitH ? vh : 0],
|
||||
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;
|
||||
// 锁章下窗外前缀页不渲染 → scrollTop/页 top 一律窗口相对;读 ph(全局前缀和)前先加回 anchor
|
||||
const anchor = locked ? ph.offset(w.start) : 0;
|
||||
|
||||
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
@@ -127,14 +125,24 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
roRef.current = ro;
|
||||
}
|
||||
|
||||
// 首次拿到 count 后恢复进度页
|
||||
// 首次拿到 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) boxRef.current.scrollTop = ph.offset(p);
|
||||
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, 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) - anchor;
|
||||
}, [ci, ph]);
|
||||
|
||||
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
|
||||
function onScroll() {
|
||||
if (scrollRaf.current) return;
|
||||
@@ -145,7 +153,9 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
const t = el.scrollTop;
|
||||
setTop(t);
|
||||
if (count) {
|
||||
curRef.current = ph.pageAt(t + vh / 2);
|
||||
curRef.current = ph.pageAt(t + anchor + vh / 2);
|
||||
if (locked) curRef.current = Math.min(curRef.current, w.end - 1); // 末屏中心落在章末卡片/他章 → 钉在本章末页
|
||||
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
|
||||
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
|
||||
}
|
||||
clearTimeout(idle.current);
|
||||
@@ -176,27 +186,47 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
});
|
||||
}
|
||||
|
||||
const cur = ph.pageAt(top + vh / 2);
|
||||
const ci = chapters ? chapters.reduce((acc, c, i) => (c.start <= cur ? i : acc), 0) : 0;
|
||||
const curRaw = ph.pageAt(top + anchor + vh / 2);
|
||||
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
|
||||
|
||||
useEffect(() => {
|
||||
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
||||
}, [toc]);
|
||||
|
||||
function jump(i: number, smooth = true) {
|
||||
function scrollToPage(i: number, smooth: boolean) {
|
||||
const el = boxRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" });
|
||||
chrome.show();
|
||||
el.scrollTo({
|
||||
top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))) - anchor,
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
});
|
||||
}
|
||||
|
||||
// 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
|
||||
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片皆此一路径);恢复进度走 show=false 静默落位
|
||||
function goPage(i: number, smooth = true, show = true) {
|
||||
const t = Math.max(0, Math.min(count - 1, i));
|
||||
if (show) 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<HTMLDivElement>) {
|
||||
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) jump(cur - 1);
|
||||
else if (x > 0.72) jump(cur + 1);
|
||||
if (x < 0.28) turnScreen(-1);
|
||||
else if (x > 0.72) turnScreen(1);
|
||||
else chrome.toggle();
|
||||
}
|
||||
|
||||
@@ -211,59 +241,79 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
);
|
||||
|
||||
const fmt: string = book.page_url_fmt;
|
||||
const [a, b] = ph.range(top, vh, 2);
|
||||
const [a0, b0] = ph.range(top + anchor, vh, 2); // 视口位置换回全局空间再查页(锁章下 top 是窗口相对的)
|
||||
const a = Math.max(a0, w.start);
|
||||
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
|
||||
const contentH = locked ? ph.offset(w.end) - anchor + vh : ph.total();
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-label="漫画阅读器,点按左右两侧翻页,点按中间显示工具栏"
|
||||
className="relative h-full"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1);
|
||||
else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={attachBox}
|
||||
onScroll={onScroll}
|
||||
onClick={onZoneClick}
|
||||
className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-proximity" : "")}
|
||||
>
|
||||
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: fitH ? "none" : MAX_W }}>
|
||||
{Array.from({ length: b - a }, (_, k) => {
|
||||
const i = a + k;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="snap-start absolute left-0 w-full"
|
||||
style={{ top: ph.offset(i), ...(fitH ? { height: vh } : null) }}
|
||||
>
|
||||
<PageImg
|
||||
url={formatPageUrl(fmt, i)}
|
||||
width={width}
|
||||
fitH={fitH}
|
||||
onLoaded={(h) => {
|
||||
if (fitH) return; // 适高模式定高一页一屏,无需量高
|
||||
function measure(i: number, h: number) {
|
||||
const el = boxRef.current;
|
||||
const topY = el ? el.scrollTop : 0;
|
||||
const topY = el ? el.scrollTop + anchor : 0; // 与 ph.offset 同处全局空间比较
|
||||
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 a0 = ph.offset(w.start); // 基线取当前实际 anchor:连续 measure 交错时闭包 anchor 可能过期,防前缀份额双扣
|
||||
const n = ph.setEst(h, topY);
|
||||
if (el && n) applyShift(n * (h - oldEst));
|
||||
// n×Δest 是全局位移;锁章下窗外前缀页的那部分不影响窗口相对几何,须扣减
|
||||
const corr = n * (h - oldEst) - (ph.offset(w.start) - a0);
|
||||
if (el && corr) applyShift(corr);
|
||||
ratio.current = h / width;
|
||||
}
|
||||
bump((x) => x + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
|
||||
className="relative h-full"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
|
||||
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
|
||||
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
|
||||
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
|
||||
const i = a + k;
|
||||
return (
|
||||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
||||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{locked && (
|
||||
<div
|
||||
className="absolute left-0 grid w-full place-items-center"
|
||||
style={{ top: ph.offset(w.end) - anchor, height: vh }}
|
||||
>
|
||||
{w.end >= count ? (
|
||||
<span className="text-sm text-stone-500">— 全书完 —</span>
|
||||
) : (
|
||||
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
||||
本章完 · 下一章 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{locked && w.mountEnd > w.end && (
|
||||
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
|
||||
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
|
||||
const i = w.end + k;
|
||||
return (
|
||||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
||||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{chrome.on ? (
|
||||
@@ -277,7 +327,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setScrub(v);
|
||||
jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
||||
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
||||
}}
|
||||
onPointerUp={() => setScrub(null)}
|
||||
onPointerCancel={() => setScrub(null)}
|
||||
@@ -290,13 +340,24 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
||||
{chapters && chapters.length >= 2 && (
|
||||
<button
|
||||
className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
|
||||
aria-pressed={continuous}
|
||||
onClick={() => setContinuous(!continuous)}
|
||||
>
|
||||
连读{continuous ? "·开" : "·关"}
|
||||
</button>
|
||||
)}
|
||||
{locked && (
|
||||
<button
|
||||
className="rd-btn"
|
||||
onClick={() => setMode(MODES[(MODES.indexOf(mode) + 1) % MODES.length])}
|
||||
aria-label="切换阅读模式"
|
||||
aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
|
||||
onClick={() => setPrefetch((p) => (p + 1) % 4)}
|
||||
>
|
||||
{MODE_LABEL[mode]}
|
||||
预读 {prefetch} 章
|
||||
</button>
|
||||
)}
|
||||
{bm.btn}
|
||||
{chapters && (
|
||||
<>
|
||||
@@ -306,13 +367,13 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
<span className="text-xs tabular-nums opacity-60">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
<button className="rd-btn" disabled={ci === 0} onClick={() => jump(chapters[ci - 1].start)}>
|
||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
|
||||
← 上一章
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn"
|
||||
disabled={ci === chapters.length - 1}
|
||||
onClick={() => jump(chapters[ci + 1].start)}
|
||||
onClick={() => goPage(chapters[ci + 1].start)}
|
||||
>
|
||||
下一章 →
|
||||
</button>
|
||||
@@ -343,7 +404,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
ref={i === ci ? activeRow : undefined}
|
||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||
onClick={() => {
|
||||
jump(c.start);
|
||||
goPage(c.start);
|
||||
setToc(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PageHeights } from "../src/lib/virt";
|
||||
import { chapterIndexAt, chapterWin, PageHeights } from "../src/lib/virt";
|
||||
|
||||
it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => {
|
||||
const ph = new PageHeights(10, 100);
|
||||
@@ -37,3 +37,35 @@ it("n=0 时一切归空", () => {
|
||||
expect(ph.range(0, 100, 2)).toEqual([0, 0]);
|
||||
expect(ph.set(0, 50)).toBe(0);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user