feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1)

This commit is contained in:
2026-09-16 16:20:08 +08:00
parent cea2414371
commit 26aafb3f53
2 changed files with 408 additions and 177 deletions
+3
View File
@@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Added / 新增 ### 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. - 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 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。 - 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。
+330 -102
View File
@@ -1,38 +1,22 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react"; import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
import { DropdownMenu } from "radix-ui"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client"; import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { BookmarksTab } from "../components/Bookmarks"; import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav, type NavTab } from "../components/reader-nav"; 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 { 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 { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt"; import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
const MAX_W = 720; const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅) 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 }) { function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
const [src, setSrc] = useState(""); const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -59,9 +43,9 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
return ( return (
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}> <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 variant="secondary" onClick={() => setNonce((n) => n + 1)}>
重试 重试
</button> </Button>
</div> </div>
); );
if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />; if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
@@ -106,10 +90,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘 const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘
const estLearned = useRef(false); // 估高只学一次(见 measure) const estLearned = useRef(false); // 估高只学一次(见 measure)
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定) const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章 const pr = useReaderPrefs();
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3 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<ReaderPrefs["cbz"]>) => 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 [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位 const pending = useRef<number | null>(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 ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
const w = useMemo( const w = useMemo(
@@ -137,11 +134,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径 // 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
useEffect(() => { useEffect(() => {
if (restored.current || !count || !boxRef.current) return; if (restored.current || !count || (mode === "strip" && !boxRef.current)) return;
restored.current = true; restored.current = true;
const p = Number(initialLocator?.page); const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false); if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false);
}, [count, initialLocator, ph]); }, [count, initialLocator, ph, mode]);
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效 // 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
useEffect(() => { useEffect(() => {
@@ -208,7 +205,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
} }
const curRaw = ph.pageAt(top + anchor + vh / 2); 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(() => { useEffect(() => {
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" }); 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) { function goPage(i: number, smooth = true, show = true) {
const t = Math.max(0, Math.min(count - 1, i)); const t = Math.max(0, Math.min(count - 1, i));
if (show) chrome.show(); if (show) chrome.show();
// 页模式:交给 PageView 统一落位(含跨章)
if (mode !== "strip") {
setPvSeek({ page: t, n: ++pvSeekN.current });
return;
}
if (locked && (t < w.start || t >= w.end)) { if (locked && (t < w.start || t >= w.end)) {
setCi(chapterIndexAt(chapters, t)); setCi(chapterIndexAt(chapters, t));
pending.current = t; pending.current = t;
@@ -235,6 +253,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
scrollToPage(t, smooth); 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) { function turnScreen(d: number) {
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" }); boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
@@ -302,13 +333,39 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
tabIndex={0} tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏" aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
className="relative h-full" className="relative h-full"
data-rd={rd}
onKeyDown={(e) => { onKeyDown={(e) => {
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); if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
else return; else return;
}
e.preventDefault(); e.preventDefault();
}} }}
> >
{mode !== "strip" ? (
<PageView
count={count}
chapters={chapters}
locked={locked}
ci={ci}
setCi={setCi}
continuous={continuous}
fmt={fmt}
rtl={rtl}
mode={mode}
saver={saver}
chrome={chrome}
pvSeek={pvSeek}
onSeekDone={() => setPvSeek(null)}
onCur={setPvCur}
onTurnHook={pvTurn}
/>
) : (
<div <div
ref={attachBox} ref={attachBox}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法 // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
@@ -339,9 +396,9 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
{w.end >= count ? ( {w.end >= count ? (
<span className="text-sm text-stone-500">— 全书完 —</span> <span className="text-sm text-stone-500">— 全书完 —</span>
) : ( ) : (
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}> <Button variant="secondary" onClick={() => goPage(chapters![ci + 1].start)}>
本章完 · 下一章 <ChevronRight size={14} className="inline" /> 本章完 · 下一章 <ChevronRight size={14} className="inline" />
</button> </Button>
)} )}
</div> </div>
)} )}
@@ -360,108 +417,103 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
)} )}
</div> </div>
</div> </div>
)}
{chrome.on ? ( {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="absolute inset-x-0 bottom-0 z-20">
<div className="flex items-center gap-3"> <ReaderSettings
<input slider={{
type="range" value: scrub ?? cur,
min={0} max: Math.max(0, count - 1),
max={Math.max(0, count - 1)} onChange: (v) => {
value={scrub ?? cur}
onChange={(e) => {
const v = Number(e.target.value);
setScrub(v); setScrub(v);
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿 goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
},
ariaLabel: "页码跳转",
}} }}
onPointerUp={() => setScrub(null)} position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`}
onPointerCancel={() => setScrub(null)} left={
onBlur={() => setScrub(null)} <>
className="rd-range" <Button
aria-label="页码跳转" variant="ghost"
/> size="sm"
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70"> className="gap-1.5"
{(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 inline-flex items-center gap-1.5"
aria-expanded={!!nav} aria-expanded={!!nav}
aria-controls="reader-nav" aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")} onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
> >
<PanelLeft size={14} /> 导航 <PanelLeft size={14} /> 导航
</button> </Button>
{chapters && ( {chapters && (
<> <>
<span className="text-xs tabular-nums opacity-60"> <span className="text-xs tabular-nums text-muted-foreground">
{ci + 1}/{chapters.length} {ci + 1}/{chapters.length}
</span> </span>
<button <Button
className="rd-btn inline-flex items-center gap-0.5" variant="ghost"
size="sm"
disabled={ci === 0} disabled={ci === 0}
onClick={() => goPage(chapters[ci - 1].start)} onClick={() => goPage(chapters[ci - 1].start)}
> >
<ChevronLeft size={14} /> 上一章 <ChevronLeft size={14} /> 上一章
</button> </Button>
<button <Button
className="rd-btn inline-flex items-center gap-0.5" variant="ghost"
size="sm"
disabled={ci === chapters.length - 1} disabled={ci === chapters.length - 1}
onClick={() => goPage(chapters[ci + 1].start)} onClick={() => goPage(chapters[ci + 1].start)}
> >
下一章 <ChevronRight size={14} /> 下一章 <ChevronRight size={14} />
</button> </Button>
</> </>
)} )}
</>
}
extra={
<>
<SettingRow label="翻页模式">
{CBZ_MODES.map((m) => (
<Button key={m} variant={mode === m ? "secondary" : "ghost"} size="xs" onClick={() => setMode(m)}>
{CBZ_MODE_LABEL[m]}
</Button>
))}
</SettingRow>
{mode !== "strip" && (
<SettingRow label="阅读方向">
<Button variant={rtl ? "secondary" : "ghost"} size="xs" onClick={() => patchCbz({ rtl: !rtl })}>
右开本(RTL)
</Button>
</SettingRow>
)}
{chapters && chapters.length >= 2 && ( {chapters && chapters.length >= 2 && (
<DropdownMenu.Root> <SettingRow label="连读(跨章)">
<DropdownMenu.Trigger <Button
className="rd-btn ml-auto inline-flex items-center gap-1.5" variant={continuous ? "secondary" : "ghost"}
aria-label="更多阅读选项" size="xs"
onClick={() => patchCbz({ continuous: !continuous })}
> >
<Settings2 size={14} /> 更多 {continuous ? "开" : "关"}
</DropdownMenu.Trigger> </Button>
<DropdownMenu.Portal> </SettingRow>
<DropdownMenu.Content )}
align="end"
side="top"
className="rd-divider z-50 min-w-44 rounded-md border bg-[var(--rd-bg,#171512)] p-1 shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
>
<DropdownMenu.CheckboxItem
className="rd-row rd-highlight cursor-pointer"
style={{ display: "flex", alignItems: "center", gap: 8 }}
checked={continuous}
onCheckedChange={(v) => setContinuous(!!v)}
>
<BookOpenCheck size={14} /> 连读(滑动跨章)
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
<Check size={14} />
</DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem>
{locked && ( {locked && (
<div className="px-2.5 pb-1.5 pt-2"> <SettingRow label="预读章数">
<div className="mb-1 text-sm">预读:锁章末尾时提前挂载其后 N 章</div> <Slider
<input className="w-32"
type="range"
min={0} min={0}
max={3} max={3}
step={1} step={1}
value={prefetch} value={[prefetch]}
onValueChange={([v]) => patchCbz({ prefetch: v })}
aria-label="预读章数" aria-label="预读章数"
className="rd-range" />
onChange={(e) => setPrefetch(Number(e.target.value))} </SettingRow>
)}
</>
}
/> />
</div> </div>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</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"> <div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-background/85 px-3 py-1.5 text-xs tabular-nums text-foreground shadow-lg ring-1 ring-border backdrop-blur">
<span> <span>
{cur + 1}/{count} {cur + 1}/{count}
</span> </span>
@@ -508,3 +560,179 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
</div> </div>
); );
} }
/** 设置行通用骨架:label + 右侧控件组。 */
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="flex items-center gap-1">{children}</span>
</div>
);
}
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<typeof useProgressSaver>;
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<number | null>(null);
const rootRef = useRef<HTMLDivElement>(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<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.3) turn(-1);
else if (x > 0.7) turn(1);
else chrome.toggle();
}
const pages = spreadPages(page0, win.end, rtl);
return (
<div
ref={rootRef}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 页模式容器须可聚焦以接键盘翻页;与长卷滚动容器同一理由
tabIndex={0}
aria-label="漫画页阅读视图"
className="relative h-full cursor-pointer select-none overflow-hidden"
style={{ background: "var(--rd-bg)", color: "var(--rd-fg)" }}
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- 手势区(两侧翻页/中间收放 chrome);键盘翻页由根容器 onKeyDown 提供
onClick={onZone}
>
{count < 1 || winViews < 1 ? null : showEndCard ? (
<div className="grid h-full w-full place-items-center">
{win.end >= count ? (
<span className="text-sm text-muted-foreground">— 全书完 —</span>
) : (
<Button variant="secondary" onClick={advanceChapter}>
本章完 · 下一章 <ChevronRight size={14} className="inline" />
</Button>
)}
</div>
) : (
<div className={`flex h-full w-full items-center justify-center ${mode === "spread" ? "gap-1" : ""}`}>
{pages.map((p) => (
<PageImg key={p} url={formatPageUrl(fmt, p)} width={pageW} onLoaded={() => {}} />
))}
</div>
)}
</div>
);
}