feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1)
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user