feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1)
This commit is contained in:
+405
-177
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
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 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<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 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 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();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={attachBox}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
|
||||
tabIndex={0}
|
||||
onScroll={onScroll}
|
||||
onClick={onZoneClick}
|
||||
onPointerDown={(e) => {
|
||||
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"
|
||||
>
|
||||
<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)} />
|
||||
{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
|
||||
ref={attachBox}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
|
||||
tabIndex={0}
|
||||
onScroll={onScroll}
|
||||
onClick={onZoneClick}
|
||||
onPointerDown={(e) => {
|
||||
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"
|
||||
>
|
||||
<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 variant="secondary" onClick={() => goPage(chapters![ci + 1].start)}>
|
||||
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||||
</Button>
|
||||
)}
|
||||
</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)}>
|
||||
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||||
</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>
|
||||
)}
|
||||
)}
|
||||
{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>
|
||||
</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);
|
||||
<div className="absolute inset-x-0 bottom-0 z-20">
|
||||
<ReaderSettings
|
||||
slider={{
|
||||
value: scrub ?? cur,
|
||||
max: Math.max(0, count - 1),
|
||||
onChange: (v) => {
|
||||
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 inline-flex items-center gap-1.5"
|
||||
aria-expanded={!!nav}
|
||||
aria-controls="reader-nav"
|
||||
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
|
||||
>
|
||||
<PanelLeft size={14} /> 导航
|
||||
</button>
|
||||
{chapters && (
|
||||
},
|
||||
ariaLabel: "页码跳转",
|
||||
}}
|
||||
position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`}
|
||||
left={
|
||||
<>
|
||||
<span className="text-xs tabular-nums opacity-60">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
<button
|
||||
className="rd-btn inline-flex items-center gap-0.5"
|
||||
disabled={ci === 0}
|
||||
onClick={() => goPage(chapters[ci - 1].start)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
aria-expanded={!!nav}
|
||||
aria-controls="reader-nav"
|
||||
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一章
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn inline-flex items-center gap-0.5"
|
||||
disabled={ci === chapters.length - 1}
|
||||
onClick={() => goPage(chapters[ci + 1].start)}
|
||||
>
|
||||
下一章 <ChevronRight size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{chapters && chapters.length >= 2 && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
className="rd-btn ml-auto inline-flex items-center gap-1.5"
|
||||
aria-label="更多阅读选项"
|
||||
>
|
||||
<Settings2 size={14} /> 更多
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<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)}
|
||||
<PanelLeft size={14} /> 导航
|
||||
</Button>
|
||||
{chapters && (
|
||||
<>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={ci === 0}
|
||||
onClick={() => goPage(chapters[ci - 1].start)}
|
||||
>
|
||||
<BookOpenCheck size={14} /> 连读(滑动跨章)
|
||||
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
|
||||
<Check size={14} />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{locked && (
|
||||
<div className="px-2.5 pb-1.5 pt-2">
|
||||
<div className="mb-1 text-sm">预读:锁章末尾时提前挂载其后 N 章</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={prefetch}
|
||||
aria-label="预读章数"
|
||||
className="rd-range"
|
||||
onChange={(e) => setPrefetch(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</div>
|
||||
<ChevronLeft size={14} /> 上一章
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={ci === chapters.length - 1}
|
||||
onClick={() => goPage(chapters[ci + 1].start)}
|
||||
>
|
||||
下一章 <ChevronRight size={14} />
|
||||
</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 && (
|
||||
<SettingRow label="连读(跨章)">
|
||||
<Button
|
||||
variant={continuous ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
onClick={() => patchCbz({ continuous: !continuous })}
|
||||
>
|
||||
{continuous ? "开" : "关"}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
)}
|
||||
{locked && (
|
||||
<SettingRow label="预读章数">
|
||||
<Slider
|
||||
className="w-32"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={[prefetch]}
|
||||
onValueChange={([v]) => patchCbz({ prefetch: v })}
|
||||
aria-label="预读章数"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{cur + 1}/{count}
|
||||
</span>
|
||||
@@ -508,3 +560,179 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user