diff --git a/FP_REFACTOR_PROGRESS.md b/FP_REFACTOR_PROGRESS.md index 729babc..14beecd 100644 --- a/FP_REFACTOR_PROGRESS.md +++ b/FP_REFACTOR_PROGRESS.md @@ -12,8 +12,8 @@ tags: ## 整體順序 1. ✅ **PWA**(`pwa/`)— 已完成 -2. ⬜ **RENDERER**(`renderer/`)— 待開始 -3. ⬜ **RN / mobile**(`mobile/`)— 待開始 +2. ✅ **RENDERER**(`renderer/`)— 已完成 +3. 🔶 **RN / mobile**(`mobile/`)— 程式碼搬移完成,待使用者實機測試 每完成一個版本先暫停,等使用者確認沒問題再進行下一個。 @@ -54,28 +54,85 @@ tags: --- -## 2. RENDERER(待開始) +## 2. RENDERER(已完成) -目標檔案:`renderer/src/page/Reader.tsx` +目標檔案:`renderer/src/page/Reader.tsx`(原始 1850 行,重構後 314 行) -**注意**:`renderer` 目前程式碼已與 `pwa` 分岔,功能不完全相同(例如少了書籤功能、annotation popup 拆成獨立的 `BookmarkPanel.tsx` / `HighlightPopup.tsx` 元件)。不能直接複製 PWA 版的重構結果,需要重新評估: +**更正**:舊版本文件寫「renderer 少了書籤功能」已過期——renderer 其實已有完整書籤功能與更早就元件化的 `BookmarkPanel.tsx` / `HighlightPopup.tsx`,只是書籤/annotation/TTS 邏輯尚未抽成純函數 + hooks。 -- [ ] 比對 `renderer` 與 `pwa` 的 `Reader.tsx` 差異,確認哪些抽出的邏輯可以共用、哪些需要為 renderer 版本量身設計 -- [ ] 決定是否要與 PWA 共用同一份純計算檔案(若邏輯完全一致) -- [ ] 依同樣的 ACD 原則拆分 renderer 版 `Reader.tsx` -- [ ] `cd renderer && yarn build` 驗證 -- [ ] 列出需使用者實測的項目(Electron 環境特有行為) +### 已完成項目 + +- [x] 新增 4 個純計算檔案(`renderer/src/components/Reader/`,與 pwa 共用同一份邏輯,逐字搬移): + - `progressCalculations.ts`、`tocLookup.ts`、`bookmarkUtils.ts`、`ttsFollowCalculations.ts` +- [x] 抽出 `useBookmarks.ts`、`useChapterPageScan.ts`、`useAnnotationPopups.ts`、`useReaderEngine.ts`(`renderer/src/hooks/reader/`) + - `useAnnotationPopups.ts` 依 renderer 現有 `HighlightPopup.tsx` 介面調整為 `{x, y}` 座標(不採用 pwa 版的 `{left, top}` clamp 寫法) + - `useReaderEngine.ts` 以 renderer 原有邏輯為底重新組織(而非直接搬 pwa 版),刻意保留 renderer 特有行為: + - TTS 高亮改用 renderer 原有的 CSS Custom Highlight API 直接繪製(不採用 pwa 版新增的 DOM overlay fallback,那是獨立的行為改動,超出本次重構範圍) + - 不引入 pwa 版新增的行動裝置專屬邏輯(觸控 selectionchange 選字路徑、iOS selectionchange 轉發、`getVisibleContentDocument` 多 iframe 命中測試)——Electron 桌面版不需要 + - 同步修正 `renderer/src/store/useAnnotationStore.ts` 的 `addAnnotation`,改為回傳新建立的 id(比照 pwa 版介面,讓 `useAnnotationPopups.ts` 可直接使用回傳值) + - 修掉章節掃描 bug:背景掃描改用獨立的 `ePub(buffer.slice(0))` 實例(`useChapterPageScan.ts`),不再複用主 `book` 建立第二個 rendition,避免掃描完成時偶爾清空主渲染器 annotation 的問題 +- [x] `Reader.tsx` 從 1850 行降至 314 行 +- [x] `cd renderer && yarn build` 驗證型別/編譯無誤 + +### 使用者實測結果(已通過) + +- [x] 翻頁、鍵盤左右鍵翻頁 +- [x] 朗讀播放/暫停/繼續/跨章節自動翻頁 +- [x] 選字高亮 popup 位置(新增/編輯/刪除標記) +- [x] 書籤新增/刪除/跳轉 +- [x] 簡繁轉換、深色模式、字體大小/字型/行距/字距設定 +- [x] 睡眠計時器 +- [x] 章節掃描:切換字體大小後多次翻頁,頁碼校正正確且高亮標記不會被意外清除 + +### 實測中發現並修正的問題 + +- [x] **朗讀跟隨螢光色閃爍、同時出現在目標與非目標句子**:`renderer/src/hooks/useTTS.ts` 仍保留舊版「估算進度計時器」(每 250ms 用預估字速推進高亮位置),與真實 `onboundary` 事件互相搶跑造成閃爍。比照 pwa 版已驗證過的修法,移除估算計時器,高亮改成只跟隨真實 boundary 事件。 +- [x] **註記跳轉後不顯示**:在 `useReaderEngine.ts` 的 `relocated` 事件中新增補救邏輯——每次換頁/跳轉後延遲檢查當前章節的既有註記是否有對應 SVG 底線,缺漏則重新呼叫 `addEpubAnnotation` 補畫(epub.js 的 `annotations.inject(view)` 在 contents 尚未完全就緒時偶爾會失敗)。 +- [x] **書庫排序按鈕(最近閱讀/書名/進度)點擊無效**:與本次 Reader 重構無關的既有 bug——`Library.tsx` 外層有 `drag-region`(Electron 視窗可拖曳區域),`SortControl.tsx` 的按鈕缺少 `no-drag` class,導致點擊被系統拖曳行為吃掉。已補上 `no-drag`。 + +**RENDERER 重構已通過完整實測,可視為此階段完成。** --- -## 3. RN / mobile(待開始) +## 3. RN / mobile(程式碼搬移完成,待實機測試) -目標目錄:`mobile/`(React Native + WebView 架構,與 PWA/RENDERER 完全不同) +目標目錄:`mobile/`(Expo Router + react-native-webview + epub.js,是兩個獨立的 JS 世界: +RN 端負責畫面與 AsyncStorage,WebView 端獨立打包一份 epub.js reader 邏輯)。 + +### 已完成項目 -- [ ] 先探索 mobile 版對應的 Reader 相關程式碼結構(WebView 架構下的 ACD 分層方式可能與 web 版差異很大) -- [ ] 評估此架構下的動作/計算/資料分層策略 -- [ ] 執行重構 -- [ ] 驗證方式待定(RN 無法用 `yarn build` 驗證執行期行為,需实機/模擬器測試) +**RN 端**(`mobile/app/reader/[id].tsx` 從 788 行降至 383 行): +- [x] 新增 `mobile/lib/reader/calculations.ts`:書籤/註記陣列運算、睡眠計時循環選項、 + URL scheme 判斷、頁碼百分比格式化,以及從 `lib/tts.ts` 搬過來的 `splitTextByLength`/ + `withFriendlyLabels`/`dedupeByLanguage`(純函數,逐字搬移零行為改動) +- [x] 抽出 `mobile/hooks/reader/useReaderEngine.ts`(WebView 訊息橋接、relocated/toc/選字狀態、 + 排版設定載入存檔)、`useBookmarks.ts`、`useAnnotations.ts`、`useTTSReading.ts`(朗讀跨章節跟讀) +- [x] `mobile/lib/library.ts` 不動(書庫列表與 Reader 共用資料層,不在本次重構範圍) + +**WebView 端**(`mobile/reader-web/index.ts` 從 1132 行降至 692 行): +- [x] 新增 `tocLookup.ts`(TOC 查找)、`progressCalculations.ts`(頁碼/百分比換算純函數)、 + `ttsFollowCalculations.ts`(朗讀跟讀「是否該自動翻頁」純判斷)、`scriptConversion.ts`(簡繁轉換)、 + `readerStyles.ts`(排版/深色模式 DOM 樣式注入)、`annotationUtils.ts`(標記差異比對純函數 + `diffAnnotations` + DOM 操作) +- [x] `index.ts` 保留 module 狀態、book/rendition 生命週期、`handleMessage` dispatcher, + 改為呼叫上述新模組 + +### 驗證進度 + +- [x] `cd mobile && npx tsc --noEmit` 型別檢查通過 +- [x] `yarn build:reader` 成功打包(esbuild 不做型別檢查,只驗證語法/bundle 成功) +- [ ] **使用者實機/模擬器測試(尚未進行,我無法在此環境驗證 RN/WebView 執行期行為)**: + - [ ] 翻頁(含 RTL、tap-zone 點擊) + - [ ] 朗讀播放/暫停/繼續/跨章節自動翻頁跟讀 + - [ ] 選字劃線(含劃線模式切換)/ 標記顏色編輯 / 刪除 / 筆記 + - [ ] 書籤新增/刪除/跳轉 + - [ ] 簡繁轉換(含書本原始語言偵測 baseScript) + - [ ] 深色模式 + - [ ] 字體大小/字型/行距/字距設定 + - [ ] 睡眠計時 + - [ ] 章節背景掃描完成後頁碼校正正確 + +**在使用者完成上述實機測試並回報無誤前,此階段不可視為完成。** --- diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index cb27122..a7a9d4a 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -1,6 +1,6 @@ import * as Clipboard from 'expo-clipboard'; import { useLocalSearchParams, router } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; @@ -8,371 +8,51 @@ import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconNote import ListPanel from '../../components/ListPanel'; import SelectionBar from '../../components/SelectionBar'; import SettingsPanel from '../../components/SettingsPanel'; -import { - type Annotation, - type BookRecord, - type BookSettings, - type Bookmark, - generateId, - getBookBase64, - listBooks, - loadAnnotations, - loadBookSettings, - loadBookmarks, - loadReadingCfi, - saveAnnotations, - saveBookSettings, - saveBookmarks, - saveReadingCfi, - touchBook, - updateProgress, -} from '../../lib/library'; +import { useAnnotations } from '../../hooks/reader/useAnnotations'; +import { useBookmarks } from '../../hooks/reader/useBookmarks'; +import { useReaderEngine } from '../../hooks/reader/useReaderEngine'; +import { useTTSReading } from '../../hooks/reader/useTTSReading'; import { READER_HTML } from '../../lib/readerHtml.generated'; -import type { OutboundMessage, TocItem } from '../../lib/readerMessages'; -import { DEFAULT_TYPOGRAPHY } from '../../lib/readerSettings'; +import { formatPageProgress, shouldOpenExternally } from '../../lib/reader/calculations'; import { useTheme } from '../../lib/theme'; -import { useTTS } from '../../lib/tts'; -import { useFocusEffect } from 'expo-router'; const ReaderScreen = () => { const { darkMode, colors } = useTheme(); const { id } = useLocalSearchParams<{ id: string }>(); - const webviewRef = useRef(null); - const webviewReadyRef = useRef(false); - const [record, setRecord] = useState(null); - const [loading, setLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(null); const [settingsVisible, setSettingsVisible] = useState(false); - const [typography, setTypography] = useState(DEFAULT_TYPOGRAPHY); - const [bookmarks, setBookmarks] = useState([]); - const [annotations, setAnnotations] = useState([]); - const [selection, setSelection] = useState<{ cfi: string; text: string } | null>(null); - const [editingAnnotationId, setEditingAnnotationId] = useState(null); - const [annotationMode, setAnnotationMode] = useState(false); - const [toc, setToc] = useState([]); - const [currentCfi, setCurrentCfi] = useState(''); - const [currentHref, setCurrentHref] = useState(''); - const [currentChapterTitle, setCurrentChapterTitle] = useState(''); - const [pageInfo, setPageInfo] = useState<{ page: number; total: number; percentage: number } | null>(null); const [listPanelTab, setListPanelTab] = useState<'bookmarks' | 'chapters' | 'bookinfo' | 'notes' | null>(null); - const settingsLoadedRef = useRef(false); - const hadSavedSettingsRef = useRef(false); - const chapterTextResolverRef = useRef<((result: { text: string; startOffset: number }) => void) | null>(null); - const chapterTextTimeoutRef = useRef | null>(null); - const relocatedResolverRef = useRef<(() => void) | null>(null); - // 供 advanceToNextChapter 判斷「是否已經跨到新章節」/「是否已到書尾」,用 ref 而非 - // state 是因為要在同一個非同步迴圈裡讀到最新值,不能等 re-render 後的閉包。 - const currentHrefRef = useRef(''); - const atEndRef = useRef(false); - const tts = useTTS(); - - useFocusEffect( - useCallback(() => { - if (!id) return; - touchBook(id); - }, [id]) - ); - - // 載入這本書上次儲存的排版設定;settingsLoadedRef 避免載入完成前的初始 state 被下面的 - // 自動存檔 effect 誤存成預設值蓋掉使用者原本存好的設定。 - useEffect(() => { - if (!id) return; - settingsLoadedRef.current = false; - hadSavedSettingsRef.current = false; - let cancelled = false; - loadBookSettings(id).then((saved) => { - if (cancelled) return; - if (saved) { - setTypography(saved); - hadSavedSettingsRef.current = true; - } else { - setTypography(DEFAULT_TYPOGRAPHY); - } - settingsLoadedRef.current = true; - }); - return () => { cancelled = true; }; - }, [id]); - - useEffect(() => { - if (!id || !settingsLoadedRef.current) return; - saveBookSettings(id, typography); - }, [id, typography]); - - useEffect(() => { - tts.reset(); - webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStop' })); - setToc([]); - setCurrentCfi(''); - setCurrentHref(''); - currentHrefRef.current = ''; - atEndRef.current = false; - setCurrentChapterTitle(''); - setPageInfo(null); - setListPanelTab(null); - setAnnotations([]); - setSelection(null); - setEditingAnnotationId(null); - setAnnotationMode(false); - webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - useEffect(() => { - if (!id) return; - let cancelled = false; - loadBookmarks(id).then((saved) => { - if (!cancelled) setBookmarks(saved); - }); - return () => { cancelled = true; }; - }, [id]); - - const handleWebViewReady = useCallback(async () => { - if (!id) return; - const books = await listBooks(); - const found = books.find((b) => b.id === id) ?? null; - setRecord(found); - if (!found) { - setErrorMessage('找不到這本書'); - setLoading(false); - return; - } - try { - const [base64, cfi, savedAnnotations] = await Promise.all([ - getBookBase64(found), - loadReadingCfi(id), - loadAnnotations(id), - ]); - setAnnotations(savedAnnotations); - webviewRef.current?.postMessage( - JSON.stringify({ - type: 'load', - base64, - cfi, - annotations: savedAnnotations.map((a) => ({ id: a.id, cfi: a.cfi, color: a.color })), - }) - ); - } catch (err) { - setErrorMessage(err instanceof Error ? err.message : String(err)); - setLoading(false); - } - }, [id]); - - const handleMessage = useCallback( - (event: { nativeEvent: { data: string } }) => { - let msg: OutboundMessage; - try { - msg = JSON.parse(event.nativeEvent.data); - } catch { - return; - } - if (msg.type === 'ready') { - webviewReadyRef.current = true; - webviewRef.current?.postMessage(JSON.stringify({ type: 'setDarkMode', darkMode })); - // settingsLoadedRef 還沒完成前先不送 setTypography,避免蓋成預設值;等 - // loadBookSettings 完成後,下面監聽 typography 變化的 effect 會補送一次真正的設定。 - if (settingsLoadedRef.current) { - webviewRef.current?.postMessage(JSON.stringify({ type: 'setTypography', ...typography })); - } - handleWebViewReady(); - return; - } - if (msg.type === 'relocated') { - setLoading(false); - setCurrentCfi(msg.cfi); - setCurrentHref(msg.href); - currentHrefRef.current = msg.href; - atEndRef.current = msg.atEnd; - setCurrentChapterTitle(msg.chapterTitle); - setPageInfo(msg.page !== null && msg.total !== null ? { page: msg.page, total: msg.total, percentage: msg.percentage } : null); - relocatedResolverRef.current?.(); - relocatedResolverRef.current = null; - if (!id) return; - saveReadingCfi(id, msg.cfi); - updateProgress(id, msg.percentage); - return; - } - if (msg.type === 'tocLoaded') { - setToc(msg.toc); - return; - } - if (msg.type === 'error') { - setErrorMessage(msg.message); - setLoading(false); - return; - } - if (msg.type === 'chapterText') { - if (chapterTextTimeoutRef.current) { - clearTimeout(chapterTextTimeoutRef.current); - chapterTextTimeoutRef.current = null; - } - chapterTextResolverRef.current?.({ text: msg.text, startOffset: msg.startOffset }); - chapterTextResolverRef.current = null; - return; - } - if (msg.type === 'textSelected') { - if (__DEV__) console.log('[reader] textSelected 收到', msg.text.slice(0, 20)); - setEditingAnnotationId(null); - setSelection({ cfi: msg.cfi, text: msg.text }); - return; - } - if (msg.type === 'selectionCleared') { - console.log('[reader] selectionCleared 收到'); - setSelection(null); - return; - } - if (msg.type === 'annotationTapped') { - console.log('[reader] annotationTapped 收到', msg.id); - setSelection(null); - setEditingAnnotationId(msg.id); - return; - } - if (msg.type === 'debug') { - console.log('[reader-web debug]', msg.message); - return; - } - if (msg.type === 'bookLanguageDetected') { - // 比照網頁版 Reader.tsx:baseScript 永遠反映書本原始語言;只有在使用者這本書 - // 從沒存過排版偏好時,才自動把顯示腳本切成跟書本原始語言一致(例如簡體書預設 - // 顯示簡體,而不是被 mobile 端寫死的預設值 'tc' 誤判成要轉換成繁體)。 - if (!hadSavedSettingsRef.current) { - setTypography((prev) => ({ ...prev, script: msg.baseScript })); - } - } - }, - [handleWebViewReady, id, darkMode, typography] - ); - - // 深色模式切換時(例如使用者從設定頁切回閱讀頁),即時通知 WebView 內的 epub 內容套用新樣式, - // 不必等下一次換頁;WebView 尚未回報 ready 前先略過,ready 當下會用最新的 darkMode 補送一次。 - useEffect(() => { - if (!webviewReadyRef.current) return; - webviewRef.current?.postMessage(JSON.stringify({ type: 'setDarkMode', darkMode })); - }, [darkMode]); - - // 排版設定變更時即時套用到 WebView 內已渲染的內容,不必等下一次換頁。 - useEffect(() => { - if (!webviewReadyRef.current) return; - webviewRef.current?.postMessage(JSON.stringify({ type: 'setTypography', ...typography })); - }, [typography]); - - // 若已有一個 getChapterText 請求在飛行中,新請求直接回空字串,避免蓋掉前一個 - // resolver 導致前一個呼叫永遠 resolve 不到正確結果;並用逾時保護 WebView 沒回應時 - // 呼叫端不會卡死。 - const requestChapterText = useCallback((): Promise<{ text: string; startOffset: number }> => { - if (chapterTextResolverRef.current) return Promise.resolve({ text: '', startOffset: 0 }); - return new Promise((resolve) => { - chapterTextResolverRef.current = resolve; - chapterTextTimeoutRef.current = setTimeout(() => { - chapterTextResolverRef.current = null; - chapterTextTimeoutRef.current = null; - resolve({ text: '', startOffset: 0 }); - }, 5000); - webviewRef.current?.postMessage(JSON.stringify({ type: 'getChapterText' })); - }); - }, []); - - // 等待下一次 relocated 事件(換頁完成);逾時保護避免 WebView 端因故沒有觸發 - // relocated(例如已在書尾)時卡住整個自動朗讀流程。 - const waitForRelocated = useCallback((): Promise => { - return new Promise((resolve) => { - relocatedResolverRef.current = resolve; - setTimeout(() => { - if (relocatedResolverRef.current === resolve) { - relocatedResolverRef.current = null; - resolve(); - } - }, 1500); - }); - }, []); - - // 記住「這次 tts.speak() 開始朗讀時,畫面所在的章節 href」,advanceToNextChapter 用這個 - // 當基準比對,而不是每次都重新取 currentHrefRef 的當下值——這個朗讀 session 期間畫面 - // 可能已經被 WebView 端的自動跟讀翻頁(見下方註解)推進到新章節了,用「呼叫當下」的 - // href 當基準會讓下面的迴圈誤判成「還沒跨到新章節」而多翻一次頁。 - const readingHrefRef = useRef(''); - - // 朗讀進行中,WebView 端(reader-web/index.ts 的 handleTTSBoundary)會自己在快讀到目前 - // 頁面底部時呼叫 turnPage('next') 把畫面翻到下一頁,不需要 RN 端介入——一個章節在 - // paginated 模式下是同一份 iframe document 用 CSS 分欄呈現好幾頁,翻頁不會觸發新的 - // chapterText,朗讀本身也不會中斷。這裡的 readNextAndContinue 只在「整章文字真的念完」 - // 時才呼叫,負責跨到下一章:多數情況 WebView 端的自動跟讀翻頁早已把畫面翻到這章最後 - // 一頁並跨過章節邊界(下面迴圈第一次檢查就會發現 href 已經變了,直接返回,不重複翻頁); - // 只有在自動跟讀翻頁還沒來得及跟上(節流/提前量測誤差造成的些微落後)時才需要额外 - // 呼叫 next() 補上,避免漏翻的頁面被跳過、也避免因為還停在同一章就重新抓到「同一份」 - // 章節文字,變成整章從頭重念一次的無窮迴圈。 - const advanceToNextChapter = useCallback(async (): Promise => { - const startHref = readingHrefRef.current; - for (let i = 0; i < 50; i++) { - if (currentHrefRef.current !== startHref) return true; - if (atEndRef.current) return false; - webviewRef.current?.postMessage(JSON.stringify({ type: 'next' })); - await waitForRelocated(); - } - return currentHrefRef.current !== startHref; - }, [waitForRelocated]); - - const continueReadingRef = useRef<() => void>(() => {}); - - const startSpeaking = useCallback((text: string, startOffset: number) => { - readingHrefRef.current = currentHrefRef.current; - webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStart' })); - tts.speak( - text, - () => continueReadingRef.current(), - (charIndex) => - webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsBoundary', charIndex: charIndex + startOffset })) - ); - }, [tts]); - - const readNextAndContinue = useCallback(async () => { - const advanced = await advanceToNextChapter(); - if (!advanced) return; - const { text, startOffset } = await requestChapterText(); - if (!text.trim()) return; - startSpeaking(text, startOffset); - }, [advanceToNextChapter, requestChapterText, startSpeaking]); - useEffect(() => { continueReadingRef.current = readNextAndContinue; }, [readNextAndContinue]); - - const handleTTSPlay = useCallback(async () => { - if (tts.paused) { - tts.resume(); - webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStart' })); - return; - } - const { text, startOffset } = await requestChapterText(); - if (!text.trim()) return; - startSpeaking(text, startOffset); - }, [tts, requestChapterText, startSpeaking]); - - const handleTTSPause = useCallback(() => { - tts.pause(); - webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStop' })); - }, [tts]); - - const handleTTSReset = useCallback(() => { - tts.reset(); - webviewRef.current?.postMessage(JSON.stringify({ type: 'ttsStop' })); - }, [tts]); - - // 朗讀控制列上的睡眠計時是快速切換用途(跟設定面板裡完整的分段選單共用同一份 - // tts.sleepMinutes/onSleepChange 狀態,不是獨立的第二套計時),每點一次照固定選項 - // 循環切換,不需要另外彈出選單。 - const SLEEP_CYCLE_OPTIONS = [0, 15, 30, 45, 60] as const; - const handleCycleSleep = useCallback(() => { - const idx = SLEEP_CYCLE_OPTIONS.indexOf(tts.sleepMinutes as (typeof SLEEP_CYCLE_OPTIONS)[number]); - const next = SLEEP_CYCLE_OPTIONS[(idx + 1) % SLEEP_CYCLE_OPTIONS.length]; - tts.onSleepChange(next); - }, [tts]); + const [annotationMode, setAnnotationMode] = useState(false); - const handleShouldStartLoadWithRequest = useCallback((request: { url: string }) => { - const { url } = request; - if (url.startsWith('about:') || url.startsWith('data:') || url.startsWith('blob:')) return true; - if (url.startsWith('http://') || url.startsWith('https://')) { - Linking.openURL(url); + const ttsResetRef = useRef<() => void>(() => {}); + const engine = useReaderEngine(id, darkMode, { onBookChange: () => ttsResetRef.current() }); + const bookmarks = useBookmarks(id, engine.currentCfi, engine.currentChapterTitle); + const annotations = useAnnotations({ + id, + annotations: engine.annotations, + setAnnotations: engine.setAnnotations, + currentChapterTitle: engine.currentChapterTitle, + selection: engine.selection, + setSelection: engine.setSelection, + setEditingAnnotationId: engine.setEditingAnnotationId, + postToWebView: engine.postToWebView, + }); + const ttsReading = useTTSReading({ + postToWebView: engine.postToWebView, + currentHrefRef: engine.currentHrefRef, + atEndRef: engine.atEndRef, + requestChapterText: engine.requestChapterText, + waitForRelocated: engine.waitForRelocated, + }); + ttsResetRef.current = ttsReading.resetForNewBook; + const tts = ttsReading.tts; + + const handleShouldStartLoadWithRequest = (request: { url: string }) => { + if (shouldOpenExternally(request.url)) { + Linking.openURL(request.url); return false; } return true; - }, []); + }; const handleBack = () => { if (router.canGoBack()) { @@ -383,113 +63,17 @@ const ReaderScreen = () => { }; const handleResetTypography = () => { - setTypography((prev) => ({ ...prev, fontSize: 16, lineHeight: 1.8, letterSpacing: 0 })); - }; - - const isBookmarked = bookmarks.some((b) => b.cfi === currentCfi); - - const handleToggleBookmark = () => { - if (!id || !currentCfi) return; - setBookmarks((prev) => { - const next = prev.some((b) => b.cfi === currentCfi) - ? prev.filter((b) => b.cfi !== currentCfi) - : [...prev, { id: generateId(), cfi: currentCfi, label: currentChapterTitle || '書籤', addedAt: Date.now() }]; - saveBookmarks(id, next); - return next; - }); - }; - - const handleDeleteBookmark = (bookmarkId: string) => { - if (!id) return; - setBookmarks((prev) => { - const next = prev.filter((b) => b.id !== bookmarkId); - saveBookmarks(id, next); - return next; - }); - }; - - const handleNavigateToTarget = (target: string) => { - webviewRef.current?.postMessage(JSON.stringify({ type: 'goto', target })); - setListPanelTab(null); - }; - - // WebView 端只負責「畫出目前這份清單長什麼樣子」,annotations 陣列本身以 RN 端/ - // AsyncStorage 為唯一資料來源;每次新增/改色/刪除都整批送一次目前完整清單, - // 讓 reader-web 的 applyAnnotations() 自己比對差異決定要新增/移除哪些標記。 - const syncAnnotationsToWebView = (next: Annotation[]) => { - console.log('[reader] syncAnnotationsToWebView 送出', next.length, '筆'); - webviewRef.current?.postMessage( - JSON.stringify({ type: 'setAnnotations', annotations: next.map((a) => ({ id: a.id, cfi: a.cfi, color: a.color })) }) - ); - }; - - const handleCreateAnnotation = (color: string) => { - if (!id || !selection) { - console.log('[reader] handleCreateAnnotation 略過:id 或 selection 為空', { hasId: Boolean(id), hasSelection: Boolean(selection) }); - return; - } - if (__DEV__) console.log('[reader] handleCreateAnnotation', color, selection.text.slice(0, 20)); - const ann: Annotation = { - id: generateId(), - cfi: selection.cfi, - text: selection.text, - color, - chapter: currentChapterTitle, - createdAt: Date.now(), - }; - const next = [...annotations, ann]; - setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); - syncAnnotationsToWebView(next); - setSelection(null); - webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); - }; - - const handleChangeAnnotationColor = (annotationId: string, color: string) => { - if (!id) return; - const next = annotations.map((a) => (a.id === annotationId ? { ...a, color } : a)); - setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); - syncAnnotationsToWebView(next); - }; - - const handleDeleteAnnotation = (annotationId: string) => { - if (!id) return; - const next = annotations.filter((a) => a.id !== annotationId); - setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); - syncAnnotationsToWebView(next); - setEditingAnnotationId(null); - }; - - const handleUpdateAnnotationNote = (annotationId: string, note: string) => { - if (!id) return; - const next = annotations.map((a) => (a.id === annotationId ? { ...a, note: note.trim() || undefined } : a)); - setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); - }; - - const handleNavigateToAnnotation = (cfi: string) => { - webviewRef.current?.postMessage(JSON.stringify({ type: 'goto', target: cfi })); - setListPanelTab(null); + engine.setTypography((prev) => ({ ...prev, fontSize: 16, lineHeight: 1.8, letterSpacing: 0 })); }; const handleCopySelection = () => { - if (!selection) return; - Clipboard.setStringAsync(selection.text); + if (!engine.selection) return; + Clipboard.setStringAsync(engine.selection.text); }; const handleSearchSelection = () => { - if (!selection) return; - Linking.openURL(`https://www.google.com/search?q=${encodeURIComponent(selection.text)}`); - }; - - // 清掉 RN 端的選取狀態同時,也要通知 WebView 清掉內容 iframe 裡實際的原生選取範圍 - // (瀏覽器藍色反白),否則只清 RN state 只會讓底部操作列消失,畫面上的選取反白仍留著。 - const clearSelectionState = () => { - setSelection(null); - setEditingAnnotationId(null); - webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); + if (!engine.selection) return; + Linking.openURL(`https://www.google.com/search?q=${encodeURIComponent(engine.selection.text)}`); }; // 設定面板/清單面板共用同一個畫面區域,一次只會顯示其中一個:開啟其中一個時 @@ -497,13 +81,13 @@ const ReaderScreen = () => { // 兩顆按鈕都是開關型:再按一次目前開著的那顆就直接關閉,不需要額外的關閉鈕/麵包屑列。 const toggleSettings = () => { setListPanelTab(null); - clearSelectionState(); + engine.clearSelectionState(); setSettingsVisible((prev) => !prev); }; const toggleListPanel = () => { setSettingsVisible(false); - clearSelectionState(); + engine.clearSelectionState(); setListPanelTab((prev) => (prev ? null : 'bookmarks')); }; @@ -514,14 +98,31 @@ const ReaderScreen = () => { const toggleAnnotationMode = () => { setSettingsVisible(false); setListPanelTab(null); - clearSelectionState(); + engine.clearSelectionState(); setAnnotationMode((prev) => { const next = !prev; - webviewRef.current?.postMessage(JSON.stringify({ type: 'setAnnotationMode', enabled: next })); + engine.postToWebView({ type: 'setAnnotationMode', enabled: next }); return next; }); }; + const handleNavigateBookmark = (cfi: string) => { + engine.navigateTo(cfi); + setListPanelTab(null); + }; + + const handleNavigateChapter = (target: string) => { + engine.navigateTo(target); + setListPanelTab(null); + }; + + const handleNavigateAnnotation = (cfi: string) => { + engine.navigateTo(cfi); + setListPanelTab(null); + }; + + const pageProgress = formatPageProgress(engine.pageInfo); + return ( @@ -534,16 +135,16 @@ const ReaderScreen = () => { - {record?.title ?? '閱讀中'} + {engine.record?.title ?? '閱讀中'} - {isBookmarked ? : } + {bookmarks.isBookmarked ? : } { {annotationMode && ( { 劃線模式中:長按文字選取即可標記,點擊畫面翻頁已暫停 )} - {loading && !errorMessage ? ( + {engine.loading && !engine.errorMessage ? ( ) : null} - {errorMessage ? ( + {engine.errorMessage ? ( - 載入失敗:{errorMessage} + 載入失敗:{engine.errorMessage} ) : null} {settingsVisible && ( setTypography((prev) => ({ ...prev, fontSize: v }))} - fontFamily={typography.fontFamily} - onFontFamilyChange={(v) => setTypography((prev) => ({ ...prev, fontFamily: v }))} - script={typography.script} - onScriptChange={(v) => setTypography((prev) => ({ ...prev, script: v }))} - readingDirection={typography.readingDirection} - onReadingDirectionChange={(v) => setTypography((prev) => ({ ...prev, readingDirection: v }))} - lineHeight={typography.lineHeight} - onLineHeightChange={(v) => setTypography((prev) => ({ ...prev, lineHeight: v }))} - letterSpacing={typography.letterSpacing} - onLetterSpacingChange={(v) => setTypography((prev) => ({ ...prev, letterSpacing: v }))} + fontSize={engine.typography.fontSize} + onFontSizeChange={(v) => engine.setTypography((prev) => ({ ...prev, fontSize: v }))} + fontFamily={engine.typography.fontFamily} + onFontFamilyChange={(v) => engine.setTypography((prev) => ({ ...prev, fontFamily: v }))} + script={engine.typography.script} + onScriptChange={(v) => engine.setTypography((prev) => ({ ...prev, script: v }))} + readingDirection={engine.typography.readingDirection} + onReadingDirectionChange={(v) => engine.setTypography((prev) => ({ ...prev, readingDirection: v }))} + lineHeight={engine.typography.lineHeight} + onLineHeightChange={(v) => engine.setTypography((prev) => ({ ...prev, lineHeight: v }))} + letterSpacing={engine.typography.letterSpacing} + onLetterSpacingChange={(v) => engine.setTypography((prev) => ({ ...prev, letterSpacing: v }))} onReset={handleResetTypography} ttsPlaying={tts.playing} ttsPaused={tts.paused} - onTTSPlay={handleTTSPlay} - onTTSPause={handleTTSPause} - onTTSReset={handleTTSReset} + onTTSPlay={ttsReading.handleTTSPlay} + onTTSPause={ttsReading.handleTTSPause} + onTTSReset={ttsReading.handleTTSReset} ttsVoices={tts.voices} ttsSelectedVoice={tts.selectedVoice} onTTSVoiceChange={tts.setSelectedVoice} @@ -653,34 +254,34 @@ const ReaderScreen = () => { {listPanelTab && ( handleNavigateToTarget(bm.cfi)} - onDeleteBookmark={handleDeleteBookmark} - toc={toc} - currentHref={currentHref} - onNavigateChapter={handleNavigateToTarget} - record={record} - annotations={annotations} - onNavigateAnnotation={handleNavigateToAnnotation} - onChangeAnnotationColor={handleChangeAnnotationColor} - onDeleteAnnotation={handleDeleteAnnotation} - onUpdateAnnotationNote={handleUpdateAnnotationNote} + bookmarks={bookmarks.bookmarks} + onNavigateBookmark={(bm) => handleNavigateBookmark(bm.cfi)} + onDeleteBookmark={bookmarks.handleDeleteBookmark} + toc={engine.toc} + currentHref={engine.currentHref} + onNavigateChapter={handleNavigateChapter} + record={engine.record} + annotations={engine.annotations} + onNavigateAnnotation={handleNavigateAnnotation} + onChangeAnnotationColor={annotations.handleChangeAnnotationColor} + onDeleteAnnotation={annotations.handleDeleteAnnotation} + onUpdateAnnotationNote={annotations.handleUpdateAnnotationNote} /> )} - {selection && ( + {engine.selection && ( )} - {editingAnnotationId && ( + {engine.editingAnnotationId && ( handleChangeAnnotationColor(editingAnnotationId, color)} - onDelete={() => handleDeleteAnnotation(editingAnnotationId)} + onChangeColor={(color) => annotations.handleChangeAnnotationColor(engine.editingAnnotationId as string, color)} + onDelete={() => annotations.handleDeleteAnnotation(engine.editingAnnotationId as string)} /> )} {/* 朗讀控制列:跟下面的頁碼列一樣固定高度、一律渲染(不論是否正在朗讀),放在內容 @@ -694,7 +295,7 @@ const ReaderScreen = () => { }} > { { { 依照初次拿到的 viewer 尺寸算的,事後才緊縮 WebView 高度容易讓已渲染好的那一頁內容 被裁切,需要等 resize 事件跑完才會重新分頁,中間會有一段畫面被蓋住的空窗期。 */} - {pageInfo && (() => { - // 跟書櫃卡片顯示的進度(來自 updateProgress 存進 library 的 msg.percentage)用同一個 - // 數值來源,避免「頁碼算出來的 page/total 比例」跟「實際存檔的精確進度」四捨五入後 - // 出現 1% 之類的落差,導致書櫃跟閱讀頁看起來不一致。 - const pct = Math.round(pageInfo.percentage * 100); - return ( - <> - - 第 {pageInfo.page} 頁 - - - - - - / {pageInfo.total} · {pct}% - - - ); - })()} + {pageProgress && ( + <> + + 第 {pageProgress.page} 頁 + + + + + + / {pageProgress.total} · {pageProgress.percent}% + + + )} diff --git a/mobile/hooks/reader/useAnnotations.ts b/mobile/hooks/reader/useAnnotations.ts new file mode 100644 index 0000000..a0d507d --- /dev/null +++ b/mobile/hooks/reader/useAnnotations.ts @@ -0,0 +1,86 @@ +import { type Annotation, generateId, saveAnnotations } from '../../lib/library'; +import type { InboundMessage } from '../../lib/readerMessages'; +import { + addAnnotationList, + removeAnnotationList, + updateAnnotationColorList, + updateAnnotationNoteList, +} from '../../lib/reader/calculations'; + +interface UseAnnotationsParams { + id: string | undefined; + annotations: Annotation[]; + setAnnotations: (next: Annotation[]) => void; + currentChapterTitle: string; + selection: { cfi: string; text: string } | null; + setSelection: (next: { cfi: string; text: string } | null) => void; + setEditingAnnotationId: (next: string | null) => void; + postToWebView: (msg: InboundMessage) => void; +} + +export const useAnnotations = ({ + id, + annotations, + setAnnotations, + currentChapterTitle, + selection, + setSelection, + setEditingAnnotationId, + postToWebView, +}: UseAnnotationsParams) => { + // WebView 端只負責「畫出目前這份清單長什麼樣子」,annotations 陣列本身以 RN 端/ + // AsyncStorage 為唯一資料來源;每次新增/改色/刪除都整批送一次目前完整清單, + // 讓 reader-web 的 applyAnnotations() 自己比對差異決定要新增/移除哪些標記。 + const syncAnnotationsToWebView = (next: Annotation[]) => { + postToWebView({ type: 'setAnnotations', annotations: next.map((a) => ({ id: a.id, cfi: a.cfi, color: a.color })) }); + }; + + const handleCreateAnnotation = (color: string) => { + if (!id || !selection) return; + const ann: Annotation = { + id: generateId(), + cfi: selection.cfi, + text: selection.text, + color, + chapter: currentChapterTitle, + createdAt: Date.now(), + }; + const next = addAnnotationList(annotations, ann); + setAnnotations(next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + syncAnnotationsToWebView(next); + setSelection(null); + postToWebView({ type: 'clearSelection' }); + }; + + const handleChangeAnnotationColor = (annotationId: string, color: string) => { + if (!id) return; + const next = updateAnnotationColorList(annotations, annotationId, color); + setAnnotations(next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + syncAnnotationsToWebView(next); + }; + + const handleDeleteAnnotation = (annotationId: string) => { + if (!id) return; + const next = removeAnnotationList(annotations, annotationId); + setAnnotations(next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + syncAnnotationsToWebView(next); + setEditingAnnotationId(null); + }; + + const handleUpdateAnnotationNote = (annotationId: string, note: string) => { + if (!id) return; + const next = updateAnnotationNoteList(annotations, annotationId, note); + setAnnotations(next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + }; + + return { + handleCreateAnnotation, + handleChangeAnnotationColor, + handleDeleteAnnotation, + handleUpdateAnnotationNote, + }; +}; diff --git a/mobile/hooks/reader/useBookmarks.ts b/mobile/hooks/reader/useBookmarks.ts new file mode 100644 index 0000000..8eb4522 --- /dev/null +++ b/mobile/hooks/reader/useBookmarks.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { type Bookmark, generateId, loadBookmarks, saveBookmarks } from '../../lib/library'; +import { isBookmarked, removeBookmarkList, toggleBookmarkList } from '../../lib/reader/calculations'; + +export const useBookmarks = (id: string | undefined, currentCfi: string, currentChapterTitle: string) => { + const [bookmarks, setBookmarks] = useState([]); + + useEffect(() => { + if (!id) return; + let cancelled = false; + loadBookmarks(id).then((saved) => { + if (!cancelled) setBookmarks(saved); + }); + return () => { + cancelled = true; + }; + }, [id]); + + const handleToggleBookmark = () => { + if (!id || !currentCfi) return; + const newBookmark: Bookmark = { + id: generateId(), + cfi: currentCfi, + label: currentChapterTitle || '書籤', + addedAt: Date.now(), + }; + let next: Bookmark[] = []; + setBookmarks((prev) => { + next = toggleBookmarkList(prev, currentCfi, newBookmark); + return next; + }); + saveBookmarks(id, next).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); + }; + + const handleDeleteBookmark = (bookmarkId: string) => { + if (!id) return; + let next: Bookmark[] = []; + setBookmarks((prev) => { + next = removeBookmarkList(prev, bookmarkId); + return next; + }); + saveBookmarks(id, next).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); + }; + + return { + bookmarks, + isBookmarked: isBookmarked(bookmarks, currentCfi), + handleToggleBookmark, + handleDeleteBookmark, + }; +}; diff --git a/mobile/hooks/reader/useReaderEngine.ts b/mobile/hooks/reader/useReaderEngine.ts new file mode 100644 index 0000000..bf4fa9f --- /dev/null +++ b/mobile/hooks/reader/useReaderEngine.ts @@ -0,0 +1,308 @@ +import { useFocusEffect } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { WebView } from 'react-native-webview'; +import { + type Annotation, + type BookRecord, + type BookSettings, + getBookBase64, + listBooks, + loadAnnotations, + loadBookSettings, + loadReadingCfi, + saveBookSettings, + saveReadingCfi, + touchBook, + updateProgress, +} from '../../lib/library'; +import type { InboundMessage, OutboundMessage, TocItem } from '../../lib/readerMessages'; +import { DEFAULT_TYPOGRAPHY } from '../../lib/readerSettings'; + +interface UseReaderEngineOptions { + onBookChange?: () => void; +} + +// 統籌 RN 端與 WebView 端(reader-web/index.ts)之間的訊息橋接:book 生命週期、排版設定 +// 載入/存檔、換頁/跳轉衍生的 relocated 狀態(cfi/href/章節標題/頁碼)、目錄,以及選字/ +// 標記點擊事件。書籤與朗讀跟讀邏輯各自獨立在 useBookmarks/useTTSReading,但都需要透過 +// 這裡的 postToWebView/currentHrefRef 等介面跟 WebView 互動。 +export const useReaderEngine = (id: string | undefined, darkMode: boolean, options: UseReaderEngineOptions = {}) => { + const webviewRef = useRef(null); + const webviewReadyRef = useRef(false); + const [record, setRecord] = useState(null); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const [typography, setTypography] = useState(DEFAULT_TYPOGRAPHY); + const [annotations, setAnnotations] = useState([]); + const [selection, setSelection] = useState<{ cfi: string; text: string } | null>(null); + const [editingAnnotationId, setEditingAnnotationId] = useState(null); + const [toc, setToc] = useState([]); + const [currentCfi, setCurrentCfi] = useState(''); + const [currentHref, setCurrentHref] = useState(''); + const [currentChapterTitle, setCurrentChapterTitle] = useState(''); + const [pageInfo, setPageInfo] = useState<{ page: number; total: number; percentage: number } | null>(null); + const settingsLoadedRef = useRef(false); + const hadSavedSettingsRef = useRef(false); + const chapterTextResolverRef = useRef<((result: { text: string; startOffset: number }) => void) | null>(null); + const chapterTextTimeoutRef = useRef | null>(null); + const relocatedResolverRef = useRef<(() => void) | null>(null); + // 供 advanceToNextChapter 判斷「是否已經跨到新章節」/「是否已到書尾」,用 ref 而非 + // state 是因為要在同一個非同步迴圈裡讀到最新值,不能等 re-render 後的閉包。 + const currentHrefRef = useRef(''); + const atEndRef = useRef(false); + + useFocusEffect( + useCallback(() => { + if (!id) return; + touchBook(id); + }, [id]) + ); + + const postToWebView = useCallback((msg: InboundMessage) => { + webviewRef.current?.postMessage(JSON.stringify(msg)); + }, []); + + // 載入這本書上次儲存的排版設定;settingsLoadedRef 避免載入完成前的初始 state 被下面的 + // 自動存檔 effect 誤存成預設值蓋掉使用者原本存好的設定。 + useEffect(() => { + if (!id) return; + settingsLoadedRef.current = false; + hadSavedSettingsRef.current = false; + let cancelled = false; + loadBookSettings(id).then((saved) => { + if (cancelled) return; + if (saved) { + setTypography(saved); + hadSavedSettingsRef.current = true; + } else { + setTypography(DEFAULT_TYPOGRAPHY); + } + settingsLoadedRef.current = true; + }); + return () => { + cancelled = true; + }; + }, [id]); + + useEffect(() => { + if (!id || !settingsLoadedRef.current) return; + saveBookSettings(id, typography); + }, [id, typography]); + + useEffect(() => { + postToWebView({ type: 'ttsStop' }); + setToc([]); + setCurrentCfi(''); + setCurrentHref(''); + currentHrefRef.current = ''; + atEndRef.current = false; + setCurrentChapterTitle(''); + setPageInfo(null); + setAnnotations([]); + setSelection(null); + setEditingAnnotationId(null); + postToWebView({ type: 'clearSelection' }); + options.onBookChange?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const handleWebViewReady = useCallback(async () => { + if (!id) return; + const books = await listBooks(); + const found = books.find((b) => b.id === id) ?? null; + setRecord(found); + if (!found) { + setErrorMessage('找不到這本書'); + setLoading(false); + return; + } + try { + const [base64, cfi, savedAnnotations] = await Promise.all([ + getBookBase64(found), + loadReadingCfi(id), + loadAnnotations(id), + ]); + setAnnotations(savedAnnotations); + postToWebView({ + type: 'load', + base64, + cfi, + annotations: savedAnnotations.map((a) => ({ id: a.id, cfi: a.cfi, color: a.color })), + }); + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : String(err)); + setLoading(false); + } + }, [id, postToWebView]); + + const handleMessage = useCallback( + (event: { nativeEvent: { data: string } }) => { + let msg: OutboundMessage; + try { + msg = JSON.parse(event.nativeEvent.data); + } catch { + return; + } + if (msg.type === 'ready') { + webviewReadyRef.current = true; + postToWebView({ type: 'setDarkMode', darkMode }); + // settingsLoadedRef 還沒完成前先不送 setTypography,避免蓋成預設值;等 + // loadBookSettings 完成後,下面監聽 typography 變化的 effect 會補送一次真正的設定。 + if (settingsLoadedRef.current) { + postToWebView({ type: 'setTypography', ...typography }); + } + handleWebViewReady(); + return; + } + if (msg.type === 'relocated') { + setLoading(false); + setCurrentCfi(msg.cfi); + setCurrentHref(msg.href); + currentHrefRef.current = msg.href; + atEndRef.current = msg.atEnd; + setCurrentChapterTitle(msg.chapterTitle); + setPageInfo(msg.page !== null && msg.total !== null ? { page: msg.page, total: msg.total, percentage: msg.percentage } : null); + relocatedResolverRef.current?.(); + relocatedResolverRef.current = null; + if (!id) return; + saveReadingCfi(id, msg.cfi); + updateProgress(id, msg.percentage); + return; + } + if (msg.type === 'tocLoaded') { + setToc(msg.toc); + return; + } + if (msg.type === 'error') { + setErrorMessage(msg.message); + setLoading(false); + return; + } + if (msg.type === 'chapterText') { + if (chapterTextTimeoutRef.current) { + clearTimeout(chapterTextTimeoutRef.current); + chapterTextTimeoutRef.current = null; + } + chapterTextResolverRef.current?.({ text: msg.text, startOffset: msg.startOffset }); + chapterTextResolverRef.current = null; + return; + } + if (msg.type === 'textSelected') { + if (__DEV__) console.log('[reader] textSelected 收到', msg.text.slice(0, 20)); + setEditingAnnotationId(null); + setSelection({ cfi: msg.cfi, text: msg.text }); + return; + } + if (msg.type === 'selectionCleared') { + if (__DEV__) console.log('[reader] selectionCleared 收到'); + setSelection(null); + return; + } + if (msg.type === 'annotationTapped') { + if (__DEV__) console.log('[reader] annotationTapped 收到', msg.id); + setSelection(null); + setEditingAnnotationId(msg.id); + return; + } + if (msg.type === 'debug') { + if (__DEV__) console.log('[reader-web debug]', msg.message); + return; + } + if (msg.type === 'bookLanguageDetected') { + // 比照網頁版 Reader.tsx:baseScript 永遠反映書本原始語言;只有在使用者這本書 + // 從沒存過排版偏好時,才自動把顯示腳本切成跟書本原始語言一致(例如簡體書預設 + // 顯示簡體,而不是被 mobile 端寫死的預設值 'tc' 誤判成要轉換成繁體)。 + if (!hadSavedSettingsRef.current) { + setTypography((prev) => ({ ...prev, script: msg.baseScript })); + } + } + }, + [handleWebViewReady, id, darkMode, typography, postToWebView] + ); + + // 深色模式切換時(例如使用者從設定頁切回閱讀頁),即時通知 WebView 內的 epub 內容套用新樣式, + // 不必等下一次換頁;WebView 尚未回報 ready 前先略過,ready 當下會用最新的 darkMode 補送一次。 + useEffect(() => { + if (!webviewReadyRef.current) return; + postToWebView({ type: 'setDarkMode', darkMode }); + }, [darkMode, postToWebView]); + + // 排版設定變更時即時套用到 WebView 內已渲染的內容,不必等下一次換頁。 + useEffect(() => { + if (!webviewReadyRef.current) return; + postToWebView({ type: 'setTypography', ...typography }); + }, [typography, postToWebView]); + + // 若已有一個 getChapterText 請求在飛行中,新請求直接回空字串,避免蓋掉前一個 + // resolver 導致前一個呼叫永遠 resolve 不到正確結果;並用逾時保護 WebView 沒回應時 + // 呼叫端不會卡死。 + const requestChapterText = useCallback((): Promise<{ text: string; startOffset: number }> => { + if (chapterTextResolverRef.current) return Promise.resolve({ text: '', startOffset: 0 }); + return new Promise((resolve) => { + chapterTextResolverRef.current = resolve; + chapterTextTimeoutRef.current = setTimeout(() => { + chapterTextResolverRef.current = null; + chapterTextTimeoutRef.current = null; + resolve({ text: '', startOffset: 0 }); + }, 5000); + postToWebView({ type: 'getChapterText' }); + }); + }, [postToWebView]); + + // 等待下一次 relocated 事件(換頁完成);逾時保護避免 WebView 端因故沒有觸發 + // relocated(例如已在書尾)時卡住整個自動朗讀流程。 + const waitForRelocated = useCallback((): Promise => { + return new Promise((resolve) => { + relocatedResolverRef.current = resolve; + setTimeout(() => { + if (relocatedResolverRef.current === resolve) { + relocatedResolverRef.current = null; + resolve(); + } + }, 1500); + }); + }, []); + + const navigateTo = useCallback( + (target: string) => { + postToWebView({ type: 'goto', target }); + }, + [postToWebView] + ); + + // 清掉 RN 端的選取狀態同時,也要通知 WebView 清掉內容 iframe 裡實際的原生選取範圍 + // (瀏覽器藍色反白),否則只清 RN state 只會讓底部操作列消失,畫面上的選取反白仍留著。 + const clearSelectionState = useCallback(() => { + setSelection(null); + setEditingAnnotationId(null); + postToWebView({ type: 'clearSelection' }); + }, [postToWebView]); + + return { + webviewRef, + record, + loading, + errorMessage, + typography, + setTypography, + annotations, + setAnnotations, + selection, + setSelection, + editingAnnotationId, + setEditingAnnotationId, + toc, + currentCfi, + currentHref, + currentChapterTitle, + pageInfo, + currentHrefRef, + atEndRef, + postToWebView, + handleMessage, + requestChapterText, + waitForRelocated, + navigateTo, + clearSelectionState, + }; +}; diff --git a/mobile/hooks/reader/useTTSReading.ts b/mobile/hooks/reader/useTTSReading.ts new file mode 100644 index 0000000..23fb04b --- /dev/null +++ b/mobile/hooks/reader/useTTSReading.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { InboundMessage } from '../../lib/readerMessages'; +import { cycleSleepOption } from '../../lib/reader/calculations'; +import { useTTS } from '../../lib/tts'; + +const SLEEP_CYCLE_OPTIONS = [0, 15, 30, 45, 60] as const; + +interface UseTTSReadingParams { + postToWebView: (msg: InboundMessage) => void; + currentHrefRef: React.MutableRefObject; + atEndRef: React.MutableRefObject; + requestChapterText: () => Promise<{ text: string; startOffset: number }>; + waitForRelocated: () => Promise; +} + +// 朗讀跨章節跟讀邏輯:比照網頁版 useReaderEngine.ts 的 TTS 跟讀部分,RN 端負責驅動 +// expo-speech 朗讀與跨章節翻頁,WebView 端(reader-web/index.ts 的 handleTTSBoundary) +// 只負責同一章節內快讀到頁尾時自動翻頁與畫底線。 +export const useTTSReading = ({ postToWebView, currentHrefRef, atEndRef, requestChapterText, waitForRelocated }: UseTTSReadingParams) => { + const tts = useTTS(); + + // 記住「這次 tts.speak() 開始朗讀時,畫面所在的章節 href」,advanceToNextChapter 用這個 + // 當基準比對,而不是每次都重新取 currentHrefRef 的當下值——這個朗讀 session 期間畫面 + // 可能已經被 WebView 端的自動跟讀翻頁推進到新章節了,用「呼叫當下」的 href 當基準會讓 + // 下面的迴圈誤判成「還沒跨到新章節」而多翻一次頁。 + const readingHrefRef = useRef(''); + + const resetForNewBook = useCallback(() => { + tts.reset(); + postToWebView({ type: 'ttsStop' }); + }, [tts, postToWebView]); + + // 朗讀進行中,WebView 端會自己在快讀到目前頁面底部時呼叫 turnPage('next') 把畫面翻到 + // 下一頁,不需要 RN 端介入。這裡的 readNextAndContinue 只在「整章文字真的念完」時才呼叫, + // 負責跨到下一章:多數情況 WebView 端的自動跟讀翻頁早已把畫面翻到這章最後一頁並跨過章節 + // 邊界(下面迴圈第一次檢查就會發現 href 已經變了,直接返回,不重複翻頁);只有在自動跟讀 + // 翻頁還沒來得及跟上時才需要额外呼叫 next() 補上。 + const advanceToNextChapter = useCallback(async (): Promise => { + const startHref = readingHrefRef.current; + for (let i = 0; i < 50; i++) { + if (currentHrefRef.current !== startHref) return true; + if (atEndRef.current) return false; + postToWebView({ type: 'next' }); + await waitForRelocated(); + } + return currentHrefRef.current !== startHref; + }, [currentHrefRef, atEndRef, postToWebView, waitForRelocated]); + + const continueReadingRef = useRef<() => void>(() => {}); + + const startSpeaking = useCallback( + (text: string, startOffset: number) => { + readingHrefRef.current = currentHrefRef.current; + postToWebView({ type: 'ttsStart' }); + tts.speak( + text, + () => continueReadingRef.current(), + (charIndex) => postToWebView({ type: 'ttsBoundary', charIndex: charIndex + startOffset }) + ); + }, + [tts, currentHrefRef, postToWebView] + ); + + const readNextAndContinue = useCallback(async () => { + const advanced = await advanceToNextChapter(); + if (!advanced) return; + const { text, startOffset } = await requestChapterText(); + if (!text.trim()) return; + startSpeaking(text, startOffset); + }, [advanceToNextChapter, requestChapterText, startSpeaking]); + useEffect(() => { + continueReadingRef.current = readNextAndContinue; + }, [readNextAndContinue]); + + const handleTTSPlay = useCallback(async () => { + if (tts.paused) { + tts.resume(); + postToWebView({ type: 'ttsStart' }); + return; + } + const { text, startOffset } = await requestChapterText(); + if (!text.trim()) return; + startSpeaking(text, startOffset); + }, [tts, requestChapterText, startSpeaking, postToWebView]); + + const handleTTSPause = useCallback(() => { + tts.pause(); + postToWebView({ type: 'ttsStop' }); + }, [tts, postToWebView]); + + const handleTTSReset = useCallback(() => { + tts.reset(); + postToWebView({ type: 'ttsStop' }); + }, [tts, postToWebView]); + + // 朗讀控制列上的睡眠計時是快速切換用途(跟設定面板裡完整的分段選單共用同一份 + // tts.sleepMinutes/onSleepChange 狀態,不是獨立的第二套計時),每點一次照固定選項 + // 循環切換,不需要另外彈出選單。 + const handleCycleSleep = useCallback(() => { + tts.onSleepChange(cycleSleepOption(tts.sleepMinutes, SLEEP_CYCLE_OPTIONS)); + }, [tts]); + + return { + tts, + resetForNewBook, + handleTTSPlay, + handleTTSPause, + handleTTSReset, + handleCycleSleep, + }; +}; diff --git a/mobile/lib/reader/calculations.ts b/mobile/lib/reader/calculations.ts new file mode 100644 index 0000000..920b6d9 --- /dev/null +++ b/mobile/lib/reader/calculations.ts @@ -0,0 +1,90 @@ +import type { Annotation, Bookmark } from '../library'; +import type { TTSVoice } from '../tts'; + +export const isBookmarked = (bookmarks: Bookmark[], cfi: string): boolean => + bookmarks.some((b) => b.cfi === cfi); + +export const toggleBookmarkList = (bookmarks: Bookmark[], cfi: string, newBookmark: Bookmark): Bookmark[] => + bookmarks.some((b) => b.cfi === cfi) ? bookmarks.filter((b) => b.cfi !== cfi) : [...bookmarks, newBookmark]; + +export const removeBookmarkList = (bookmarks: Bookmark[], bookmarkId: string): Bookmark[] => + bookmarks.filter((b) => b.id !== bookmarkId); + +export const addAnnotationList = (annotations: Annotation[], annotation: Annotation): Annotation[] => [ + ...annotations, + annotation, +]; + +export const updateAnnotationColorList = (annotations: Annotation[], annotationId: string, color: string): Annotation[] => + annotations.map((a) => (a.id === annotationId ? { ...a, color } : a)); + +export const removeAnnotationList = (annotations: Annotation[], annotationId: string): Annotation[] => + annotations.filter((a) => a.id !== annotationId); + +export const updateAnnotationNoteList = (annotations: Annotation[], annotationId: string, note: string): Annotation[] => + annotations.map((a) => (a.id === annotationId ? { ...a, note: note.trim() || undefined } : a)); + +export const cycleSleepOption = (current: number, options: readonly number[]): number => { + const idx = options.indexOf(current); + return options[(idx + 1) % options.length]; +}; + +export const shouldOpenExternally = (url: string): boolean => url.startsWith('http://') || url.startsWith('https://'); + +export const formatPageProgress = ( + pageInfo: { page: number; total: number; percentage: number } | null +): { page: number; total: number; percent: number } | null => + pageInfo ? { page: pageInfo.page, total: pageInfo.total, percent: Math.round(pageInfo.percentage * 100) } : null; + +// 手機系統 TTS 引擎對單次 utterance 的文字長度可能有限制(比照網頁版 useTTS.ts 的 +// MAX_UTTERANCE_LENGTH 保護),過長文字先按標點切成多段,依序朗讀。 +const MAX_UTTERANCE_LENGTH = 3000; + +export const splitTextByLength = (text: string): string[] => { + if (text.length <= MAX_UTTERANCE_LENGTH) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + let chunk = remaining.slice(0, MAX_UTTERANCE_LENGTH); + const lastPunctIdx = Math.max( + chunk.lastIndexOf('。'), + chunk.lastIndexOf(','), + chunk.lastIndexOf('!'), + chunk.lastIndexOf('?'), + chunk.lastIndexOf(';'), + chunk.lastIndexOf('\n') + ); + if (lastPunctIdx > MAX_UTTERANCE_LENGTH * 0.7) chunk = chunk.slice(0, lastPunctIdx + 1); + chunks.push(chunk); + remaining = remaining.slice(chunk.length); + } + return chunks.length > 0 ? chunks : [text]; +}; + +// Android 上 expo-speech 回傳的語音沒有像 iOS 那樣的人類可讀名稱(name 常常就是 +// identifier 本身,例如 "zh-TW-language"、"cmn-cn-x-cce-local")。不用語言代碼標地區 +// (例如「中國」/「台灣」),一律用同一個通用標籤+編號區分,避免地區用字的爭議。 +const GENERIC_CHINESE_VOICE_LABEL = '中文語音'; + +export const withFriendlyLabels = (list: TTSVoice[]): TTSVoice[] => + list.map((v, i) => ({ + ...v, + name: list.length > 1 ? `${GENERIC_CHINESE_VOICE_LABEL} ${i + 1}` : GENERIC_CHINESE_VOICE_LABEL, + })); + +// 實測發現 Android 上同一個語言(例如 zh-TW)常常同時列出好幾組不同合成引擎的變體 +// (ccc/ccd/cce/ssa/ctc/ctd/cte...),還各自有 -local(離線)/-network(連網)兩份, +// 光是中文相關語音就有十幾筆,使用者體感是「選項多到不知道選哪個」。實際上使用者只在意 +// 「腔調(語言)」而不是背後合成引擎,所以每個 language 只保留一筆代表(呼叫前已把 +// -local 排到前面,所以每個語言留下來的會是離線版本),比照 iOS 只留「婷婷/美佳」兩個 +// 選項的精神。 +export const dedupeByLanguage = (list: TTSVoice[]): TTSVoice[] => { + const seen = new Set(); + const result: TTSVoice[] = []; + for (const v of list) { + if (seen.has(v.language)) continue; + seen.add(v.language); + result.push(v); + } + return result; +}; diff --git a/mobile/lib/readerHtml.generated.ts b/mobile/lib/readerHtml.generated.ts index 37913f2..6a4cb71 100644 --- a/mobile/lib/readerHtml.generated.ts +++ b/mobile/lib/readerHtml.generated.ts @@ -1,3 +1,3 @@ // 此檔案由 `yarn build:reader`(scripts/build-reader-html.js)自動產生,請勿手動編輯。 // 原始碼位於 mobile/reader-web/index.ts。 -export const READER_HTML = "\n\n\n\n\n\n\n\n
\n
\n
\n
\n
\n\n\n"; +export const READER_HTML = "\n\n\n\n\n\n\n\n
\n
\n
\n
\n
\n\n\n"; diff --git a/mobile/lib/tts.ts b/mobile/lib/tts.ts index d100bea..fd15986 100644 --- a/mobile/lib/tts.ts +++ b/mobile/lib/tts.ts @@ -1,31 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState } from 'react-native'; import * as Speech from 'expo-speech'; - -// 手機系統 TTS 引擎對單次 utterance 的文字長度可能有限制(比照網頁版 useTTS.ts 的 -// MAX_UTTERANCE_LENGTH 保護),過長文字先按標點切成多段,依序朗讀。 -const MAX_UTTERANCE_LENGTH = 3000; - -const splitTextByLength = (text: string): string[] => { - if (text.length <= MAX_UTTERANCE_LENGTH) return [text]; - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - let chunk = remaining.slice(0, MAX_UTTERANCE_LENGTH); - const lastPunctIdx = Math.max( - chunk.lastIndexOf('。'), - chunk.lastIndexOf(','), - chunk.lastIndexOf('!'), - chunk.lastIndexOf('?'), - chunk.lastIndexOf(';'), - chunk.lastIndexOf('\n') - ); - if (lastPunctIdx > MAX_UTTERANCE_LENGTH * 0.7) chunk = chunk.slice(0, lastPunctIdx + 1); - chunks.push(chunk); - remaining = remaining.slice(chunk.length); - } - return chunks.length > 0 ? chunks : [text]; -}; +import { dedupeByLanguage, splitTextByLength, withFriendlyLabels } from './reader/calculations'; export interface TTSVoice { identifier: string; @@ -33,34 +9,6 @@ export interface TTSVoice { language: string; } -// Android 上 expo-speech 回傳的語音沒有像 iOS 那樣的人類可讀名稱(name 常常就是 -// identifier 本身,例如 "zh-TW-language"、"cmn-cn-x-cce-local")。不用語言代碼標地區 -// (例如「中國」/「台灣」),一律用同一個通用標籤+編號區分,避免地區用字的爭議。 -const GENERIC_CHINESE_VOICE_LABEL = '中文語音'; - -const withFriendlyLabels = (list: TTSVoice[]): TTSVoice[] => - list.map((v, i) => ({ - ...v, - name: list.length > 1 ? `${GENERIC_CHINESE_VOICE_LABEL} ${i + 1}` : GENERIC_CHINESE_VOICE_LABEL, - })); - -// 實測發現 Android 上同一個語言(例如 zh-TW)常常同時列出好幾組不同合成引擎的變體 -// (ccc/ccd/cce/ssa/ctc/ctd/cte...),還各自有 -local(離線)/-network(連網)兩份, -// 光是中文相關語音就有十幾筆,使用者體感是「選項多到不知道選哪個」。實際上使用者只在意 -// 「腔調(語言)」而不是背後合成引擎,所以每個 language 只保留一筆代表(呼叫前已把 -// -local 排到前面,所以每個語言留下來的會是離線版本),比照 iOS 只留「婷婷/美佳」兩個 -// 選項的精神。 -const dedupeByLanguage = (list: TTSVoice[]): TTSVoice[] => { - const seen = new Set(); - const result: TTSVoice[] = []; - for (const v of list) { - if (seen.has(v.language)) continue; - seen.add(v.language); - result.push(v); - } - return result; -}; - // 簡化版 TTS:只朗讀「目前章節」文字(由呼叫端透過 speak() 傳入),沒有網頁版 // useTTS.ts 那套逐字元 boundary 追蹤與畫面高亮同步(見 RN_SETUP_GUIDE.md 第十二輪紀錄)。 export const useTTS = () => { diff --git a/mobile/reader-web/annotationUtils.ts b/mobile/reader-web/annotationUtils.ts new file mode 100644 index 0000000..7acbd2c --- /dev/null +++ b/mobile/reader-web/annotationUtils.ts @@ -0,0 +1,149 @@ +import { EpubCFI } from 'epubjs'; +import type { Rendition } from 'epubjs'; +import type { AnnotationMark, OutboundMessage } from '../lib/readerMessages'; + +export interface AnnotationDiff { + toRemove: AnnotationMark[]; + toAdd: AnnotationMark[]; +} + +// 比對「目前畫面上已渲染」跟「應該顯示」的兩份標記清單:清單裡消失的 id 移除標記、 +// 新出現的 id 新增標記、顏色或 cfi 變了的先移除再重新加上(epub.js 的 annotations.add +// 沒有提供改色 API,只能整個換掉)。純函數,抽出自原本 applyAnnotations 內的差異比對邏輯。 +export const diffAnnotations = (rendered: Map, list: AnnotationMark[]): AnnotationDiff => { + const nextIds = new Set(list.map((a) => a.id)); + const toRemove: AnnotationMark[] = []; + rendered.forEach((prev, id) => { + if (!nextIds.has(id)) toRemove.push(prev); + }); + const toAdd: AnnotationMark[] = []; + list.forEach((ann) => { + const prev = rendered.get(ann.id); + if (!prev) { + toAdd.push(ann); + } else if (prev.color !== ann.color || prev.cfi !== ann.cfi) { + toRemove.push(prev); + toAdd.push(ann); + } + }); + return { toRemove, toAdd }; +}; + +type Post = (msg: OutboundMessage) => void; +type DebugLog = (...args: unknown[]) => void; + +// 比照網頁版 Reader.tsx 的 addEpubAnnotation:epub.js 的 Annotations 類別是在 +// `rendition.hooks.render.register(this.inject.bind(this))` 掛上去的(見 +// node_modules/epubjs/src/annotations.js),而 hooks.render 有時候會比 iframe 的 contents +// (真正的 document/尺寸)就緒得早,導致 marks-pane 拿到的量測基準還沒準備好,SVG 標記 +// 因此可能「呼叫沒有拋例外,但畫面上什麼都沒畫出來」。這裡照抄網頁版的保險機制:呼叫完成 +// 後延遲檢查 DOM 裡有沒有真的生出這個標記的元素,沒有就強制 clear()+inject() 重新掛一次, +// 順便加 log 記錄 SVG 標記實際的量測結果,方便判斷是「完全沒生成標記元素」還是「元素生成 +// 了但位置/尺寸算錯變成看不到」兩種不同情況。 +const logMarkGeometry = (debugLog: DebugLog, id: string, label: string) => { + const markEl = document.querySelector(`.ann-${id}`); + if (!markEl) { + debugLog(`[markGeometry:${label}]`, id, '在最外層 document 找不到 .ann- 元素'); + return; + } + const svg = markEl.closest('svg'); + const markRect = markEl.getBoundingClientRect(); + const svgRect = svg?.getBoundingClientRect(); + const iframe = document.querySelector('#viewer iframe') as HTMLIFrameElement | null; + const iframeRect = iframe?.getBoundingClientRect(); + debugLog( + `[markGeometry:${label}]`, id, + 'mark=', { w: Math.round(markRect.width), h: Math.round(markRect.height), x: Math.round(markRect.x), y: Math.round(markRect.y) }, + 'svg=', svgRect ? { w: Math.round(svgRect.width), h: Math.round(svgRect.height) } : 'no-svg', + 'iframe=', iframeRect ? { w: Math.round(iframeRect.width), h: Math.round(iframeRect.height), x: Math.round(iframeRect.x), y: Math.round(iframeRect.y) } : 'no-iframe', + 'iframeScroll=', { w: iframe?.contentWindow?.innerWidth, scrollW: iframe?.contentDocument?.documentElement?.scrollWidth } + ); +}; + +// 強制重新掛所有目前的 annotation(clear + inject),共用給「新增標記後檢查」跟「每次新內容 +// 渲染後檢查」兩個呼叫點。 +export const reinjectAllAnnotations = (rendition: Rendition | null, debugLog: DebugLog, label: string) => { + if (!rendition) return; + debugLog('[reinjectAllAnnotations]', label); + // 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); + }); +}; + +// 檢查目前應該顯示的標記,DOM 裡是不是真的都有對應的 元素;缺漏就整批強制重掛一次。 +// renderedAnnotations 是整本書的標記清單,但畫面上(DOM)任何時刻只會有目前顯示中章節的 +// 標記被實際掛出來——只比對「目前可見章節」涵蓋到的標記,避免把其他章節本來就不該出現在 +// DOM 裡的標記誤判成「缺漏」,觸發不必要的 reinjectAllAnnotations。 +export const verifyAnnotationsRendered = ( + rendition: Rendition | null, + debugLog: DebugLog, + renderedAnnotations: Map, + label: string +) => { + if (renderedAnnotations.size === 0 || !rendition) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contents = ((rendition as any).getContents?.() ?? []) as { sectionIndex: number }[]; + const visibleSections = new Set(contents.map((c) => c.sectionIndex)); + if (visibleSections.size === 0) return; + const expected = [...renderedAnnotations.values()].filter((ann) => { + try { + return visibleSections.has(new EpubCFI(ann.cfi).spinePos); + } catch { + return false; + } + }); + if (expected.length === 0) return; + const missing = expected.filter((ann) => !document.querySelector(`.ann-${ann.id} line`)); + debugLog('[verifyAnnotationsRendered]', label, '目前章節應顯示', expected.length, '筆,缺少', missing.length, '筆', missing.map((a) => a.id)); + if (missing.length > 0) reinjectAllAnnotations(rendition, debugLog, `verify:${label}`); +}; + +// 劃線註記:用 epub.js 內建的 annotations API('underline' 型別,SVG 標記,不修改 DOM 文字節點)。 +export const addAnnotationMark = (rendition: Rendition | null, post: Post, debugLog: DebugLog, ann: AnnotationMark): boolean => { + if (!rendition) { + debugLog('[addAnnotationMark] 略過:rendition 尚未就緒', ann.id); + return false; + } + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (rendition.annotations as any).underline( + ann.cfi, + {}, + () => { + debugLog('[annotationTapped]', ann.id); + post({ type: 'annotationTapped', id: ann.id }); + }, + `ann-${ann.id}`, + { stroke: ann.color, 'stroke-opacity': '1', 'stroke-width': '3', fill: 'none' } + ); + debugLog('[addAnnotationMark] underline() 呼叫完成', ann.id, 'cfi=', ann.cfi.slice(0, 40), 'result=', result ? 'ok' : 'undefined/null'); + logMarkGeometry(debugLog, ann.id, 'immediately-after-call'); + setTimeout(() => { + logMarkGeometry(debugLog, ann.id, '300ms-later'); + const line = document.querySelector(`.ann-${ann.id} line`); + if (!line) { + debugLog('[addAnnotationMark] 300ms 後仍找不到 元素,強制 clear+inject 重新掛一次', ann.id); + reinjectAllAnnotations(rendition, debugLog, `create:${ann.id}`); + setTimeout(() => logMarkGeometry(debugLog, ann.id, 'after-reinject'), 100); + } + }, 300); + return true; + } catch (err) { + debugLog('[addAnnotationMark] underline() 拋出例外', ann.id, err instanceof Error ? err.message : String(err)); + return false; + } +}; + +export const removeAnnotationMark = (rendition: Rendition | null, debugLog: DebugLog, ann: AnnotationMark) => { + if (!rendition) return; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rendition.annotations as any).remove(ann.cfi, 'underline'); + debugLog('[removeAnnotationMark] 已移除', ann.id); + } catch (err) { + debugLog('[removeAnnotationMark] 拋出例外', ann.id, err instanceof Error ? err.message : String(err)); + } +}; diff --git a/mobile/reader-web/index.ts b/mobile/reader-web/index.ts index 9fe7806..7bb8877 100644 --- a/mobile/reader-web/index.ts +++ b/mobile/reader-web/index.ts @@ -1,15 +1,18 @@ -import ePub, { EpubCFI } from 'epubjs'; +import ePub from 'epubjs'; import type { Book, Rendition } from 'epubjs'; -import * as OpenCC from 'opencc-js'; import type { AnnotationMark, InboundMessage, OutboundMessage, TocItem } from '../lib/readerMessages'; -import { DEFAULT_TYPOGRAPHY, normalizeFontFamily, type TypographySettings } from '../lib/readerSettings'; +import { DEFAULT_TYPOGRAPHY, type TypographySettings } from '../lib/readerSettings'; +import { addAnnotationMark, diffAnnotations, reinjectAllAnnotations, removeAnnotationMark, verifyAnnotationsRendered } from './annotationUtils'; +import { computeProgress } from './progressCalculations'; +import { applyDarkOverride, applyTypographyToDoc } from './readerStyles'; +import { buildToc, getChapterLabel, resolveNavTarget } from './tocLookup'; +import { shouldAutoAdvancePage } from './ttsFollowCalculations'; import { clearTTSHighlight, createRangeFromTextOffset, ensureTTSHighlightStyle, getBoundaryOffsetFromRange, getTextIndex, - invalidateTextIndex, paintTTSHighlightOverlay, } from './ttsHighlight'; @@ -24,28 +27,12 @@ let rendition: Rendition | null = null; let darkMode = false; let typography: TypographySettings = DEFAULT_TYPOGRAPHY; // 書本本身原始使用的文字(依 epub metadata 的 language 判斷,比照網頁版 Reader.tsx 的 -// baseScriptRef)。轉換/還原都要拿這個當基準,不能寫死假設書本原始文字一定是繁體—— -// 上一版沒有偵測這個值,導致「書本原本就是簡體」時,切成「繁體」只會呼叫 restoreDoc() -// 還原成書本原本的簡體文字,並不會真的轉換成繁體。 +// baseScriptRef)。轉換/還原都要拿這個當基準,不能寫死假設書本原始文字一定是繁體。 let baseScript: TypographySettings['script'] = 'tc'; const contentDocs = new Set(); -// 全書頁碼/精確進度百分比:比照網頁版 Reader.tsx 的 scanAllChapterPages/chapterPagesRef, -// 背景用一個隱藏的 hiddenRendition 依序 display() 每個 spine 章節,讀 epub.js 真正算出來的 -// displayed.total(該章節在目前字級/行距/字距設定下的實際頁數,不是概算),加總起來就是全書 -// 精確頁數,章節內 displayed.page 本身就是使用者「翻一次就走一個真正的頁面」,因此全書頁碼也會 -// 精準跟著每次翻頁動作 1:1 增減。 -// -// 中間踩過的坑:一開始改用 book.locations.generate(chars) 對整本書做「每 N 字元切一個位置」的 -// 概算索引,兩個副作用都不理想:(1) 概算的位置跟使用者實際看到的螢幕頁面不是 1:1(N 字元可能 -// 橫跨不只一個螢幕頁,使用者會覺得「翻好幾頁頁碼才跳一次」);(2) 就算把 generate() 換到獨立的 -// book 實例上執行,Locations.generate() 內部逐 section 呼叫 section.load()/section.unload() 仍是 -// 吃 CPU 的背景工作,在 Android 模擬器上可能拖慢/干擾使用者正在操作的翻頁。改成這裡的 -// hiddenRendition 方案後,用的是 epub.js Rendition 本身既有的分頁結果(不是自己另外算的概算值), -// 且 Rendition 導覽本身不會呼叫 section.unload()(只有 Locations 才會),兩個問題應該一併解決。 +// 全書頁碼/精確進度百分比背景掃描狀態,詳見 progressCalculations.ts 開頭的說明。 let chapterPageCounts: Map = new Map(); -// 背景章節掃描完成後才會是 true;完成前 relocated 事件送出的全書頁碼(page/total)為 null, -// 畫面上先不顯示頁數,避免顯示還在累加中、之後會跳動的暫時值。 let locationsReady = false; // 最近一次 relocated 事件的原始 loc 物件,供背景掃描跑完後主動重算一次全書頁碼並補送——若使用者 // 開書後沒有繼續翻頁,光是掃描完成這件事本身不會觸發新的 relocated 事件,若不補送,頁數列會一直 @@ -53,185 +40,21 @@ let locationsReady = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any let lastRelocatedLoc: any = null; // 全書最後一個 linear 章節的 spine index(由 scanAllChapterPages 設定)。postRelocated 的 -// stuck-CFI 校正只在這一章才會生效,見該處的說明。 +// stuck-CFI 校正只在這一章才會生效,見 progressCalculations.ts 的說明。 let lastLinearSpineIndex: number | null = null; // 每次 loadBook 換書時遞增,scanAllChapterPages 完成時比對這個值,避免舊書的背景掃描 // 在使用者已經換到新書之後才跑完,把新書的 chapterPageCounts 覆寫成舊書算出來的頁數。 let loadGeneration = 0; -// 章節目錄快取(目錄面板顯示用)與 spine href 順序(比照網頁版 Reader.tsx 的 -// getChapterTitle/getBookmarkLabel,用來把目前位置換算成章節標題)。 +// 章節目錄快取(目錄面板顯示用)與 spine href 順序,供 tocLookup.ts 的純函數查找章節標題用。 let tocCache: TocItem[] = []; let spineHrefs: string[] = []; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const buildToc = (items: any[]): TocItem[] => - items.map((item) => ({ - id: (item.href as string) || (item.label as string) || Math.random().toString(36).slice(2), - href: (item.href as string) ?? '', - label: (item.label as string)?.trim() ?? '', - subitems: item.subitems?.length ? buildToc(item.subitems) : undefined, - })); - -const hrefToSpineIndex = (href: string): number => { - const file = href.split('#')[0]; - return spineHrefs.findIndex( - (h) => h === file || h === href || (Boolean(file) && (h.endsWith(`/${file}`) || file.endsWith(`/${h}`))) - ); -}; - -// 同檔案完全相符、深度越深(越靠近葉節點)優先,比照網頁版 getChapterTitle。 -const findExactChapterLabel = (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; - bestDepth = depth; - } - if (item.subitems?.length) search(item.subitems, depth + 1); - } - }; - search(tocCache, 0); - return bestLabel; -}; - -// 找不到精確相符時,退而求其次找 spine 索引最接近(但不超過)目前章節的目錄項目, -// 比照網頁版 getBookmarkLabel 的 fallback 邏輯。 -const findNearestChapterLabel = (curSpineIdx: number): string => { - let bestLabel = ''; - let bestIdx = -1; - const search = (items: TocItem[]) => { - for (const item of items) { - const si = hrefToSpineIndex(item.href); - if (si !== -1 && si <= curSpineIdx && si > bestIdx) { - bestLabel = item.label; - bestIdx = si; - } - if (item.subitems?.length) search(item.subitems); - } - }; - search(tocCache); - return bestLabel; -}; - -const getChapterLabel = (href: string, spineIdx: number): string => { - const curFile = (href ?? '').split('#')[0]; - return findExactChapterLabel(curFile) || findNearestChapterLabel(spineIdx) || '書籤'; -}; - -// 比照 renderer/src/components/Reader/scriptConversion.ts:opencc-js 轉換器延遲建立, -// originalTexts 記住轉換前的原始文字,切回原始 script 時可以還原(不必重新解析文件)。 -let toSC: ((s: string) => string) | null = null; -let toTC: ((s: string) => string) | null = null; -const getToSC = () => { if (!toSC) toSC = OpenCC.Converter({ from: 'tw', to: 'cn' }); return toSC; }; -const getToTC = () => { if (!toTC) toTC = OpenCC.Converter({ from: 'cn', to: 'tw' }); return toTC; }; -const originalTexts = new WeakMap(); - -const convertDoc = (doc: Document, convert: (s: string) => string) => { - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); - let node: Node | null; - while ((node = walker.nextNode())) { - if (node.nodeValue && !originalTexts.has(node)) { - originalTexts.set(node, node.nodeValue); - node.nodeValue = convert(node.nodeValue); - } - } -}; - -const restoreDoc = (doc: Document) => { - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); - let node: Node | null; - while ((node = walker.nextNode())) { - const original = originalTexts.get(node); - if (original !== undefined) { - node.nodeValue = original; - originalTexts.delete(node); - } - } -}; - -// 只有「顯示腳本」跟「書本原始腳本」不同時才需要轉換;相同就還原成書本原文, -// 比照網頁版 Reader.tsx 的 `if (scriptRef.current !== baseScriptRef.current)` 判斷。 -const applyScriptToDoc = (doc: Document) => { - if (!doc.body) return; - if (typography.script === baseScript) { - restoreDoc(doc); - } else { - convertDoc(doc, typography.script === 'sc' ? getToSC() : getToTC()); - } - invalidateTextIndex(doc); -}; - -// 比照 renderer/src/components/Reader/readerStyles.ts 的字體/行距/字距覆寫邏輯, -// 這幾個功能各自用獨立的