Skip to content

Commit e5e5ac6

Browse files
committed
Fix PWA TTS highlight refresh handling
1 parent 4a43e06 commit e5e5ac6

5 files changed

Lines changed: 245 additions & 11 deletions

File tree

pwa/src/App.tsx

Lines changed: 25 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 && (

pwa/src/components/Library.tsx

Lines changed: 59 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,47 @@ 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, color: logoMenuOpen ? inkCol : paperBg,
289+
display: 'flex', alignItems: 'center', justifyContent: 'center',
290+
fontFamily: SERIF, fontStyle: 'italic', fontWeight: 700, fontSize: 14,
291+
cursor: 'pointer',
292+
}}
293+
aria-label="Travel in Time 選單"
294+
>
295+
T
296+
</button>
297+
{logoMenuOpen && (
298+
<div
299+
style={{
300+
position: 'absolute', left: 0, top: 32, zIndex: 60,
301+
width: 178, padding: 6, borderRadius: 8,
302+
background: paperBg, border: `1px solid ${borderCol}`,
303+
boxShadow: '0 14px 32px -14px rgba(0,0,0,0.45)',
304+
}}
305+
>
306+
<button
307+
onClick={handleApplyLatestVersion}
308+
disabled={applyingUpdate}
309+
style={{
310+
width: '100%', minHeight: 34, borderRadius: 6, padding: '8px 10px',
311+
display: 'flex', alignItems: 'center', gap: 8,
312+
color: applyingUpdate ? ink3Col : inkCol,
313+
fontFamily: 'inherit', fontSize: 13, textAlign: 'left',
314+
cursor: applyingUpdate ? 'default' : 'pointer',
315+
opacity: applyingUpdate ? 0.7 : 1,
316+
}}
317+
>
318+
<IconRefresh />
319+
<span>{applyingUpdate ? '更新中…' : '套用最新版'}</span>
320+
</button>
321+
</div>
322+
)}
323+
</div>
272324
<span style={{ fontFamily: SERIF, fontSize: 16, fontWeight: 500, letterSpacing: '0.01em' }}>Travel in Time</span>
273325
<span style={{ fontFamily: MONO, fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase', color: ink3Col }}>Library</span>
274326

pwa/src/components/Reader.tsx

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,6 +1442,36 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
14421442
}, 50)
14431443
}
14441444

