diff --git a/web/src/lib/virt.ts b/web/src/lib/virt.ts new file mode 100644 index 0000000..6b85f13 --- /dev/null +++ b/web/src/lib/virt.ts @@ -0,0 +1,63 @@ +// CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正, +// 前缀和 + 二分定位。纯逻辑,无 DOM,vitest 全量覆盖。 +export class PageHeights { + private actual: (number | undefined)[]; + private cum: number[]; // cum[i] = 第 i 页顶部偏移;cum[n] = total + + constructor( + private n: number, + private est: number, + ) { + this.actual = new Array(n).fill(undefined); + this.cum = new Array(n + 1); + this.rebuild(0); + } + + private rebuild(from: number): void { + if (from === 0) this.cum[0] = 0; + for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est); + } + + /** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */ + set(i: number, h: number): number { + if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0; + const old = this.actual[i]; + if (old === h) return 0; + this.actual[i] = h; + this.rebuild(i); + return h - (old ?? this.est); + } + + offset(i: number): number { + if (this.n === 0) return 0; + return this.cum[Math.max(0, Math.min(i, this.n))]; + } + + total(): number { + return this.cum[this.n]; + } + + /** 覆盖 y 的那一页。 */ + pageAt(y: number): number { + if (this.n === 0) return 0; + let lo = 0; + let hi = this.n - 1; + let ans = 0; + const v = Math.max(0, y); + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (this.cum[mid] <= v) { + ans = mid; + lo = mid + 1; + } else hi = mid - 1; + } + return ans; + } + + range(scrollTop: number, viewport: number, overscan: number): [number, number] { + if (this.n === 0) return [0, 0]; + const a = Math.max(0, this.pageAt(scrollTop) - overscan); + const b = Math.min(this.n, this.pageAt(scrollTop + viewport) + overscan); + return [a, b]; + } +} diff --git a/web/src/pages/Reader.tsx b/web/src/pages/Reader.tsx index 8c6f3cf..9291437 100644 --- a/web/src/pages/Reader.tsx +++ b/web/src/pages/Reader.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Suspense, type ComponentType, type LazyExoticComponent } from "react"; +import { Suspense, lazy, type ComponentType, type LazyExoticComponent } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { api } from "../api/client"; import type { Format } from "../api/types"; @@ -9,7 +9,9 @@ import { btn } from "../components/ui"; import type { ReaderProps } from "../lib/useProgress"; // reader 任务逐个往里加:Task 7 cbz → Task 8 txt/md → Task 9 pdf → Task 10 epub -const READERS: Partial>>> = {}; +const READERS: Partial>>> = { + cbz: lazy(() => import("../readers/CbzReader")), +}; export default function Reader() { const { id: idStr } = useParams(); diff --git a/web/src/readers/CbzReader.tsx b/web/src/readers/CbzReader.tsx new file mode 100644 index 0000000..04e5ab7 --- /dev/null +++ b/web/src/readers/CbzReader.tsx @@ -0,0 +1,144 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { api, formatPageUrl } from "../api/client"; +import { btn } from "../components/ui"; +import { fetchObjectUrl } from "../lib/authImage"; +import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; +import { PageHeights } from "../lib/virt"; + +const MAX_W = 720; +const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅) + +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); + useEffect(() => { + let dead = false; + setSrc(""); + setFailed(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 }: 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 saver = useProgressSaver(book.id); + const boxRef = useRef(null); + const [width, setWidth] = useState(480); + const [vh, setVh] = useState(600); + const [top, setTop] = useState(0); + const [, bump] = useState(0); + const restored = useRef(false); + + const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]); + + // 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准) + useEffect(() => { + const el = boxRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + setWidth(Math.min(el.clientWidth, MAX_W)); + setVh(el.clientHeight); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // 首次拿到 count 后恢复进度页 + useEffect(() => { + if (restored.current || !count || !boxRef.current) return; + restored.current = true; + const p = Number(initialLocator?.page); + if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p); + }, [count, initialLocator, ph]); + + function onScroll() { + const el = boxRef.current; + if (!el) return; + const t = el.scrollTop; + setTop(t); + if (count) { + const cur = ph.pageAt(t + vh / 2); + saver.report({ page: cur }, (cur + 1) / count); + for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++) + fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页 + } + } + + if (countQ.isPending) 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 [a, b] = ph.range(top, vh, 2); + const cur = ph.pageAt(top + vh / 2); + + return ( +
+
+
+ {Array.from({ length: b - a }, (_, k) => { + const i = a + k; + return ( +
+ { + const el = boxRef.current; + const topOfI = ph.offset(i); + const d = ph.set(i, h); + if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动 + bump((x) => x + 1); + }} + /> +
+ ); + })} +
+
+
+ {cur + 1}/{count} +
+
+ ); +} diff --git a/web/test/virt.test.ts b/web/test/virt.test.ts new file mode 100644 index 0000000..aafceeb --- /dev/null +++ b/web/test/virt.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import { PageHeights } from "../src/lib/virt"; + +it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => { + const ph = new PageHeights(10, 100); + expect(ph.offset(3)).toBe(300); + expect(ph.total()).toBe(1000); + expect(ph.pageAt(350)).toBe(3); + expect(ph.pageAt(-10)).toBe(0); + expect(ph.pageAt(99_999)).toBe(9); + expect(ph.range(0, 100, 2)).toEqual([0, 3]); + expect(ph.range(950, 100, 2)).toEqual([7, 10]); +}); + +it("set 修正高度并平移后续 offset;返回增量;幂等", () => { + const ph = new PageHeights(10, 100); + expect(ph.set(2, 150)).toBe(50); + expect(ph.offset(2)).toBe(200); // 自身 offset 不受自己影响 + expect(ph.offset(3)).toBe(350); // 后续整体 +50 + expect(ph.total()).toBe(1050); + expect(ph.pageAt(349)).toBe(2); + expect(ph.pageAt(351)).toBe(3); + expect(ph.set(2, 150)).toBe(0); +}); + +it("n=0 时一切归空", () => { + const ph = new PageHeights(0, 100); + expect(ph.total()).toBe(0); + expect(ph.range(0, 100, 2)).toEqual([0, 0]); + expect(ph.set(0, 50)).toBe(0); +});