Skip to content

Commit e791419

Browse files
Retsommclaude
andcommitted
refactor(renderer): 依 Grokking Simplicity 原則重構 Reader.tsx,分離 action/calculation/data
- 抽出 4 個純計算檔案與 4 個 hooks(useBookmarks/useChapterPageScan/useAnnotationPopups/useReaderEngine),Reader.tsx 從 1850 行降至 314 行 - 修正章節掃描背景渲染共用主 book 導致偶爾清空 annotation 的問題 - 修正朗讀跟隨高亮閃爍(移除搶跑的估算進度計時器,比照 pwa 已驗證修法) - 修正註記跳轉後不顯示(relocated 後補救重新注入遺漏的 SVG 底線) - 修正書庫排序按鈕缺少 no-drag class 導致點擊被視窗拖曳吃掉 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 868219b commit e791419

14 files changed

Lines changed: 2049 additions & 1639 deletions

FP_REFACTOR_PROGRESS.md

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ tags:
1212
## 整體順序
1313

1414
1.**PWA**`pwa/`)— 已完成
15-
2. **RENDERER**`renderer/`)— 待開始
15+
2. **RENDERER**`renderer/`)— 已完成
1616
3.**RN / mobile**`mobile/`)— 待開始
1717

1818
每完成一個版本先暫停,等使用者確認沒問題再進行下一個。
@@ -54,17 +54,43 @@ tags:
5454

5555
---
5656

57-
## 2. RENDERER(待開始
57+
## 2. RENDERER(已完成
5858

59-
目標檔案:`renderer/src/page/Reader.tsx`
59+
目標檔案:`renderer/src/page/Reader.tsx`(原始 1850 行,重構後 314 行)
6060

61-
**注意**`renderer` 目前程式碼已與 `pwa` 分岔,功能不完全相同(例如少了書籤功能、annotation popup 拆成獨立的 `BookmarkPanel.tsx` / `HighlightPopup.tsx` 元件)。不能直接複製 PWA 版的重構結果,需要重新評估:
61+
**更正**舊版本文件寫「renderer 少了書籤功能」已過期——renderer 其實已有完整書籤功能與更早就元件化的 `BookmarkPanel.tsx` / `HighlightPopup.tsx`,只是書籤/annotation/TTS 邏輯尚未抽成純函數 + hooks。
6262

