Skip to content

Commit 5fdf91d

Browse files
committed
Fix PWA TTS highlight stability
1 parent 4a43e06 commit 5fdf91d

5 files changed

Lines changed: 263 additions & 36 deletions

File tree

pwa/src/App.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,30 @@ const App = () => {
3232
setView('library')
3333
}
3434

35+
const handleApplyLatestVersion = async () => {
36+
try {
37+
if ('serviceWorker' in navigator) {
38+
const registrations = await navigator.serviceWorker.getRegistrations()
39+
await Promise.all(registrations.map((registration) => registration.unregister()))
40+
}
41+
} catch (err) {
42+
console.warn('[PWA] Service Worker 清除失敗:', err)
43+
}
44+
45+
try {
46+
if ('caches' in window) {
47+
const keys = await caches.keys()
48+
await Promise.all(keys.map((key) => caches.delete(key)))
49+
}
50+
} catch (err) {
51+
console.warn('[PWA] Cache Storage 清除失敗:', err)
52+
}
53+
54+
const url = new URL(window.location.href)
55+
url.searchParams.set('refresh', String(Date.now()))
56+
window.location.replace(url.toString())
57+
}
58+
3559
return (
3660
<div className={darkMode ? 'dark' : ''}>
3761
<div className="min-h-screen bg-stone-50 dark:bg-gray-900 transition-colors">
@@ -44,6 +68,7 @@ const App = () => {
4468
onRemoveBook={removeBook}
4569
darkMode={darkMode}
4670
onToggleDark={() => setDarkMode(!darkMode)}
71+
onApplyLatestVersion={handleApplyLatestVersion}
4772
/>
4873
)}
4974
{view === 'reader' && activeBookUrl && (
@@ -56,6 +81,7 @@ const App = () => {
5681
darkMode={darkMode}
5782
onToggleDark={() => setDarkMode(!darkMode)}
5883
onUpdateProgress={(pct) => updateProgress(activeBookId, pct)}
84+
onApplyLatestVersion={handleApplyLatestVersion}
5985
/>
6086
)}
6187
</div>

pwa/src/components/Library.tsx

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ const IconPlus = () => (
2727
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
2828
</svg>
2929
)
30+
const IconRefresh = () => (
31+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
32+
<path d="M21 12a9 9 0 0 1-15.5 6.2" />
33+
<path d="M3 12A9 9 0 0 1 18.5 5.8" />
34+
<path d="M18 2v5h-5" />
35+
<path d="M6 22v-5h5" />
36+
</svg>
37+
)
3038

3139
// ── Cover styles ────────────────────────────────────────────────────────
3240

@@ -170,23 +178,32 @@ interface Props {
170178
onRemoveBook: (id: string) => void
171179
darkMode: boolean
172180
onToggleDark: () => void
181+
onApplyLatestVersion: () => void | Promise<void>
173182
}
174183

175184
type SortKey = 'recent' | 'title' | 'progress'
176185

177-
const Library = ({ records, getCoverDataUrl, onAddBooks, onOpenBook, onRemoveBook, darkMode, onToggleDark }: Props) => {
186+
const Library = ({ records, getCoverDataUrl, onAddBooks, onOpenBook, onRemoveBook, darkMode, onToggleDark, onApplyLatestVersion }: Props) => {
178187
const fileInputRef = useRef<HTMLInputElement>(null)
179188
const [loading, setLoading] = useState(false)
180189
const [pendingRemove, setPendingRemove] = useState<{ id: string; title: string } | null>(null)
181190
const [query, setQuery] = useState('')
182191
const [sort, setSort] = useState<SortKey>('recent')
192+
const [logoMenuOpen, setLogoMenuOpen] = useState(false)
193+
const [applyingUpdate, setApplyingUpdate] = useState(false)
194+
const logoMenuRef = useRef<HTMLDivElement>(null)
183195

184196
const handleRemoveRequest = (id: string) => {
185197
const record = records.find((r) => r.id === id)
186198
if (record) setPendingRemove({ id, title: record.title })
187199
}
188200
const handleConfirmRemove = () => { if (pendingRemove) { onRemoveBook(pendingRemove.id); setPendingRemove(null) } }
189201
const handleCancelRemove = () => setPendingRemove(null)
202+
const handleApplyLatestVersion = async () => {
203+
if (applyingUpdate) return
204+
setApplyingUpdate(true)
205+
await onApplyLatestVersion()
206+
}
190207

191208
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
192209
const files = e.target.files
@@ -263,12 +280,48 @@ const Library = ({ records, getCoverDataUrl, onAddBooks, onOpenBook, onRemoveBoo
263280
<div style={{ borderBottom: `1px solid ${borderCol}`, background: paperBg }}>
264281
{/* 第一行:Logo + 標題 + 操作按鈕 */}
265282
<div className="flex items-center gap-2 px-4 pt-3 pb-2">
266-
<div style={{
267-
width: 26, height: 26, borderRadius: 6, flexShrink: 0,
268-
background: inkCol, color: paperBg,
269-
display: 'flex', alignItems: 'center', justifyContent: 'center',
270-
fontFamily: SERIF, fontStyle: 'italic', fontWeight: 700, fontSize: 14,
271-
}}>T</div>
283+
<div ref={logoMenuRef} style={{ position: 'relative', flexShrink: 0 }}>
284+
<button
285+
onClick={() => setLogoMenuOpen((open) => !open)}
286+
style={{
287+
width: 26, height: 26, borderRadius: 6,
288+
background: logoMenuOpen ? paperBg2 : inkCol,
289+
color: logoMenuOpen ? inkCol : paperBg,
290+
display: 'flex', alignItems: 'center', justifyContent: 'center',
291+
fontFamily: SERIF, fontStyle: 'italic', fontWeight: 700, fontSize: 14,
292+
cursor: 'pointer',
293+
}}
294+
aria-label="Travel in Time 選單"
295+
>
296+
T
297+
</button>
298+
{logoMenuOpen && (
299+
<div
300+
style={{
301+
position: 'absolute', left: 0, top: 32, zIndex: 60,
302+
width: 178, padding: 6, borderRadius: 8,
303+
background: paperBg, border: `1px solid ${borderCol}`,
304+
boxShadow: '0 14px 32px -14px rgba(0,0,0,0.45)',
305+
}}
306+
>
307+
<button
308+
onClick={handleApplyLatestVersion}
309+
disabled={applyingUpdate}
310+
style={{
311+
width: '100%', minHeight: 34, borderRadius: 6, padding: '8px 10px',
312+
display: 'flex', alignItems: 'center', gap: 8,
313+
color: applyingUpdate ? ink3Col : inkCol,
314+
fontFamily: 'inherit', fontSize: 13, textAlign: 'left',
315+
cursor: applyingUpdate ? 'default' : 'pointer',
316+
opacity: applyingUpdate ? 0.7 : 1,
317+
}}
318+
>
319+
<IconRefresh />
320+
<span>{applyingUpdate ? '更新中…' : '套用最新版'}</span>
321+
</button>
322+
</div>
323+
)}
324+
</div>
272325
<span style={{ fontFamily: SERIF, fontSize: 16, fontWeight: 500, letterSpacing: '0.01em' }}>Travel in Time</span>
273326
<span style={{ fontFamily: MONO, fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase', color: ink3Col }}>Library</span>
274327

pwa/src/components/Reader.tsx

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const copyTextToClipboard = async (text: string) => {
107107

108108
const TTS_HIGHLIGHT_ID = 'tit-tts-progress'
109109
const TTS_HIGHLIGHT_STYLE_ID = 'tit-tts-progress-style'
110+
const TTS_HIGHLIGHT_OVERLAY_ID = 'tit-tts-progress-overlay'
110111
const TTS_HIGHLIGHT_INTERVAL = 80
111112
const TTS_USER_INPUT_GRACE = 180
112113
const TTS_HIGHLIGHT_LENGTH = 4
@@ -143,6 +144,7 @@ const ensureTTSHighlightStyle = (doc: Document) => {
143144
const clearTTSHighlight = (doc: Document | null | undefined) => {
144145
const highlights = (doc?.defaultView as any)?.CSS?.highlights
145146
highlights?.delete?.(TTS_HIGHLIGHT_ID)
147+
doc?.getElementById(TTS_HIGHLIGHT_OVERLAY_ID)?.remove()
146148
}
147149

148150
const clearTTSHighlights = (docs: Iterable<Document | null | undefined>) => {
@@ -279,6 +281,57 @@ const createRangeFromTextOffset = (doc: Document, start: number, length = TTS_HI
279281
return range
280282
}
281283

284+
285+
const paintTTSHighlightOverlay = (doc: Document, range: Range) => {
286+
const body = doc.body
287+
if (!body) return false
288+
289+
let overlay = doc.getElementById(TTS_HIGHLIGHT_OVERLAY_ID)
290+
if (!overlay) {
291+
overlay = doc.createElement('div')
292+
overlay.id = TTS_HIGHLIGHT_OVERLAY_ID
293+
Object.assign(overlay.style, {
294+
position: 'fixed',
295+
inset: '0',
296+
pointerEvents: 'none',
297+
zIndex: '2147483647',
298+
overflow: 'hidden',
299+
contain: 'layout style paint',
300+
})
301+
body.appendChild(overlay)
302+
}
303+
304+
const viewportWidth = doc.documentElement.clientWidth || doc.defaultView?.innerWidth || 0
305+
const viewportHeight = doc.documentElement.clientHeight || doc.defaultView?.innerHeight || 0
306+
const rects = Array.from(range.getClientRects())
307+
.filter((rect) =>
308+
rect.width > 0 &&
309+
rect.height > 0 &&
310+
rect.bottom >= 0 &&
311+
rect.top <= viewportHeight &&
312+
rect.right >= 0 &&
313+
rect.left <= viewportWidth
314+
)
315+
316+
overlay.replaceChildren()
317+
for (const rect of rects) {
318+
const mark = doc.createElement('div')
319+
Object.assign(mark.style, {
320+
position: 'fixed',
321+
left: `${Math.max(0, rect.left)}px`,
322+
top: `${Math.max(0, rect.top)}px`,
323+
width: `${Math.max(1, Math.min(rect.width, viewportWidth - Math.max(0, rect.left)))}px`,
324+
height: `${Math.max(1, Math.min(rect.height, viewportHeight - Math.max(0, rect.top)))}px`,
325+
background: 'rgba(245, 158, 11, 0.32)',
326+
borderRadius: '2px',
327+
pointerEvents: 'none',
328+
})
329+
overlay.appendChild(mark)
330+
}
331+
332+
return rects.length > 0
333+
}
334+
282335
const getTTSRangeViewportState = (doc: Document, range: Range | null): TTSRangeViewportState => {
283336
const viewportWidth = doc.documentElement.clientWidth || doc.defaultView?.innerWidth || 0
284337
const viewportHeight = doc.documentElement.clientHeight || doc.defaultView?.innerHeight || 0
@@ -421,6 +474,7 @@ interface Props {
421474
darkMode: boolean
422475
onToggleDark: () => void
423476
onUpdateProgress?: (pct: number) => void
477+
onApplyLatestVersion: () => void | Promise<void>
424478
}
425479

426480
const SERIF = '"Source Serif 4", "Noto Serif TC", Georgia, serif'
@@ -630,7 +684,7 @@ const patchIframeViewPrototype = (proto: Record<string, unknown>) => {
630684
}
631685
}
632686

633-
const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMode, onToggleDark, onUpdateProgress }: Props) => {
687+
const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMode, onToggleDark, onUpdateProgress, onApplyLatestVersion }: Props) => {
634688
const viewerRef = useRef<HTMLDivElement>(null)
635689
const bookRef = useRef<Book | null>(null)
636690
const renditionRef = useRef<Rendition | null>(null)
@@ -1442,6 +1496,31 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
14421496
}, 50)
14431497
}
14441498

1499+
1500+
const getVisibleContentDocument = (): Document | null => {
1501+
const viewer = viewerRef.current
1502+
if (!viewer) return null
1503+
1504+
const iframe = viewer.querySelector('iframe') as HTMLIFrameElement | null
1505+
if (iframe?.contentDocument?.body) return iframe.contentDocument
1506+
1507+
const viewerRect = viewer.getBoundingClientRect()
1508+
let best: { doc: Document; area: number } | null = null
1509+
for (const frame of Array.from(viewer.querySelectorAll('iframe')) as HTMLIFrameElement[]) {
1510+
try {
1511+
const doc = frame.contentDocument
1512+
if (!doc?.body) continue
1513+
const rect = frame.getBoundingClientRect()
1514+
const width = Math.max(0, Math.min(rect.right, viewerRect.right) - Math.max(rect.left, viewerRect.left))
1515+
const height = Math.max(0, Math.min(rect.bottom, viewerRect.bottom) - Math.max(rect.top, viewerRect.top))
1516+
const area = width * height
1517+
if (area > 0 && (!best || area > best.area)) best = { doc, area }
1518+
} catch { /* ignore cross-origin iframe */ }
1519+
}
1520+
1521+
return best?.doc ?? currentDocRef.current
1522+
}
1523+
14451524
// 字體家族(獨立,不影響其他設定)
14461525
useEffect(() => {
14471526
if (!ready) return
@@ -1929,7 +2008,7 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
19292008

19302009
const updateTTSHighlight = (absoluteOffset: number, allowAutoFollow = true, source: TTSProgressSource = 'boundary') => {
19312010
ttsLastAbsoluteOffsetRef.current = absoluteOffset
1932-
const doc = currentDocRef.current
2011+
const doc = getVisibleContentDocument() ?? currentDocRef.current
19332012
if (!doc) {
19342013
if (DEBUG_TTS_FOLLOW) console.log('[TTS:follow] skip highlight: no current doc', { absoluteOffset })
19352014
return
@@ -1955,16 +2034,13 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
19552034
return
19562035
}
19572036

1958-
const highlights = (doc.defaultView as any)?.CSS?.highlights
1959-
const HighlightCtor = (doc.defaultView as any)?.Highlight
1960-
if (highlights && HighlightCtor) {
1961-
clearOtherTTSHighlights(doc)
1962-
highlights.delete(TTS_HIGHLIGHT_ID)
1963-
highlights.set(TTS_HIGHLIGHT_ID, new HighlightCtor(range))
2037+
clearOtherTTSHighlights(doc)
2038+
const painted = paintTTSHighlightOverlay(doc, range)
2039+
if (painted) {
19642040
ttsHighlightedDocRef.current = doc
19652041
} else {
1966-
clearAllTTSHighlights()
1967-
if (DEBUG_TTS_FOLLOW) console.log('[TTS:follow] Custom Highlight API unavailable, only auto-follow by progress', { absoluteOffset })
2042+
clearTTSHighlight(doc)
2043+
if (DEBUG_TTS_FOLLOW) console.log('[TTS:follow] overlay 無可用 rect,僅自動跟隨進度', { absoluteOffset })
19682044
}
19692045
followTTSRange(range, doc, absoluteOffset, allowAutoFollow, source)
19702046
}
@@ -2117,10 +2193,10 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
21172193

21182194
const speakCurrentPage = () => {
21192195
if (!viewerRef.current) return
2120-
const iframe = viewerRef.current.querySelector('iframe')
2121-
if (!iframe?.contentDocument?.body) return
2196+
const visibleDoc = getVisibleContentDocument()
2197+
if (!visibleDoc?.body) return
21222198

2123-
const textIndex = getTextIndex(iframe.contentDocument)
2199+
const textIndex = getTextIndex(visibleDoc)
21242200
const fullText = textIndex?.text ?? ''
21252201
if (!fullText) return
21262202

@@ -2196,11 +2272,11 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
21962272
}
21972273
ttsVisibleSpineIndexRef.current = currentSpineIdx
21982274
ttsVisibleStartOffsetRef.current = startOffset
2199-
ttsCurrentPageStartOffsetRef.current = measureCurrentPageStartOffset(iframe.contentDocument, loc)
2275+
ttsCurrentPageStartOffsetRef.current = measureCurrentPageStartOffset(visibleDoc, loc)
22002276
ttsLastAbsoluteOffsetRef.current = startOffset
22012277
ttsChapterTextLengthRef.current = fullText.length
22022278
ttsChapterPageTotalRef.current = totalPages
2203-
ttsVisiblePageEndOffsetRef.current = measureCurrentPageEndOffset(iframe.contentDocument, loc)
2279+
ttsVisiblePageEndOffsetRef.current = measureCurrentPageEndOffset(visibleDoc, loc)
22042280
updateTTSHighlight(startOffset, false)
22052281
console.log('[TTS] speakCurrentPage 開始', {
22062282
currentSpineIdx,
@@ -2459,6 +2535,7 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
24592535
isBookmarked={isBookmarked}
24602536
onToggleBookmark={handleToggleBookmark}
24612537
onToggleBookmarkList={() => togglePanel('bookmarks')}
2538+
onApplyLatestVersion={onApplyLatestVersion}
24622539
/>
24632540
<div className="flex flex-1 overflow-hidden">
24642541
<div

0 commit comments

Comments
 (0)