Skip to content

Commit 2c0ecb2

Browse files
Retsommclaude
andcommitted
feat: 新增書籤功能(pwa + renderer)
- Toolbar 新增書籤按鈕,未收藏顯示線性 icon,已收藏顯示填充 icon - 點擊切換收藏狀態,以當前章節標題 + 頁碼作為 label - 書籤以 CFI 為識別鍵,存入 localStorage (tit-bookmarks-{bookId}) - 移除書本時同步清除對應書籤資料 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2bb09df commit 2c0ecb2

6 files changed

Lines changed: 142 additions & 6 deletions

File tree

pwa/src/components/Reader.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import useTTS from '../hooks/useTTS'
1111
import { useReaderStore } from '../store/useReaderStore'
1212
import type { Script } from '../store/useReaderStore'
1313
import { useAnnotationStore, loadAnnotationsForBook, saveAnnotationsForBook } from '../store/useAnnotationStore'
14-
import { saveProgress, loadProgress, saveBookSettings, loadBookSettings } from '../hooks/useLibrary'
15-
import type { BookRecord } from '../hooks/useLibrary'
14+
import { saveProgress, loadProgress, saveBookSettings, loadBookSettings, loadBookmarks, saveBookmarks } from '../hooks/useLibrary'
15+
import type { BookRecord, Bookmark } from '../hooks/useLibrary'
1616

1717
let _toSC: ((s: string) => string) | null = null
1818
let _toTC: ((s: string) => string) | null = null
@@ -344,6 +344,9 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
344344
const lastIframeClickRef = useRef({ x: 0, y: 0 }) // iframe 內最後一次點擊的主視窗座標
345345
const [activePanel, setActivePanel] = useState<'notes' | 'chapters' | 'settings' | 'bookinfo' | 'mobilepanel' | null>(null)
346346
const [mobilePanelTab, setMobilePanelTab] = useState<'bookinfo' | 'chapters' | 'notes'>('chapters')
347+
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => loadBookmarks(bookId))
348+
const [currentCfi, setCurrentCfi] = useState<string>('')
349+
const isBookmarked = bookmarks.some((b) => b.cfi === currentCfi)
347350
const [toc, setToc] = useState<TocItem[]>([])
348351
const [currentHref, setCurrentHref] = useState('')
349352
const [ready, setReady] = useState(false)
@@ -837,7 +840,10 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
837840
// eslint-disable-next-line @typescript-eslint/no-explicit-any
838841
const l = loc as any
839842
setCurrentHref((l?.start?.href ?? '').split('#')[0])
840-
if (l?.start?.cfi) saveProgress(bookId, l.start.cfi)
843+
if (l?.start?.cfi) {
844+
saveProgress(bookId, l.start.cfi)
845+
setCurrentCfi(l.start.cfi)
846+
}
841847
setAtStart(l?.atStart ?? false)
842848
setAtEnd(l?.atEnd ?? false)
843849
atEndRef.current = l?.atEnd ?? false
@@ -977,6 +983,8 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
977983
setPopup(null)
978984
setToc([])
979985
setCurrentHref('')
986+
setCurrentCfi('')
987+
setBookmarks([])
980988
setBookTitle('')
981989
setPageInfo(null)
982990
chapterPagesRef.current = new Map()
@@ -1204,6 +1212,28 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
12041212
const togglePanel = (panel: 'notes' | 'chapters' | 'settings' | 'bookinfo' | 'mobilepanel') =>
12051213
setActivePanel((cur) => (cur === panel ? null : panel))
12061214

