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 (
+
+ );
+ })}
+ {locked && (
+
+ {w.end >= count ? (
+ — 全书完 —
+ ) : (
+ goPage(chapters![ci + 1].start)}>
+ 本章完 · 下一章
+
+ )}
- );
- })}
- {locked && (
-
- {w.end >= count ? (
- — 全书完 —
- ) : (
- goPage(chapters![ci + 1].start)}>
- 本章完 · 下一章
-
- )}
-
- )}
- {locked && w.mountEnd > w.end && (
- // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
-
- {Array.from({ length: w.mountEnd - w.end }, (_, k) => {
- const i = w.end + k;
- return (
-
- );
- })}
-
- )}
+ )}
+ {locked && w.mountEnd > w.end && (
+ // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
+
+ {Array.from({ length: w.mountEnd - w.end }, (_, k) => {
+ const i = w.end + k;
+ return (
+
+ );
+ })}
+
+ )}
+
-
+ )}
{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)}%
-
-
-
-
setNav(nav ? null : chapters ? "toc" : "bm")}
- >
- 导航
-
- {chapters && (
+ },
+ ariaLabel: "页码跳转",
+ }}
+ position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`}
+ left={
<>
-
- {ci + 1}/{chapters.length}
-
-
goPage(chapters[ci - 1].start)}
+ setNav(nav ? null : chapters ? "toc" : "bm")}
>
- 上一章
-
- goPage(chapters[ci + 1].start)}
- >
- 下一章
-
- >
- )}
- {chapters && chapters.length >= 2 && (
-
-
- 更多
-
-
-
- setContinuous(!!v)}
+ 导航
+
+ {chapters && (
+ <>
+
+ {ci + 1}/{chapters.length}
+
+
goPage(chapters[ci - 1].start)}
>
- 连读(滑动跨章)
-
-
-
-
- {locked && (
-
-
预读:锁章末尾时提前挂载其后 N 章
-
setPrefetch(Number(e.target.value))}
- />
-
- )}
-
-
-
- )}
-
+
上一章
+
+
goPage(chapters[ci + 1].start)}
+ >
+ 下一章
+
+ >
+ )}
+ >
+ }
+ extra={
+ <>
+
+ {CBZ_MODES.map((m) => (
+ setMode(m)}>
+ {CBZ_MODE_LABEL[m]}
+
+ ))}
+
+ {mode !== "strip" && (
+
+ patchCbz({ rtl: !rtl })}>
+ 右开本(RTL)
+
+
+ )}
+ {chapters && chapters.length >= 2 && (
+
+ patchCbz({ continuous: !continuous })}
+ >
+ {continuous ? "开" : "关"}
+
+
+ )}
+ {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) => (
+
{}} />
+ ))}
+
+ )}
+
+ );
+}