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 (
本页加载失败
); if (!src) return
; return ( { 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); const activeRow = useRef(null); const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); const boxRef = useRef(null); const [width, setWidth] = useState(480); const [vh, setVh] = useState(600); const [top, setTop] = useState(0); const [scrub, setScrub] = useState(null); // 页滑条拖拽中的临时值 const [, bump] = useState(0); const restored = useRef(false); const scrollRaf = useRef(0); const idle = useRef>(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(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(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) { 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
页索引加载中…
; if (countQ.isError || !book.page_url_fmt) return (
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
); 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 (
{ if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); else return; e.preventDefault(); }} >
{ 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" >
{Array.from({ length: Math.max(0, b - a) }, (_, k) => { const i = a + k; return (
measure(i, h)} />
); })} {locked && (
{w.end >= count ? ( — 全书完 — ) : ( )}
)} {locked && w.mountEnd > w.end && ( // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) )}
{chrome.on ? (
{ 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="页码跳转" /> {(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
{chapters && ( <> {ci + 1}/{chapters.length} )} {chapters && chapters.length >= 2 && ( 更多 setContinuous(!!v)} > 连读(滑动跨章) {locked && ( )} )}
) : (
{cur + 1}/{count} {Math.round(((cur + 1) / Math.max(1, count)) * 100)}%
)} {nav && ( setNav(null)} bookmarks={ { goPage(Number(l.page) || 0); setNav(null); }} /> } toc={ chapters && (
    {chapters.map((c, i) => (
  • ))}
) } /> )}
); }