1215+
const handleToggleBookmark = () => {
1216+
const cfi = currentCfi
1217+
if (!cfi) return
1218+
setBookmarks((prev) => {
1219+
let next: Bookmark[]
1220+
if (prev.some((b) => b.cfi === cfi)) {
1221+
next = prev.filter((b) => b.cfi !== cfi)
1222+
} else {
1223+
const chapterTitle = getChapterTitle()
1224+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1225+
const loc = (renditionRef.current as any)?.currentLocation?.()
1226+
const pageNum = loc?.start?.displayed?.page as number | undefined
1227+
const label = chapterTitle
1228+
? `${chapterTitle}${pageNum ? ` · 第${pageNum}頁` : ''}`
1229+
: pageNum ? `第${pageNum}頁` : '書籤'
1230+
next = [...prev, { id: crypto.randomUUID(), cfi, label, addedAt: Date.now() }]
1231+
}
1232+
saveBookmarks(bookId, next)
1233+
return next
1234+
})
1235+
}
1236+
12071237
const speakCurrentPage = () => {
12081238
if (!viewerRef.current) return
12091239
const iframe = viewerRef.current.querySelector('iframe')
@@ -1440,6 +1470,8 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
14401470
onToggleChapters={() => togglePanel('chapters')}
14411471
onToggleSettings={() => togglePanel('settings')}
14421472
activePanel={activePanel}
1473+
isBookmarked={isBookmarked}
1474+
onToggleBookmark={handleToggleBookmark}
14431475
/>
14441476
<div className="flex flex-1 overflow-hidden">
14451477
<div

pwa/src/components/Toolbar.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ const IconPanels = () => (
6868
</svg>
6969
)
7070

71+
const IconBookmarkOutline = () => (
72+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
73+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
74+
</svg>
75+
)
76+
77+
const IconBookmarkFill = () => (
78+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
79+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
80+
</svg>
81+
)
82+
7183
export type ActivePanel = 'notes' | 'chapters' | 'settings' | 'bookinfo' | 'mobilepanel' | null
7284

7385
interface Props {
@@ -83,6 +95,8 @@ interface Props {
8395
onToggleBookInfo: () => void
8496
onToggleMobilePanel: () => void
8597
activePanel: ActivePanel
98+
isBookmarked: boolean
99+
onToggleBookmark: () => void
86100
}
87101

88102
const SERIF = '"Source Serif 4", "Noto Serif TC", Georgia, serif'
@@ -101,6 +115,8 @@ const Toolbar = ({
101115
onToggleBookInfo,
102116
onToggleMobilePanel,
103117
activePanel,
118+
isBookmarked,
119+
onToggleBookmark,
104120
}: Props) => {
105121
const paperBg = darkMode ? '#1a1816' : '#f9f7f2'
106122
const borderCol = darkMode ? '#3a3430' : '#e4ddd0'
@@ -185,6 +201,8 @@ const Toolbar = ({
185201

186202
{/* 右:圖示按鈕 */}
187203
<div style={{ display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
204+
{/* 收藏按鈕(手機 + 桌面共用) */}
205+
{btn(isBookmarked, onToggleBookmark, isBookmarked ? <IconBookmarkFill /> : <IconBookmarkOutline />, isBookmarked ? '移除書籤' : '加入書籤')}
188206
{/* 手機版:panels + settings(wrapper 只用 className,不加 inline display) */}
189207
<div className="flex md:hidden items-center">
190208
{btn(activePanel === 'mobilepanel', onToggleMobilePanel, <IconPanels />, '書籍資訊/目錄/註記')}

pwa/src/hooks/useLibrary.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ const extractMeta = (
126126
return Promise.race([work().catch(() => fallback), timeout])
127127
}
128128

129+
// ── Bookmarks ──────────────────────────────────────────────────────────
130+
131+
export interface Bookmark {
132+
id: string
133+
cfi: string
134+
label: string
135+
addedAt: number
136+
}
137+
138+
const bookmarksKey = (bookId: string) => `tit-bookmarks-${bookId}`
139+
140+
export const loadBookmarks = (bookId: string): Bookmark[] => {
141+
try { return JSON.parse(localStorage.getItem(bookmarksKey(bookId)) ?? '[]') } catch { return [] }
142+
}
143+
144+
export const saveBookmarks = (bookId: string, bookmarks: Bookmark[]) =>
145+
localStorage.setItem(bookmarksKey(bookId), JSON.stringify(bookmarks))
146+
129147
// ── Reading progress ───────────────────────────────────────────────────
130148

131149
const progressKey = (bookId: string) => `tit-progress-${bookId}`
@@ -218,6 +236,7 @@ export const useLibrary = () => {
218236
await idbDelete('covers', id)
219237
localStorage.removeItem(progressKey(id))
220238
localStorage.removeItem(settingsKey(id))
239+
localStorage.removeItem(bookmarksKey(id))
221240
setRecords((prev) => {
222241
const next = prev.filter((r) => r.id !== id)
223242
saveMeta(next)

renderer/src/components/Reader.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import useTTS from '../hooks/useTTS'
1111
import { useReaderStore } from '../store/useReaderStore'
1212
import type { Script } from '../store/useReaderStore'
1313
import { useAnnotationStore, loadAnnotationsForBook, saveAnnotationsForBook } from '../store/useAnnotationStore'
14-
import { saveProgress, loadProgress, saveBookSettings, loadBookSettings } from '../hooks/useLibrary'
14+
import { saveProgress, loadProgress, saveBookSettings, loadBookSettings, loadBookmarks, saveBookmarks } from '../hooks/useLibrary'
15+
import type { Bookmark } from '../hooks/useLibrary'
1516

1617
let _toSC: ((s: string) => string) | null = null
1718
let _toTC: ((s: string) => string) | null = null
@@ -289,6 +290,9 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
289290
const currentDocRef = useRef<Document | null>(null)
290291
const lastIframeClickRef = useRef({ x: 0, y: 0 }) // iframe 內最後一次點擊的主視窗座標
291292
const [activePanel, setActivePanel] = useState<'notes' | 'chapters' | 'settings' | 'bookinfo' | null>(null)
293+
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => loadBookmarks(bookId))
294+
const [currentCfi, setCurrentCfi] = useState<string>('')
295+
const isBookmarked = bookmarks.some((b) => b.cfi === currentCfi)
292296
const [toc, setToc] = useState<TocItem[]>([])
293297
const [currentHref, setCurrentHref] = useState('')
294298
const [ready, setReady] = useState(false)
@@ -631,7 +635,10 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
631635
// eslint-disable-next-line @typescript-eslint/no-explicit-any
632636
const l = loc as any
633637
setCurrentHref((l?.start?.href ?? '').split('#')[0])
634-
if (l?.start?.cfi) saveProgress(bookId, l.start.cfi)
638+
if (l?.start?.cfi) {
639+
saveProgress(bookId, l.start.cfi)
640+
setCurrentCfi(l.start.cfi)
641+
}
635642
setAtStart(l?.atStart ?? false)
636643
setAtEnd(l?.atEnd ?? false)
637644

@@ -745,6 +752,8 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
745752
saveBookSettings(bookId, { fontSize: fs, fontFamily: ff, script: sc, lineHeight: lh, letterSpacing: ls, readingDirection: rd })
746753
scanAbortRef.current.aborted = true
747754
if (scanTimerRef.current) clearTimeout(scanTimerRef.current)
755+
setCurrentCfi('')
756+
setBookmarks([])
748757
setReady(false)
749758
setPopup(null)
750759
setToc([])
@@ -984,6 +993,28 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
984993
const togglePanel = (panel: 'notes' | 'chapters' | 'settings' | 'bookinfo') =>
985994
setActivePanel((cur) => (cur === panel ? null : panel))
986995

996+
const handleToggleBookmark = () => {
997+
const cfi = currentCfi
998+
if (!cfi) return
999+
setBookmarks((prev) => {
1000+
let next: Bookmark[]
1001+
if (prev.some((b) => b.cfi === cfi)) {
1002+
next = prev.filter((b) => b.cfi !== cfi)
1003+
} else {
1004+
const chapterTitle = getChapterTitle()
1005+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1006+
const loc = (renditionRef.current as any)?.currentLocation?.()
1007+
const pageNum = loc?.start?.displayed?.page as number | undefined
1008+
const label = chapterTitle
1009+
? `${chapterTitle}${pageNum ? ` · 第${pageNum}頁` : ''}`
1010+
: pageNum ? `第${pageNum}頁` : '書籤'
1011+
next = [...prev, { id: crypto.randomUUID(), cfi, label, addedAt: Date.now() }]
1012+
}
1013+
saveBookmarks(bookId, next)
1014+
return next
1015+
})
1016+
}
1017+
9871018
const speakCurrentPage = () => {
9881019
if (!viewerRef.current) return
9891020
const iframe = viewerRef.current.querySelector('iframe')
@@ -1108,6 +1139,8 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
11081139
onToggleChapters={() => togglePanel('chapters')}
11091140
onToggleSettings={() => togglePanel('settings')}
11101141
activePanel={activePanel}
1142+
isBookmarked={isBookmarked}
1143+
onToggleBookmark={handleToggleBookmark}
11111144
/>
11121145
<div className="flex flex-1 overflow-hidden">
11131146
<div className="flex-1 relative overflow-hidden">

renderer/src/components/Toolbar.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ const IconBook = () => (
4343
</svg>
4444
)
4545

46+
const IconBookmarkOutline = () => (
47+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
48+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
49+
</svg>
50+
)
51+
52+
const IconBookmarkFill = () => (
53+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
54+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
55+
</svg>
56+
)
57+
4658
export type ActivePanel = 'notes' | 'chapters' | 'settings' | 'bookinfo' | null
4759

4860
interface Props {
@@ -57,6 +69,8 @@ interface Props {
5769
onToggleSettings: () => void
5870
onToggleBookInfo: () => void
5971
activePanel: ActivePanel
72+
isBookmarked: boolean
73+
onToggleBookmark: () => void
6074
}
6175

6276
const SERIF = '"Source Serif 4", "Noto Serif TC", Georgia, serif'
@@ -66,7 +80,7 @@ const Toolbar = ({
6680
onBack, bookTitle, bookAuthor, pageInfo,
6781
darkMode, onToggleDark,
6882
onToggleNotes, onToggleChapters, onToggleSettings, onToggleBookInfo,
69-
activePanel,
83+
activePanel, isBookmarked, onToggleBookmark,
7084
}: Props) => {
7185
const paperBg = darkMode ? '#1a1816' : '#f9f7f2'
7286
const borderCol = darkMode ? '#3a3430' : '#e4ddd0'
@@ -151,6 +165,7 @@ const Toolbar = ({
151165

152166
{/* 右:圖示按鈕 */}
153167
<div style={{ display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
168+
{btn(isBookmarked, onToggleBookmark, isBookmarked ? <IconBookmarkFill /> : <IconBookmarkOutline />, isBookmarked ? '移除書籤' : '加入書籤')}
154169
{btn(activePanel === 'bookinfo', onToggleBookInfo, <IconBook />, '書籍資訊')}
155170
{btn(activePanel === 'settings', onToggleSettings, <IconSettings />, '排版與語音設定')}
156171
{btn(activePanel === 'chapters', onToggleChapters, <IconChapters />, '章節目錄')}

renderer/src/hooks/useLibrary.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,24 @@ const extractMeta = (
128128

129129
// ── Reading progress ───────────────────────────────────────────────────
130130

131+
// ── Bookmarks ──────────────────────────────────────────────────────────
132+
133+
export interface Bookmark {
134+
id: string
135+
cfi: string
136+
label: string
137+
addedAt: number
138+
}
139+
140+
const bookmarksKey = (bookId: string) => `tit-bookmarks-${bookId}`
141+
142+
export const loadBookmarks = (bookId: string): Bookmark[] => {
143+
try { return JSON.parse(localStorage.getItem(bookmarksKey(bookId)) ?? '[]') } catch { return [] }
144+
}
145+
146+
export const saveBookmarks = (bookId: string, bookmarks: Bookmark[]) =>
147+
localStorage.setItem(bookmarksKey(bookId), JSON.stringify(bookmarks))
148+
131149
const progressKey = (bookId: string) => `tit-progress-${bookId}`
132150

133151
export const saveProgress = (bookId: string, cfi: string) =>
@@ -218,6 +236,7 @@ export const useLibrary = () => {
218236
await idbDelete('covers', id)
219237
localStorage.removeItem(progressKey(id))
220238
localStorage.removeItem(settingsKey(id))
239+
localStorage.removeItem(bookmarksKey(id))
221240
setRecords((prev) => {
222241
const next = prev.filter((r) => r.id !== id)
223242
saveMeta(next)

0 commit comments

Comments
 (0)