From 167bf58c14e4e6c847e29ed592f5e1488dfd50b5 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sun, 5 Jul 2026 17:49:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(mobile):=20=E6=96=B0=E5=A2=9E=E9=96=B1?= =?UTF-8?q?=E8=AE=80=E9=A0=81=E5=8A=83=E7=B7=9A=E8=A8=BB=E8=A8=98=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BF=AE=E6=AD=A3=E9=81=B8=E5=AD=97=E6=89=8B?= =?UTF-8?q?=E5=8B=A2=E8=88=87=E6=A8=99=E8=A8=98=E9=A1=AF=E7=A4=BA=E5=95=8F?= =?UTF-8?q?=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 劃線/顏色/感想筆記/清單管理/匯出(Share.share)第一版實作 - 新增「劃線模式」切換,暫停翻頁點擊區以避免攔截長按選字手勢 - 補上網頁版已有的 annotations hooks.render 競態保險機制(verify+reinject) Co-Authored-By: Claude Sonnet 5 --- RN_SETUP_GUIDE.md | 44 +++++- mobile/app/reader/[id].tsx | 189 +++++++++++++++++++++++- mobile/components/ListPanel.tsx | 223 ++++++++++++++++++++++++++++- mobile/components/SelectionBar.tsx | 94 ++++++++++++ mobile/lib/annotationColors.ts | 9 ++ mobile/lib/library.ts | 25 +++- mobile/lib/readerHtml.generated.ts | 2 +- mobile/lib/readerMessages.ts | 19 ++- mobile/reader-web/index.ts | 212 ++++++++++++++++++++++++++- 9 files changed, 800 insertions(+), 17 deletions(-) create mode 100644 mobile/components/SelectionBar.tsx create mode 100644 mobile/lib/annotationColors.ts diff --git a/RN_SETUP_GUIDE.md b/RN_SETUP_GUIDE.md index 01c3cb9..d0d3b3c 100644 --- a/RN_SETUP_GUIDE.md +++ b/RN_SETUP_GUIDE.md @@ -296,7 +296,7 @@ Monorepo 常見誤區:使用者回報「手機上」的問題,實際上可 4. ~~**閱讀頁(`reader/[id].tsx`)實作**~~ **MVP 已完成**(併入第 1 點):接上 epub 渲染方案、tap 翻頁、閱讀進度回存。**下一步待驗證**:實機/模擬器實測 → 再補翻頁滑動手勢、閱讀設定(字體/深色)套用進 WebView 內容。 5. **TTS 朗讀功能**:評估 `expo-speech`(朗讀)+ 需自行實作高亮同步邏輯(web 版原本綁定 epub.js 的 CFI,RN 需視渲染方案重新設計事件橋接)。 6. **主題/深色模式**:比照 Electron 版 `darkMode` 狀態,搬到設定頁骨架,需決定用 RN 的 `useColorScheme` 自動跟隨系統,或維持手動切換 + 持久化(`AsyncStorage`)。 -7. **註記筆記功能**:搬移「感想筆記」邏輯(見 `408b1eb` commit),儲存方式同第 2 點的本地儲存策略。 +7. ~~**註記筆記功能**~~ **第一版已完成**(2026-07-05,見上方「第十八輪」):劃線+顏色+感想筆記+清單管理+匯出(`Share.share`)皆已實作,儲存方式同第 2 點的本地儲存策略。**尚未實機測試**,且劃線操作列刻意改成底部固定列而非網頁版的浮動泡泡(避開已知的 iframe 座標系問題)。 8. **鍵盤處理**:若任一頁面(例如新增筆記表單)需要文字輸入,套用第 6 節的 iOS/Android 分開處理原則。 9. **iOS 26 原生 header 問題**:目前 `reader/[id].tsx` 已預先採用自繪 header,其餘頁面若未來改用原生 header 且有返回按鈕,需留意第 7 節的白色圓形背景問題。 10. **實機/模擬器測試**:待功能有基本可視內容後,盡早進行 `expo run:android`/`expo run:ios` 或 Dev Client 實機測試,不要等到功能全部做完才測(累積的整合風險會變大)。 @@ -562,4 +562,46 @@ Monorepo 常見誤區:使用者回報「手機上」的問題,實際上可 - `mobile/app/reader/[id].tsx`:`handleMessage` 移除 `msg.type === 'debug'` 的分支(原本會 `console.log('[reader-web debug][...]', ...)`);`handleNavigateToTarget` 移除上一輪新增的純 RN 端診斷 `console.log`;連帶移除已經沒用到的 `Platform` import。 - **驗證狀態**:`tsc --noEmit`、`yarn build:reader`、`expo-doctor`(20/20)皆通過,純移除診斷程式碼,不涉及任何功能邏輯改動,理論上不需要使用者額外測試,但如果之後這幾個功能(翻頁頁碼、目錄跳轉)又出現問題,會需要重新加回類似的 log 才能除錯。 +**第十八輪:註記筆記功能第一版(對應重構任務第 7 項起點,2026-07-05)**: +- 對應「後續重構任務」第 7 項:搬移網頁版「劃線+感想筆記」邏輯(`renderer/src/store/useAnnotationStore.ts`/`NotePanel.tsx`/`HighlightPopup.tsx`/`annotationUtils.ts`/`annotationExport.ts`,見 `408b1eb` commit)。mobile 這是第一次動這個功能,之前都只有 `ListPanel.tsx` 的「註記」分頁空殼文字。 +- **資料模型**:`mobile/lib/library.ts` 新增 `Annotation` 介面(`id`/`cfi`/`text`/`color`/`chapter`/`createdAt`/`note?`,欄位跟網頁版 `useAnnotationStore.ts` 的 `Annotation` 一致)與 `loadAnnotations`/`saveAnnotations`(存 AsyncStorage,key 比照書籤模式 `tit:annotations:`),`removeBook()` 一併清除。新增 `mobile/lib/annotationColors.ts` 的 `HIGHLIGHT_COLORS`(5 色,數值跟網頁版 `annotationUtils.ts` 保持一致)。 +- **重要設計決定:劃線操作列改用「畫面底部固定列」,不是網頁版那種浮在選取文字旁邊的懸浮泡泡**:網頁版 `HighlightPopup.tsx` 靠滑鼠位置精確定位,但 mobile 這邊選取文字發生在 epub.js 內容 iframe 裡,這個 iframe 在 iOS WKWebView 上的內部座標系統這幾輪已經反覆證實不可靠(見第十四輪「iOS WKWebView 裡...window.innerWidth 跟觸控 clientX 座標數值高達數千 px」那個坑,翻頁點擊區也是因為同一個問題才改成蓋在最外層的 tap-zone)。與其冒同樣的風險去算選取文字的精確螢幕座標,這次刻意選擇跳過這整類座標數學:選取文字或點擊既有標記時,一律在畫面底部彈出固定操作列(`mobile/components/SelectionBar.tsx`,`mode: 'selection' | 'edit'`),完全不需要計算文字在螢幕上的位置。這是刻意的簡化取捨,不是漏做。 +- **`mobile/reader-web/index.ts`**: + - 劃線標記渲染/點擊:`applyAnnotations(list)` 整批比對目前畫面上的標記跟 RN 端傳來的最新清單(新增/顏色變更就 `rendition.annotations.underline()` 重畫、清單裡消失的就 `rendition.annotations.remove()`),照抄網頁版 `addEpubAnnotation` 用的 `'underline'` 型別+`stroke`/`stroke-width` 樣式;epub.js 的 `Annotations` 類別本身會在 `hooks.render` 自動把已加入的標記套用到每個新渲染的章節 iframe(見 `node_modules/epubjs/src/annotations.js` 建構子),換頁/換章節不需要手動重掛。 + - 文字選取:改用 `rendition.on('selected', (cfiRange, contents) => ...)`——epub.js 的 `Contents` 類別本身已經監聽 `selectionchange`(`node_modules/epubjs/src/contents.js`)並幫忙把選取範圍換算成 CFI 字串,不需要自己手動算 CFI 或碰觸選取範圍的螢幕座標。但這個事件**只在選取範圍非空時**才會 emit,使用者點掉選取/收合選取範圍完全沒有對應事件,因此另外在既有的 `hooks.content.register` 內加一個自己的 `selectionchange` 監聽,只在偵測到「收合」時 post `selectionCleared` 關閉操作列,兩者互不干擾(各自關注不同的狀態轉換)。 + - 新增 inbound 訊息 `setAnnotations`(RN 每次新增/改色/刪除註記都送一次完整清單)、`clearSelection`(劃線動作完成後,清掉目前顯示中 iframe 的原生選取反白,讓畫面改由新畫的底線標記接手);`load` 訊息新增 `annotations` 欄位,開書時連同 cfi 一起帶入,`rendition.display()` 完成後立即 `applyAnnotations(initialAnnotations)` 畫出這本書已存的所有標記。新增 outbound 訊息 `textSelected`(cfi + 選取文字)、`selectionCleared`、`annotationTapped`(使用者點了既有標記,只帶 id,不帶座標——沿用上面「不算座標」的設計決定)。 +- **`mobile/app/reader/[id].tsx`**:新增 `annotations`/`selection`/`editingAnnotationId` state;`handleWebViewReady` 的 `Promise.all` 一併 `loadAnnotations(id)`,跟 base64/cfi 一起送進 `load` 訊息。新增 `handleCreateAnnotation`(用 `selection.cfi`/`.text` + 使用者選的顏色 + 目前 `currentChapterTitle` 組成新記錄,存檔並整批同步給 WebView,同時送 `clearSelection` 清掉原生選取反白)、`handleChangeAnnotationColor`、`handleDeleteAnnotation`、`handleUpdateAnnotationNote`(筆記純粹是 RN 端資料,不影響標記顏色,不需要通知 WebView)、`handleNavigateToAnnotation`(複用既有的 `goto` 訊息)、`handleCopySelection`(`expo-clipboard`,已是既有依賴)、`handleSearchSelection`(`Linking.openURL` 開 Google 搜尋,比照網頁版 `HighlightPopup` 的 G 按鈕)。`toggleSettings`/`toggleListPanel` 也一併清掉 `selection`/`editingAnnotationId`,避免底部操作列跟設定/清單面板同時疊在畫面上。 +- **`mobile/components/ListPanel.tsx` 的「註記」分頁**:從原本的「尚未支援」空殼文字,改成完整清單:每筆顯示選取文字(左側色條標示顏色,`numberOfLines=4`)+章節+日期,可勾選(全選/個別)、點顏色圓點展開色票更換顏色、點 ✕ 兩段式確認刪除、感想筆記可新增/編輯/清空(`TextInput` 多行,儲存/取消/刪除筆記三個按鈕,比照網頁版 `NotePanel.tsx` 的 `editingNoteId`/`editingNoteText` 狀態機)、點文字本身跳轉到該段落(複用 `goto`)。**匯出改用 RN 內建的 `Share.share()`**,不是網頁版的「產生 .txt 檔案下載」:mobile 沒有「下載資料夾」這個概念,`Share` 更貼近手機使用情境(可以分享到訊息、筆記、其他 App),不需要額外裝 `expo-sharing`;匯出文字格式(依章節分組、`• 文字` + `筆記:...`)照抄網頁版 `annotationExport.ts`。 +- **依賴變更**:無新增套件,全部沿用既有的 `expo-clipboard`(`ListPanel.tsx` 已裝)與 RN 內建 `Share`/`Linking`。 +- **驗證狀態**:`yarn build:reader`、`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform android`、`expo export --platform ios` 皆通過,純打包/型別驗證,這次沒有新增原生模組,不需要重新編譯 Dev Client,reload 即可生效。**完全尚未實機/模擬器測試**,這是全新功能的第一版,風險比之前的小修正高很多,麻煩重點確認: + 1. 在書本內文中間三分之一區域(`build-reader-html.js` 的 tap-zone 只蓋左右各 33%,中間留給選字)長按選取一段文字,畫面底部是否會跳出顏色選取列;點任一色是否會在該段文字下方畫出對應顏色的底線標記。 + 2. 點擊已經畫好的底線標記,底部是否改成「換色/刪除」操作列;換色是否即時反映在畫面上的標記顏色;刪除是否會讓底線標記消失。 + 3. 翻頁/回到同一頁後,先前劃的線是否還在(驗證 epub.js 標記有沒有正確跨頁重掛);離開書本再重新打開這本書,標記是否還原(驗證 AsyncStorage 存讀)。 + 4. 清單/設定面板打開時,底部操作列是否有正確一併關閉,不會疊在一起。 + 5. 「清單」按鈕→「註記」分頁:新增的標記是否出現在清單、點文字是否正確跳轉、更換顏色/刪除/新增感想筆記是否正常運作、全選+匯出是否會跳出系統分享選單且內容格式正確。 + 6. iOS 這邊的選取手勢(長按出現選取控點、拖曳調整範圍)在 epub.js iframe 內是否運作正常——這是這次唯一沒有迴避掉、必須依賴系統原生選字手勢的部分,如果 iOS 這裡本身就有已知的 WKWebView 選字限制,可能需要另外處理,但目前沒有機制可以在這個環境驗證。 + 這次没有事先預告哪一步最可能出問題,因為是全新功能沒有既有的踩坑紀錄可以參考,麻煩實測後把出問題的那個步驟明確告訴我(例如「第 1 步完全沒反應」或「第 3 步翻頁後標記消失」),才能對應到正確的環節排查,不要籠統說「註記功能怪怪的」。 + +**第十九輪:劃線完全無反應,加除錯 log 後新增「劃線模式」切換按鈕(2026-07-05,使用者兩種模擬器實測回報)**: +- 使用者回報:在畫面中間三分之一窄帶(tap-zone 沒蓋到的區域)長按嘗試選字,兩種模擬器都完全無效,沒有反白也沒有畫出標記。 +- 依上一輪加的 log 排查:`[content touchstart]` 有正常印出(代表觸控確實有送達 epub 內容 iframe,原本懷疑的「tap-zone 整個擋掉觸控」不是唯一問題),但 `[selectionchange]`/`[rendition selected]` 完全沒有出現——代表長按手勢從頭到尾沒有真正進入「文字選取模式」,問題發生在觸控送達之後、原生選字手勢啟動之前的環節。 +- **判斷**:使用者的長按操作,手指在按住/微調的過程中很可能會有些微位移,一旦位移掃出中間那條窄帶、碰到左右兩側不透明的 tap-zone div,長按手勢就會被中斷(tap-zone 蓋在最上層攔截觸控,見第十八輪/`registerTapZone` 的說明)。中間窄帶只有螢幕寬度的 33%,要求使用者精準壓在這麼窄的範圍內完全不移動並不實際。**這是推測,不是確認過的根因**——log 只能證明「送達了 touchstart 但選字沒有啟動」,無法區分是「手勢位移出窄帶被攔截」還是「模擬器的滑鼠模擬觸控本身就無法正確觸發長按選字手勢」(後者是模擬器環境的已知限制,跟程式碼無關,這個環境沒有辦法驗證是不是這個原因)。 +- **應對方式(使用者提議,已採納)**:與其要求使用者精準操作,新增一個「劃線模式」切換按鈕(頂部工具列書籤圖示右側,`IconNotes`),讓使用者可以主動把畫面切到「整個寬度都能長按選字」的狀態: + - `mobile/lib/readerMessages.ts` 新增 inbound 訊息 `setAnnotationMode`。 + - `mobile/reader-web/index.ts` 新增 `setAnnotationMode(enabled)`:開啟時把 `#tap-zone-prev`/`#tap-zone-next` 兩個 div 的 `style.pointerEvents` 設成 `'none'`,讓觸控直接穿透到底下的 epub 內容 iframe(等於畫面全寬都能長按選字),關閉時恢復 `'auto'`。 + - `mobile/app/reader/[id].tsx` 新增 `annotationMode` state 與 `toggleAnnotationMode()`:切換時送出 `setAnnotationMode` 訊息,關閉時額外送 `clearSelection` 清掉可能殘留的選取;跟既有的設定/清單面板互斥邏輯一致(開啟其中一個要順便關掉其他覆蓋層);換書時(`[id]` reset effect)一併重置成 `false`。畫面上方新增一條提示橫幅(劃線模式開啟時顯示「劃線模式中:長按文字選取即可標記,點擊畫面翻頁已暫停」),提醒使用者這段期間點擊畫面兩側不會翻頁——這是刻意的取捨,翻頁跟選字兩種手勢原本就會搶同一塊觸控區域,不用「模式切換」很難兩全。 +- **誠實說明限制**:這個修法解決的是「中間窄帶太窄、長按容易滑出去被攔截」這個已知可能成因;但log 沒辦法排除「模擬器的觸控模擬本身就不會觸發原生長按選字」這個可能性,這種情況下就算切到劃線模式(畫面全寬可選字),可能還是選不到,需要使用者這輪實測後回報結果才能確認。如果切到劃線模式後選字仍然完全沒反應,下一步應該優先懷疑是模擬器環境限制,可能需要在實機上才能真正驗證這個功能是否可用。 +- **驗證狀態**:`yarn build:reader`、`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform android` 皆通過,純打包/型別驗證,純 JS/TSX 改動不涉及原生模組,不需要重新編譯 Dev Client。**尚未實機/模擬器驗證**:麻煩點擊書籤圖示右側新增的圖示進入劃線模式(頂部會出現提示橫幅),再嘗試長按選字(這次畫面任何位置都可以,不限中間窄帶),確認是否出現反白/選取控點;不管有沒有選到,都請把這次的 `[reader-web debug]`/`[reader]` log 貼給我。 + +**第二十輪:iOS 模擬器選字/建立註記已成功,但畫面上完全看不到底線標記(2026-07-05,使用者截圖回報)**: +- 好消息:使用者這輪截圖證實「劃線模式」按鈕跟長按選字都正常運作——選取文字、挑色、存成註記、出現在清單、點清單項目正確跳轉,這幾步(上一輪懷疑的選字手勢問題)**都已解決**。 +- 新問題:跳轉到某筆註記所在的段落後,畫面上完全看不到對應顏色的底線標記,即使文字內容跟清單裡的註記完全吻合。 +- **判斷(尚未證實,找到一個已知、合理的候選機制)**:epub.js 的 `Annotations` 類別是掛在 `rendition.hooks.render` 自動把已存在的標記重新掛到每個新渲染的頁面/章節(`node_modules/epubjs/src/annotations.js` 建構子),但 `hooks.render` 觸發的時間點有時候會比這一頁 iframe 的內容/尺寸真正準備好還早,導致標記用來畫線的 `marks-pane` SVG 函式庫算出來的位置/尺寸基準還沒就緒,呼叫本身不會拋例外,但畫面上什麼都畫不出來——這正是網頁版 `Reader.tsx` 的 `addEpubAnnotation` 當初就踩過、也已經修過的同一顆坑(該處註解:「hooks.render 比 contents 就緒早,部分 annotation inject 可能失敗」),mobile 這次第一版漏搬這段保險機制。這個解釋能同時說明「建立當下沒看到、跳轉過去也沠看到」,但**還沒有實機證據能 100% 確認就是這個原因**,也不能排除是 mobile 特有的 iframe 座標問題(第十四輪記錄過 iOS WKWebView 的內容 iframe 內部座標系統跟畫面實際尺寸對不上的先例)。 +- **這輪的修法(照抄網頁版保險機制 + 加大量診斷 log,而不是盲猜著改)**: + - `mobile/reader-web/index.ts` 新增 `verifyAnnotationsRendered()`/`reinjectAllAnnotations()`:`underline()` 呼叫完成後 300ms,檢查 DOM 裡是不是真的生出對應的 `.ann- line` 元素,沒有的話呼叫 `rendition.annotations` 的 `clear(view)`/`inject(view)` 對目前所有 view 強制重新掛一次(跟網頁版 `addEpubAnnotation` 的 300ms 後備援邏輯同一招)。 + - 同一套 verify 也掛進既有的 `rendition.hooks.content.register(...)`(每次新頁面/章節內容渲染完都跑一次,300ms 後檢查目前 `renderedAnnotations` 清單裡的每一筆是不是都找得到對應的 DOM 元素),涵蓋「點清單跳轉到別的章節後,那一頁本來就該有標記卻沒被正確掛上」這個情境,不只是「剛建立當下」這一種情況。 + - 新增 `logMarkGeometry()` 診斷函式:印出標記 SVG 元素本身的量測結果(寬高/座標)、其所在 `` pane 的量測結果、目前內容 iframe 的 `getBoundingClientRect()` 與 `contentWindow.innerWidth`/`scrollWidth`——這幾個數字能直接判斷是「完全沒有生成標記元素」還是「元素生成了、但因為座標系統跟第十四輪那個已知的 iframe 座標坑一樣算錯了位置變成跑到看不到的地方」,這兩種情況修法完全不同,這次先蒐證不瞎猜。呼叫時機:`underline()` 呼叫完成當下、300ms 後(reinject 判斷前)、如果有觸發 reinject 則再追加一次 reinject 後的量測。 + - 標記線條 `stroke-width` 從 `2` 調成 `3`,順手排除「其實有畫出來,只是線太細在深色模式下不明顯」這個更單純的可能性(不影響前面的診斷邏輯,即使真正根因是別的,這個改動本身也無害)。 +- **驗證狀態**:`yarn build:reader`、`tsc --noEmit`、`expo-doctor`(20/20)、`expo export --platform ios` 皆通過,純打包/型別驗證,純 JS 改動不涉及原生模組,不需要重新編譯 Dev Client。**尚未實機/模擬器驗證這次的 verify+reinject 是否真的解決問題**:麻煩重新整理後再測一次「跳轉到某筆註記」,並把這次新增的 `[markGeometry:*]`/`[verifyAnnotationsRendered]`/`[reinjectAllAnnotations]` 這幾行 log 完整貼給我——特別留意 `mark=` 那組數字是 `no-svg`/寬高是 0,還是有正常的寬高但位置座標看起來異常(例如負值或遠超過螢幕尺寸),這能直接判斷是「完全沒生成」還是「生成了但位置算錯」,才能決定下一步往哪個方向修,不要只回報「還是看不到」。 + **Why 記錄這段**:mobile/ 的建置細節分散在多次對話中,若不集中記錄,下次對話容易重複「已經做過的初始化」或忘記 epub.js 在 RN 上不能直接用這個關鍵限制。 diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index 3c4d71b..5b85374 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -1,21 +1,26 @@ +import * as Clipboard from 'expo-clipboard'; import { useLocalSearchParams, router } from 'expo-router'; import { useCallback, useEffect, 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'; -import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconSettings } from '../../components/icons'; +import { IconBack, IconBookmarkFill, IconBookmarkOutline, IconChapters, IconNotes, IconSettings } from '../../components/icons'; 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, @@ -40,6 +45,10 @@ const ReaderScreen = () => { 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(''); @@ -93,6 +102,10 @@ const ReaderScreen = () => { setCurrentChapterTitle(''); setPageInfo(null); setListPanelTab(null); + setAnnotations([]); + setSelection(null); + setEditingAnnotationId(null); + setAnnotationMode(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); @@ -116,8 +129,20 @@ const ReaderScreen = () => { return; } try { - const [base64, cfi] = await Promise.all([getBookBase64(found), loadReadingCfi(id)]); - webviewRef.current?.postMessage(JSON.stringify({ type: 'load', base64, cfi })); + 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); @@ -174,6 +199,27 @@ const ReaderScreen = () => { chapterTextResolverRef.current = null; return; } + if (msg.type === 'textSelected') { + 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 永遠反映書本原始語言;只有在使用者這本書 // 從沒存過排版偏好時,才自動把顯示腳本切成跟書本原始語言一致(例如簡體書預設 @@ -301,19 +347,111 @@ const ReaderScreen = () => { 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; + } + 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); + 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); + syncAnnotationsToWebView(next); + }; + + const handleDeleteAnnotation = (annotationId: string) => { + if (!id) return; + const next = annotations.filter((a) => a.id !== annotationId); + setAnnotations(next); + saveAnnotations(id, next); + 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); + }; + + const handleNavigateToAnnotation = (cfi: string) => { + webviewRef.current?.postMessage(JSON.stringify({ type: 'goto', target: cfi })); + setListPanelTab(null); + }; + + const handleCopySelection = () => { + if (!selection) return; + Clipboard.setStringAsync(selection.text); + }; + + const handleSearchSelection = () => { + if (!selection) return; + Linking.openURL(`https://www.google.com/search?q=${encodeURIComponent(selection.text)}`); + }; + // 設定面板/清單面板共用同一個畫面區域,一次只會顯示其中一個:開啟其中一個時 // 要順便關掉另一個,否則兩個 overlay 疊在一起,關掉上層那個又會露出底下還開著的另一個。 // 兩顆按鈕都是開關型:再按一次目前開著的那顆就直接關閉,不需要額外的關閉鈕/麵包屑列。 const toggleSettings = () => { setListPanelTab(null); + setSelection(null); + setEditingAnnotationId(null); setSettingsVisible((prev) => !prev); }; const toggleListPanel = () => { setSettingsVisible(false); + setSelection(null); + setEditingAnnotationId(null); setListPanelTab((prev) => (prev ? null : 'bookmarks')); }; + // 劃線模式:使用者實測回報「畫面中間三分之一窄帶長按選字沒有反應」,log 顯示觸控確實有 + // 送到內容 iframe,但長按手勢常常在按住/微調時位移滑出那條窄帶、掃進左右兩側的翻頁點擊區 + // 而被中斷。開啟這個模式時通知 WebView 把左右兩側的翻頁點擊區關掉 pointer-events,讓整個 + // 畫面寬度都能長按選字(代價是這段期間點擊畫面兩側不會翻頁,需使用者自己切回一般模式)。 + const toggleAnnotationMode = () => { + setSettingsVisible(false); + setListPanelTab(null); + setSelection(null); + setEditingAnnotationId(null); + setAnnotationMode((prev) => { + const next = !prev; + webviewRef.current?.postMessage(JSON.stringify({ type: 'setAnnotationMode', enabled: next })); + if (!next) webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); + return next; + }); + }; + return ( @@ -337,6 +475,19 @@ const ReaderScreen = () => { > {isBookmarked ? : } + + + { overScrollMode="never" style={{ flex: 1, opacity: loading ? 0 : 1 }} /> + {annotationMode && ( + + 劃線模式中:長按文字選取即可標記,點擊畫面翻頁已暫停 + + )} {loading && !errorMessage ? ( @@ -428,6 +590,27 @@ const ReaderScreen = () => { currentHref={currentHref} onNavigateChapter={handleNavigateToTarget} record={record} + annotations={annotations} + onNavigateAnnotation={handleNavigateToAnnotation} + onChangeAnnotationColor={handleChangeAnnotationColor} + onDeleteAnnotation={handleDeleteAnnotation} + onUpdateAnnotationNote={handleUpdateAnnotationNote} + /> + )} + {selection && ( + + )} + {editingAnnotationId && ( + handleChangeAnnotationColor(editingAnnotationId, color)} + onDelete={() => handleDeleteAnnotation(editingAnnotationId)} /> )} {/* 固定高度、一律渲染(即使 pageInfo 還是 null 也只是內容留白):避免 pageInfo 從 diff --git a/mobile/components/ListPanel.tsx b/mobile/components/ListPanel.tsx index b487276..ee60b9f 100644 --- a/mobile/components/ListPanel.tsx +++ b/mobile/components/ListPanel.tsx @@ -1,8 +1,9 @@ import * as Clipboard from 'expo-clipboard'; -import { useMemo, useState } from 'react'; -import { Image, Pressable, ScrollView, Text, View } from 'react-native'; +import { useMemo, useRef, useState } from 'react'; +import { Image, Pressable, ScrollView, Share, Text, TextInput, View } from 'react-native'; import { IconCopy } from './icons'; -import { getCoverUri, type BookRecord, type Bookmark } from '../lib/library'; +import { HIGHLIGHT_COLORS } from '../lib/annotationColors'; +import { getCoverUri, type Annotation, type BookRecord, type Bookmark } from '../lib/library'; import type { TocItem } from '../lib/readerMessages'; import { coverStyleFor } from '../lib/coverStyles'; import { useTheme } from '../lib/theme'; @@ -41,6 +42,11 @@ interface Props { currentHref: string; onNavigateChapter: (href: string) => void; record: BookRecord | null; + annotations: Annotation[]; + onNavigateAnnotation: (cfi: string) => void; + onChangeAnnotationColor: (id: string, color: string) => void; + onDeleteAnnotation: (id: string) => void; + onUpdateAnnotationNote: (id: string, note: string) => void; } const ListPanel = ({ @@ -48,12 +54,61 @@ const ListPanel = ({ bookmarks, onNavigateBookmark, onDeleteBookmark, toc, currentHref, onNavigateChapter, record, + annotations, onNavigateAnnotation, onChangeAnnotationColor, onDeleteAnnotation, onUpdateAnnotationNote, }: Props) => { const { colors } = useTheme(); const [tab, setTab] = useState(initialTab); const [pendingDeleteId, setPendingDeleteId] = useState(null); const activeHref = findActiveHref(toc, currentHref.split('#')[0]); + const [colorPickerId, setColorPickerId] = useState(null); + const [pendingDeleteAnnId, setPendingDeleteAnnId] = useState(null); + const [selectedAnnIds, setSelectedAnnIds] = useState>(new Set()); + const [editingNoteId, setEditingNoteId] = useState(null); + const [editingNoteText, setEditingNoteText] = useState(''); + const noteInputRef = useRef(null); + + const startEditNote = (a: Annotation) => { + setColorPickerId(null); + setPendingDeleteAnnId(null); + setEditingNoteId(a.id); + setEditingNoteText(a.note ?? ''); + setTimeout(() => noteInputRef.current?.focus(), 50); + }; + const saveNote = (id: string) => { onUpdateAnnotationNote(id, editingNoteText); setEditingNoteId(null); }; + const cancelNote = () => setEditingNoteId(null); + const deleteNote = (id: string) => { onUpdateAnnotationNote(id, ''); setEditingNoteId(null); }; + + const allAnnSelected = annotations.length > 0 && selectedAnnIds.size === annotations.length; + const toggleSelectAllAnn = () => setSelectedAnnIds(allAnnSelected ? new Set() : new Set(annotations.map((a) => a.id))); + const toggleSelectAnn = (id: string) => + setSelectedAnnIds((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + + // 比照網頁版 annotationExport.ts 的匯出格式(依章節分組),輸出用 RN 內建的 Share API + // 讓使用者選擇分享到訊息/筆記/檔案等目的地,而不是額外裝 expo-sharing 寫入檔案下載 + // ——mobile 沒有「下載資料夾」這個概念,Share 更貼近手機使用情境。 + const handleExportAnnotations = () => { + const selected = annotations.filter((a) => selectedAnnIds.has(a.id)); + if (!selected.length) return; + const sorted = [...selected].sort((a, b) => a.createdAt - b.createdAt); + const grouped = new Map(); + sorted.forEach((a) => { + const ch = a.chapter || '未分類'; + if (!grouped.has(ch)) grouped.set(ch, []); + grouped.get(ch)!.push(a); + }); + const lines: string[] = ['我的閱讀註記', `匯出時間:${new Date().toLocaleString('zh-TW')}`, `共 ${selected.length} 筆`, '']; + grouped.forEach((anns, chapter) => { + lines.push(chapter); + anns.forEach((a) => { + lines.push(`• ${a.text}`); + if (a.note) lines.push(` 筆記:${a.note}`); + lines.push(''); + }); + }); + Share.share({ message: lines.join('\n'), title: record?.title ? `${record.title}的閱讀註記` : '閱讀註記' }); + }; + const renderTocItem = (item: TocItem, depth: number) => ( - 註記功能尚未支援 + + {annotations.length > 0 && ( + + + {allAnnSelected ? '取消全選' : '全選'} + + 0 ? colors.progressFill : 'transparent', + opacity: selectedAnnIds.size > 0 ? 1 : 0.5, + }} + > + 0 ? '#fff' : colors.ink3 }}> + 匯出{selectedAnnIds.size > 0 ? ` (${selectedAnnIds.size})` : ''} + + + + )} + + {annotations.length === 0 ? ( + + 尚無註記 + + 在書本內文選取文字,即可劃線、加註感想。 + + + ) : ( + annotations.map((a) => ( + + + toggleSelectAnn(a.id)} + hitSlop={8} + accessibilityRole="checkbox" + accessibilityState={{ checked: selectedAnnIds.has(a.id) }} + style={{ + width: 18, height: 18, borderRadius: 4, marginTop: 3, alignItems: 'center', justifyContent: 'center', + borderWidth: 1.5, borderColor: selectedAnnIds.has(a.id) ? colors.progressFill : colors.borderColor, + backgroundColor: selectedAnnIds.has(a.id) ? colors.progressFill : 'transparent', + }} + > + {selectedAnnIds.has(a.id) && } + + + onNavigateAnnotation(a.cfi)} accessibilityRole="button" accessibilityLabel="跳至此註記"> + + {a.text} + + + {a.chapter || ''} + {formatDate(a.createdAt)} + + + + { setPendingDeleteAnnId(null); setColorPickerId(colorPickerId === a.id ? null : a.id); }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="更換顏色" + style={{ width: 16, height: 16, borderRadius: 8, backgroundColor: a.color, marginTop: 3 }} + /> + { setColorPickerId(null); setPendingDeleteAnnId(pendingDeleteAnnId === a.id ? null : a.id); }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="刪除此註記" + > + + + + + {colorPickerId === a.id && ( + + {HIGHLIGHT_COLORS.map((c) => ( + { onChangeAnnotationColor(a.id, c.value); setColorPickerId(null); }} + accessibilityRole="button" + accessibilityLabel={`${c.label}色`} + style={{ + width: 22, height: 22, borderRadius: 11, backgroundColor: c.value, + borderWidth: 2, borderColor: a.color === c.value ? colors.ink : 'transparent', + }} + /> + ))} + + )} + + {pendingDeleteAnnId === a.id && ( + + 確定刪除? + setPendingDeleteAnnId(null)} style={{ paddingHorizontal: 8, height: 22, borderRadius: 5, backgroundColor: colors.paperBg2, alignItems: 'center', justifyContent: 'center' }}> + 取消 + + { onDeleteAnnotation(a.id); setSelectedAnnIds((prev) => { const n = new Set(prev); n.delete(a.id); return n; }); setPendingDeleteAnnId(null); }} + style={{ paddingHorizontal: 8, height: 22, borderRadius: 5, backgroundColor: '#ef4444', alignItems: 'center', justifyContent: 'center' }} + > + 刪除 + + + )} + + + {editingNoteId === a.id ? ( + + + + saveNote(a.id)} style={{ height: 24, paddingHorizontal: 10, borderRadius: 5, backgroundColor: colors.progressFill, alignItems: 'center', justifyContent: 'center' }}> + 儲存 + + + 取消 + + {a.note && ( + deleteNote(a.id)} style={{ height: 24, paddingHorizontal: 10, borderRadius: 5, alignItems: 'center', justifyContent: 'center' }}> + 刪除筆記 + + )} + + + ) : a.note ? ( + startEditNote(a)} accessibilityRole="button" accessibilityLabel="編輯筆記"> + + {a.note} + + + ) : ( + startEditNote(a)} accessibilityRole="button" accessibilityLabel="新增感想"> + + 新增感想 + + )} + + + )) + )} + )} diff --git a/mobile/components/SelectionBar.tsx b/mobile/components/SelectionBar.tsx new file mode 100644 index 0000000..53d9e80 --- /dev/null +++ b/mobile/components/SelectionBar.tsx @@ -0,0 +1,94 @@ +import { Pressable, Text, View } from 'react-native'; +import { IconCopy } from './icons'; +import { HIGHLIGHT_COLORS } from '../lib/annotationColors'; +import { useTheme } from '../lib/theme'; + +// 比照網頁版 HighlightPopup.tsx 的「選取中/編輯既有註記」兩種模式,但改成畫面底部固定的 +// 操作列,不是浮在選取文字旁邊的懸浮泡泡——網頁版靠滑鼠選取,浮動泡泡可以準確算出滑鼠位置; +// mobile 這裡的選取來自 epub.js 內容 iframe 裡的原生觸控選字,這個 iframe 在 WKWebView +// 上的內部座標系統已經證實不可靠(見 reader-web/index.ts 的 registerTapZone 說明,同一類問題 +// 這幾輪已經踩過好幾次),改成固定底部列可以完全不需要計算選取文字的螢幕座標,两个平台都穩定。 +interface SelectionProps { + mode: 'selection'; + text: string; + onHighlight: (color: string) => void; + onCopy: () => void; + onSearch: () => void; +} + +interface EditProps { + mode: 'edit'; + onChangeColor: (color: string) => void; + onDelete: () => void; +} + +type Props = SelectionProps | EditProps; + +const SelectionBar = (props: Props) => { + const { colors } = useTheme(); + return ( + + {props.mode === 'selection' && ( + + {props.text} + + )} + + {HIGHLIGHT_COLORS.map((c) => ( + (props.mode === 'edit' ? props.onChangeColor(c.value) : props.onHighlight(c.value))} + accessibilityRole="button" + accessibilityLabel={`${c.label}色${props.mode === 'edit' ? '' : '標記'}`} + style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: c.value, borderWidth: 2, borderColor: colors.paperBg }} + /> + ))} + + {props.mode === 'selection' ? ( + <> + + G + + + + + + ) : ( + + + + )} + + + ); +}; + +export default SelectionBar; diff --git a/mobile/lib/annotationColors.ts b/mobile/lib/annotationColors.ts new file mode 100644 index 0000000..0eca3b6 --- /dev/null +++ b/mobile/lib/annotationColors.ts @@ -0,0 +1,9 @@ +// 比照網頁版 renderer/src/components/Reader/annotationUtils.ts 的 HIGHLIGHT_COLORS, +// 顏色值需保持一致(劃線標記顏色是使用者跨平台共通的視覺記憶)。 +export const HIGHLIGHT_COLORS = [ + { label: '黃', value: '#eab308' }, + { label: '綠', value: '#22c55e' }, + { label: '藍', value: '#3b82f6' }, + { label: '粉', value: '#f9b9d7' }, + { label: '橘', value: '#f97316' }, +]; diff --git a/mobile/lib/library.ts b/mobile/lib/library.ts index 8df9d85..a435d94 100644 --- a/mobile/lib/library.ts +++ b/mobile/lib/library.ts @@ -21,6 +21,16 @@ export interface Bookmark { addedAt: number; } +export interface Annotation { + id: string; + cfi: string; + text: string; + color: string; + chapter: string; + createdAt: number; + note?: string; +} + export interface BookSettings { fontSize: number; fontFamily: string; @@ -34,6 +44,7 @@ const META_KEY = 'tit:library:meta'; const progressKey = (id: string) => `tit:progress:${id}`; const settingsKey = (id: string) => `tit:settings:${id}`; const bookmarksKey = (id: string) => `tit:bookmarks:${id}`; +const annotationsKey = (id: string) => `tit:annotations:${id}`; const booksDir = () => new Directory(Paths.document, 'books'); const coversDir = () => new Directory(Paths.document, 'covers'); @@ -138,7 +149,7 @@ export const removeBook = (id: string) => if (cover.exists) cover.delete(); } } - AsyncStorage.multiRemove([progressKey(id), settingsKey(id), bookmarksKey(id)]); + AsyncStorage.multiRemove([progressKey(id), settingsKey(id), bookmarksKey(id), annotationsKey(id)]); return [records.filter((r) => r.id !== id), undefined]; }); @@ -215,3 +226,15 @@ export const loadBookmarks = async (id: string): Promise => { export const saveBookmarks = (id: string, bookmarks: Bookmark[]) => AsyncStorage.setItem(bookmarksKey(id), JSON.stringify(bookmarks)); + +export const loadAnnotations = async (id: string): Promise => { + try { + const raw = await AsyncStorage.getItem(annotationsKey(id)); + return raw ? (JSON.parse(raw) as Annotation[]) : []; + } catch { + return []; + } +}; + +export const saveAnnotations = (id: string, annotations: Annotation[]) => + AsyncStorage.setItem(annotationsKey(id), JSON.stringify(annotations)); diff --git a/mobile/lib/readerHtml.generated.ts b/mobile/lib/readerHtml.generated.ts index 033bff6..2ec13fa 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/readerMessages.ts b/mobile/lib/readerMessages.ts index dc93686..2197757 100644 --- a/mobile/lib/readerMessages.ts +++ b/mobile/lib/readerMessages.ts @@ -7,15 +7,24 @@ export interface TocItem { subitems?: TocItem[]; } +export interface AnnotationMark { + id: string; + cfi: string; + color: string; +} + export type InboundMessage = - | { type: 'load'; base64: string; cfi: string | null } + | { type: 'load'; base64: string; cfi: string | null; annotations: AnnotationMark[] } | { type: 'prev' } | { type: 'next' } | { type: 'goto'; target: string } | { type: 'extractMeta'; base64: string } | { type: 'setDarkMode'; darkMode: boolean } | ({ type: 'setTypography' } & TypographySettings) - | { type: 'getChapterText' }; + | { type: 'getChapterText' } + | { type: 'setAnnotations'; annotations: AnnotationMark[] } + | { type: 'clearSelection' } + | { type: 'setAnnotationMode'; enabled: boolean }; export type OutboundMessage = | { type: 'ready' } @@ -25,4 +34,8 @@ export type OutboundMessage = | { type: 'metaError'; message: string } | { type: 'chapterText'; text: string } | { type: 'bookLanguageDetected'; baseScript: Script } - | { type: 'tocLoaded'; toc: TocItem[] }; + | { type: 'tocLoaded'; toc: TocItem[] } + | { type: 'textSelected'; cfi: string; text: string } + | { type: 'selectionCleared' } + | { type: 'annotationTapped'; id: string } + | { type: 'debug'; message: string }; diff --git a/mobile/reader-web/index.ts b/mobile/reader-web/index.ts index 2548425..34884b6 100644 --- a/mobile/reader-web/index.ts +++ b/mobile/reader-web/index.ts @@ -1,7 +1,7 @@ import ePub from 'epubjs'; import type { Book, Rendition } from 'epubjs'; import * as OpenCC from 'opencc-js'; -import type { InboundMessage, OutboundMessage, TocItem } from '../lib/readerMessages'; +import type { AnnotationMark, InboundMessage, OutboundMessage, TocItem } from '../lib/readerMessages'; import { DEFAULT_TYPOGRAPHY, normalizeFontFamily, type TypographySettings } from '../lib/readerSettings'; declare global { @@ -291,6 +291,12 @@ const post = (msg: OutboundMessage) => { window.ReactNativeWebView?.postMessage(JSON.stringify(msg)); }; +// 暫時性診斷用(比照先前翻頁/目錄跳轉除錯時的做法,見 RN_SETUP_GUIDE.md 第十四/十七輪), +// 確認穩定後應該移除。 +const debugLog = (...args: unknown[]) => { + post({ type: 'debug', message: args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') }); +}; + const base64ToArrayBuffer = (base64: string): ArrayBuffer => { const binary = atob(base64); const bytes = new Uint8Array(binary.length); @@ -403,10 +409,27 @@ const gotoTarget = async (rawTarget: string) => { // 不會經過 iframe 內部那套失準的座標系,兩平台都可靠。 // 右→左(RTL)閱讀方向下,畫面左側該觸發下一頁、右側觸發上一頁,跟 LTR 相反 // (比照網頁版 Reader.tsx 的翻頁箭頭在 rtl 時互換 prevPage/nextPage 的邏輯)。 +// 劃線模式:使用者實測回報「畫面中間三分之一窄帶長按選字仍然沒有反應」,log 顯示內容 iframe +// 有收到 touchstart,但完全沒有進入 selectionchange/rendition 的 selected 事件,研判長按手勢 +// 常常會在按住/微調過程中位移超出中間那條窄帶(滑進左右 tap-zone 範圍),這兩塊 tap-zone 是 +// 蓋在最上層、不透明地攔截觸控的 div(見 registerTapZone 上方註解),只要手勢途中掃到那塊區域 +// 就會被攔截、選字手勢跟著中斷。與其要求使用者精準壓在窄帶不動,改成使用者可以主動切換「劃線 +// 模式」:開啟時把左右兩塊 tap-zone 的 pointer-events 關掉,讓觸控整個穿透到底下的 epub 內容 +// iframe(等於畫面全寬都能長按選字),代價是這段期間點擊畫面左右兩側不會翻頁,需要使用者自己 +// 切回一般模式才能繼續用點擊翻頁——這是刻意的取捨,避免翻頁跟選字手勢互搶同一塊觸控區域。 +const setAnnotationMode = (enabled: boolean) => { + debugLog('[setAnnotationMode]', enabled); + ['tap-zone-prev', 'tap-zone-next'].forEach((id) => { + const el = document.getElementById(id); + if (el) el.style.pointerEvents = enabled ? 'none' : 'auto'; + }); +}; + const registerTapZone = (id: string, baseDirection: 'prev' | 'next') => { const el = document.getElementById(id); if (!el) return; el.addEventListener('click', () => { + debugLog('[tap-zone]', id, '被點擊,觸發翻頁(提醒:這塊區域會整個擋掉底下的文字選取手勢)'); const direction = typography.readingDirection === 'rtl' ? (baseDirection === 'prev' ? 'next' : 'prev') : baseDirection; @@ -414,6 +437,138 @@ const registerTapZone = (id: string, baseDirection: 'prev' | 'next') => { }); }; +// 劃線註記:用 epub.js 內建的 annotations API('underline' 型別,SVG 標記,不修改 DOM 文字節點)。 +// RN 端才是唯一的資料來源(annotations 陣列存在 AsyncStorage),這裡只負責「把目前這份清單 +// 渲染成畫面上的標記」——每次收到 setAnnotations(或換書時 load 訊息附帶的初始清單)都整批 +// 比對一次:清單裡消失的 id 移除標記、新出現的 id 新增標記、顏色變了的先移除再重新加上 +// (epub.js 的 annotations.add 沒有提供改色 API,只能整個換掉)。 +// rendition.annotations 內部會在 hooks.render 自動把已加入的標記重新套用到每個新渲染的 +// 章節/頁面 iframe,不需要在換頁時手動重掛。 +let renderedAnnotations = new Map(); + +// 比照網頁版 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 = (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),共用給「新增標記後檢查」跟「每次新內容 +// 渲染後檢查」兩個呼叫點。 +const reinjectAllAnnotations = (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 裡是不是真的都有對應的 元素;缺漏就整批強制重掛一次。 +const verifyAnnotationsRendered = (label: string) => { + if (renderedAnnotations.size === 0) return; + const missing = [...renderedAnnotations.keys()].filter((id) => !document.querySelector(`.ann-${id} line`)); + debugLog('[verifyAnnotationsRendered]', label, '應顯示', renderedAnnotations.size, '筆,缺少', missing.length, '筆', missing); + if (missing.length > 0) reinjectAllAnnotations(`verify:${label}`); +}; + +const addAnnotationMark = (ann: AnnotationMark) => { + if (!rendition) { + debugLog('[addAnnotationMark] 略過:rendition 尚未就緒', ann.id); + return; + } + 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(ann.id, 'immediately-after-call'); + setTimeout(() => { + logMarkGeometry(ann.id, '300ms-later'); + const line = document.querySelector(`.ann-${ann.id} line`); + if (!line) { + debugLog('[addAnnotationMark] 300ms 後仍找不到 元素,強制 clear+inject 重新掛一次', ann.id); + reinjectAllAnnotations(`create:${ann.id}`); + setTimeout(() => logMarkGeometry(ann.id, 'after-reinject'), 100); + } + }, 300); + } catch (err) { + debugLog('[addAnnotationMark] underline() 拋出例外', ann.id, err instanceof Error ? err.message : String(err)); + } +}; + +const removeAnnotationMark = (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)); + } +}; + +const applyAnnotations = (list: AnnotationMark[]) => { + debugLog('[applyAnnotations] 收到清單,共', list.length, '筆,目前畫面上有', renderedAnnotations.size, '筆'); + if (!rendition) { + debugLog('[applyAnnotations] 略過:rendition 尚未就緒'); + return; + } + const nextIds = new Set(list.map((a) => a.id)); + renderedAnnotations.forEach((prev, id) => { + if (!nextIds.has(id)) removeAnnotationMark(prev); + }); + list.forEach((ann) => { + const prev = renderedAnnotations.get(ann.id); + if (!prev) { + addAnnotationMark(ann); + } else if (prev.color !== ann.color || prev.cfi !== ann.cfi) { + removeAnnotationMark(prev); + addAnnotationMark(ann); + } + }); + renderedAnnotations = new Map(list.map((a) => [a.id, a])); +}; + +// 使用者點空白處/開始劃下一段選取時,清掉目前顯示中章節 iframe 的原生選取範圍, +// 讓畫面上的藍色選取反白消失(劃線動作完成後改由 addAnnotationMark 畫出的底線標記接手)。 +const clearNativeSelection = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contents = (rendition as any)?.getContents?.() as { window: Window }[] | undefined; + contents?.forEach((c) => c.window?.getSelection()?.removeAllRanges()); +}; + const blobToBase64 = (blob: Blob): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); @@ -557,7 +712,7 @@ const scanAllChapterPages = async (generation: number, targetBook: Book) => { locationsReady = true; }; -const loadBook = async (base64: string, cfi: string | null) => { +const loadBook = async (base64: string, cfi: string | null, initialAnnotations: AnnotationMark[]) => { const viewer = document.getElementById('viewer'); if (!viewer) return; @@ -566,6 +721,7 @@ const loadBook = async (base64: string, cfi: string | null) => { locationsReady = false; lastRelocatedLoc = null; lastLinearSpineIndex = null; + renderedAnnotations = new Map(); try { book = ePub(base64ToArrayBuffer(base64)); @@ -591,6 +747,27 @@ const loadBook = async (base64: string, cfi: string | null) => { contentDocs.add(doc); applyDarkOverride(doc, darkMode); applyTypographyToDoc(doc); + // epub.js 的 Contents 類別本身只在「選取範圍非空」時才會 emit 'selected'(見 + // node_modules/epubjs/src/contents.js 的 triggerSelectedEvent),使用者點掉選取/ + // 選取範圍收合完全沒有對應事件可以監聽,因此另外自己掛一個 selectionchange, + // 只在偵測到「收合」時回報給 RN 端關閉選取操作列,不跟 epub.js 內建的 250ms + // debounce 邏輯衝突(各自關注不同的狀態轉換)。 + doc.addEventListener('selectionchange', () => { + const sel = doc.defaultView?.getSelection(); + debugLog('[selectionchange]', 'collapsed=', sel?.isCollapsed ?? 'no-selection', 'text=', sel?.toString().slice(0, 20) ?? ''); + if (!sel || sel.isCollapsed) post({ type: 'selectionCleared' }); + }); + // 確認觸控事件本身有沒有傳到這份 content document(排除 tap-zone 疊在上層整個 + // 擋掉觸控、或 iframe sandbox 權限問題導致內容 iframe 根本收不到觸控的可能性)。 + doc.addEventListener('touchstart', (e: TouchEvent) => { + const t = e.touches[0]; + debugLog('[content touchstart]', 'x=', Math.round(t?.clientX ?? -1), 'y=', Math.round(t?.clientY ?? -1)); + }); + // 每次新章節/頁面內容渲染完,順便確認這一頁該顯示的標記是不是真的有畫出來—— + // epub.js 的 Annotations 類別本身也是掛在 hooks.render 自動重掛標記,但跟這裡的 + // hooks.content 一樣可能有「render 觸發時 contents 還沒完全就緒」的競態,見 + // addAnnotationMark 上方的說明,這裡用同一套 verify+reinject 補一次保險。 + setTimeout(() => verifyAnnotationsRendered('content-rendered'), 300); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -668,6 +845,28 @@ const loadBook = async (base64: string, cfi: string | null) => { postRelocated(loc, previousCfi); }); + // 文字選取 → 劃線註記的第一步:epub.js 已經幫忙把選取範圍換算成 CFI 字串, + // 不需要自己手動用 range.getBoundingClientRect() 去換算螢幕座標——epub.js 內容 + // iframe 在部分手機瀏覽器(尤其先前踩過的 iOS WKWebView)匯報的內部座標系不可靠 + // (見 registerTapZone 上方註解),選取彈出操作列改用畫面底部固定列(見 RN 端 + // SelectionBar),完全不需要精確定位在選取文字旁邊,順便避開這整類座標問題。 + rendition.on('selected', (cfiRange: string, contents: unknown) => { + debugLog('[rendition selected]', 'cfiRange=', cfiRange.slice(0, 40)); + const c = contents as { window: Window }; + const selection = c.window?.getSelection(); + if (!selection || selection.isCollapsed) { + debugLog('[rendition selected] 略過:selection 為空或已收合'); + return; + } + const text = selection.toString().trim(); + if (!text) { + debugLog('[rendition selected] 略過:selection.toString() 是空字串'); + return; + } + debugLog('[rendition selected] 送出 textSelected,text=', text.slice(0, 20)); + post({ type: 'textSelected', cfi: cfiRange, text }); + }); + await book.ready; // 簡體判斷比照網頁版 Reader.tsx:zh-CN / zh-Hans / zh-SG,或單獨的 "zh"(不帶 region code)。 // 一定要在 rendition.display() 之前判斷完成,因為 display() 會觸發 content hook 套用 script。 @@ -684,6 +883,7 @@ const loadBook = async (base64: string, cfi: string | null) => { post({ type: 'tocLoaded', toc: tocCache }); await rendition.display(cfi ?? undefined); + applyAnnotations(initialAnnotations); // 全書精確頁數/進度百分比:背景跑 scanAllChapterPages()(見上方定義)。延後幾秒才開始, // 避開使用者開書後最常見的「馬上連續翻頁測試」這段時間,減少背景渲染跟使用者操作互搶 // 主執行緒的機會;不 await,維持「不拖慢開書速度」的行為,期間的 relocated 事件會先用 @@ -727,7 +927,7 @@ const handleMessage = (event: MessageEvent) => { } catch { return; } - if (msg.type === 'load') loadBook(msg.base64, msg.cfi); + if (msg.type === 'load') loadBook(msg.base64, msg.cfi, msg.annotations); if (msg.type === 'prev') turnPage('prev'); if (msg.type === 'next') turnPage('next'); if (msg.type === 'goto') gotoTarget(msg.target); @@ -738,6 +938,12 @@ const handleMessage = (event: MessageEvent) => { setTypography(settings); } if (msg.type === 'getChapterText') post({ type: 'chapterText', text: getChapterText() }); + if (msg.type === 'setAnnotations') applyAnnotations(msg.annotations); + if (msg.type === 'clearSelection') { + debugLog('[clearSelection] 收到訊息'); + clearNativeSelection(); + } + if (msg.type === 'setAnnotationMode') setAnnotationMode(msg.enabled); }; // RN WebView 在 Android 觸發 document 的 message 事件,iOS 觸發 window 的,兩者都要監聽 From 255831bbeb9977780b8e51bf24f0fe58d9a9729c Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Sun, 5 Jul 2026 18:44:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(mobile):=20=E4=BF=AE=E6=AD=A3=20code=20?= =?UTF-8?q?review=20=E7=99=BC=E7=8F=BE=E7=9A=84=E9=81=B8=E5=8F=96=E6=AE=98?= =?UTF-8?q?=E7=95=99=E3=80=81=E6=A8=99=E8=A8=98=E9=81=BA=E5=A4=B1=E8=88=87?= =?UTF-8?q?=E9=99=A4=E9=8C=AF=E5=A4=96=E6=B4=A9=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- mobile/app/reader/[id].tsx | 31 ++++++++++++--------- mobile/lib/library.ts | 7 +++-- mobile/lib/readerHtml.generated.ts | 2 +- mobile/reader-web/index.ts | 44 +++++++++++++++++++++++------- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/mobile/app/reader/[id].tsx b/mobile/app/reader/[id].tsx index 5b85374..ea76c00 100644 --- a/mobile/app/reader/[id].tsx +++ b/mobile/app/reader/[id].tsx @@ -106,6 +106,7 @@ const ReaderScreen = () => { setSelection(null); setEditingAnnotationId(null); setAnnotationMode(false); + webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); @@ -200,7 +201,7 @@ const ReaderScreen = () => { return; } if (msg.type === 'textSelected') { - console.log('[reader] textSelected 收到', msg.text.slice(0, 20)); + if (__DEV__) console.log('[reader] textSelected 收到', msg.text.slice(0, 20)); setEditingAnnotationId(null); setSelection({ cfi: msg.cfi, text: msg.text }); return; @@ -362,7 +363,7 @@ const ReaderScreen = () => { console.log('[reader] handleCreateAnnotation 略過:id 或 selection 為空', { hasId: Boolean(id), hasSelection: Boolean(selection) }); return; } - console.log('[reader] handleCreateAnnotation', color, selection.text.slice(0, 20)); + if (__DEV__) console.log('[reader] handleCreateAnnotation', color, selection.text.slice(0, 20)); const ann: Annotation = { id: generateId(), cfi: selection.cfi, @@ -373,7 +374,7 @@ const ReaderScreen = () => { }; const next = [...annotations, ann]; setAnnotations(next); - saveAnnotations(id, next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); syncAnnotationsToWebView(next); setSelection(null); webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); @@ -383,7 +384,7 @@ const ReaderScreen = () => { if (!id) return; const next = annotations.map((a) => (a.id === annotationId ? { ...a, color } : a)); setAnnotations(next); - saveAnnotations(id, next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); syncAnnotationsToWebView(next); }; @@ -391,7 +392,7 @@ const ReaderScreen = () => { if (!id) return; const next = annotations.filter((a) => a.id !== annotationId); setAnnotations(next); - saveAnnotations(id, next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); syncAnnotationsToWebView(next); setEditingAnnotationId(null); }; @@ -400,7 +401,7 @@ const ReaderScreen = () => { if (!id) return; const next = annotations.map((a) => (a.id === annotationId ? { ...a, note: note.trim() || undefined } : a)); setAnnotations(next); - saveAnnotations(id, next); + saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); }; const handleNavigateToAnnotation = (cfi: string) => { @@ -418,20 +419,26 @@ const ReaderScreen = () => { 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' })); + }; + // 設定面板/清單面板共用同一個畫面區域,一次只會顯示其中一個:開啟其中一個時 // 要順便關掉另一個,否則兩個 overlay 疊在一起,關掉上層那個又會露出底下還開著的另一個。 // 兩顆按鈕都是開關型:再按一次目前開著的那顆就直接關閉,不需要額外的關閉鈕/麵包屑列。 const toggleSettings = () => { setListPanelTab(null); - setSelection(null); - setEditingAnnotationId(null); + clearSelectionState(); setSettingsVisible((prev) => !prev); }; const toggleListPanel = () => { setSettingsVisible(false); - setSelection(null); - setEditingAnnotationId(null); + clearSelectionState(); setListPanelTab((prev) => (prev ? null : 'bookmarks')); }; @@ -442,12 +449,10 @@ const ReaderScreen = () => { const toggleAnnotationMode = () => { setSettingsVisible(false); setListPanelTab(null); - setSelection(null); - setEditingAnnotationId(null); + clearSelectionState(); setAnnotationMode((prev) => { const next = !prev; webviewRef.current?.postMessage(JSON.stringify({ type: 'setAnnotationMode', enabled: next })); - if (!next) webviewRef.current?.postMessage(JSON.stringify({ type: 'clearSelection' })); return next; }); }; diff --git a/mobile/lib/library.ts b/mobile/lib/library.ts index a435d94..168851e 100644 --- a/mobile/lib/library.ts +++ b/mobile/lib/library.ts @@ -137,8 +137,9 @@ export const getBookBase64 = (record: BookRecord): Promise => const resolveCoverFilename = (record: BookRecord): string | undefined => record.coverFilename ?? record.coverUri?.split('/').pop(); -export const removeBook = (id: string) => - updateMeta((records) => { +export const removeBook = async (id: string) => { + await AsyncStorage.multiRemove([progressKey(id), settingsKey(id), bookmarksKey(id), annotationsKey(id)]); + return updateMeta((records) => { const record = records.find((r) => r.id === id); if (record) { const file = new File(booksDir(), record.filename); @@ -149,9 +150,9 @@ export const removeBook = (id: string) => if (cover.exists) cover.delete(); } } - AsyncStorage.multiRemove([progressKey(id), settingsKey(id), bookmarksKey(id), annotationsKey(id)]); return [records.filter((r) => r.id !== id), undefined]; }); +}; export const updateBookMeta = ( id: string, diff --git a/mobile/lib/readerHtml.generated.ts b/mobile/lib/readerHtml.generated.ts index 2ec13fa..62a36e7 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/reader-web/index.ts b/mobile/reader-web/index.ts index 34884b6..007e122 100644 --- a/mobile/reader-web/index.ts +++ b/mobile/reader-web/index.ts @@ -1,4 +1,4 @@ -import ePub from 'epubjs'; +import ePub, { EpubCFI } from 'epubjs'; import type { Book, Rendition } from 'epubjs'; import * as OpenCC from 'opencc-js'; import type { AnnotationMark, InboundMessage, OutboundMessage, TocItem } from '../lib/readerMessages'; @@ -292,8 +292,11 @@ const post = (msg: OutboundMessage) => { }; // 暫時性診斷用(比照先前翻頁/目錄跳轉除錯時的做法,見 RN_SETUP_GUIDE.md 第十四/十七輪), -// 確認穩定後應該移除。 +// 確認穩定後應該移除。這份 bundle 沒有區分 dev/production 建置,預設關閉避免觸控座標/ +// 選取文字內容經由 RN bridge 外流;需要除錯時手動改成 true 再跑 `yarn build:reader`。 +const DEBUG_BRIDGE = false; const debugLog = (...args: unknown[]) => { + if (!DEBUG_BRIDGE) return; post({ type: 'debug', message: args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') }); }; @@ -488,17 +491,32 @@ const reinjectAllAnnotations = (label: string) => { }; // 檢查目前應該顯示的標記,DOM 裡是不是真的都有對應的 元素;缺漏就整批強制重掛一次。 +// renderedAnnotations 是整本書的標記清單,但畫面上(DOM)任何時刻只會有目前顯示中章節的 +// 標記被實際掛出來——只比對「目前可見章節」涵蓋到的標記,避免把其他章節本來就不該出現在 +// DOM 裡的標記誤判成「缺漏」,觸發不必要的 reinjectAllAnnotations。 const verifyAnnotationsRendered = (label: string) => { - if (renderedAnnotations.size === 0) return; - const missing = [...renderedAnnotations.keys()].filter((id) => !document.querySelector(`.ann-${id} line`)); - debugLog('[verifyAnnotationsRendered]', label, '應顯示', renderedAnnotations.size, '筆,缺少', missing.length, '筆', missing); + 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(`verify:${label}`); }; -const addAnnotationMark = (ann: AnnotationMark) => { +const addAnnotationMark = (ann: AnnotationMark): boolean => { if (!rendition) { debugLog('[addAnnotationMark] 略過:rendition 尚未就緒', ann.id); - return; + return false; } try { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -523,8 +541,10 @@ const addAnnotationMark = (ann: AnnotationMark) => { setTimeout(() => logMarkGeometry(ann.id, 'after-reinject'), 100); } }, 300); + return true; } catch (err) { debugLog('[addAnnotationMark] underline() 拋出例外', ann.id, err instanceof Error ? err.message : String(err)); + return false; } }; @@ -549,16 +569,20 @@ const applyAnnotations = (list: AnnotationMark[]) => { renderedAnnotations.forEach((prev, id) => { if (!nextIds.has(id)) removeAnnotationMark(prev); }); + // 只有 addAnnotationMark 回報成功(underline() 沒有拋例外)的標記才記進 + // renderedAnnotations;underline() 失敗時若仍記成「已渲染」,下次 applyAnnotations 收到 + // 同一份未變更的標記會誤判成「已存在且沒變」而完全跳過重試,永遠補不回來。 + const failedIds = new Set(); list.forEach((ann) => { const prev = renderedAnnotations.get(ann.id); if (!prev) { - addAnnotationMark(ann); + if (!addAnnotationMark(ann)) failedIds.add(ann.id); } else if (prev.color !== ann.color || prev.cfi !== ann.cfi) { removeAnnotationMark(prev); - addAnnotationMark(ann); + if (!addAnnotationMark(ann)) failedIds.add(ann.id); } }); - renderedAnnotations = new Map(list.map((a) => [a.id, a])); + renderedAnnotations = new Map(list.filter((a) => !failedIds.has(a.id)).map((a) => [a.id, a])); }; // 使用者點空白處/開始劃下一段選取時,清掉目前顯示中章節 iframe 的原生選取範圍,