Files
book-comic-library/frontend/src/readers/CbzReader.tsx
T

505 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { BookmarksTab } from "../components/Bookmarks";
import { RdSlider } from "../components/rd-slider";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { fetchObjectUrl } from "../lib/authImage";
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);
const [nonce, setNonce] = useState(0);
// url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染);换页/重试时的表现与原先一致
const key = url + "#" + nonce;
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setSrc("");
setFailed(false);
}
useEffect(() => {
let dead = false;
fetchObjectUrl(url)
.then((s) => !dead && setSrc(s))
.catch(() => !dead && setFailed(true));
return () => {
dead = true;
};
}, [url, nonce]);
if (failed)
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>
</div>
);
if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
alt=""
style={{ width }}
className="block"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
}}
/>
);
}
export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const countQ = useQuery({
queryKey: ["pages", book.id],
queryFn: () => api.pageCount(book.pages_url ?? ""),
enabled: !!book.pages_url,
retry: 0,
});
const count = countQ.data?.count ?? 0;
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
const [nav, setNav] = useState<null | NavTab>(null);
const activeRow = useRef<HTMLButtonElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
const [top, setTop] = useState(0);
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
const [, bump] = useState(0);
const restored = useRef(false);
const scrollRaf = useRef(0);
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
const curRef = useRef(0);
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
const shiftRaf = useRef(0);
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 [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
const w = useMemo(
() => chapterWin(chapters, count, ci, continuous, prefetch),
[chapters, count, ci, continuous, prefetch],
);
const locked = !!chapters && chapters.length >= 2 && !continuous;
// 锁章下窗外前缀页不渲染 → scrollTop/页 top 一律窗口相对;读 ph(全局前缀和)前先加回 anchor
const anchor = locked ? ph.offset(w.start) : 0;
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
const roRef = useRef<ResizeObserver | null>(null);
function attachBox(el: HTMLDivElement | null) {
boxRef.current = el;
roRef.current?.disconnect();
roRef.current = null;
if (!el) return;
const ro = new ResizeObserver(() => {
setWidth(Math.min(el.clientWidth, MAX_W));
setVh(el.clientHeight);
});
ro.observe(el);
roRef.current = ro;
}
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
useEffect(() => {
if (restored.current || !count || !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]);
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 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) - anchor;
}, [ci, ph]);
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
function onScroll() {
if (scrollRaf.current) return;
scrollRaf.current = requestAnimationFrame(() => {
scrollRaf.current = 0;
const el = boxRef.current;
if (!el) return;
const t = el.scrollTop;
setTop(t);
if (count) {
curRef.current = ph.pageAt(t + anchor + vh / 2);
if (locked) curRef.current = Math.min(curRef.current, w.end - 1); // 末屏中心落在章末卡片/他章 → 钉在本章末页
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
}
clearTimeout(idle.current);
idle.current = setTimeout(() => {
for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
}, 250);
});
}
useEffect(
() => () => {
cancelAnimationFrame(scrollRaf.current);
cancelAnimationFrame(shiftRaf.current);
clearTimeout(idle.current);
},
[],
);
useEffect(() => {
const up = () => (dragging.current = false);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
return () => {
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
};
}, []);
function applyShift(d: number) {
if (dragging.current) return; // 拖拽中 JS 写 scrollTop 会和浏览器"滑块位置→滚动"映射抢方向盘
shift.current += d;
if (shiftRaf.current) return;
shiftRaf.current = requestAnimationFrame(() => {
shiftRaf.current = 0;
const el = boxRef.current;
if (el && shift.current) el.scrollTop += shift.current;
shift.current = 0;
});
}
const curRaw = ph.pageAt(top + anchor + vh / 2);
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
useEffect(() => {
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
function scrollToPage(i: number, smooth: boolean) {
const el = boxRef.current;
if (!el) return;
el.scrollTo({
top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))) - anchor,
behavior: smooth ? "smooth" : "auto",
});
}
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片皆此一路径);恢复进度走 show=false 静默落位
function goPage(i: number, smooth = true, show = true) {
const t = Math.max(0, Math.min(count - 1, i));
if (show) 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>) {
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.28) turnScreen(-1);
else if (x > 0.72) turnScreen(1);
else chrome.toggle();
}
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt)
return (
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
</div>
);
const fmt: string = book.page_url_fmt;
const [a0, b0] = ph.range(top + anchor, vh, 2); // 视口位置换回全局空间再查页(锁章下 top 是窗口相对的)
const a = Math.max(a0, w.start);
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
const contentH = locked ? ph.offset(w.end) - anchor + vh : ph.total();
function measure(i: number, h: number) {
const el = boxRef.current;
// anchor 一律现取:setEst 会改写窗外未量页前缀的高度,渲染闭包里的 anchor 同帧内即过期,
// 拿它换算 topY → keep 页算错数十页 → 重锚把视口弹飞 → 新页量高再触发翻转 = 章尾震荡
const aNow = ph.offset(w.start);
const topY = el ? el.scrollTop + aNow : 0; // 与 ph.offset 同处全局空间比较
const topOfI = ph.offset(i);
const d = ph.set(i, h);
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
// 让未量高页的估高收敛到实测页高。重估瞬间改写 contentH,浏览器会先把 scrollTop 夹断,
// 增量补偿在已坏的基线上算 → 章尾大量未量页时"到底后被重置到顶部"。
// 改为绝对重锚:钉住当前视口顶页在原屏幕位置,不依赖任何修正量。
// 估高只学一次:混开本(横竖版混排)书里"按最近一页重估"会让 est 反复翻转,
// 每次翻转都改写全部未量页几何(contentH ±16k 呼吸),拖滑块到底就是在这上面震荡。
// 学一次钉死后,后续每页量高只动自己那一格(≤单页高),几何单调稳定。
if (width > 0 && !estLearned.current) {
estLearned.current = true;
const keep = el ? ph.pageAt(topY) : 0;
let keepIn = el ? topY - ph.offset(keep) : 0;
ph.setEst(h, topY);
if (el && !dragging.current) {
keepIn = Math.min(keepIn, Math.max(0, ph.offset(keep + 1) - ph.offset(keep)));
shift.current = 0; // 增量补偿作废,重锚已含全部几何变化
el.scrollTop = ph.offset(keep) - ph.offset(w.start) + keepIn;
}
ratio.current = h / width;
}
bump((x) => x + 1);
}
return (
<div
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
className="relative h-full"
onKeyDown={(e) => {
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}
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 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>
)}
</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);
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 && (
<>
<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)}
>
<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)}
>
<BookOpenCheck size={14} /> 连读(滑动跨章)
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
<Check size={14} />
</DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem>
{locked && (
<RdSlider
label="预读:锁章末尾时提前挂载其后 N 章"
ariaLabel="预读章数"
min={0}
max={3}
value={prefetch}
onChange={setPrefetch}
/>
)}
</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">
<span>
{cur + 1}/{count}
</span>
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
</div>
)}
{nav && (
<ReaderNav
tab={chapters ? nav : "bm"}
onTab={setNav}
onClose={() => setNav(null)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
goPage(Number(l.page) || 0);
setNav(null);
}}
/>
}
toc={
chapters && (
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goPage(c.start);
setNav(null);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
)
}
/>
)}
</div>
);
}