From 65f6baed9fabcdf685cbe33f5062df01c5b0214e Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Tue, 7 Jul 2026 10:06:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(pwa):=20=E4=BE=9D=20Grokking=20Sim?= =?UTF-8?q?plicity=20=E5=8E=9F=E5=89=87=E9=87=8D=E6=A7=8B=20Reader.tsx?= =?UTF-8?q?=EF=BC=8C=E5=88=86=E9=9B=A2=20action/calculation/data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出純計算函數:progressCalculations、tocLookup、ttsFollowCalculations、bookmarkUtils - 抽出 useBookmarks、useAnnotationPopups、useChapterPageScan 三個低耦合 hooks - book 生命週期與 TTS 跟讀邏輯因雙向耦合,合併為單一 useReaderEngine hook - Reader.tsx 從 2383 行降至 641 行,已通過完整功能實測 Co-Authored-By: Claude Sonnet 5 --- FP_REFACTOR_PROGRESS.md | 84 + functional-thinking.md.md | 71 + pwa/src/components/Reader/bookmarkUtils.ts | 17 + .../components/Reader/progressCalculations.ts | 34 + pwa/src/components/Reader/tocLookup.ts | 62 + .../Reader/ttsFollowCalculations.ts | 100 + pwa/src/hooks/reader/useAnnotationPopups.ts | 182 ++ pwa/src/hooks/reader/useBookmarks.ts | 37 + pwa/src/hooks/reader/useChapterPageScan.ts | 170 ++ pwa/src/hooks/reader/useReaderEngine.ts | 1514 +++++++++++++ pwa/src/page/Reader.tsx | 1906 +---------------- 11 files changed, 2353 insertions(+), 1824 deletions(-) create mode 100644 FP_REFACTOR_PROGRESS.md create mode 100644 functional-thinking.md.md create mode 100644 pwa/src/components/Reader/bookmarkUtils.ts create mode 100644 pwa/src/components/Reader/progressCalculations.ts create mode 100644 pwa/src/components/Reader/tocLookup.ts create mode 100644 pwa/src/components/Reader/ttsFollowCalculations.ts create mode 100644 pwa/src/hooks/reader/useAnnotationPopups.ts create mode 100644 pwa/src/hooks/reader/useBookmarks.ts create mode 100644 pwa/src/hooks/reader/useChapterPageScan.ts create mode 100644 pwa/src/hooks/reader/useReaderEngine.ts diff --git a/FP_REFACTOR_PROGRESS.md b/FP_REFACTOR_PROGRESS.md new file mode 100644 index 0000000..729babc --- /dev/null +++ b/FP_REFACTOR_PROGRESS.md @@ -0,0 +1,84 @@ +--- +tags: + - refactor + - progress +--- +# 函數式重構進度追蹤(PWA / RENDERER / RN) + +> 依循 [functional-thinking.md.md](functional-thinking.md.md) 的 Grokking Simplicity 原則(action / calculation / data 分層),依序重構 PWA、RENDERER、RN 三個版本。 + +--- + +## 整體順序 + +1. ✅ **PWA**(`pwa/`)— 已完成 +2. ⬜ **RENDERER**(`renderer/`)— 待開始 +3. ⬜ **RN / mobile**(`mobile/`)— 待開始 + +每完成一個版本先暫停,等使用者確認沒問題再進行下一個。 + +--- + +## 1. PWA(已完成) + +目標檔案:`pwa/src/page/Reader.tsx`(原始 2383 行,混雜計算邏輯與副作用) + +### 已完成項目 + +- [x] 新增 4 個純計算檔案(`pwa/src/components/Reader/`),逐字搬移邏輯,零行為改動: + - `progressCalculations.ts` — 頁碼/總頁數換算 + - `tocLookup.ts` — TOC / spine 章節查找 + - `ttsFollowCalculations.ts` — TTS 翻頁判斷邏輯 + - `bookmarkUtils.ts` — 書籤資料操作 +- [x] 抽出 `useBookmarks.ts`(`pwa/src/hooks/reader/`) +- [x] 抽出 `useAnnotationPopups.ts`(`pwa/src/hooks/reader/`) +- [x] 抽出 `useChapterPageScan.ts`(`pwa/src/hooks/reader/`) +- [x] 合併 book 生命週期與 TTS 跟讀邏輯為 `useReaderEngine.ts`(`pwa/src/hooks/reader/`) + - 原規劃拆成 `useEpubBook` + `useTTSFollow` 兩個獨立檔案,但發現兩者實際雙向耦合(`relocated` 事件直接呼叫 TTS 函數,TTS 又依賴 book 初始化建立的 refs),經與使用者確認後改為合併成單一 hook +- [x] `Reader.tsx` 從 2383 行降至 641 行,現在主要是 refs/hooks 組裝 + JSX render +- [x] 每個階段皆執行 `cd pwa && yarn build` 驗證型別/編譯無誤 + +### 使用者實測結果(已通過) + +- [x] 翻頁(含 RTL 模式) +- [x] 朗讀播放 / 暫停 / 繼續 / 跨章節自動翻頁 +- [x] 選字高亮 popup(新增/編輯/刪除標記) +- [x] 書籤新增 / 刪除 / 跳轉 +- [x] 簡繁轉換 +- [x] 深色模式 +- [x] 字體大小 / 字型 / 行距 / 字距設定 +- [x] 睡眠計時器 +- [x] 手機版 Safari(過去有 iOS 專屬的 selectionchange / 觸控 bug,本次確認正常) + +**PWA 重構已通過完整實測,可視為此階段完成。** + +--- + +## 2. RENDERER(待開始) + +目標檔案:`renderer/src/page/Reader.tsx` + +**注意**:`renderer` 目前程式碼已與 `pwa` 分岔,功能不完全相同(例如少了書籤功能、annotation popup 拆成獨立的 `BookmarkPanel.tsx` / `HighlightPopup.tsx` 元件)。不能直接複製 PWA 版的重構結果,需要重新評估: + +- [ ] 比對 `renderer` 與 `pwa` 的 `Reader.tsx` 差異,確認哪些抽出的邏輯可以共用、哪些需要為 renderer 版本量身設計 +- [ ] 決定是否要與 PWA 共用同一份純計算檔案(若邏輯完全一致) +- [ ] 依同樣的 ACD 原則拆分 renderer 版 `Reader.tsx` +- [ ] `cd renderer && yarn build` 驗證 +- [ ] 列出需使用者實測的項目(Electron 環境特有行為) + +--- + +## 3. RN / mobile(待開始) + +目標目錄:`mobile/`(React Native + WebView 架構,與 PWA/RENDERER 完全不同) + +- [ ] 先探索 mobile 版對應的 Reader 相關程式碼結構(WebView 架構下的 ACD 分層方式可能與 web 版差異很大) +- [ ] 評估此架構下的動作/計算/資料分層策略 +- [ ] 執行重構 +- [ ] 驗證方式待定(RN 無法用 `yarn build` 驗證執行期行為,需实機/模擬器測試) + +--- + +## 相關文件 + +- 重構指導原則:[functional-thinking.md.md](functional-thinking.md.md) diff --git a/functional-thinking.md.md b/functional-thinking.md.md new file mode 100644 index 0000000..0b43568 --- /dev/null +++ b/functional-thinking.md.md @@ -0,0 +1,71 @@ +--- +tags: + - skill +--- +# functional-thinking.md + +## 技能名稱:函數式思考開發專家 (Grokking Simplicity Style) + +### **核心目標** + +你是一位精通函數式思考的資深工程師,在編寫、重構或審查程式碼時,必須將「降低複雜性」作為最高指導原則。你必須透過區分「動作、計算與資料」來提升軟體的穩定性、可測試性與重用性。 + +--- + +### **1\. 動作、計算與資料 (ACD) 分類準則** + +每次編寫程式碼前,你必須先識別這三種類別: + +- **動作 (Actions)**:執行結果取決於「執行時機」或「執行次數」的程式碼(例如:發送 Email、寫入資料庫、修改全域變數、獲取當前時間)。你必須將動作與其餘邏輯隔離,並將其影響範圍縮至最小。 + +- **計算 (Calculations)**:從輸入到輸出的映射,只要輸入相同,結果永遠一致,且不會對外部產生副作用(即「純函數」)。**應優先將邏輯轉寫為計算**,因為它們易於測試且不具備時間依賴性。 + +- **資料 (Data)**:關於事件的事實記錄。資料是靜態且透明的,應優先使用通用資料結構(如 JavaScript 的物件或陣列)來表示,以利於多種方式的解釋與處理。 + +### **2\. 嚴格實踐不可變性 (Immutability)** + +在處理狀態變更時,嚴禁直接修改原始資料結構,必須使用以下兩種學科: + +- **寫入時複製 (Copy-on-write)**:用於你可控的內部程式碼。 + + - **標準三步驟**:(1) 製作副本;(2) 修改副本;(3) 回傳副本。 + +- **防禦性複製 (Defensive copying)**:用於與外部「不受信任」的遺留代碼 (Legacy Code) 或庫交換資料時。 + + - **核心規則**:資料進入安全區前製作深層副本 (Deep Copy);資料離開安全區傳給外部前亦製作深層副本。 + +### **3\. 分層設計與抽象 (Stratified Design)** + +組織程式碼時應建立清晰的抽象層級: + +- **直截了當的實現 (Straightforward Implementation)**:一個函數內的所有呼叫應處於相似的抽象層次,避免在同一函數中混合高階邏輯與底層細節。 + +- **建立抽象屏障 (Abstraction Barriers)**:重要的資料結構應透過一組介面函數存取,讓上層邏輯無需關心底層實作細節。 + +- **極簡介面 (Minimal Interface)**:底層應保持精簡且功能強大,新功能應盡可能在現有介面上層實作,避免底層污染。 + +### **4\. 使用高階函數優化邏輯** + +- **告別手寫迴圈**:優先使用 `map()` (轉換資料)、`filter()` (篩選資料) 與 `reduce()` (聚合資料) 等高階函數來處理集合。 + +- **抽象化行為**:利用「將函數作為參數」或「回傳函數的函數」來封裝重複的行為模式(如錯誤處理、寫入時複製的模版)。 + +### **5\. 時間軸與併發管理 (Timelines)** + +處理非同步或分散式系統時: + +- **繪製時間軸**:視覺化動作的順序與並行關係,識別可能的競態條件 (Race Conditions)。 + +- **減少共享資源**:偏好使用區域變數而非全域變數,以減少不同時間軸間的干擾。 + +- **使用協調原語**:利用 `Queue` (確保順序執行) 或 `Cut` (確保所有並行動作完成後才執行下一步) 等並發原語來管理時間。 + +--- + +### **對 Agent 的操作指令範例** + +- *「請重構這段程式碼,將其中的動作與計算分離,並套用寫入時複製步驟。」* + +- *「這段邏輯有競態條件風險,請繪製時間軸分析並使用 `Cut` 原語解決。」* + +- *「請遵循極簡介面原則,在現有購物車屏障上實作此折扣邏輯。」* \ No newline at end of file diff --git a/pwa/src/components/Reader/bookmarkUtils.ts b/pwa/src/components/Reader/bookmarkUtils.ts new file mode 100644 index 0000000..c954f29 --- /dev/null +++ b/pwa/src/components/Reader/bookmarkUtils.ts @@ -0,0 +1,17 @@ +import type { Bookmark } from '@/hooks/useLibrary' + +export const sortBookmarksByAddedAt = (bookmarks: Bookmark[]): Bookmark[] => + [...bookmarks].sort((a, b) => a.addedAt - b.addedAt) + +export const removeBookmarkById = (bookmarks: Bookmark[], id: string): Bookmark[] => + bookmarks.filter((b) => b.id !== id) + +export const toggleBookmark = (bookmarks: Bookmark[], cfi: string, label: string): Bookmark[] => { + if (bookmarks.some((b) => b.cfi === cfi)) { + return bookmarks.filter((b) => b.cfi !== cfi) + } + return [...bookmarks, { id: crypto.randomUUID(), cfi, label, addedAt: Date.now() }] +} + +export const formatBookmarkDate = (addedAt: number): string => + new Date(addedAt).toLocaleDateString('zh-TW', { month: 'numeric', day: 'numeric' }) diff --git a/pwa/src/components/Reader/progressCalculations.ts b/pwa/src/components/Reader/progressCalculations.ts new file mode 100644 index 0000000..4550228 --- /dev/null +++ b/pwa/src/components/Reader/progressCalculations.ts @@ -0,0 +1,34 @@ +export const computeChapterAverage = (known: Map): number => { + const knownValues = [...known.values()] + return knownValues.reduce((a, b) => a + b, 0) / knownValues.length +} + +export const computeAccurateTotal = (spineTotal: number, known: Map, avg: number): number => { + let totalPages = 0 + for (let i = 0; i < spineTotal; i++) totalPages += known.get(i) ?? avg + return Math.round(totalPages) +} + +export const computeGlobalPage = (spineIdx: number, chapterPage: number, known: Map, avg: number): number => { + let prevPages = 0 + for (let i = 0; i < spineIdx; i++) prevPages += known.get(i) ?? avg + return Math.max(Math.round(prevPages + chapterPage), 1) +} + +export const resolveInitialPageInfo = (params: { + storedProgress: number | null | undefined + estimatedTotal: number + knownSize: number + spineTotal: number + page: number +}): { page: number; total: number } => { + const { storedProgress, estimatedTotal, knownSize, spineTotal, page } = params + if (storedProgress != null && storedProgress > 0 && knownSize < spineTotal) { + const storedPage = Math.max(Math.round(storedProgress * estimatedTotal), 1) + return { page: storedPage, total: estimatedTotal } + } + return { page, total: estimatedTotal } +} + +export const clampProgressRatio = (rawRatio: number, ttsActive: boolean): number => + ttsActive ? Math.min(rawRatio, 0.99) : rawRatio diff --git a/pwa/src/components/Reader/tocLookup.ts b/pwa/src/components/Reader/tocLookup.ts new file mode 100644 index 0000000..9a02797 --- /dev/null +++ b/pwa/src/components/Reader/tocLookup.ts @@ -0,0 +1,62 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TocItem = { href?: string; label?: string; subitems?: any[] } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SpineItem = { href?: string; idref?: string } + +export const findChapterTitleByHref = (tocItems: TocItem[], curFile: string): string => { + let bestLabel = '' + let bestDepth = -1 + + const search = (items: TocItem[], depth: number) => { + for (const item of items) { + const itemFile = (item.href ?? '').split('#')[0] + if (itemFile === curFile && depth > bestDepth) { + bestLabel = item.label?.trim() ?? '' + bestDepth = depth + } + if (item.subitems?.length) search(item.subitems, depth + 1) + } + } + search(tocItems, 0) + return bestLabel +} + +export const hrefToSpineIndex = (spineItems: SpineItem[], href: string): number => { + const file = href.split('#')[0] + return spineItems.findIndex((s) => + s.href === file || s.href === href || + (file && (s.href?.endsWith('/' + file) || file?.endsWith('/' + (s.href ?? '')))) + ) +} + +export const findNearestChapterLabel = (tocItems: TocItem[], spineItems: SpineItem[], curSpineIdx: number): string => { + let bestLabel = '' + let bestIdx = -1 + + const search = (items: TocItem[]) => { + for (const item of items) { + const si = hrefToSpineIndex(spineItems, item.href ?? '') + if (si !== -1 && si <= curSpineIdx && si > bestIdx) { + bestLabel = item.label?.trim() ?? '' + bestIdx = si + } + if (item.subitems?.length) search(item.subitems) + } + } + search(tocItems) + return bestLabel +} + +export const resolveSpineTarget = (spineItems: SpineItem[], href: string): string => { + const cleanHref = href.split('#')[0] + const filename = cleanHref.split('/').pop() ?? '' + const spineItem = spineItems.find((item) => + item.href === href || + item.href === cleanHref || + item.idref === cleanHref || + item.idref === filename || + (filename && item.href?.endsWith('/' + filename)) || + (filename && item.href === filename) + ) + return spineItem?.href ?? href +} diff --git a/pwa/src/components/Reader/ttsFollowCalculations.ts b/pwa/src/components/Reader/ttsFollowCalculations.ts new file mode 100644 index 0000000..1c5756d --- /dev/null +++ b/pwa/src/components/Reader/ttsFollowCalculations.ts @@ -0,0 +1,100 @@ +import type { TTSProgressSource } from '@/hooks/useTTS' + +export const computeContinuousPage = (absoluteOffset: number, textLength: number, totalPages: number): number => + absoluteOffset / textLength * totalPages + 1 + +export const computePageTurnOffset = (pageStartOffset: number | null, pageEndOffset: number | null, lead: number): number | null => + pageEndOffset === null ? null : Math.max(pageStartOffset ?? 0, pageEndOffset - lead) + +export const computePageEntryGuard = (pageStartOffset: number | null, pageSpan: number | null, maxGuard: number): number => + pageStartOffset !== null ? Math.min(maxGuard, Math.max(12, Math.floor((pageSpan ?? maxGuard) * 0.22))) : 0 + +export type ShouldAdvanceParams = { + absoluteOffset: number + pageStartOffset: number | null + pageEndOffset: number | null + pageTurnOffset: number | null + continuousPage: number + currentPage: number + source: TTSProgressSource + outsidePage: boolean + pageEntryGuard: number +} + +export type ShouldAdvanceResult = { + shouldAdvance: boolean + reason: 'page-end-cfi' | 'progress-fallback' | 'none' + beforeCurrentPageGuard: boolean + insideNewPageGuard: boolean +} + +// 完整搬移自 Reader.tsx 舊版 followTTSRange 的翻頁判斷區塊,邏輯不變。 +export const shouldAdvanceTTSPage = (params: ShouldAdvanceParams): ShouldAdvanceResult => { + const { absoluteOffset, pageStartOffset, pageEndOffset, pageTurnOffset, continuousPage, currentPage, source, outsidePage, pageEntryGuard } = params + const distanceToPageEnd = pageTurnOffset === null ? null : pageTurnOffset - absoluteOffset + const beforeCurrentPageGuard = pageStartOffset !== null && absoluteOffset < pageStartOffset - 2 + const insideNewPageGuard = + pageStartOffset !== null && + absoluteOffset >= pageStartOffset && + absoluteOffset < pageStartOffset + pageEntryGuard + const progressShouldAdvance = + !beforeCurrentPageGuard && + !insideNewPageGuard && + pageEndOffset === null && ( + outsidePage || + (source === 'boundary' && continuousPage >= currentPage + 1.05) || + (continuousPage >= currentPage + 1.12) + ) + const measuredPageEndReached = + !beforeCurrentPageGuard && + pageEndOffset !== null && + distanceToPageEnd !== null && + distanceToPageEnd <= 0 + + if (measuredPageEndReached) return { shouldAdvance: true, reason: 'page-end-cfi', beforeCurrentPageGuard, insideNewPageGuard } + if (progressShouldAdvance) return { shouldAdvance: true, reason: 'progress-fallback', beforeCurrentPageGuard, insideNewPageGuard } + return { shouldAdvance: false, reason: 'none', beforeCurrentPageGuard, insideNewPageGuard } +} + +export const computeApproxOffsetFromPercentage = ( + percentage: number | undefined, + currentPageIdx: number, + totalPages: number, + textLength: number, +): number => + (typeof percentage === 'number' && percentage > 0) + ? Math.floor(percentage * textLength) + : Math.floor(currentPageIdx / totalPages * textLength) + +// 在全文中搜尋 sample 文字樣本,選擇距離 approx 最近的符合位置;找不到回傳 null。 +export const locateApproxOffsetByFuzzyMatch = (fullText: string, approx: number, sample: string): number | null => { + if (sample.length < 3) return null + const normalFull = fullText.replace(/\s+/g, '') + const approxNorm = Math.round(approx / fullText.length * normalFull.length) + let bestNormIdx = -1 + let bestDist = Infinity + let pos = 0 + while (true) { + const idx = normalFull.indexOf(sample, pos) + if (idx < 0) break + const dist = Math.abs(idx - approxNorm) + if (dist < bestDist) { bestDist = dist; bestNormIdx = idx } + pos = idx + 1 + } + if (bestNormIdx < 0) return null + + let normCount = 0 + for (let i = 0; i < fullText.length; i++) { + if (!/\s/.test(fullText[i])) { + if (normCount === bestNormIdx) return i + normCount++ + } + } + return null +} + +export const snapOffsetToWordStart = (fullText: string, approx: number): number => { + let startOffset = approx + while (startOffset > 0 && !/[\s\n]/.test(fullText[startOffset - 1])) startOffset-- + return startOffset +} diff --git a/pwa/src/hooks/reader/useAnnotationPopups.ts b/pwa/src/hooks/reader/useAnnotationPopups.ts new file mode 100644 index 0000000..51659d0 --- /dev/null +++ b/pwa/src/hooks/reader/useAnnotationPopups.ts @@ -0,0 +1,182 @@ +import { useCallback, useRef, useState } from 'react' +import type { Rendition } from 'epubjs' +import { useAnnotationStore } from '@/store/useAnnotationStore' +import { copyTextToClipboard } from '@/components/Reader/annotationUtils' + +export type PopupState = { left: number; top: number; cfi: string; text: string } | null +export type EditPopupState = { left: number; top: number; annotationId: string } | null + +export const useAnnotationPopups = (params: { + renditionRef: React.RefObject + viewerRef: React.RefObject + lastIframeClickRef: React.RefObject<{ x: number; y: number }> + getChapterTitle: () => string +}) => { + const { renditionRef, viewerRef, lastIframeClickRef, getChapterTitle } = params + const [popup, setPopup] = useState(null) + const [editPopup, setEditPopup] = useState(null) + const pendingAnnotationCfiRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const addEpubAnnotationRef = useRef<((r: any, ann: { cfi: string; color: string; id: string }) => void) | null>(null) + const { addAnnotation, updateColor, removeAnnotation } = useAnnotationStore() + + // 建立 annotation SVG 標記的 helper(使用 epub.js 內建 annotations,不修改 DOM 文字節點) + const addEpubAnnotation = useCallback(( + rendition: Rendition, + ann: { cfi: string; color: string; id: string } + ) => { + const annotationId = ann.id // closure 確保 id 可用,不依賴 callback 參數 + console.log('[Ann:add] annotations.add 呼叫 id=', ann.id, 'cfi=', ann.cfi.substring(0, 60)) + try { + rendition.annotations.add( + 'underline', + ann.cfi, + { id: ann.id }, + // 不依賴 epubjs callback 傳入的 event(版本差異大,可能為 undefined) + // 改為直接從 iframe DOM 找到該 annotation 的 SVG 元素,計算其位置 + () => { + // marks-pane 的 SVG 在 outer document,直接用 document.querySelector + const annEl = document.querySelector(`.ann-${annotationId}`) + let x: number + let y: number + if (annEl) { + const r = annEl.getBoundingClientRect() + x = r.left + r.width / 2 + y = r.top + } else { + x = lastIframeClickRef.current?.x ?? 0 + y = lastIframeClickRef.current?.y ?? 0 + } + + setPopup(null) + setEditPopup({ + left: Math.min(Math.max(x, 115), window.innerWidth - 115), + top: y - 52 >= 8 ? y - 52 : Math.min(y + 8, window.innerHeight - 52), + annotationId, + }) + }, + `ann-${ann.id}`, + { stroke: ann.color, 'stroke-opacity': '1', 'stroke-width': '1.5', fill: 'none' } + ) + } catch (e) { + console.error('[Ann:add] annotations.add 拋出例外:', e) + } + // hooks.render 比 contents 就緒早,可能 inject 失敗;延遲以 clear+inject 補渲染 + setTimeout(() => { + if (!document.querySelector(`g.ann-${ann.id} line`)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const annApi = rendition.annotations as any + rendition.views().forEach((view: unknown) => { + annApi.clear(view) + annApi.inject(view) + }) + } + }, 300) + }, [lastIframeClickRef]) + addEpubAnnotationRef.current = addEpubAnnotation + + const addPendingAnnotation = useCallback((rendition: Rendition, cfi: string) => { + if (pendingAnnotationCfiRef.current) { + try { rendition.annotations.remove(pendingAnnotationCfiRef.current, 'underline') } catch {} + } + pendingAnnotationCfiRef.current = cfi + try { + rendition.annotations.add('underline', cfi, {}, undefined, 'tit-pending', + { stroke: '#6366f1', 'stroke-opacity': '0.8', 'stroke-width': '2.5', fill: 'none' }) + } catch {} + }, []) + + const removePendingAnnotation = useCallback((rendition: Rendition) => { + if (!pendingAnnotationCfiRef.current) return + try { rendition.annotations.remove(pendingAnnotationCfiRef.current, 'underline') } catch {} + pendingAnnotationCfiRef.current = null + }, []) + + const handleHighlight = (color: string) => { + if (!popup) return + const iframe = viewerRef.current?.querySelector('iframe') + const win = iframe?.contentWindow + if (win) win.getSelection()?.removeAllRanges() + if (renditionRef.current) removePendingAnnotation(renditionRef.current) + + const ann = { cfi: popup.cfi, text: popup.text, color, chapter: getChapterTitle() } + addAnnotation(ann) + const id = useAnnotationStore.getState().annotations.at(-1)?.id ?? crypto.randomUUID() + + if (renditionRef.current) { + addEpubAnnotation(renditionRef.current, { cfi: popup.cfi, color, id }) + } + + setPopup(null) + } + + const handleSearchSelectedText = () => { + if (!popup) return + const text = popup.text.trim() + if (!text) return + + const iframe = viewerRef.current?.querySelector('iframe') + iframe?.contentWindow?.getSelection()?.removeAllRanges() + if (renditionRef.current) removePendingAnnotation(renditionRef.current) + window.open(`https://www.google.com/search?q=${encodeURIComponent(text)}`, '_blank', 'noopener,noreferrer') + setPopup(null) + } + + const handleCopySelectedText = async () => { + if (!popup) return + const text = popup.text.trim() + if (!text) return + + const iframe = viewerRef.current?.querySelector('iframe') + iframe?.contentWindow?.getSelection()?.removeAllRanges() + if (renditionRef.current) removePendingAnnotation(renditionRef.current) + await copyTextToClipboard(text) + setPopup(null) + } + + const handleChangeColor = (id: string, color: string) => { + const ann = useAnnotationStore.getState().annotations.find((a) => a.id === id) + if (ann && renditionRef.current) { + renditionRef.current.annotations.remove(ann.cfi, 'underline') + addEpubAnnotation(renditionRef.current, { cfi: ann.cfi, color, id }) + } + updateColor(id, color) + } + + const handleDeleteMark = (id: string) => { + const ann = useAnnotationStore.getState().annotations.find((a) => a.id === id) + if (ann) { + renditionRef.current?.annotations.remove(ann.cfi, 'underline') + } + removeAnnotation(id) + setEditPopup(null) + } + + const handleEditColor = (id: string, color: string) => { + handleChangeColor(id, color) + setEditPopup(null) + } + + const handleNavigateToAnnotation = (cfi: string) => { + renditionRef.current?.display(cfi).catch((err: unknown) => { + console.warn('[Reader] 跳轉至註記失敗(No Section Found?):', err) + }) + } + + return { + popup, setPopup, + editPopup, setEditPopup, + pendingAnnotationCfiRef, + addEpubAnnotationRef, + addEpubAnnotation, + addPendingAnnotation, + removePendingAnnotation, + handleHighlight, + handleSearchSelectedText, + handleCopySelectedText, + handleChangeColor, + handleDeleteMark, + handleEditColor, + handleNavigateToAnnotation, + } +} diff --git a/pwa/src/hooks/reader/useBookmarks.ts b/pwa/src/hooks/reader/useBookmarks.ts new file mode 100644 index 0000000..689be0d --- /dev/null +++ b/pwa/src/hooks/reader/useBookmarks.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import { loadBookmarks, saveBookmarks } from '@/hooks/useLibrary' +import type { Bookmark } from '@/hooks/useLibrary' +import { removeBookmarkById, toggleBookmark } from '@/components/Reader/bookmarkUtils' + +export const useBookmarks = (bookId: string) => { + const [bookmarks, setBookmarks] = useState(() => loadBookmarks(bookId)) + const [pendingDeleteId, setPendingDeleteId] = useState(null) + + // bookId 變更時(或 StrictMode 雙重 invoke 後)重新從 localStorage 載入書籤 + useEffect(() => { setBookmarks(loadBookmarks(bookId)) }, [bookId]) + + const isBookmarked = (cfi: string) => bookmarks.some((b) => b.cfi === cfi) + + const toggle = (cfi: string, label: string) => { + setBookmarks((prev) => { + const next = toggleBookmark(prev, cfi, label) + saveBookmarks(bookId, next) + return next + }) + } + + const remove = (id: string) => { + setBookmarks((prev) => { + const next = removeBookmarkById(prev, id) + saveBookmarks(bookId, next) + return next + }) + } + + const reset = () => { + setBookmarks([]) + setPendingDeleteId(null) + } + + return { bookmarks, pendingDeleteId, setPendingDeleteId, isBookmarked, toggle, remove, reset } +} diff --git a/pwa/src/hooks/reader/useChapterPageScan.ts b/pwa/src/hooks/reader/useChapterPageScan.ts new file mode 100644 index 0000000..2e808b5 --- /dev/null +++ b/pwa/src/hooks/reader/useChapterPageScan.ts @@ -0,0 +1,170 @@ +import { useCallback, useRef } from 'react' +import ePub from 'epubjs' +import type { Book, Rendition } from 'epubjs' +import { applyFontFamilyOverride, applyLetterSpacingOverride, applyLineHeightOverride } from '@/components/Reader/readerStyles' +import { computeAccurateTotal, computeChapterAverage, computeGlobalPage } from '@/components/Reader/progressCalculations' + +type PageInfo = { page: number; total: number } + +// 背景逐章渲染以取得精確全書頁數(反映當前字型、字距、行距),並在完成時校正 pageInfo。 +export const useChapterPageScan = (params: { + viewerRef: React.RefObject + bookRef: React.RefObject + renditionRef: React.RefObject + fontSizeRef: { current: number } + fontFamilyRef: { current: string } + lineHeightRef: { current: number } + letterSpacingRef: { current: number } + ttsActiveRef: { current: boolean } + setPageInfo: (updater: (prev: PageInfo | null) => PageInfo | null) => void +}) => { + const { viewerRef, bookRef, renditionRef, fontSizeRef, fontFamilyRef, lineHeightRef, letterSpacingRef, ttsActiveRef, setPageInfo } = params + + const chapterPagesRef = useRef>(new Map()) // spineIndex → 已渲染的章節總頁數 + const currentChapterPageRef = useRef(1) // 當前章節內頁碼,供掃描完成後重算 page + const bookBufferRef = useRef(null) // 儲存書本原始 buffer,供掃描用獨立 ePub 實例使用 + const scanAbortRef = useRef<{ aborted: boolean }>({ aborted: false }) + const scanTimerRef = useRef | null>(null) + + const scanAllChapterPages = useCallback(async () => { + const book = bookRef.current + const viewer = viewerRef.current + const buffer = bookBufferRef.current + if (!book || !viewer || !buffer) return + if (ttsActiveRef.current) return + + scanAbortRef.current.aborted = true + const token = { aborted: false } + scanAbortRef.current = token + chapterPagesRef.current = new Map() + + const { width, height } = viewer.getBoundingClientRect() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spineItems = ((book as any).spine?.items ?? []) as any[] + if (!spineItems.length || width === 0 || height === 0) return + + const hiddenEl = document.createElement('div') + Object.assign(hiddenEl.style, { + position: 'fixed', top: '-9999px', left: '-9999px', + width: `${width}px`, height: `${height}px`, + overflow: 'hidden', visibility: 'hidden', pointerEvents: 'none', + }) + document.body.appendChild(hiddenEl) + + // 建立完全獨立的 ePub 實例進行掃描,避免與主渲染器共享 book.spine.hooks 等狀態 + // (epub.js 的 book.renderTo 會覆寫 book.rendition,且 spine.hooks.content 為共享, + // 使用同一 book 的第二個 rendition 會導致 hooks 互相觸發,進而讓主渲染器內容消失) + const scanBook = ePub(buffer.slice(0)) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hiddenRendition = (scanBook as any).renderTo(hiddenEl, { + width, height, spread: 'none', flow: 'paginated', allowScriptedContent: true, + }) + + try { + hiddenRendition.themes.override('font-size', `${fontSizeRef.current}px`) + hiddenRendition.themes.override('font-family', fontFamilyRef.current) + hiddenRendition.themes.override('line-height', String(lineHeightRef.current)) + hiddenRendition.themes.override('letter-spacing', `${letterSpacingRef.current}em`) + } catch { /* ignore */ } + + hiddenRendition.hooks.content.register((view: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const doc = (view as any).document as Document | undefined + if (!doc) return + applyFontFamilyOverride(doc, fontFamilyRef.current) + applyLineHeightOverride(doc, lineHeightRef.current) + applyLetterSpacingOverride(doc, letterSpacingRef.current) + }) + + const spineTotal = spineItems.length + try { + for (const item of spineItems) { + if (token.aborted || ttsActiveRef.current) break + const href = item.href as string | undefined + if (!href) continue + try { + await hiddenRendition.display(href) + if (token.aborted || ttsActiveRef.current) break + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loc = (hiddenRendition as any).currentLocation?.() + const d = loc?.start?.displayed as { page: number; total: number } | undefined + const idx = item.index as number | undefined + if (d && idx !== undefined) { + chapterPagesRef.current = new Map(chapterPagesRef.current).set(idx, d.total) + // 掃描全部完成才更新 total,避免 avg 隨掃描進度改變造成總頁數持續跳動 + const scannedCount = chapterPagesRef.current.size + if (scannedCount === spineTotal) { + const known = chapterPagesRef.current + const avg = computeChapterAverage(known) + const accurateTotal = computeAccurateTotal(spineTotal, known, avg) + console.log('[Progress] 掃描完成,更新 total', { + spineTotal, + chapterPageCounts: Object.fromEntries(known), + accurateTotal, + }) + // 直接從主渲染器讀取即時位置,避免 ref 快取和 scanner/主渲染器章節數差異問題 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mainLoc = (renditionRef.current as any)?.currentLocation?.() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mainD = mainLoc?.start?.displayed as { page: number; total: number } | undefined + const mainSpineIdx = mainLoc?.start?.index as number | undefined + setPageInfo(prev => { + if (!prev) return prev + const accuratePage = (mainD && mainSpineIdx !== undefined) + ? computeGlobalPage(mainSpineIdx, mainD.page, known, avg) + : prev.page + console.log('[Progress] setPageInfo(掃描後)', { + prevPage: prev.page, + prevTotal: prev.total, + mainSpineIdx, + mainChapterPage: mainD?.page, + accuratePage, + accurateTotal, + prevPct: `${Math.round(prev.page / prev.total * 100)}%`, + newPct: `${Math.round(accuratePage / accurateTotal * 100)}%`, + }) + return { page: accuratePage, total: Math.max(accurateTotal, accuratePage) } + }) + } + } + } catch { /* 略過無法渲染的章節 */ } + await new Promise(r => setTimeout(r, 0)) // 讓 UI 有機會更新 + } + } finally { + hiddenEl.remove() + // scanBook 是完全獨立的 ePub 實例,destroy() 不影響主渲染器 + try { (scanBook as any).destroy() } catch { /* ignore */ } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const triggerScan = useCallback(() => { + if (ttsActiveRef.current) return + if (scanTimerRef.current) clearTimeout(scanTimerRef.current) + scanTimerRef.current = setTimeout(scanAllChapterPages, 600) + }, [scanAllChapterPages, ttsActiveRef]) + + const cancelScan = useCallback(() => { + scanAbortRef.current.aborted = true + if (scanTimerRef.current) { + clearTimeout(scanTimerRef.current) + scanTimerRef.current = null + } + }, []) + + const resetScanState = useCallback(() => { + cancelScan() + chapterPagesRef.current = new Map() + currentChapterPageRef.current = 1 + }, [cancelScan]) + + return { + chapterPagesRef, + currentChapterPageRef, + bookBufferRef, + scanAllChapterPages, + triggerScan, + cancelScan, + resetScanState, + } +} diff --git a/pwa/src/hooks/reader/useReaderEngine.ts b/pwa/src/hooks/reader/useReaderEngine.ts new file mode 100644 index 0000000..afb3417 --- /dev/null +++ b/pwa/src/hooks/reader/useReaderEngine.ts @@ -0,0 +1,1514 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import ePub from 'epubjs' +import type { Book, Rendition } from 'epubjs' +import type { TocItem } from '@/components/ChapterPanel' +import type { TTSProgressSource } from '@/hooks/useTTS' +import { useReaderStore } from '@/store/useReaderStore' +import type { Script } from '@/store/useReaderStore' +import { useAnnotationStore, loadAnnotationsForBook, saveAnnotationsForBook } from '@/store/useAnnotationStore' +import type { Annotation } from '@/store/useAnnotationStore' +import { saveProgress, loadProgress, saveBookSettings, loadBookSettings } from '@/hooks/useLibrary' +import type { BookRecord } from '@/hooks/useLibrary' +import { patchIframeViewPrototype, patchRenditionPrototype } from '@/components/Reader/epubPatches' +import { applyDarkOverride, applyFontFamilyOverride, applyFontSizeOverride, applyLetterSpacingOverride, applyLineHeightOverride, normalizeFontFamily } from '@/components/Reader/readerStyles' +import { convertDoc, getToSC, getToTC, restoreDoc } from '@/components/Reader/scriptConversion' +import { DEBUG_TTS_FOLLOW, TTS_HIGHLIGHT_INTERVAL, TTS_NEW_PAGE_AUTO_FOLLOW_GUARD, TTS_PAGE_END_FIXED_LEAD, TTS_USER_INPUT_GRACE, clearTTSHighlight, clearTTSHighlights, collectContentDocuments, createRangeFromTextOffset, ensureTTSHighlightStyle, getBoundaryOffsetFromRange, getTTSRangeViewportState, getTextIndex, paintTTSHighlightOverlay, ttsTextIndexCache } from '@/components/Reader/ttsHighlight' +import { computeAccurateTotal, computeChapterAverage, computeGlobalPage, clampProgressRatio, resolveInitialPageInfo } from '@/components/Reader/progressCalculations' +import { resolveSpineTarget } from '@/components/Reader/tocLookup' +import { computeApproxOffsetFromPercentage, computeContinuousPage, computePageEntryGuard, computePageTurnOffset, locateApproxOffsetByFuzzyMatch, shouldAdvanceTTSPage, snapOffsetToWordStart } from '@/components/Reader/ttsFollowCalculations' + +type PageInfo = { page: number; total: number } +type PopupState = { left: number; top: number; cfi: string; text: string } | null +type EditPopupState = { left: number; top: number; annotationId: string } | null + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyLoc = any + +// book/rendition 生命週期與 TTS 跟讀邏輯實際上雙向耦合(relocated 事件會呼叫 TTS 的頁尾更新函數, +// TTS 又依賴 book 初始化時建立的 currentDocRef/ttsVisibleSpineIndexRef 等狀態),因此合併為單一 hook, +// 而非強行拆成兩個獨立但仍要互相呼叫的檔案。 +export const useReaderEngine = (params: { + bookPath: string + bookId: string + bookRecord: BookRecord | null + darkMode: boolean + activePanel: string | null + onUpdateProgress?: (pct: number) => void + + viewerRef: { current: HTMLDivElement | null } + bookRef: { current: Book | null } + renditionRef: { current: Rendition | null } + lastIframeClickRef: { current: { x: number; y: number } } + + fontSize: number + fontFamily: string + script: Script + lineHeight: number + letterSpacing: number + readingDirection: 'ltr' | 'rtl' + setFontFamily: (v: string) => void + setScript: (v: Script) => void + resetScript: () => void + fontSizeRef: { current: number } + fontFamilyRef: { current: string } + lineHeightRef: { current: number } + letterSpacingRef: { current: number } + + playing: boolean + ttsPaused: boolean + speak: (text: string, onDone: () => void, onProgress: (charIdx: number, source: TTSProgressSource) => void) => void + pause: () => void + resume: () => void + stop: () => void + resetTTS: () => void + ttsActiveRef: { current: boolean } + + chapterPagesRef: { current: Map } + currentChapterPageRef: { current: number } + bookBufferRef: { current: ArrayBuffer | null } + scanAllChapterPages: () => Promise + triggerScan: () => void + cancelScan: () => void + resetScanState: () => void + + setPopup: (v: PopupState) => void + setEditPopup: (v: EditPopupState) => void + pendingAnnotationCfiRef: { current: string | null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addEpubAnnotation: (rendition: Rendition, ann: { cfi: string; color: string; id: string }) => void + addPendingAnnotation: (rendition: Rendition, cfi: string) => void + removePendingAnnotation: (rendition: Rendition) => void + + pageInfo: PageInfo | null + setPageInfo: (updater: PageInfo | null | ((prev: PageInfo | null) => PageInfo | null)) => void + + loadForBook: (annotations: Annotation[]) => void + clearAnnotations: () => void + resetBookmarks: () => void +}) => { + const { + bookPath, bookId, bookRecord, darkMode, activePanel, onUpdateProgress, + viewerRef, bookRef, renditionRef, lastIframeClickRef, + fontSize, fontFamily, script, lineHeight, letterSpacing, readingDirection, + setFontFamily, setScript, resetScript, + fontSizeRef, fontFamilyRef, lineHeightRef, letterSpacingRef, + playing, ttsPaused, speak, pause, resume, stop, resetTTS, ttsActiveRef, + chapterPagesRef, currentChapterPageRef, bookBufferRef, scanAllChapterPages, triggerScan, cancelScan, resetScanState, + setPopup, setEditPopup, pendingAnnotationCfiRef, addEpubAnnotation, addPendingAnnotation, removePendingAnnotation, + pageInfo, setPageInfo, + loadForBook, clearAnnotations, resetBookmarks, + } = params + + const bookRecordRef = useRef(bookRecord) + useEffect(() => { bookRecordRef.current = bookRecord }, [bookRecord]) + + const scriptRef = useRef