feat: add EPUB reading notes and highlights
This commit is contained in:
@@ -26,6 +26,21 @@
|
||||
.epub-error button { display: flex; align-items: center; gap: 6px; margin-top: 8px; text-decoration: underline; }
|
||||
.epub-selection { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 10px 16px; background: #dfe9df; font-size: 12px; }
|
||||
.epub-selection button { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background: var(--accent); color: white; border-radius: 6px; }
|
||||
.epub-selection input { width: min(190px, 18vw); min-width: 90px; padding: 7px 9px; border: 1px solid #cbd5c9; border-radius: 5px; background: #fffdf8; font-size: 12px; }
|
||||
.epub-selection button:nth-of-type(n+2) { color: var(--fg); background: transparent; border: 1px solid #c3cec1; }
|
||||
.epub-selection button:hover { background: #eaf0e8; }
|
||||
.epub-notes { position: absolute; z-index: 8; top: 82px; right: 20px; bottom: 78px; width: min(360px, calc(100vw - 24px)); overflow-y: auto; border: 1px solid var(--border); background: var(--bg-card); box-shadow: 0 10px 34px #283d3026; }
|
||||
.epub-notes-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--border); background: var(--bg-card); font-size: 13px; }
|
||||
.epub-notes-head button, .epub-note-actions button { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 5px; }
|
||||
.epub-notes-head button:hover, .epub-note-actions button:hover { background: var(--bg-hover); }
|
||||
.epub-note-item { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.epub-note-text { display: flex; width: 100%; flex-direction: column; gap: 7px; text-align: left; }
|
||||
.epub-note-text small { color: var(--fg-muted); font-size: 10px; }
|
||||
.epub-note-text span { font: 14px/1.7 Georgia, "Songti SC", serif; overflow-wrap: anywhere; }
|
||||
.epub-note-text em { color: #64746b; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.epub-note-actions { display: flex; justify-content: flex-end; gap: 3px; margin-top: 7px; }
|
||||
.epub-notes-empty { padding: 18px; color: var(--fg-muted); font-size: 12px; }
|
||||
.epub-notice { position: absolute; z-index: 10; left: 50%; bottom: 82px; transform: translateX(-50%); max-width: calc(100vw - 24px); padding: 9px 14px; color: white; background: #243b33; font-size: 12px; }
|
||||
.epub-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 68px; padding: 10px 28px; padding-bottom: max(10px, env(safe-area-inset-bottom)); background: var(--bg-card); border-top: 1px solid var(--border); }
|
||||
.epub-footer > div { min-width: 0; max-width: 65%; text-align: center; }
|
||||
.epub-footer strong, .epub-footer small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
@@ -40,6 +55,10 @@
|
||||
.epub-paper { padding: 8px 0; }
|
||||
.epub-toc { position: absolute; z-index: 5; top: 72px; bottom: 80px; left: 8px; width: min(300px, calc(100% - 16px)); box-shadow: 10px 0 32px #283d3033; }
|
||||
.epub-footer { padding: 8px; }
|
||||
.epub-selection { padding: 8px; gap: 5px; flex-wrap: wrap; }
|
||||
.epub-selection input { flex: 1 1 100%; width: 100%; }
|
||||
.epub-selection button { padding: 7px; font-size: 11px; }
|
||||
.epub-notes { top: 70px; right: 8px; bottom: 70px; width: min(340px, calc(100vw - 16px)); }
|
||||
.epub-footer button span { display: none; }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
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 } from "lucide-react";
|
||||
import { api, type Book } from "@/lib/api";
|
||||
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, List, Loader2, Minus, Plus, RotateCcw, MessageSquare, Copy, BookmarkPlus, Highlighter, StickyNote, Trash2, Pencil, X } from "lucide-react";
|
||||
import { api, type Book, type ReadingNote } from "@/lib/api";
|
||||
|
||||
interface TocEntry { href: string; label: string; depth: number }
|
||||
function flattenToc(items: NavItem[], depth = 0): TocEntry[] {
|
||||
@@ -37,6 +37,11 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
const [fontSize, setFontSize] = useState(18);
|
||||
const [location, setLocation] = useState<Location | null>(null);
|
||||
const [selectedText, setSelectedText] = useState("");
|
||||
const [selectedCfi, setSelectedCfi] = useState("");
|
||||
const [notes, setNotes] = useState<ReadingNote[]>([]);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [draftNote, setDraftNote] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [turning, setTurning] = useState(false);
|
||||
const positionKey = `bookmate.reader.${book.id}`;
|
||||
|
||||
@@ -93,6 +98,14 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
spread: "none", flow: "paginated", 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. */ }
|
||||
}
|
||||
}
|
||||
rendition.themes.default({
|
||||
body: { color: "#243b33", background: "#fffdf8", "line-height": "1.9 !important", "font-family": 'Georgia, "Songti SC", "SimSun", serif' },
|
||||
"img, svg": { "max-width": "100% !important", "max-height": "90vh !important" },
|
||||
@@ -107,7 +120,7 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
});
|
||||
rendition.on("selected", (cfi: string, contents: Contents) => {
|
||||
if (disposed) return;
|
||||
try { setSelectedText(contents.range(cfi).toString().trim().slice(0, 4000)); } catch { /* No selection. */ }
|
||||
try { setSelectedText(contents.range(cfi).toString().trim().slice(0, 4000)); setSelectedCfi(cfi); } catch { /* No selection. */ }
|
||||
});
|
||||
rendition.on("keyup", (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeRef.current();
|
||||
@@ -166,11 +179,57 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
const currentChapter = toc.find((entry) => entry.href === location?.start.href)
|
||||
|| toc.find((entry) => entry.href.split("#")[0] === currentHref);
|
||||
|
||||
const copySelection = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
setNotice("已复制到剪贴板");
|
||||
} catch {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = selectedText;
|
||||
area.style.position = "fixed";
|
||||
area.style.opacity = "0";
|
||||
document.body.append(area); area.select();
|
||||
const copied = document.execCommand("copy"); area.remove();
|
||||
setNotice(copied ? "已复制到剪贴板" : "复制失败,请检查浏览器剪贴板权限");
|
||||
}
|
||||
window.setTimeout(() => setNotice(""), 2200);
|
||||
};
|
||||
const saveSelection = async (kind: "excerpt" | "highlight") => {
|
||||
if (!selectedText || !selectedCfi) return;
|
||||
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" });
|
||||
setDraftNote(""); setSelectedText(""); setNotesOpen(true); setNotice(kind === "highlight" ? "已添加高亮" : "已保存摘录");
|
||||
window.setTimeout(() => setNotice(""), 2200);
|
||||
} catch (err) { setNotice(err instanceof Error ? err.message : "保存失败"); }
|
||||
};
|
||||
const jumpToNote = async (item: ReadingNote) => {
|
||||
try { await renditionRef.current?.display(item.cfi); setNotesOpen(false); }
|
||||
catch { setNotice("原文位置已失效,可能是 EPUB 文件已更换"); }
|
||||
};
|
||||
const removeNote = async (item: ReadingNote) => {
|
||||
try {
|
||||
await api.deleteReadingNote(book.id, item.id);
|
||||
if (item.kind === "highlight") renditionRef.current?.annotations.remove(item.cfi, "highlight");
|
||||
setNotes((items) => items.filter((n) => n.id !== item.id));
|
||||
} catch (err) { setNotice(err instanceof Error ? err.message : "删除失败"); }
|
||||
};
|
||||
const editNote = async (item: ReadingNote) => {
|
||||
const value = window.prompt("编辑笔记", item.note || "");
|
||||
if (value === null) return;
|
||||
try {
|
||||
const updated = await api.updateReadingNote(book.id, item.id, { note: value, color: item.color });
|
||||
setNotes((items) => items.map((n) => n.id === item.id ? updated : n));
|
||||
} catch (err) { setNotice(err instanceof Error ? err.message : "更新失败"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={dialogRef} className="epub-reader" role="dialog" aria-modal="true" aria-label={`阅读《${book.title || book.filename}》`}>
|
||||
<header className="epub-toolbar">
|
||||
<button onClick={onClose} className="epub-return"><ArrowLeft size={18} /><span>返回</span></button>
|
||||
<div className="epub-book-title"><strong>{book.title || book.filename}</strong><small>{book.author || "完整 EPUB 阅读"}</small></div>
|
||||
<button aria-label="切换摘录与笔记" aria-expanded={notesOpen} onClick={() => setNotesOpen(!notesOpen)}><StickyNote size={18} /><span>摘录 {notes.length || ""}</span></button>
|
||||
<button aria-label="切换目录" aria-expanded={tocOpen} onClick={() => setTocOpen(!tocOpen)}><List size={19} /><span>目录</span></button>
|
||||
<div className="epub-font-controls"><button aria-label="缩小字号" disabled={fontSize <= 14 || loading} onClick={() => changeFont(-2)}><Minus size={16} /></button><span>{fontSize}</span><button aria-label="增大字号" disabled={fontSize >= 28 || loading} onClick={() => changeFont(2)}><Plus size={16} /></button></div>
|
||||
</header>
|
||||
@@ -184,7 +243,9 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
<div ref={viewerRef} className="epub-viewport" aria-label="书籍正文" />
|
||||
</div>
|
||||
</div>
|
||||
{selectedText && <div className="epub-selection"><span>已选中 {selectedText.length} 字</span><button onClick={() => onAsk(`请结合《${book.title || book.filename}》解释这段原文:\n\n「${selectedText}」`)}><MessageSquare size={15} /> 交给 AI 解读</button></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>}
|
||||
{selectedText && <div className="epub-selection"><span>已选中 {selectedText.length} 字</span><button onClick={() => void copySelection()}><Copy size={15} /> 复制</button><button onClick={() => void saveSelection("excerpt")}><BookmarkPlus size={15} /> 保存摘录</button><button onClick={() => void saveSelection("highlight")}><Highlighter size={15} /> 高亮</button><button 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">
|
||||
<button onClick={() => void navigate("prev")} disabled={loading || turning || !location || location.atStart}><ChevronLeft size={18} /><span>上一页</span></button>
|
||||
<div aria-live="polite"><strong>{currentChapter?.label || "阅读原文"}</strong><small>{location ? `本节 ${location.start.displayed.page} / ${location.start.displayed.total} 页` : "保留原书正文、图片与目录"} · 自动记住阅读位置</small></div>
|
||||
|
||||
@@ -14,6 +14,19 @@ export interface BookDetail extends Book {
|
||||
segment_count: number;
|
||||
}
|
||||
|
||||
export interface ReadingNote {
|
||||
id: string;
|
||||
book_id: string;
|
||||
kind: "excerpt" | "highlight";
|
||||
excerpt: string;
|
||||
note: string | null;
|
||||
cfi: string;
|
||||
chapter_title: string | null;
|
||||
color: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
book_id: string;
|
||||
@@ -127,6 +140,13 @@ export const api = {
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
listReadingNotes: (bookId: string) => request<ReadingNote[]>(`/books/${bookId}/notes`),
|
||||
createReadingNote: (bookId: string, data: Pick<ReadingNote, "kind" | "excerpt" | "cfi" | "chapter_title" | "color"> & { note?: string | null }) =>
|
||||
request<ReadingNote>(`/books/${bookId}/notes`, { method: "POST", body: JSON.stringify(data) }),
|
||||
updateReadingNote: (bookId: string, noteId: string, data: Pick<ReadingNote, "note" | "color">) =>
|
||||
request<ReadingNote>(`/books/${bookId}/notes/${noteId}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteReadingNote: (bookId: string, noteId: string) =>
|
||||
request<{ deleted: string }>(`/books/${bookId}/notes/${noteId}`, { method: "DELETE" }),
|
||||
getChapters: (bookId: string) => request<Chapter[]>(`/books/${bookId}/chapters`),
|
||||
getSegments: (bookId: string, chapterId?: string) =>
|
||||
request<Segment[]>(`/books/${bookId}/segments${chapterId ? `?chapter_id=${chapterId}` : ""}`),
|
||||
|
||||
Reference in New Issue
Block a user