feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes
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 is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
|
||||||
|
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
|
||||||
|
|
||||||
- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
|
- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
|
||||||
- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
|
- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,30 @@ import { btn } from "../components/ui";
|
|||||||
import { useBookmarks } from "../components/Bookmarks";
|
import { useBookmarks } from "../components/Bookmarks";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
import { PageHeights } 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(漫画常见竖幅)
|
||||||
|
|
||||||
type PageMode = "scroll" | "snap" | "fit"; // 连读 / 整页(适宽) / 适高(一页一屏)
|
function useStoredBool(key: string, def: boolean) {
|
||||||
const MODES: PageMode[] = ["scroll", "snap", "fit"];
|
const [v, setV] = useState(() => {
|
||||||
const MODE_LABEL: Record<PageMode, string> = { scroll: "连读", snap: "整页", fit: "适高" };
|
const s = localStorage.getItem(key);
|
||||||
|
return s === null ? def : s === "1";
|
||||||
function useStoredMode() {
|
|
||||||
const [mode, setMode] = useState<PageMode>(() => {
|
|
||||||
const v = localStorage.getItem("cbz-mode");
|
|
||||||
return MODES.includes(v as PageMode) ? (v as PageMode) : "scroll";
|
|
||||||
});
|
});
|
||||||
useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]);
|
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
|
||||||
return [mode, setMode] as const;
|
return [v, setV] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageImg({
|
function useStoredPrefetch() {
|
||||||
url,
|
const [n, setN] = useState(() => {
|
||||||
width,
|
const v = Number(localStorage.getItem("cbz-prefetch"));
|
||||||
fitH,
|
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
|
||||||
onLoaded,
|
});
|
||||||
}: {
|
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
|
||||||
url: string;
|
return [n, setN] as const;
|
||||||
width: number;
|
}
|
||||||
fitH: boolean;
|
|
||||||
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);
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
@@ -58,11 +53,7 @@ function PageImg({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!src)
|
if (!src)
|
||||||
return (
|
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
||||||
<div className="animate-pulse bg-stone-800" style={{ width, height: fitH ? "100%" : width * EST_RATIO }} />
|
|
||||||
);
|
|
||||||
if (fitH)
|
|
||||||
return <img src={src} alt="" className="mx-auto block h-full w-full object-contain" loading="eager" />;
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -85,11 +76,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
const count = countQ.data?.count ?? 0;
|
const count = countQ.data?.count ?? 0;
|
||||||
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
|
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
|
||||||
const [toc, setToc] = useState(false);
|
const [toc, setToc] = useState(false);
|
||||||
const activeRow = useRef<HTMLButtonElement>(null);
|
const activeRow = useRef<HTMLButtonElement>(null);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => jump(Number(l.page) || 0) });
|
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(480);
|
const [width, setWidth] = useState(480);
|
||||||
const [vh, setVh] = useState(600);
|
const [vh, setVh] = useState(600);
|
||||||
@@ -103,14 +94,17 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
|
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
|
||||||
const shiftRaf = useRef(0);
|
const shiftRaf = useRef(0);
|
||||||
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
||||||
const [mode, setMode] = useStoredMode();
|
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
|
||||||
const fitH = mode === "fit";
|
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
|
||||||
|
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
|
||||||
|
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
|
||||||
|
|
||||||
// vh 只在适高模式参与几何;滚动模式里地址栏收展改 vh 不得重建(会丢全部实测页高→页间错位+重挂图请求风暴)
|
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
|
||||||
const ph = useMemo(
|
const w = useMemo(
|
||||||
() => new PageHeights(count, fitH ? vh : width * ratio.current),
|
() => chapterWin(chapters, count, ci, continuous, prefetch),
|
||||||
[count, width, fitH ? vh : 0],
|
[chapters, count, ci, continuous, prefetch],
|
||||||
);
|
);
|
||||||
|
const locked = !!chapters && chapters.length >= 2 && !continuous;
|
||||||
|
|
||||||
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
|
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
|
||||||
const roRef = useRef<ResizeObserver | null>(null);
|
const roRef = useRef<ResizeObserver | null>(null);
|
||||||
@@ -127,14 +121,24 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
roRef.current = ro;
|
roRef.current = ro;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次拿到 count 后恢复进度页
|
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restored.current || !count || !boxRef.current) return;
|
if (restored.current || !count || !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) boxRef.current.scrollTop = ph.offset(p);
|
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false);
|
||||||
}, [count, initialLocator, ph]);
|
}, [count, initialLocator, ph]);
|
||||||
|
|
||||||
|
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending.current === null) return;
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const t = pending.current;
|
||||||
|
pending.current = null;
|
||||||
|
el.scrollTop = ph.offset(t);
|
||||||
|
}, [ci, ph]);
|
||||||
|
|
||||||
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
|
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (scrollRaf.current) return;
|
if (scrollRaf.current) return;
|
||||||
@@ -146,6 +150,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
setTop(t);
|
setTop(t);
|
||||||
if (count) {
|
if (count) {
|
||||||
curRef.current = ph.pageAt(t + vh / 2);
|
curRef.current = ph.pageAt(t + vh / 2);
|
||||||
|
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
|
||||||
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
|
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
|
||||||
}
|
}
|
||||||
clearTimeout(idle.current);
|
clearTimeout(idle.current);
|
||||||
@@ -177,26 +182,42 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cur = ph.pageAt(top + vh / 2);
|
const cur = ph.pageAt(top + vh / 2);
|
||||||
const ci = chapters ? chapters.reduce((acc, c, i) => (c.start <= cur ? i : acc), 0) : 0;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
}, [toc]);
|
}, [toc]);
|
||||||
|
|
||||||
function jump(i: number, smooth = true) {
|
function scrollToPage(i: number, smooth: boolean) {
|
||||||
const el = boxRef.current;
|
const el = boxRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" });
|
el.scrollTo({ top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))), behavior: smooth ? "smooth" : "auto" });
|
||||||
chrome.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
|
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径)
|
||||||
|
function goPage(i: number, smooth = true) {
|
||||||
|
const t = Math.max(0, Math.min(count - 1, i));
|
||||||
|
chrome.show();
|
||||||
|
if (locked && (t < w.start || t >= w.end)) {
|
||||||
|
setCi(chapterIndexAt(chapters, t));
|
||||||
|
pending.current = t;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollToPage(t, smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
|
||||||
|
function turnScreen(d: number) {
|
||||||
|
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
|
||||||
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
|
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
|
||||||
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
|
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
|
||||||
if (window.getSelection()?.toString()) return;
|
if (window.getSelection()?.toString()) return;
|
||||||
const r = e.currentTarget.getBoundingClientRect();
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
const x = (e.clientX - r.left) / r.width;
|
const x = (e.clientX - r.left) / r.width;
|
||||||
if (x < 0.28) jump(cur - 1);
|
if (x < 0.28) turnScreen(-1);
|
||||||
else if (x > 0.72) jump(cur + 1);
|
else if (x > 0.72) turnScreen(1);
|
||||||
else chrome.toggle();
|
else chrome.toggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,59 +232,76 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fmt: string = book.page_url_fmt;
|
const fmt: string = book.page_url_fmt;
|
||||||
const [a, b] = ph.range(top, vh, 2);
|
const [a0, b0] = ph.range(top, vh, 2);
|
||||||
|
const a = Math.max(a0, w.start);
|
||||||
|
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
|
||||||
|
const contentH = locked ? ph.offset(w.end) + vh : ph.total();
|
||||||
|
|
||||||
|
function measure(i: number, h: number) {
|
||||||
|
const el = boxRef.current;
|
||||||
|
const topY = el ? el.scrollTop : 0;
|
||||||
|
const oldEst = ph.estHeight;
|
||||||
|
const topOfI = ph.offset(i);
|
||||||
|
const d = ph.set(i, h);
|
||||||
|
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
|
||||||
|
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
|
||||||
|
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
|
||||||
|
const n = ph.setEst(h, topY);
|
||||||
|
if (el && n) applyShift(n * (h - oldEst));
|
||||||
|
ratio.current = h / width;
|
||||||
|
}
|
||||||
|
bump((x) => x + 1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="漫画阅读器,点按左右两侧翻页,点按中间显示工具栏"
|
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
|
||||||
className="relative h-full"
|
className="relative h-full"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1);
|
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
|
||||||
else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1);
|
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
|
||||||
else return;
|
else return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
|
||||||
ref={attachBox}
|
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
|
||||||
onScroll={onScroll}
|
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
|
||||||
onClick={onZoneClick}
|
|
||||||
className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-proximity" : "")}
|
|
||||||
>
|
|
||||||
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: fitH ? "none" : MAX_W }}>
|
|
||||||
{Array.from({ length: b - a }, (_, k) => {
|
|
||||||
const i = a + k;
|
const i = a + k;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
|
||||||
key={i}
|
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||||
className="snap-start absolute left-0 w-full"
|
|
||||||
style={{ top: ph.offset(i), ...(fitH ? { height: vh } : null) }}
|
|
||||||
>
|
|
||||||
<PageImg
|
|
||||||
url={formatPageUrl(fmt, i)}
|
|
||||||
width={width}
|
|
||||||
fitH={fitH}
|
|
||||||
onLoaded={(h) => {
|
|
||||||
if (fitH) return; // 适高模式定高一页一屏,无需量高
|
|
||||||
const el = boxRef.current;
|
|
||||||
const topY = el ? el.scrollTop : 0;
|
|
||||||
const oldEst = ph.estHeight;
|
|
||||||
const topOfI = ph.offset(i);
|
|
||||||
const d = ph.set(i, h);
|
|
||||||
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
|
|
||||||
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
|
|
||||||
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
|
|
||||||
const n = ph.setEst(h, topY);
|
|
||||||
if (el && n) applyShift(n * (h - oldEst));
|
|
||||||
ratio.current = h / width;
|
|
||||||
}
|
|
||||||
bump((x) => x + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{locked && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 grid w-full place-items-center"
|
||||||
|
style={{ top: ph.offset(w.end), height: vh }}
|
||||||
|
>
|
||||||
|
{w.end >= count ? (
|
||||||
|
<span className="text-sm text-stone-500">— 全书完 —</span>
|
||||||
|
) : (
|
||||||
|
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
||||||
|
本章完 · 下一章 →
|
||||||
|
</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) }}>
|
||||||
|
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on ? (
|
{chrome.on ? (
|
||||||
@@ -277,7 +315,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = Number(e.target.value);
|
const v = Number(e.target.value);
|
||||||
setScrub(v);
|
setScrub(v);
|
||||||
jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => setScrub(null)}
|
onPointerUp={() => setScrub(null)}
|
||||||
onPointerCancel={() => setScrub(null)}
|
onPointerCancel={() => setScrub(null)}
|
||||||
@@ -291,12 +329,21 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
||||||
<button
|
<button
|
||||||
className="rd-btn"
|
className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
|
||||||
onClick={() => setMode(MODES[(MODES.indexOf(mode) + 1) % MODES.length])}
|
aria-pressed={continuous}
|
||||||
aria-label="切换阅读模式"
|
onClick={() => setContinuous(!continuous)}
|
||||||
>
|
>
|
||||||
{MODE_LABEL[mode]}
|
连读{continuous ? "·开" : "·关"}
|
||||||
</button>
|
</button>
|
||||||
|
{locked && (
|
||||||
|
<button
|
||||||
|
className="rd-btn"
|
||||||
|
aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
|
||||||
|
onClick={() => setPrefetch((p) => (p + 1) % 4)}
|
||||||
|
>
|
||||||
|
预读 {prefetch} 章
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{bm.btn}
|
{bm.btn}
|
||||||
{chapters && (
|
{chapters && (
|
||||||
<>
|
<>
|
||||||
@@ -306,13 +353,13 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
<span className="text-xs tabular-nums opacity-60">
|
<span className="text-xs tabular-nums opacity-60">
|
||||||
{ci + 1}/{chapters.length}
|
{ci + 1}/{chapters.length}
|
||||||
</span>
|
</span>
|
||||||
<button className="rd-btn" disabled={ci === 0} onClick={() => jump(chapters[ci - 1].start)}>
|
<button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
|
||||||
← 上一章
|
← 上一章
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rd-btn"
|
className="rd-btn"
|
||||||
disabled={ci === chapters.length - 1}
|
disabled={ci === chapters.length - 1}
|
||||||
onClick={() => jump(chapters[ci + 1].start)}
|
onClick={() => goPage(chapters[ci + 1].start)}
|
||||||
>
|
>
|
||||||
下一章 →
|
下一章 →
|
||||||
</button>
|
</button>
|
||||||
@@ -343,7 +390,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
ref={i === ci ? activeRow : undefined}
|
ref={i === ci ? activeRow : undefined}
|
||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
jump(c.start);
|
goPage(c.start);
|
||||||
setToc(false);
|
setToc(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user