63-
- [ ] 比對 `renderer``pwa``Reader.tsx` 差異,確認哪些抽出的邏輯可以共用、哪些需要為 renderer 版本量身設計
64-
- [ ] 決定是否要與 PWA 共用同一份純計算檔案(若邏輯完全一致)
65-
- [ ] 依同樣的 ACD 原則拆分 renderer 版 `Reader.tsx`
66-
- [ ] `cd renderer && yarn build` 驗證
67-
- [ ] 列出需使用者實測的項目(Electron 環境特有行為)
63+
### 已完成項目
64+
65+
- [x] 新增 4 個純計算檔案(`renderer/src/components/Reader/`,與 pwa 共用同一份邏輯,逐字搬移):
66+
- `progressCalculations.ts``tocLookup.ts``bookmarkUtils.ts``ttsFollowCalculations.ts`
67+
- [x] 抽出 `useBookmarks.ts``useChapterPageScan.ts``useAnnotationPopups.ts``useReaderEngine.ts``renderer/src/hooks/reader/`
68+
- `useAnnotationPopups.ts` 依 renderer 現有 `HighlightPopup.tsx` 介面調整為 `{x, y}` 座標(不採用 pwa 版的 `{left, top}` clamp 寫法)
69+
- `useReaderEngine.ts` 以 renderer 原有邏輯為底重新組織(而非直接搬 pwa 版),刻意保留 renderer 特有行為:
70+
- TTS 高亮改用 renderer 原有的 CSS Custom Highlight API 直接繪製(不採用 pwa 版新增的 DOM overlay fallback,那是獨立的行為改動,超出本次重構範圍)
71+
- 不引入 pwa 版新增的行動裝置專屬邏輯(觸控 selectionchange 選字路徑、iOS selectionchange 轉發、`getVisibleContentDocument` 多 iframe 命中測試)——Electron 桌面版不需要
72+
- 同步修正 `renderer/src/store/useAnnotationStore.ts``addAnnotation`,改為回傳新建立的 id(比照 pwa 版介面,讓 `useAnnotationPopups.ts` 可直接使用回傳值)
73+
- 修掉章節掃描 bug:背景掃描改用獨立的 `ePub(buffer.slice(0))` 實例(`useChapterPageScan.ts`),不再複用主 `book` 建立第二個 rendition,避免掃描完成時偶爾清空主渲染器 annotation 的問題
74+
- [x] `Reader.tsx` 從 1850 行降至 314 行
75+
- [x] `cd renderer && yarn build` 驗證型別/編譯無誤
76+
77+
### 使用者實測結果(已通過)
78+
79+
- [x] 翻頁、鍵盤左右鍵翻頁
80+
- [x] 朗讀播放/暫停/繼續/跨章節自動翻頁
81+
- [x] 選字高亮 popup 位置(新增/編輯/刪除標記)
82+
- [x] 書籤新增/刪除/跳轉
83+
- [x] 簡繁轉換、深色模式、字體大小/字型/行距/字距設定
84+
- [x] 睡眠計時器
85+
- [x] 章節掃描:切換字體大小後多次翻頁,頁碼校正正確且高亮標記不會被意外清除
86+
87+
### 實測中發現並修正的問題
88+
89+
- [x] **朗讀跟隨螢光色閃爍、同時出現在目標與非目標句子**`renderer/src/hooks/useTTS.ts` 仍保留舊版「估算進度計時器」(每 250ms 用預估字速推進高亮位置),與真實 `onboundary` 事件互相搶跑造成閃爍。比照 pwa 版已驗證過的修法,移除估算計時器,高亮改成只跟隨真實 boundary 事件。
90+
- [x] **註記跳轉後不顯示**:在 `useReaderEngine.ts``relocated` 事件中新增補救邏輯——每次換頁/跳轉後延遲檢查當前章節的既有註記是否有對應 SVG 底線,缺漏則重新呼叫 `addEpubAnnotation` 補畫(epub.js 的 `annotations.inject(view)` 在 contents 尚未完全就緒時偶爾會失敗)。
91+
- [x] **書庫排序按鈕(最近閱讀/書名/進度)點擊無效**:與本次 Reader 重構無關的既有 bug——`Library.tsx` 外層有 `drag-region`(Electron 視窗可拖曳區域),`SortControl.tsx` 的按鈕缺少 `no-drag` class,導致點擊被系統拖曳行為吃掉。已補上 `no-drag`
92+
93+
**RENDERER 重構已通過完整實測,可視為此階段完成。**
6894

6995
---
7096

