From 26aafb3f53283cf4a76bff0f794b4754956c41c9 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Wed, 16 Sep 2026 16:20:08 +0800 Subject: [PATCH] feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1) --- docs/CHANGELOG_web.md | 3 + frontend/src/readers/CbzReader.tsx | 582 ++++++++++++++++++++--------- 2 files changed, 408 insertions(+), 177 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 889338b..5eac058 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 gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support. +- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。 + - Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card. - 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。 diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 9e9f1e6..8e639a8 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -1,38 +1,22 @@ import { useQuery } from "@tanstack/react-query"; -import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react"; -import { DropdownMenu } from "radix-ui"; -import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { api, formatPageUrl } from "../api/client"; -import { btn } from "../components/ui"; import { BookmarksTab } from "../components/Bookmarks"; import { ReaderNav, type NavTab } from "../components/reader-nav"; +import { ReaderSettings } from "../components/reader-settings"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; import { fetchObjectUrl } from "../lib/authImage"; +import { pageIndexToView, spreadPages, turnView, viewCount, viewToPageIndex, type PageMode } from "../lib/cbzPages"; +import { CBZ_MODES, CBZ_MODE_LABEL, resolveRdTheme, useReaderPrefs, type ReaderPrefs } from "../lib/readerPrefs"; +import { useGlobalDark } from "../components/theme"; 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 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); @@ -59,9 +43,9 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade return (
本页加载失败 - +
); if (!src) return
; @@ -106,10 +90,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘 const estLearned = useRef(false); // 估高只学一次(见 measure) const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定) - const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章 - const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3 + const pr = useReaderPrefs(); + const globalDark = useGlobalDark(); + const rd = resolveRdTheme(pr.prefs.themeMode, globalDark); + const mode = pr.prefs.cbz.mode; // strip | page | spread;双向阅读方向独立的 rtl 开关(仅页模式生效) + const patchCbz = useCallback( + (part: Partial) => pr.update((p) => ({ ...p, cbz: { ...p.cbz, ...part } })), + [pr.update], + ); + const continuous = pr.prefs.cbz.continuous; // 连读:滑动可跨章 + const prefetch = pr.prefs.cbz.prefetch; // 锁章预读章数 0–3 + const rtl = pr.prefs.cbz.rtl; const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步 const pending = useRef(null); // 跨章跳页:切窗后下一帧落位 + const [pvCur, setPvCur] = useState(0); // 页模式当前页(PageView 上报,供滑条/HUD/位置文本) + const [pvSeek, setPvSeek] = useState<{ page: number; n: number } | null>(null); // 页模式外部跳页(书签/目录/滑动条/恢复进度) + const pvSeekN = useRef(0); + const pvTurn = useRef<((d: number) => void) | null>(null); // 页模式翻页函数(容器键盘转发用) const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]); const w = useMemo( @@ -137,11 +134,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome // 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径 useEffect(() => { - if (restored.current || !count || !boxRef.current) return; + if (restored.current || !count || (mode === "strip" && !boxRef.current)) return; restored.current = true; const p = Number(initialLocator?.page); if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false); - }, [count, initialLocator, ph]); + }, [count, initialLocator, ph, mode]); // 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效 useEffect(() => { @@ -208,7 +205,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome } const curRaw = ph.pageAt(top + anchor + vh / 2); - const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度) + // 页模式读数 = PageView 上报的全局页;长卷维持滚动换算 + const cur = mode !== "strip" ? pvCur : locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度) + + // 页模式 → 长卷切换后的落位:容器重挂且几何渲染完成后滚动 + const [stripSeek, setStripSeek] = useState<{ page: number; n: number } | null>(null); + useEffect(() => { + if (!stripSeek || mode !== "strip" || !boxRef.current || !count) return; + const t = stripSeek.page; + setStripSeek(null); + if (locked && (t < w.start || t >= w.end)) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 回长卷的跨章落位须等容器重挂后才能滚动;setCi+pending 与长卷既有「跨章跳页」路径同一实现 + setCi(chapterIndexAt(chapters, t)); + pending.current = t; + } else { + scrollToPage(t, false); + } + }, [stripSeek, mode]); useEffect(() => { if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" }); @@ -227,6 +240,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome function goPage(i: number, smooth = true, show = true) { const t = Math.max(0, Math.min(count - 1, i)); if (show) chrome.show(); + // 页模式:交给 PageView 统一落位(含跨章) + if (mode !== "strip") { + setPvSeek({ page: t, n: ++pvSeekN.current }); + return; + } if (locked && (t < w.start || t >= w.end)) { setCi(chapterIndexAt(chapters, t)); pending.current = t; @@ -235,6 +253,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome scrollToPage(t, smooth); } + // 模式切换保持当前页:页↔页换算视图起点,回长卷用现有滚动定位落位 + function setMode(m: ReaderPrefs["cbz"]["mode"]) { + if (m === mode) return; + const t = scrub ?? pvCur; + if (m === "strip") { + patchCbz({ mode: m }); + setStripSeek({ page: t, n: ++pvSeekN.current }); // 长卷容器重挂后由 clip effect 滚动落位 + return; + } + setPvSeek({ page: t, n: ++pvSeekN.current }); + patchCbz({ mode: m }); + } + // 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去 function turnScreen(d: number) { boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" }); @@ -302,166 +333,187 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome tabIndex={0} aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏" className="relative h-full" + data-rd={rd} onKeyDown={(e) => { - if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); - else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); - else return; + if (mode !== "strip") { + if (e.key === "ArrowRight" || e.key === "PageDown") pvTurn.current?.(1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") pvTurn.current?.(-1); + else return; + } else { + if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); + else return; + } e.preventDefault(); }} > -
{ - const el = e.currentTarget; - const r = el.getBoundingClientRect(); - if (e.clientX - r.left >= el.clientWidth || e.clientY - r.top >= el.clientHeight) dragging.current = true; // 按在滚动条上 - }} - className="h-full overflow-y-auto" - > -
- {Array.from({ length: Math.max(0, b - a) }, (_, k) => { - const i = a + k; - return ( -
- measure(i, h)} /> + {mode !== "strip" ? ( + setPvSeek(null)} + onCur={setPvCur} + onTurnHook={pvTurn} + /> + ) : ( +
{ + const el = e.currentTarget; + const r = el.getBoundingClientRect(); + if (e.clientX - r.left >= el.clientWidth || e.clientY - r.top >= el.clientHeight) dragging.current = true; // 按在滚动条上 + }} + className="h-full overflow-y-auto" + > +
+ {Array.from({ length: Math.max(0, b - a) }, (_, k) => { + const i = a + k; + return ( +
+ measure(i, h)} /> +
+ ); + })} + {locked && ( +
+ {w.end >= count ? ( + — 全书完 — + ) : ( + + )}
- ); - })} - {locked && ( -
- {w.end >= count ? ( - — 全书完 — - ) : ( - - )} -
- )} - {locked && w.mountEnd > w.end && ( - // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) - - )} + )} + {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)}% - -
-
- - {chapters && ( + }, + ariaLabel: "页码跳转", + }} + position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`} + left={ <> - - {ci + 1}/{chapters.length} - - - - - )} - {chapters && chapters.length >= 2 && ( - - - 更多 - - - - setContinuous(!!v)} + 导航 + + {chapters && ( + <> + + {ci + 1}/{chapters.length} + +
+ 上一章 + + + + )} + + } + extra={ + <> + + {CBZ_MODES.map((m) => ( + + ))} + + {mode !== "strip" && ( + + + + )} + {chapters && chapters.length >= 2 && ( + + + + )} + {locked && ( + + patchCbz({ prefetch: v })} + aria-label="预读章数" + /> + + )} + + } + />
) : ( -
+
{cur + 1}/{count} @@ -508,3 +560,179 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
); } + +/** 设置行通用骨架:label + 右侧控件组。 */ +function SettingRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +type Chrome = { on: boolean; toggle: () => void; show: () => void }; + +/** 页/双页横向阅读:章锁=章末卡片止步,连读=自动进下一章;RTL 翻转翻页方向;视图配对手动按 [2i,2i+1]。 */ +function PageView({ + count, + chapters, + locked, + ci, + setCi, + continuous, + fmt, + rtl, + mode, + saver, + chrome, + pvSeek, + onSeekDone, + onCur, + onTurnHook, +}: { + count: number; + chapters: { title: string; start: number }[] | null; + locked: boolean; + ci: number; + setCi: (i: number) => void; + continuous: boolean; + fmt: string; + rtl: boolean; + mode: PageMode; + saver: ReturnType; + chrome: Chrome; + pvSeek: { page: number; n: number } | null; + onSeekDone: () => void; + onCur: (p: number) => void; + onTurnHook: { current: ((d: number) => void) | null }; +}) { + // 章窗口:locked 时视图范围 = 本章页;continuous/扁平 = 全书 + const win = + locked && chapters + ? { start: chapters[ci].start, end: ci + 1 < chapters.length ? chapters[ci + 1].start : count } + : { start: 0, end: count }; + const winViews = viewCount(Math.max(0, win.end - win.start), mode); + const [v, setV] = useState(0); + const [showEndCard, setShowEndCard] = useState(false); + const pend = useRef(null); + const rootRef = useRef(null); + const [cw, setCw] = useState(0); + + // 容器宽度(单页=宽度全用,双页=对半) + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setCw(el.clientWidth)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const pageW = mode === "spread" ? Math.min(Math.floor(cw / 2), MAX_W) : Math.min(cw, MAX_W); + + // 外部跳页(pvSeek):锁章时切章,等 ci 生效再落位(pending ref 模式,与 strip 路径同一惯性) + useEffect(() => { + if (pvSeek == null) return; + pend.current = pvSeek.page; + if (locked && chapters) setCi(chapterIndexAt(chapters, pvSeek.page)); + onSeekDone(); + }, [pvSeek, locked, chapters, setCi, onSeekDone]); + useEffect(() => { + if (pend.current === null || winViews === 0) return; + const t = pend.current; + pend.current = null; + setShowEndCard(false); + const at = Math.max(win.start, Math.min(win.end - 1, t)); + setV(pageIndexToView(at - win.start, mode)); + }, [pvSeek, ci, winViews, win.start, win.end, mode]); + + const page0 = win.start + viewToPageIndex(v, mode); + // 当前页由 PageView 上报(供滑条/HUD/位置文本);进度随页落(与长卷路径同一 report 语义) + useEffect(() => { + onCur(page0); + saver.report({ page: page0 }, (page0 + 1) / count); + }, [page0, onCur]); // saver/report 每页一次,依赖从简(同 fetchObjectUrl 前载语义) + + useEffect(() => { + // 相邻页预载(±2) + for (const p of [page0 - 2, page0 + 2]) + if (p >= 0 && p < count) fetchObjectUrl(formatPageUrl(fmt, p)).catch(() => {}); + }, [page0, fmt, count]); + + function turn(d: number) { + chrome.show(); + const fwd = rtl ? d < 0 : d > 0; + if (showEndCard) { + if (fwd) advanceChapter(); + else { + setShowEndCard(false); + setV(winViews - 1); + } + return; + } + const nv = turnView(v, d, rtl, winViews); + if (nv !== v) { + setV(nv); + return; + } + if (fwd && v >= winViews - 1) { + // 视图尽头:章末卡片(锁章止步/全书完)或跨章 + if (win.end >= count) setShowEndCard(true); + else if (locked && !continuous) setShowEndCard(true); + else advanceChapter(); + } + } + function advanceChapter() { + if (!chapters || ci + 1 >= chapters.length) return; // 全书完:停在末页 + setCi(ci + 1); + setShowEndCard(false); + setV(0); + } + useEffect(() => { + onTurnHook.current = turn; + return () => { + onTurnHook.current = null; + }; + }); + + function onZone(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.3) turn(-1); + else if (x > 0.7) turn(1); + else chrome.toggle(); + } + + const pages = spreadPages(page0, win.end, rtl); + return ( +
+ {count < 1 || winViews < 1 ? null : showEndCard ? ( +
+ {win.end >= count ? ( + — 全书完 — + ) : ( + + )} +
+ ) : ( +
+ {pages.map((p) => ( + {}} /> + ))} +
+ )} +
+ ); +}