From f63b639f64b4472fbc6e5b6bd22dc78857d26831 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 15:01:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(cbz):=20chapter-scoped=20continuous=20read?= =?UTF-8?q?ing=20=E2=80=94=20lock=20scroll=20at=20chapter=20end,=20screen?= =?UTF-8?q?=20paging,=20prefetch=20next=20N=20chapters;=20drop=20=E8=BF=9E?= =?UTF-8?q?=E8=AF=BB/=E6=95=B4=E9=A1=B5/=E9=80=82=E9=AB=98=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 3 + frontend/src/readers/CbzReader.tsx | 227 +++++++++++++++++------------ 2 files changed, 140 insertions(+), 90 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index a2eac5c..713116d 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -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)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。 diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 800445d..15bc4d3 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -5,35 +5,30 @@ 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 = { scroll: "连读", snap: "整页", fit: "适高" }; - -function useStoredMode() { - const [mode, setMode] = useState(() => { - 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 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); @@ -58,11 +53,7 @@ function PageImg({ ); if (!src) - return ( -
- ); - if (fitH) - return ; + return
; return ( (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(null); const [width, setWidth] = useState(480); const [vh, setVh] = useState(600); @@ -103,14 +94,17 @@ 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(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; // 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点) const roRef = useRef(null); @@ -127,14 +121,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); }, [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; @@ -146,6 +150,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome 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); @@ -177,26 +182,42 @@ 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; + 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))), behavior: smooth ? "smooth" : "auto" }); } - // 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区) + // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径) + 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) 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 +232,76 @@ 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, 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") jump(cur + 1); - else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1); + 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: b - a }, (_, k) => { +
+
+ {Array.from({ length: Math.max(0, b - a) }, (_, k) => { const i = a + k; return ( -
- { - if (fitH) return; // 适高模式定高一页一屏,无需量高 - 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); - }} - /> +
+ measure(i, h)} />
); })} + {locked && ( +
+ {w.end >= count ? ( + — 全书完 — + ) : ( + + )} +
+ )} + {locked && w.mountEnd > w.end && ( + // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) + + )}
{chrome.on ? ( @@ -277,7 +315,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)} @@ -291,12 +329,21 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
+ {locked && ( + + )} {bm.btn} {chapters && ( <> @@ -306,13 +353,13 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {ci + 1}/{chapters.length} - @@ -343,7 +390,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); }} >