-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(pwa): 依 Grokking Simplicity 原則重構 Reader.tsx,分離 action/calcul… #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| --- | ||
| tags: | ||
| - refactor | ||
| - progress | ||
| --- | ||
| # 函數式重構進度追蹤(PWA / RENDERER / RN) | ||
|
|
||
| > 依循 [functional-thinking.md.md](functional-thinking.md.md) 的 Grokking Simplicity 原則(action / calculation / data 分層),依序重構 PWA、RENDERER、RN 三個版本。 | ||
|
|
||
| --- | ||
|
|
||
| ## 整體順序 | ||
|
|
||
| 1. ✅ **PWA**(`pwa/`)— 已完成 | ||
| 2. ⬜ **RENDERER**(`renderer/`)— 待開始 | ||
| 3. ⬜ **RN / mobile**(`mobile/`)— 待開始 | ||
|
|
||
| 每完成一個版本先暫停,等使用者確認沒問題再進行下一個。 | ||
|
|
||
| --- | ||
|
|
||
| ## 1. PWA(已完成) | ||
|
|
||
| 目標檔案:`pwa/src/page/Reader.tsx`(原始 2383 行,混雜計算邏輯與副作用) | ||
|
|
||
| ### 已完成項目 | ||
|
|
||
| - [x] 新增 4 個純計算檔案(`pwa/src/components/Reader/`),逐字搬移邏輯,零行為改動: | ||
| - `progressCalculations.ts` — 頁碼/總頁數換算 | ||
| - `tocLookup.ts` — TOC / spine 章節查找 | ||
| - `ttsFollowCalculations.ts` — TTS 翻頁判斷邏輯 | ||
| - `bookmarkUtils.ts` — 書籤資料操作 | ||
| - [x] 抽出 `useBookmarks.ts`(`pwa/src/hooks/reader/`) | ||
| - [x] 抽出 `useAnnotationPopups.ts`(`pwa/src/hooks/reader/`) | ||
| - [x] 抽出 `useChapterPageScan.ts`(`pwa/src/hooks/reader/`) | ||
| - [x] 合併 book 生命週期與 TTS 跟讀邏輯為 `useReaderEngine.ts`(`pwa/src/hooks/reader/`) | ||
| - 原規劃拆成 `useEpubBook` + `useTTSFollow` 兩個獨立檔案,但發現兩者實際雙向耦合(`relocated` 事件直接呼叫 TTS 函數,TTS 又依賴 book 初始化建立的 refs),經與使用者確認後改為合併成單一 hook | ||
| - [x] `Reader.tsx` 從 2383 行降至 641 行,現在主要是 refs/hooks 組裝 + JSX render | ||
| - [x] 每個階段皆執行 `cd pwa && yarn build` 驗證型別/編譯無誤 | ||
|
|
||
| ### 使用者實測結果(已通過) | ||
|
|
||
| - [x] 翻頁(含 RTL 模式) | ||
| - [x] 朗讀播放 / 暫停 / 繼續 / 跨章節自動翻頁 | ||
| - [x] 選字高亮 popup(新增/編輯/刪除標記) | ||
| - [x] 書籤新增 / 刪除 / 跳轉 | ||
| - [x] 簡繁轉換 | ||
| - [x] 深色模式 | ||
| - [x] 字體大小 / 字型 / 行距 / 字距設定 | ||
| - [x] 睡眠計時器 | ||
| - [x] 手機版 Safari(過去有 iOS 專屬的 selectionchange / 觸控 bug,本次確認正常) | ||
|
|
||
| **PWA 重構已通過完整實測,可視為此階段完成。** | ||
|
|
||
| --- | ||
|
|
||
| ## 2. RENDERER(待開始) | ||
|
|
||
| 目標檔案:`renderer/src/page/Reader.tsx` | ||
|
|
||
| **注意**:`renderer` 目前程式碼已與 `pwa` 分岔,功能不完全相同(例如少了書籤功能、annotation popup 拆成獨立的 `BookmarkPanel.tsx` / `HighlightPopup.tsx` 元件)。不能直接複製 PWA 版的重構結果,需要重新評估: | ||
|
|
||
| - [ ] 比對 `renderer` 與 `pwa` 的 `Reader.tsx` 差異,確認哪些抽出的邏輯可以共用、哪些需要為 renderer 版本量身設計 | ||
| - [ ] 決定是否要與 PWA 共用同一份純計算檔案(若邏輯完全一致) | ||
| - [ ] 依同樣的 ACD 原則拆分 renderer 版 `Reader.tsx` | ||
| - [ ] `cd renderer && yarn build` 驗證 | ||
| - [ ] 列出需使用者實測的項目(Electron 環境特有行為) | ||
|
|
||
| --- | ||
|
|
||
| ## 3. RN / mobile(待開始) | ||
|
|
||
| 目標目錄:`mobile/`(React Native + WebView 架構,與 PWA/RENDERER 完全不同) | ||
|
|
||
| - [ ] 先探索 mobile 版對應的 Reader 相關程式碼結構(WebView 架構下的 ACD 分層方式可能與 web 版差異很大) | ||
| - [ ] 評估此架構下的動作/計算/資料分層策略 | ||
| - [ ] 執行重構 | ||
| - [ ] 驗證方式待定(RN 無法用 `yarn build` 驗證執行期行為,需实機/模擬器測試) | ||
|
|
||
| --- | ||
|
|
||
| ## 相關文件 | ||
|
|
||
| - 重構指導原則:[functional-thinking.md.md](functional-thinking.md.md) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| --- | ||
| tags: | ||
| - skill | ||
| --- | ||
| # functional-thinking.md | ||
|
|
||
| ## 技能名稱:函數式思考開發專家 (Grokking Simplicity Style) | ||
|
|
||
| ### **核心目標** | ||
|
|
||
| 你是一位精通函數式思考的資深工程師,在編寫、重構或審查程式碼時,必須將「降低複雜性」作為最高指導原則。你必須透過區分「動作、計算與資料」來提升軟體的穩定性、可測試性與重用性。 | ||
|
|
||
| --- | ||
|
|
||
| ### **1\. 動作、計算與資料 (ACD) 分類準則** | ||
|
|
||
| 每次編寫程式碼前,你必須先識別這三種類別: | ||
|
|
||
| - **動作 (Actions)**:執行結果取決於「執行時機」或「執行次數」的程式碼(例如:發送 Email、寫入資料庫、修改全域變數、獲取當前時間)。你必須將動作與其餘邏輯隔離,並將其影響範圍縮至最小。 | ||
|
|
||
| - **計算 (Calculations)**:從輸入到輸出的映射,只要輸入相同,結果永遠一致,且不會對外部產生副作用(即「純函數」)。**應優先將邏輯轉寫為計算**,因為它們易於測試且不具備時間依賴性。 | ||
|
|
||
| - **資料 (Data)**:關於事件的事實記錄。資料是靜態且透明的,應優先使用通用資料結構(如 JavaScript 的物件或陣列)來表示,以利於多種方式的解釋與處理。 | ||
|
|
||
| ### **2\. 嚴格實踐不可變性 (Immutability)** | ||
|
|
||
| 在處理狀態變更時,嚴禁直接修改原始資料結構,必須使用以下兩種學科: | ||
|
|
||
| - **寫入時複製 (Copy-on-write)**:用於你可控的內部程式碼。 | ||
|
|
||
| - **標準三步驟**:(1) 製作副本;(2) 修改副本;(3) 回傳副本。 | ||
|
|
||
| - **防禦性複製 (Defensive copying)**:用於與外部「不受信任」的遺留代碼 (Legacy Code) 或庫交換資料時。 | ||
|
|
||
| - **核心規則**:資料進入安全區前製作深層副本 (Deep Copy);資料離開安全區傳給外部前亦製作深層副本。 | ||
|
|
||
| ### **3\. 分層設計與抽象 (Stratified Design)** | ||
|
|
||
| 組織程式碼時應建立清晰的抽象層級: | ||
|
|
||
| - **直截了當的實現 (Straightforward Implementation)**:一個函數內的所有呼叫應處於相似的抽象層次,避免在同一函數中混合高階邏輯與底層細節。 | ||
|
|
||
| - **建立抽象屏障 (Abstraction Barriers)**:重要的資料結構應透過一組介面函數存取,讓上層邏輯無需關心底層實作細節。 | ||
|
|
||
| - **極簡介面 (Minimal Interface)**:底層應保持精簡且功能強大,新功能應盡可能在現有介面上層實作,避免底層污染。 | ||
|
|
||
| ### **4\. 使用高階函數優化邏輯** | ||
|
|
||
| - **告別手寫迴圈**:優先使用 `map()` (轉換資料)、`filter()` (篩選資料) 與 `reduce()` (聚合資料) 等高階函數來處理集合。 | ||
|
|
||
| - **抽象化行為**:利用「將函數作為參數」或「回傳函數的函數」來封裝重複的行為模式(如錯誤處理、寫入時複製的模版)。 | ||
|
|
||
| ### **5\. 時間軸與併發管理 (Timelines)** | ||
|
|
||
| 處理非同步或分散式系統時: | ||
|
|
||
| - **繪製時間軸**:視覺化動作的順序與並行關係,識別可能的競態條件 (Race Conditions)。 | ||
|
|
||
| - **減少共享資源**:偏好使用區域變數而非全域變數,以減少不同時間軸間的干擾。 | ||
|
|
||
| - **使用協調原語**:利用 `Queue` (確保順序執行) 或 `Cut` (確保所有並行動作完成後才執行下一步) 等並發原語來管理時間。 | ||
|
|
||
| --- | ||
|
|
||
| ### **對 Agent 的操作指令範例** | ||
|
|
||
| - *「請重構這段程式碼,將其中的動作與計算分離,並套用寫入時複製步驟。」* | ||
|
|
||
| - *「這段邏輯有競態條件風險,請繪製時間軸分析並使用 `Cut` 原語解決。」* | ||
|
|
||
| - *「請遵循極簡介面原則,在現有購物車屏障上實作此折扣邏輯。」* | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import type { Bookmark } from '@/hooks/useLibrary' | ||
|
|
||
| export const sortBookmarksByAddedAt = (bookmarks: Bookmark[]): Bookmark[] => | ||
| [...bookmarks].sort((a, b) => a.addedAt - b.addedAt) | ||
|
|
||
| export const removeBookmarkById = (bookmarks: Bookmark[], id: string): Bookmark[] => | ||
| bookmarks.filter((b) => b.id !== id) | ||
|
|
||
| export const toggleBookmark = (bookmarks: Bookmark[], cfi: string, label: string, id: string, addedAt: number): Bookmark[] => { | ||
| if (bookmarks.some((b) => b.cfi === cfi)) { | ||
| return bookmarks.filter((b) => b.cfi !== cfi) | ||
| } | ||
| return [...bookmarks, { id, cfi, label, addedAt }] | ||
| } | ||
|
|
||
| export const formatBookmarkDate = (addedAt: number): string => | ||
| new Date(addedAt).toLocaleDateString('zh-TW', { month: 'numeric', day: 'numeric' }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| export const computeChapterAverage = (known: Map<number, number>): number => { | ||
| const knownValues = [...known.values()] | ||
| return knownValues.reduce((a, b) => a + b, 0) / knownValues.length | ||
| } | ||
|
|
||
| export const computeAccurateTotal = (spineTotal: number, known: Map<number, number>, avg: number): number => { | ||
| let totalPages = 0 | ||
| for (let i = 0; i < spineTotal; i++) totalPages += known.get(i) ?? avg | ||
| return Math.round(totalPages) | ||
| } | ||
|
|
||
| export const computeGlobalPage = (spineIdx: number, chapterPage: number, known: Map<number, number>, avg: number): number => { | ||
| let prevPages = 0 | ||
| for (let i = 0; i < spineIdx; i++) prevPages += known.get(i) ?? avg | ||
| return Math.max(Math.round(prevPages + chapterPage), 1) | ||
| } | ||
|
|
||
| export const resolveInitialPageInfo = (params: { | ||
| storedProgress: number | null | undefined | ||
| estimatedTotal: number | ||
| knownSize: number | ||
| spineTotal: number | ||
| page: number | ||
| }): { page: number; total: number } => { | ||
| const { storedProgress, estimatedTotal, knownSize, spineTotal, page } = params | ||
| if (storedProgress != null && storedProgress > 0 && knownSize < spineTotal) { | ||
| const storedPage = Math.max(Math.round(storedProgress * estimatedTotal), 1) | ||
| return { page: storedPage, total: estimatedTotal } | ||
| } | ||
| return { page, total: estimatedTotal } | ||
| } | ||
|
|
||
| export const clampProgressRatio = (rawRatio: number, ttsActive: boolean): number => | ||
| ttsActive ? Math.min(rawRatio, 0.99) : rawRatio |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| type TocItem = { href?: string; label?: string; subitems?: any[] } | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| type SpineItem = { href?: string; idref?: string } | ||
|
|
||
| export const findChapterTitleByHref = (tocItems: TocItem[], curFile: string): string => { | ||
| let bestLabel = '' | ||
| let bestDepth = -1 | ||
|
|
||
| const search = (items: TocItem[], depth: number) => { | ||
| for (const item of items) { | ||
| const itemFile = (item.href ?? '').split('#')[0] | ||
| if (itemFile === curFile && depth > bestDepth) { | ||
| bestLabel = item.label?.trim() ?? '' | ||
| bestDepth = depth | ||
| } | ||
| if (item.subitems?.length) search(item.subitems, depth + 1) | ||
| } | ||
| } | ||
| search(tocItems, 0) | ||
| return bestLabel | ||
| } | ||
|
|
||
| export const hrefToSpineIndex = (spineItems: SpineItem[], href: string): number => { | ||
| const file = href.split('#')[0] | ||
| return spineItems.findIndex((s) => | ||
| s.href === file || s.href === href || | ||
| (file && (s.href?.endsWith('/' + file) || file?.endsWith('/' + (s.href ?? '')))) | ||
| ) | ||
| } | ||
|
|
||
| export const findNearestChapterLabel = (tocItems: TocItem[], spineItems: SpineItem[], curSpineIdx: number): string => { | ||
| let bestLabel = '' | ||
| let bestIdx = -1 | ||
|
|
||
| const search = (items: TocItem[]) => { | ||
| for (const item of items) { | ||
| const si = hrefToSpineIndex(spineItems, item.href ?? '') | ||
| if (si !== -1 && si <= curSpineIdx && si > bestIdx) { | ||
| bestLabel = item.label?.trim() ?? '' | ||
| bestIdx = si | ||
| } | ||
| if (item.subitems?.length) search(item.subitems) | ||
| } | ||
| } | ||
| search(tocItems) | ||
| return bestLabel | ||
| } | ||
|
|
||
| export const resolveSpineTarget = (spineItems: SpineItem[], href: string): string => { | ||
| const cleanHref = href.split('#')[0] | ||
| const filename = cleanHref.split('/').pop() ?? '' | ||
| const spineItem = spineItems.find((item) => | ||
| item.href === href || | ||
| item.href === cleanHref || | ||
| item.idref === cleanHref || | ||
| item.idref === filename || | ||
| (filename && item.href?.endsWith('/' + filename)) || | ||
| (filename && item.href === filename) | ||
| ) | ||
| return spineItem?.href ?? href | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import type { TTSProgressSource } from '@/hooks/useTTS' | ||
|
|
||
| export const computeContinuousPage = (absoluteOffset: number, textLength: number, totalPages: number): number => | ||
| absoluteOffset / textLength * totalPages + 1 | ||
|
|
||
| export const computePageTurnOffset = (pageStartOffset: number | null, pageEndOffset: number | null, lead: number): number | null => | ||
| pageEndOffset === null ? null : Math.max(pageStartOffset ?? 0, pageEndOffset - lead) | ||
|
|
||
| export const computePageEntryGuard = (pageStartOffset: number | null, pageSpan: number | null, maxGuard: number): number => | ||
| pageStartOffset !== null ? Math.min(maxGuard, Math.max(12, Math.floor((pageSpan ?? maxGuard) * 0.22))) : 0 | ||
|
|
||
| export type ShouldAdvanceParams = { | ||
| absoluteOffset: number | ||
| pageStartOffset: number | null | ||
| pageEndOffset: number | null | ||
| pageTurnOffset: number | null | ||
| continuousPage: number | ||
| currentPage: number | ||
| source: TTSProgressSource | ||
| outsidePage: boolean | ||
| pageEntryGuard: number | ||
| } | ||
|
|
||
| export type ShouldAdvanceResult = { | ||
| shouldAdvance: boolean | ||
| reason: 'page-end-cfi' | 'progress-fallback' | 'none' | ||
| beforeCurrentPageGuard: boolean | ||
| insideNewPageGuard: boolean | ||
| } | ||
|
|
||
| // 完整搬移自 Reader.tsx 舊版 followTTSRange 的翻頁判斷區塊,邏輯不變。 | ||
| export const shouldAdvanceTTSPage = (params: ShouldAdvanceParams): ShouldAdvanceResult => { | ||
| const { absoluteOffset, pageStartOffset, pageEndOffset, pageTurnOffset, continuousPage, currentPage, source, outsidePage, pageEntryGuard } = params | ||
| const distanceToPageEnd = pageTurnOffset === null ? null : pageTurnOffset - absoluteOffset | ||
| const beforeCurrentPageGuard = pageStartOffset !== null && absoluteOffset < pageStartOffset - 2 | ||
| const insideNewPageGuard = | ||
| pageStartOffset !== null && | ||
| absoluteOffset >= pageStartOffset && | ||
| absoluteOffset < pageStartOffset + pageEntryGuard | ||
| const progressShouldAdvance = | ||
| !beforeCurrentPageGuard && | ||
| !insideNewPageGuard && | ||
| pageEndOffset === null && ( | ||
| outsidePage || | ||
| (source === 'boundary' && continuousPage >= currentPage + 1.05) || | ||
| (continuousPage >= currentPage + 1.12) | ||
| ) | ||
| const measuredPageEndReached = | ||
| !beforeCurrentPageGuard && | ||
| pageEndOffset !== null && | ||
| distanceToPageEnd !== null && | ||
| distanceToPageEnd <= 0 | ||
|
|
||
| if (measuredPageEndReached) return { shouldAdvance: true, reason: 'page-end-cfi', beforeCurrentPageGuard, insideNewPageGuard } | ||
| if (progressShouldAdvance) return { shouldAdvance: true, reason: 'progress-fallback', beforeCurrentPageGuard, insideNewPageGuard } | ||
| return { shouldAdvance: false, reason: 'none', beforeCurrentPageGuard, insideNewPageGuard } | ||
| } | ||
|
|
||
| export const computeApproxOffsetFromPercentage = ( | ||
| percentage: number | undefined, | ||
| currentPageIdx: number, | ||
| totalPages: number, | ||
| textLength: number, | ||
| ): number => | ||
| (typeof percentage === 'number' && percentage > 0) | ||
| ? Math.floor(percentage * textLength) | ||
| : Math.floor(currentPageIdx / totalPages * textLength) | ||
|
|
||
| // 在全文中搜尋 sample 文字樣本,選擇距離 approx 最近的符合位置;找不到回傳 null。 | ||
| export const locateApproxOffsetByFuzzyMatch = (fullText: string, approx: number, sample: string): number | null => { | ||
| if (sample.length < 3) return null | ||
| const normalFull = fullText.replace(/\s+/g, '') | ||
| const approxNorm = Math.round(approx / fullText.length * normalFull.length) | ||
| let bestNormIdx = -1 | ||
| let bestDist = Infinity | ||
| let pos = 0 | ||
| while (true) { | ||
| const idx = normalFull.indexOf(sample, pos) | ||
| if (idx < 0) break | ||
| const dist = Math.abs(idx - approxNorm) | ||
| if (dist < bestDist) { bestDist = dist; bestNormIdx = idx } | ||
| pos = idx + 1 | ||
| } | ||
| if (bestNormIdx < 0) return null | ||
|
|
||
| let normCount = 0 | ||
| for (let i = 0; i < fullText.length; i++) { | ||
| if (!/\s/.test(fullText[i])) { | ||
| if (normCount === bestNormIdx) return i | ||
| normCount++ | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| export const snapOffsetToWordStart = (fullText: string, approx: number): number => { | ||
| let startOffset = approx | ||
| while (startOffset > 0 && !/[\s\n]/.test(fullText[startOffset - 1])) startOffset-- | ||
| return startOffset | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Minor grammar issues flagged by static analysis.
LanguageTool flags a few wording issues: missing quantifier near line 17, adverbial "地" usage near lines 39/41, and a likely typo near line 67 ("步驟" vs "不驟" mis-render). Since this doc is used as an operating guideline for contributors/agents, worth a quick polish pass.
Also applies to: 39-39, 41-41, 67-67
🧰 Tools
🪛 LanguageTool
[uncategorized] ~17-~17: 数词与名词之间一般应存在量词,可能缺少量词。
Context: ... 動作、計算與資料 (ACD) 分類準則** 每次編寫程式碼前,你必須先識別這三種類別: - 動作 (Actions):執行結果取決於「執行時機」或「執行...
(wa5)
🤖 Prompt for AI Agents
Source: Linters/SAST tools