feat(web): epub reader via epubjs arraybuffer bootstrap with cfi progress

This commit is contained in:
2026-09-07 16:15:10 +08:00
parent de3015e537
commit 4f4eff871c
3 changed files with 104 additions and 1 deletions
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
interface EpubLocation {
start?: { cfi?: string; sectionIndex?: number };
}
interface EpubRendition {
display(target?: string): unknown;
next(): unknown;
prev(): unknown;
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
destroy(): void;
}
interface EpubBookLike {
ready: Promise<unknown>;
spine?: unknown[];
renderTo(el: HTMLElement, opts: Record<string, unknown>): EpubRendition;
destroy(): void;
}
async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
const m = (await import("epubjs")) as { default?: unknown };
return m.default as (data: Uint8Array) => EpubBookLike;
}
export default function EpubReader({ book, initialLocator }: ReaderProps) {
const hostRef = useRef<HTMLDivElement>(null);
const rendRef = useRef<EpubRendition | null>(null);
const saver = useProgressSaver(book.id);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
const [ready, setReady] = useState(false);
useEffect(() => {
let dead = false;
let bookObj: EpubBookLike | null = null;
setErr("");
setReady(false);
(async () => {
const ePub = await loadEpub();
const buf = await apiRaw(book.file_url ?? "").then((r) => r.arrayBuffer());
if (dead) return;
bookObj = ePub(new Uint8Array(buf)); // 字节已在内存,内部 iframe 不再回源,绕开鉴权问题
await bookObj.ready;
if (dead || !hostRef.current) return;
const spineLen = bookObj.spine?.length ?? 1;
const r = bookObj.renderTo(hostRef.current, { width: "100%", height: "100%", flow: "paginated" });
rendRef.current = r;
r.on("relocated", (loc) => {
const cfi = loc.start?.cfi ?? "";
const idx = loc.start?.sectionIndex ?? 0;
const pct = spineLen > 1 ? (idx + 1) / spineLen : 0.5;
saver.report(cfi ? { cfi } : {}, Math.min(1, pct));
});
const cfi = typeof initialLocator?.cfi === "string" ? (initialLocator.cfi as string) : undefined;
try {
r.display(cfi); // cfi 失效(书被替换等)时 epubjs 会抛
} catch {
r.display();
}
setReady(true);
})().catch((e) => !dead && setErr(e instanceof Error ? e.message : "EPUB 加载失败"));
return () => {
dead = true;
rendRef.current?.destroy();
rendRef.current = null;
bookObj?.destroy();
};
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
if (err)
return (
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-3 text-red-400">{err}</p>
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
重试
</button>
</div>
</div>
);
return (
<div className="grid h-full grid-rows-[1fr_auto]">
<div className="relative min-h-0 bg-white">
<div ref={hostRef} className="absolute inset-0" />
{!ready && !err && <div className="grid h-full place-items-center bg-zinc-950 text-zinc-500">EPUB 加载中…</div>}
</div>
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<button className={btn} onClick={() => rendRef.current?.prev()}>
上一页
</button>
<button className={btn} onClick={() => rendRef.current?.next()}>
下一页
</button>
</div>
</div>
);
}