Improve EPUB reading experience

This commit is contained in:
2026-10-26 15:41:10 +08:00
parent 520fa3f185
commit 946ffc75cb
2 changed files with 64 additions and 14 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
.epub-toc button:hover, .epub-toc button[aria-current] { background: var(--accent-soft); color: var(--accent); }
.epub-toc > p { padding: 16px; font-size: 12px; color: var(--fg-muted); }
.epub-paper { flex: 1; position: relative; min-width: 0; max-width: 1000px; margin: 0 auto; padding: 18px 24px; background: var(--bg-card); border: 1px solid #e3dfd3; box-shadow: 0 4px 24px #283d3010; border-radius: 4px; }
.epub-viewport { width: 100%; height: 100%; overflow: hidden; }
.epub-viewport { width: 100%; height: 100%; min-height: 0; overflow: auto; overscroll-behavior: contain; }
.epub-loading { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--bg-card); color: var(--accent); font-size: 13px; }
.epub-error { position: absolute; top: 12px; left: 12px; right: 12px; z-index: 3; padding: 16px; background: #f6e7dc; border: 1px solid #dbbf9d; color: #835c43; font-size: 13px; }
.epub-error button { display: flex; align-items: center; gap: 6px; margin-top: 8px; text-decoration: underline; }
+63 -13
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import type { Book as EpubBook, Rendition, Location, NavItem, Contents } from "epubjs";
import type Section from "epubjs/types/section";
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare, Copy, BookmarkPlus, Highlighter, StickyNote, Trash2, Pencil, X } from "lucide-react";
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare, Copy, BookmarkPlus, Highlighter, StickyNote, Trash2, Pencil, X, Download } from "lucide-react";
import { api, type Book, type ReadingNote } from "@/lib/api";
interface TocEntry { href: string; label: string; depth: number }
@@ -43,6 +43,8 @@ export default function EpubReader({ book, onClose, onAsk }: {
const [draftNote, setDraftNote] = useState("");
const [notice, setNotice] = useState("");
const [turning, setTurning] = useState(false);
const savedPositionRef = useRef<string | null>(null);
const restoringPositionRef = useRef(true);
const positionKey = `bookmate.reader.${book.id}`;
useEffect(() => { closeRef.current = onClose; }, [onClose]);
@@ -93,19 +95,26 @@ export default function EpubReader({ book, onClose, onAsk }: {
setToc(entries);
setFontSize(initialFont);
const host = viewerRef.current!;
const saved = readSaved(positionKey);
savedPositionRef.current = saved;
restoringPositionRef.current = true;
const rendition = epub.renderTo(host, {
width: host.clientWidth, height: host.clientHeight,
spread: "none", flow: "paginated", allowScriptedContent: false,
spread: "none", flow: "scrolled-doc", allowScriptedContent: false,
});
renditionRef.current = rendition;
const savedNotes = await api.listReadingNotes(book.id).catch(() => [] as ReadingNote[]);
if (disposed) return;
setNotes(savedNotes);
for (const item of savedNotes) {
if (item.kind === "highlight") {
try { rendition.annotations.highlight(item.cfi, { "data-note-id": item.id }, () => setNotesOpen(true), "epub-saved-highlight", { fill: item.color === "green" ? "#b8d6b0" : item.color === "blue" ? "#b8d0e5" : "#f3df8a", "fill-opacity": "0.45" }); } catch { /* A stale CFI must not prevent the book from opening. */ }
}
}
const drawnHighlights = new Set<string>();
const highlightStyles = (color: string) => ({ fill: color === "green" ? "#b8d6b0" : color === "blue" ? "#b8d0e5" : "#f3df8a", "fill-opacity": "0.55", "mix-blend-mode": "multiply" });
const drawHighlight = (item: ReadingNote) => {
if (item.kind !== "highlight" || drawnHighlights.has(item.id)) return;
try {
rendition.annotations.highlight(item.cfi, { "data-note-id": item.id }, () => setNotesOpen(true), "epub-saved-highlight", highlightStyles(item.color));
drawnHighlights.add(item.id);
} catch { /* A stale CFI must not prevent the book from opening. */ }
};
rendition.themes.default({
body: { color: "#243b33", background: "#fffdf8", "line-height": "1.9 !important", "font-family": 'Georgia, "Songti SC", "SimSun", serif' },
"body, body *": { "user-select": "text !important", "-webkit-user-select": "text !important" },
@@ -116,8 +125,17 @@ export default function EpubReader({ book, onClose, onAsk }: {
rendition.on("relocated", (next: Location) => {
if (disposed) return;
setLocation(next);
setSelectedText("");
if (next.start.cfi) save(positionKey, next.start.cfi);
if (restoringPositionRef.current) {
const restored = savedPositionRef.current;
if (restored && next.start.cfi === restored) restoringPositionRef.current = false;
else if (!restored) restoringPositionRef.current = false;
} else if (next.start.cfi) {
save(positionKey, next.start.cfi);
}
});
rendition.on("rendered", () => {
if (disposed) return;
for (const item of savedNotes) drawHighlight(item);
});
rendition.on("selected", (cfi: string, contents: Contents) => {
if (disposed) return;
@@ -133,13 +151,18 @@ export default function EpubReader({ book, onClose, onAsk }: {
rendition.on("displayError", () => {
if (!disposed) setError("这一页未能显示,请重试或从目录选择其他章节。");
});
const saved = readSaved(positionKey);
try { await rendition.display(saved || undefined); }
catch {
if (!saved) throw new Error("无法显示书籍正文");
await rendition.display();
}
if (disposed) return;
// Ignore the initial relocation event while restoring, then persist the
// actual displayed position so later navigation can update it normally.
restoringPositionRef.current = false;
const displayed = await rendition.currentLocation();
if (displayed?.cfi) save(positionKey, displayed.cfi);
for (const item of savedNotes) drawHighlight(item);
setError("");
setLoading(false);
observer = new ResizeObserver(([entry]) => {
@@ -204,7 +227,11 @@ export default function EpubReader({ book, onClose, onAsk }: {
try {
const created = await api.createReadingNote(book.id, { kind, excerpt: selectedText, cfi: selectedCfi, chapter_title: currentChapter?.label || null, color: "yellow", note: draftNote.trim() || null });
setNotes((items) => [created, ...items]);
if (kind === "highlight") renditionRef.current?.annotations.highlight(created.cfi, { "data-note-id": created.id }, () => setNotesOpen(true), "epub-saved-highlight", { fill: "#f3df8a", "fill-opacity": "0.45" });
if (kind === "highlight") {
const styles = { fill: "#f3df8a", "fill-opacity": "0.55", "mix-blend-mode": "multiply" };
await renditionRef.current?.display(created.cfi);
try { renditionRef.current?.annotations.highlight(created.cfi, { "data-note-id": created.id }, () => setNotesOpen(true), "epub-saved-highlight", styles); } catch { /* The saved note remains available from the notes panel. */ }
}
setDraftNote(""); setSelectedText(""); setSelectedCfi(""); setNotesOpen(true); setNotice(kind === "highlight" ? "已添加高亮" : "已保存摘录");
window.setTimeout(() => setNotice(""), 2200);
} catch (err) { setNotice(err instanceof Error ? err.message : "保存失败"); }
@@ -216,7 +243,7 @@ export default function EpubReader({ book, onClose, onAsk }: {
const removeNote = async (item: ReadingNote) => {
try {
await api.deleteReadingNote(book.id, item.id);
if (item.kind === "highlight") renditionRef.current?.annotations.remove(item.cfi, "highlight");
if (item.kind === "highlight") renditionRef.current?.annotations.remove(item.cfi, "epub-saved-highlight");
setNotes((items) => items.filter((n) => n.id !== item.id));
} catch (err) { setNotice(err instanceof Error ? err.message : "删除失败"); }
};
@@ -228,6 +255,29 @@ export default function EpubReader({ book, onClose, onAsk }: {
setNotes((items) => items.map((n) => n.id === item.id ? updated : n));
} catch (err) { setNotice(err instanceof Error ? err.message : "更新失败"); }
};
const exportNotes = () => {
const content = [
`# 《${book.title || book.filename}》摘录与笔记`,
"",
...notes.flatMap((item) => [
`## ${item.chapter_title || "书中摘录"} · ${item.kind === "highlight" ? "高亮" : "摘录"}`,
"",
`> ${item.excerpt.replaceAll("\n", "\n> ")}`,
...(item.note ? ["", `笔记:${item.note}`] : []),
"",
`记录时间:${new Date(item.created_at).toLocaleString()}`,
"",
"---",
"",
]),
].join("\n");
const url = URL.createObjectURL(new Blob([content], { type: "text/markdown;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = `${book.title || book.filename.replace(/\.epub$/i, "")}-摘录笔记.md`;
link.click();
URL.revokeObjectURL(url);
};
return (
<div ref={dialogRef} className="epub-reader" role="dialog" aria-modal="true" aria-label={`阅读《${book.title || book.filename}》`}>
@@ -248,7 +298,7 @@ export default function EpubReader({ book, onClose, onAsk }: {
<div ref={viewerRef} className="epub-viewport" aria-label="书籍正文" />
</div>
</div>
{notesOpen && <aside className="epub-notes" aria-label="摘录与笔记"><div className="epub-notes-head"><strong>摘录与笔记</strong><button aria-label="关闭摘录面板" onClick={() => setNotesOpen(false)}><X size={17} /></button></div>{notes.length ? notes.map((item) => <article className="epub-note-item" key={item.id}><button className="epub-note-text" onClick={() => void jumpToNote(item)}><small>{item.chapter_title || "书中摘录"} · {item.kind === "highlight" ? "高亮" : "摘录"}</small><span>{item.excerpt}</span>{item.note && <em>{item.note}</em>}</button><div className="epub-note-actions"><button title="编辑笔记" aria-label="编辑笔记" onClick={() => void editNote(item)}><Pencil size={14} /></button><button title="交给 AI 解读" aria-label="交给 AI 解读" onClick={() => onAsk(`请结合《${book.title || book.filename}》解读这段原文${item.note ? `,并参考我的笔记:${item.note}` : ""}:\n\n「${item.excerpt}」`)}><MessageSquare size={14} /></button><button title="删除摘录" aria-label="删除摘录" onClick={() => void removeNote(item)}><Trash2 size={14} /></button></div></article>) : <p className="epub-notes-empty">选中正文后可保存摘录或高亮。</p>}</aside>}
{notesOpen && <aside className="epub-notes" aria-label="摘录与笔记"><div className="epub-notes-head"><strong>摘录与笔记</strong><div className="epub-notes-head-actions"><button aria-label="导出摘录与笔记" title="导出 Markdown" disabled={!notes.length} onClick={exportNotes}><Download size={16} /></button><button aria-label="关闭摘录面板" onClick={() => setNotesOpen(false)}><X size={17} /></button></div></div>{notes.length ? notes.map((item) => <article className="epub-note-item" key={item.id}><button className="epub-note-text" onClick={() => void jumpToNote(item)}><small>{item.chapter_title || "书中摘录"} · {item.kind === "highlight" ? "高亮" : "摘录"}</small><span>{item.excerpt}</span>{item.note && <em>{item.note}</em>}</button><div className="epub-note-actions"><button title="编辑笔记" aria-label="编辑笔记" onClick={() => void editNote(item)}><Pencil size={14} /></button><button title="交给 AI 解读" aria-label="交给 AI 解读" onClick={() => onAsk(`请结合《${book.title || book.filename}》解读这段原文${item.note ? `,并参考我的笔记:${item.note}` : ""}:\n\n「${item.excerpt}」`)}><MessageSquare size={14} /></button><button title="删除摘录" aria-label="删除摘录" onClick={() => void removeNote(item)}><Trash2 size={14} /></button></div></article>) : <p className="epub-notes-empty">选中正文后可保存摘录。</p>}</aside>}
{selectedText && <div className="epub-selection"><span>已选中 {selectedText.length} 字</span><button onMouseDown={(event) => event.preventDefault()} onClick={() => void copySelection()}><Copy size={15} /> 复制</button><button onMouseDown={(event) => event.preventDefault()} onClick={() => void saveSelection("excerpt")}><BookmarkPlus size={15} /> 保存摘录</button><button onMouseDown={(event) => event.preventDefault()} onClick={() => void saveSelection("highlight")}><Highlighter size={15} /> 高亮</button><button onMouseDown={(event) => event.preventDefault()} onClick={() => onAsk(`请结合《${book.title || book.filename}》解释这段原文:\n\n「${selectedText}」`)}><MessageSquare size={15} /> 交给 AI 解读</button><input aria-label="添加笔记" value={draftNote} onChange={(event) => setDraftNote(event.target.value)} placeholder="添加笔记(可选)" /></div>}
{notice && <div className="epub-notice" role="status">{notice}</div>}
<footer className="epub-footer">