1445+
const getTTSRenditionViews = (): unknown[] => {
1446+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1447+
const rawViews = (renditionRef.current as any)?.views?.()
1448+
if (!rawViews) return []
1449+
if (Array.isArray(rawViews)) return rawViews
1450+
if (typeof rawViews.toArray === 'function') return rawViews.toArray()
1451+
if (Array.isArray(rawViews._views)) return rawViews._views
1452+
if (Array.isArray(rawViews.views)) return rawViews.views
1453+
1454+
const collected: unknown[] = []
1455+
if (typeof rawViews.forEach === 'function') {
1456+
rawViews.forEach((view: unknown) => collected.push(view))
1457+
}
1458+
return collected
1459+
}
1460+
1461+
const getTTSViewDocument = (visibleSpineIdx?: number): Document | null => {
1462+
if (visibleSpineIdx === undefined) return currentDocRef.current
1463+
1464+
for (const view of getTTSRenditionViews()) {
1465+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1466+
const v = view as any
1467+
const spineIndex = v?.index ?? v?.section?.index
1468+
const doc = v?.document as Document | undefined
1469+
if (spineIndex === visibleSpineIdx && doc?.body) return doc
1470+
}
1471+
1472+
return currentDocRef.current
1473+
}
1474+
14451475
// 字體家族(獨立,不影響其他設定)
14461476
useEffect(() => {
14471477
if (!ready) return
@@ -1929,13 +1959,13 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
19291959

19301960
const updateTTSHighlight = (absoluteOffset: number, allowAutoFollow = true, source: TTSProgressSource = 'boundary') => {
19311961
ttsLastAbsoluteOffsetRef.current = absoluteOffset
1932-
const doc = currentDocRef.current
1962+
const loc = (renditionRef.current as any)?.currentLocation?.()
1963+
const visibleSpineIdx = loc?.start?.index as number | undefined
1964+
const doc = getTTSViewDocument(visibleSpineIdx)
19331965
if (!doc) {
19341966
if (DEBUG_TTS_FOLLOW) console.log('[TTS:follow] skip highlight: no current doc', { absoluteOffset })
19351967
return
19361968
}
1937-
const loc = (renditionRef.current as any)?.currentLocation?.()
1938-
const visibleSpineIdx = loc?.start?.index as number | undefined
19391969
if (visibleSpineIdx !== undefined && ttsVisibleSpineIndexRef.current !== visibleSpineIdx) {
19401970
if (DEBUG_TTS_FOLLOW) console.log('[TTS:follow] skip highlight: visible spine 不符合朗讀 spine', {
19411971
absoluteOffset,
@@ -1958,7 +1988,7 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
19581988
const highlights = (doc.defaultView as any)?.CSS?.highlights
19591989
const HighlightCtor = (doc.defaultView as any)?.Highlight
19601990
if (highlights && HighlightCtor) {
1961-
clearOtherTTSHighlights(doc)
1991+
clearAllTTSHighlights()
19621992
highlights.delete(TTS_HIGHLIGHT_ID)
19631993
highlights.set(TTS_HIGHLIGHT_ID, new HighlightCtor(range))
19641994
ttsHighlightedDocRef.current = doc
@@ -2434,6 +2464,36 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
24342464
console.log('[TTS] handleTTSReset: 已重置朗讀進度')
24352465
}
24362466

2467+
const handleApplyLatestVersion = async () => {
2468+
stopRef.current()
2469+
clearSleepTimer()
2470+
loadingAbortRef.current.aborted = true
2471+
cancelScheduledTTSHighlight()
2472+
clearAllTTSHighlights()
2473+
2474+
try {
2475+
if ('serviceWorker' in navigator) {
2476+
const registrations = await navigator.serviceWorker.getRegistrations()
2477+
await Promise.all(registrations.map((registration) => registration.unregister()))
2478+
}
2479+
} catch (err) {
2480+
console.warn('[PWA] Service Worker 清除失敗:', err)
2481+
}
2482+
2483+
try {
2484+
if ('caches' in window) {
2485+
const keys = await caches.keys()
2486+
await Promise.all(keys.map((key) => caches.delete(key)))
2487+
}
2488+
} catch (err) {
2489+
console.warn('[PWA] Cache Storage 清除失敗:', err)
2490+
}
2491+
2492+
const url = new URL(window.location.href)
2493+
url.searchParams.set('refresh', String(Date.now()))
2494+
window.location.replace(url.toString())
2495+
}
2496+
24372497

24382498
return (
24392499
<div
@@ -2459,6 +2519,7 @@ const Reader = ({ bookPath, bookId, bookRecord, getCoverDataUrl, onBack, darkMod
24592519
isBookmarked={isBookmarked}
24602520
onToggleBookmark={handleToggleBookmark}
24612521
onToggleBookmarkList={() => togglePanel('bookmarks')}
2522+
onApplyLatestVersion={handleApplyLatestVersion}
24622523
/>
24632524
<div className="flex flex-1 overflow-hidden">
24642525
<div

pwa/src/components/Toolbar.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
const IconRefresh = () => (
4+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
5+
<path d="M21 12a9 9 0 0 1-15.5 6.2" />
6+
<path d="M3 12A9 9 0 0 1 18.5 5.8" />
7+
<path d="M18 2v5h-5" />
8+
<path d="M6 22v-5h5" />
9+
</svg>
10+
)
11+
112
const IconSettings = () => (
213
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
314
<circle cx="12" cy="12" r="3" />
@@ -107,6 +118,7 @@ interface Props {
107118
isBookmarked: boolean
108119
onToggleBookmark: () => void
109120
onToggleBookmarkList: () => void
121+
onApplyLatestVersion: () => void | Promise<void>
110122
}
111123

112124
const SERIF = '"Source Serif 4", "Noto Serif TC", Georgia, serif'
@@ -128,7 +140,12 @@ const Toolbar = ({
128140
isBookmarked,
129141
onToggleBookmark,
130142
onToggleBookmarkList,
143+
onApplyLatestVersion,
131144
}: Props) => {
145+
const [logoMenuOpen, setLogoMenuOpen] = useState(false)
146+
const [applyingUpdate, setApplyingUpdate] = useState(false)
147+
const logoMenuRef = useRef<HTMLDivElement>(null)
148+
132149
const paperBg = darkMode ? '#1a1816' : '#f9f7f2'
133150
const borderCol = darkMode ? '#3a3430' : '#e4ddd0'
134151
const inkCol = darkMode ? '#e8e0d4' : '#2a2420'
@@ -141,6 +158,22 @@ const Toolbar = ({
141158
? Math.round(pageInfo.page / pageInfo.total * 100)
142159
: null
143160

161+
useEffect(() => {
162+
if (!logoMenuOpen) return
163+
const onPointerDown = (event: PointerEvent) => {
164+
if (logoMenuRef.current?.contains(event.target as Node)) return
165+
setLogoMenuOpen(false)
166+
}
167+
document.addEventListener('pointerdown', onPointerDown)
168+
return () => document.removeEventListener('pointerdown', onPointerDown)
169+
}, [logoMenuOpen])
170+
171+
const handleApplyLatestVersion = async () => {
172+
if (applyingUpdate) return
173+
setApplyingUpdate(true)
174+
await onApplyLatestVersion()
175+
}
176+
144177
const btn = (
145178
isActive: boolean,
146179
onClick: () => void,
@@ -177,6 +210,55 @@ const Toolbar = ({
177210
{/* 左:返回 + 書名作者(固定寬度區塊) */}
178211
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
179212
{btn(false, onBack, <IconBack />, '返回書庫', { color: inkCol })}
213+
<div ref={logoMenuRef} style={{ position: 'relative', flexShrink: 0 }}>
214+
<button
215+
onClick={(e) => { e.stopPropagation(); setLogoMenuOpen((open) => !open) }}
216+
onTouchEnd={(e) => { e.preventDefault(); e.stopPropagation(); setLogoMenuOpen((open) => !open) }}
217+
aria-label="Travel in Time 選單"
218+
title="Travel in Time"
219+
style={{
220+
width: 34, height: 34, borderRadius: 8,
221+
background: logoMenuOpen ? accentBg : inkCol,
222+
color: logoMenuOpen ? accentCol : paperBg,
223+
display: 'flex', alignItems: 'center', justifyContent: 'center',
224+
fontFamily: SERIF, fontStyle: 'italic', fontWeight: 700, fontSize: 17,
225+
cursor: 'pointer', touchAction: 'manipulation',
226+
}}
227+
>
228+
T
229+
</button>
230+
{logoMenuOpen && (
231+
<div
232+
style={{
233+
position: 'absolute', left: 0, top: 40, zIndex: 60,
234+
width: 178, padding: 6, borderRadius: 8,
235+
background: paperBg, border: `1px solid ${borderCol}`,
236+
boxShadow: '0 14px 32px -14px rgba(0,0,0,0.45)',
237+
}}
238+
onClick={(e) => e.stopPropagation()}
239+
onTouchStart={(e) => e.stopPropagation()}
240+
>
241+
<button
242+
onClick={handleApplyLatestVersion}
243+
disabled={applyingUpdate}
244+
style={{
245+
width: '100%', minHeight: 34, borderRadius: 6, padding: '8px 10px',
246+
display: 'flex', alignItems: 'center', gap: 8,
247+
color: applyingUpdate ? ink3Col : inkCol,
248+
background: 'transparent',
249+
fontFamily: 'inherit', fontSize: 13, textAlign: 'left',
250+
cursor: applyingUpdate ? 'default' : 'pointer',
251+
opacity: applyingUpdate ? 0.7 : 1,
252+
}}
253+
onMouseEnter={(e) => { if (!applyingUpdate) e.currentTarget.style.background = hoverBg }}
254+
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
255+
>
256+
<IconRefresh />
257+
<span>{applyingUpdate ? '更新中…' : '套用最新版'}</span>
258+
</button>
259+
</div>
260+
)}
261+
</div>
180262
{bookTitle && (
181263
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: 150 }}>
182264
<div style={{ fontFamily: SERIF, fontSize: 13, color: inkCol, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}>

pwa/src/main.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ import ReactDOM from 'react-dom/client'
33
import App from './App'
44
import './index.css'
55

6+
if ((import.meta as any).env?.DEV) {
7+
if ('serviceWorker' in navigator) {
8+
navigator.serviceWorker.getRegistrations()
9+
.then((registrations) => registrations.forEach((registration) => registration.unregister()))
10+
.catch(() => {})
11+
}
12+
13+
if ('caches' in window) {
14+
caches.keys()
15+
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
16+
.catch(() => {})
17+
}
18+
}
19+
620
ReactDOM.createRoot(document.getElementById('root')!).render(
721
<React.StrictMode>
822
<App />

0 commit comments

Comments
 (0)