134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { apiRaw } from "../api/client";
|
|
import { BookmarksTab } from "../components/Bookmarks";
|
|
import { ReaderNav } from "../components/reader-nav";
|
|
import { btn } from "../components/ui";
|
|
import { toast } from "../components/Toaster";
|
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
|
|
|
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
|
interface EpubLocation {
|
|
start?: { cfi?: string; sectionIndex?: number };
|
|
}
|
|
interface EpubRendition {
|
|
display(target?: string): Promise<unknown> | 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, initialPercent }: ReaderProps) {
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
const rendRef = useRef<EpubRendition | null>(null);
|
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
|
const [nav, setNav] = useState(false);
|
|
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;
|
|
// epubjs 对失效 cfi 以 reject Promise signalling,而非同步 throw → 走 then/catch 回退
|
|
Promise.resolve(cfi ? r.display(cfi) : r.display())
|
|
.then(() => setReady(true))
|
|
.catch(() => {
|
|
if (!cfi) {
|
|
setErr("EPUB 无法打开");
|
|
return;
|
|
}
|
|
Promise.resolve(r.display())
|
|
.then(() => setReady(true))
|
|
.catch(() => setErr("EPUB 无法打开"));
|
|
});
|
|
})().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="relative grid h-full grid-rows-[1fr_auto]">
|
|
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
|
|
<div ref={hostRef} className="absolute inset-0" />
|
|
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
|
|
</div>
|
|
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
|
|
<button className={btn} onClick={() => rendRef.current?.prev()}>
|
|
上一页
|
|
</button>
|
|
<button className={btn} onClick={() => rendRef.current?.next()}>
|
|
下一页
|
|
</button>
|
|
<button className="rd-btn" aria-expanded={nav} aria-controls="reader-nav" onClick={() => setNav((v) => !v)}>
|
|
书签
|
|
</button>
|
|
</div>
|
|
{nav && (
|
|
<ReaderNav
|
|
tab="bm"
|
|
onClose={() => setNav(false)}
|
|
bookmarks={
|
|
<BookmarksTab
|
|
book={book}
|
|
capture={saver.capture}
|
|
seek={(l) => {
|
|
const cfi = String(l.cfi ?? "");
|
|
setNav(false);
|
|
if (!cfi) return;
|
|
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|