renderer/src/components/Library/SortControl.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const SortControl = ({ count, sort, paperBg, paperBg2, borderCol, inkCol, ink3Co
2222
</span>
2323
</div>
2424

25-
<div className="flex items-center gap-0.5 p-0.5 rounded-lg" style={{ background: paperBg2, border: `1px solid ${borderCol}` }}>
25+
<div className="no-drag flex items-center gap-0.5 p-0.5 rounded-lg" style={{ background: paperBg2, border: `1px solid ${borderCol}` }}>
2626
{(['recent', 'title', 'progress'] as SortKey[]).map((key) => (
2727
<button
2828
key={key}

renderer/src/components/Reader/BookmarkPanel.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Bookmark } from '@/hooks/useLibrary'
22
import { MONO, SERIF } from '@/components/Reader/bookCoverStyles'
3+
import { formatBookmarkDate, sortBookmarksByAddedAt } from '@/components/Reader/bookmarkUtils'
34

45
interface Props {
56
bookmarks: Bookmark[]
@@ -31,12 +32,12 @@ const BookmarkPanel = ({ bookmarks, darkMode, pendingDeleteId, onClose, onNaviga
3132
<div style={{ padding: '32px 20px', textAlign: 'center', fontFamily: MONO, fontSize: 12, color: ink3Col, letterSpacing: '0.04em' }}>尚無書籤</div>
3233
) : (
3334
<div style={{ display: 'flex', flexDirection: 'column' }}>
34-
{[...bookmarks].sort((a, b) => a.addedAt - b.addedAt).map((bm) => (
35+
{sortBookmarksByAddedAt(bookmarks).map((bm) => (
3536
<div key={bm.id} className="no-drag" style={{ borderBottom: `1px solid ${borderCol}`, padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 6, cursor: 'pointer', transition: 'background .12s' }} onClick={() => onNavigate(bm)} onMouseEnter={(e) => (e.currentTarget.style.background = darkMode ? '#231f1c' : '#f1ede4')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
3637
<div style={{ fontFamily: SERIF, fontSize: 13, color: inkCol, lineHeight: 1.5, wordBreak: 'break-all' }}>{bm.label}</div>
3738
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
3839
<span style={{ fontFamily: MONO, fontSize: 10, color: ink3Col, letterSpacing: '0.04em' }}>
39-
{new Date(bm.addedAt).toLocaleDateString('zh-TW', { month: 'numeric', day: 'numeric' })}
40+
{formatBookmarkDate(bm.addedAt)}
4041
</span>
4142
<button className="no-drag" onClick={(e) => { e.stopPropagation(); onDeleteRequest(pendingDeleteId === bm.id ? null : bm.id) }} style={{ fontFamily: MONO, fontSize: 10, color: pendingDeleteId === bm.id ? '#ef4444' : ink3Col, cursor: 'pointer', padding: '2px 6px', borderRadius: 4, transition: 'all .12s', background: pendingDeleteId === bm.id ? (darkMode ? '#3a1a1a' : '#fff0f0') : 'transparent' }} onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; e.currentTarget.style.background = darkMode ? '#3a1a1a' : '#fff0f0' }} onMouseLeave={(e) => { e.currentTarget.style.color = pendingDeleteId === bm.id ? '#ef4444' : ink3Col; e.currentTarget.style.background = pendingDeleteId === bm.id ? (darkMode ? '#3a1a1a' : '#fff0f0') : 'transparent' }} aria-label="移除書籤">
4243
移除
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Bookmark } from '@/hooks/useLibrary'
2+
3+
export const sortBookmarksByAddedAt = (bookmarks: Bookmark[]): Bookmark[] =>
4+
[...bookmarks].sort((a, b) => a.addedAt - b.addedAt)
5+
6+
export const removeBookmarkById = (bookmarks: Bookmark[], id: string): Bookmark[] =>
7+
bookmarks.filter((b) => b.id !== id)
8+
9+
export const toggleBookmark = (bookmarks: Bookmark[], cfi: string, label: string, id: string, addedAt: number): Bookmark[] => {
10+
if (bookmarks.some((b) => b.cfi === cfi)) {
11+
return bookmarks.filter((b) => b.cfi !== cfi)
12+
}
13+
return [...bookmarks, { id, cfi, label, addedAt }]
14+
}
15+
16+
export const formatBookmarkDate = (addedAt: number): string =>
17+
new Date(addedAt).toLocaleDateString('zh-TW', { month: 'numeric', day: 'numeric' })
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export const computeChapterAverage = (known: Map<number, number>): number => {
2+
const knownValues = [...known.values()]
3+
return knownValues.reduce((a, b) => a + b, 0) / knownValues.length
4+
}
5+
6+
export const computeAccurateTotal = (spineTotal: number, known: Map<number, number>, avg: number): number => {
7+
let totalPages = 0
8+
for (let i = 0; i < spineTotal; i++) totalPages += known.get(i) ?? avg
9+
return Math.round(totalPages)
10+
}
11+
12+
export const computeGlobalPage = (spineIdx: number, chapterPage: number, known: Map<number, number>, avg: number): number => {
13+
let prevPages = 0
14+
for (let i = 0; i < spineIdx; i++) prevPages += known.get(i) ?? avg
15+
return Math.max(Math.round(prevPages + chapterPage), 1)
16+
}
17+
18+
export const resolveInitialPageInfo = (params: {
19+
storedProgress: number | null | undefined
20+
estimatedTotal: number
21+
knownSize: number
22+
spineTotal: number
23+
page: number
24+
}): { page: number; total: number } => {
25+
const { storedProgress, estimatedTotal, knownSize, spineTotal, page } = params
26+
if (storedProgress != null && storedProgress > 0 && knownSize < spineTotal) {
27+
const storedPage = Math.max(Math.round(storedProgress * estimatedTotal), 1)
28+
return { page: storedPage, total: estimatedTotal }
29+
}
30+
return { page, total: estimatedTotal }
31+
}
32+
33+
export const clampProgressRatio = (rawRatio: number, ttsActive: boolean): number =>
34+
ttsActive ? Math.min(rawRatio, 0.99) : rawRatio
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2+
type TocItem = { href?: string; label?: string; subitems?: any[] }
3+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4+
type SpineItem = { href?: string; idref?: string }
5+
6+
export const findChapterTitleByHref = (tocItems: TocItem[], curFile: string): string => {
7+
let bestLabel = ''
8+
let bestDepth = -1
9+
10+
const search = (items: TocItem[], depth: number) => {
11+
for (const item of items) {
12+
const itemFile = (item.href ?? '').split('#')[0]
13+
if (itemFile === curFile && depth > bestDepth) {
14+
bestLabel = item.label?.trim() ?? ''
15+
bestDepth = depth
16+
}
17+
if (item.subitems?.length) search(item.subitems, depth + 1)
18+
}
19+
}
20+
search(tocItems, 0)
21+
return bestLabel
22+
}
23+
24+
export const hrefToSpineIndex = (spineItems: SpineItem[], href: string): number => {
25+
const file = href.split('#')[0]
26+
return spineItems.findIndex((s) =>
27+
s.href === file || s.href === href ||
28+
(file && (s.href?.endsWith('/' + file) || file?.endsWith('/' + (s.href ?? ''))))
29+
)
30+
}
31+
32+
export const findNearestChapterLabel = (tocItems: TocItem[], spineItems: SpineItem[], curSpineIdx: number): string => {
33+
let bestLabel = ''
34+
let bestIdx = -1
35+
36+
const search = (items: TocItem[]) => {
37+
for (const item of items) {
38+
const si = hrefToSpineIndex(spineItems, item.href ?? '')
39+
if (si !== -1 && si <= curSpineIdx && si > bestIdx) {
40+
bestLabel = item.label?.trim() ?? ''
41+
bestIdx = si
42+
}
43+
if (item.subitems?.length) search(item.subitems)
44+
}
45+
}
46+
search(tocItems)
47+
return bestLabel
48+
}
49+
50+
export const resolveSpineTarget = (spineItems: SpineItem[], href: string): string => {
51+
const cleanHref = href.split('#')[0]
52+
const filename = cleanHref.split('/').pop() ?? ''
53+
const spineItem = spineItems.find((item) =>
54+
item.href === href ||
55+
item.href === cleanHref ||
56+
item.idref === cleanHref ||
57+
item.idref === filename ||
58+
(filename && item.href?.endsWith('/' + filename)) ||
59+
(filename && item.href === filename)
60+
)
61+
return spineItem?.href ?? href
62+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { TTSProgressSource } from '@/hooks/useTTS'
2+
3+
export const computeContinuousPage = (absoluteOffset: number, textLength: number, totalPages: number): number =>
4+
absoluteOffset / textLength * totalPages + 1
5+
6+
export const computePageTurnOffset = (pageStartOffset: number | null, pageEndOffset: number | null, lead: number): number | null =>
7+
pageEndOffset === null ? null : Math.max(pageStartOffset ?? 0, pageEndOffset - lead)
8+
9+
export const computePageEntryGuard = (pageStartOffset: number | null, pageSpan: number | null, maxGuard: number): number =>
10+
pageStartOffset !== null ? Math.min(maxGuard, Math.max(12, Math.floor((pageSpan ?? maxGuard) * 0.22))) : 0
11+
12+
export type ShouldAdvanceParams = {
13+
absoluteOffset: number
14+
pageStartOffset: number | null
15+
pageEndOffset: number | null
16+
pageTurnOffset: number | null
17+
continuousPage: number
18+
currentPage: number
19+
source: TTSProgressSource
20+
outsidePage: boolean
21+
pageEntryGuard: number
22+
}
23+
24+
export type ShouldAdvanceResult = {
25+
shouldAdvance: boolean
26+
reason: 'page-end-cfi' | 'progress-fallback' | 'none'
27+
beforeCurrentPageGuard: boolean
28+
insideNewPageGuard: boolean
29+
}
30+
31+
// 完整搬移自 Reader.tsx 舊版 followTTSRange 的翻頁判斷區塊,邏輯不變。
32+
export const shouldAdvanceTTSPage = (params: ShouldAdvanceParams): ShouldAdvanceResult => {
33+
const { absoluteOffset, pageStartOffset, pageEndOffset, pageTurnOffset, continuousPage, currentPage, source, outsidePage, pageEntryGuard } = params
34+
const distanceToPageEnd = pageTurnOffset === null ? null : pageTurnOffset - absoluteOffset
35+
const beforeCurrentPageGuard = pageStartOffset !== null && absoluteOffset < pageStartOffset - 2
36+
const insideNewPageGuard =
37+
pageStartOffset !== null &&
38+
absoluteOffset >= pageStartOffset &&
39+
absoluteOffset < pageStartOffset + pageEntryGuard
40+
const progressShouldAdvance =
41+
!beforeCurrentPageGuard &&
42+
!insideNewPageGuard &&
43+
pageEndOffset === null && (
44+
outsidePage ||
45+
(source === 'boundary' && continuousPage >= currentPage + 1.05) ||
46+
(continuousPage >= currentPage + 1.12)
47+
)
48+
const measuredPageEndReached =
49+
!beforeCurrentPageGuard &&
50+
pageEndOffset !== null &&
51+
distanceToPageEnd !== null &&
52+
distanceToPageEnd <= 0
53+
54+
if (measuredPageEndReached) return { shouldAdvance: true, reason: 'page-end-cfi', beforeCurrentPageGuard, insideNewPageGuard }
55+
if (progressShouldAdvance) return { shouldAdvance: true, reason: 'progress-fallback', beforeCurrentPageGuard, insideNewPageGuard }
56+
return { shouldAdvance: false, reason: 'none', beforeCurrentPageGuard, insideNewPageGuard }
57+
}
58+
59+
export const computeApproxOffsetFromPercentage = (
60+
percentage: number | undefined,
61+
currentPageIdx: number,
62+
totalPages: number,
63+
textLength: number,
64+
): number =>
65+
(typeof percentage === 'number' && percentage > 0)
66+
? Math.floor(percentage * textLength)
67+
: Math.floor(currentPageIdx / totalPages * textLength)
68+
69+
// 在全文中搜尋 sample 文字樣本,選擇距離 approx 最近的符合位置;找不到回傳 null。
70+
export const locateApproxOffsetByFuzzyMatch = (fullText: string, approx: number, sample: string): number | null => {
71+
if (sample.length < 3) return null
72+
const normalFull = fullText.replace(/\s+/g, '')
73+
const approxNorm = Math.round(approx / fullText.length * normalFull.length)
74+
let bestNormIdx = -1
75+
let bestDist = Infinity
76+
let pos = 0
77+
while (true) {
78+
const idx = normalFull.indexOf(sample, pos)
79+
if (idx < 0) break
80+
const dist = Math.abs(idx - approxNorm)
81+
if (dist < bestDist) { bestDist = dist; bestNormIdx = idx }
82+
pos = idx + 1
83+
}
84+
if (bestNormIdx < 0) return null
85+
86+
let normCount = 0
87+
for (let i = 0; i < fullText.length; i++) {
88+
if (!/\s/.test(fullText[i])) {
89+
if (normCount === bestNormIdx) return i
90+
normCount++
91+
}
92+
}
93+
return null
94+
}
95+
96+
export const snapOffsetToWordStart = (fullText: string, approx: number): number => {
97+
let startOffset = approx
98+
while (startOffset > 0 && !/[\s\n]/.test(fullText[startOffset - 1])) startOffset--
99+
return startOffset
100+
}

0 commit comments

Comments
 (0)