diff --git a/src/App.tsx b/src/App.tsx index cb7b954..ee3d559 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,10 +5,14 @@ import { SettingsDrawer } from './components/SettingsDrawer'; import { calculateWpm } from './core/timing'; import { tokenizeEnglishText } from './core/tokenizer'; import { loadSavedState, saveState } from './db'; +import { getCachedTokens, saveCachedTokens } from './db/bookCache'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useReaderEngine } from './hooks/useReaderEngine'; import type { FontSize, FocusState } from './types'; +const BOOK_FILENAME = 'The Well of Ascension.txt'; +const BOOK_TITLE = BOOK_FILENAME.replace(/\.[^/.]+$/, ''); + export default function App() { const [saved] = useState(() => loadSavedState()); const [flashesPerMinute, setFlashesPerMinute] = useState( @@ -17,31 +21,61 @@ export default function App() { const [wordsPerChunk, setWordsPerChunk] = useState(saved.wordsPerChunk); const [fontSize, setFontSize] = useState(saved.fontSize); const [words, setWords] = useState([]); - const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isUserActive, setIsUserActive] = useState(true); const inactivityTimerRef = useRef(null); - // Load built-in book text + // Fast-path IndexedDB restore with silent background verification useEffect(() => { - async function loadBook() { - try { - const res = await fetch( - encodeURI('/books/The Well of Ascension.txt') - ); - if (!res.ok) { - throw new Error(`Failed to load book: ${res.statusText}`); + let isCancelled = false; + + async function loadBookData() { + // 1. Instantly read parsed tokens from IndexedDB + const cached = await getCachedTokens(BOOK_FILENAME); + if (!isCancelled && cached && cached.tokens.length > 0) { + setWords(cached.tokens); + } + + // 2. Background silent verification without blocking main thread + const runSilentSync = async () => { + try { + const res = await fetch(encodeURI(`/books/${BOOK_FILENAME}`)); + if (!res.ok || isCancelled) return; + const text = await res.text(); + if (isCancelled) return; + + // If content length identical to cache, no need to re-tokenize + if (cached && cached.textLength === text.length) { + return; + } + + const parsed = tokenizeEnglishText(text); + if (!isCancelled) { + setWords(parsed); + } + await saveCachedTokens(BOOK_FILENAME, parsed, text.length); + } catch (err) { + console.warn('Silent sync error:', err); } - const text = await res.text(); - const parsedWords = tokenizeEnglishText(text); - setWords(parsedWords); - } catch (err) { - console.error('Failed to load book:', err); - } finally { - setIsLoading(false); + }; + + if (cached && cached.tokens.length > 0) { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => runSilentSync(), { timeout: 3000 }); + } else { + setTimeout(runSilentSync, 1000); + } + } else { + // Cold start (no cache): fetch immediately + await runSilentSync(); } } - loadBook(); + + loadBookData(); + + return () => { + isCancelled = true; + }; }, []); // Sync state changes to localStorage @@ -67,6 +101,11 @@ export default function App() { const fpmRef = useRef(flashesPerMinute); const chunkRef = useRef(wordsPerChunk); const fontSizeRef = useRef(fontSize); + const wordsRef = useRef(words); + + useEffect(() => { + wordsRef.current = words; + }, [words]); useEffect(() => { fpmRef.current = flashesPerMinute; @@ -80,19 +119,31 @@ export default function App() { fontSizeRef.current = fontSize; }, [fontSize]); - const persistCurrentState = useCallback((targetIndex?: number) => { - const idx = - typeof targetIndex === 'number' - ? targetIndex - : lastSavedIndexRef.current; - lastSavedIndexRef.current = idx; - saveState({ - wordIndex: idx, - flashesPerMinute: fpmRef.current, - wordsPerChunk: chunkRef.current, - fontSize: fontSizeRef.current, - }); - }, []); + const persistCurrentState = useCallback( + (targetIndex?: number) => { + const idx = + typeof targetIndex === 'number' + ? targetIndex + : lastSavedIndexRef.current; + lastSavedIndexRef.current = idx; + + const curWords = wordsRef.current; + let chunkStr = ''; + if (curWords.length > 0) { + chunkStr = curWords.slice(idx, idx + chunkRef.current).join(' '); + } + + saveState({ + wordIndex: idx, + flashesPerMinute: fpmRef.current, + wordsPerChunk: chunkRef.current, + fontSize: fontSizeRef.current, + savedChunkText: chunkStr, + totalWords: curWords.length || saved.totalWords || 0, + }); + }, + [saved.totalWords] + ); // Autosave periodically every 3 seconds useEffect(() => { @@ -204,17 +255,74 @@ export default function App() { isDrawerOpen: isSettingsOpen, }); + // Zero-millisecond instantaneous display metrics + const effectiveTotal = + totalWords > 0 ? totalWords : (saved.totalWords || 0); + + const effectiveIndex = currentIndex; + const progressPercent = - totalWords > 0 - ? Math.min(100, Math.max(0, (currentIndex / (totalWords - 1 || 1)) * 100)) + effectiveTotal > 0 + ? Math.min( + 100, + Math.max(0, (effectiveIndex / (effectiveTotal - 1 || 1)) * 100) + ) : 0; + const activeChunkText = currentChunkText || saved.savedChunkText || ''; + const wpm = calculateWpm(flashesPerMinute, wordsPerChunk); const hudOpacity = isPlaying && !isUserActive ? 'opacity-0 pointer-events-none' : 'opacity-100 pointer-events-auto'; + // Smooth, jitter-free fixed height progress bar interactions + const progressTrackRef = useRef(null); + const isDraggingRef = useRef(false); + + const seekByClientX = useCallback( + (clientX: number) => { + if (!progressTrackRef.current) return; + const count = totalWords > 0 ? totalWords : (saved.totalWords || 0); + if (count <= 0) return; + const rect = progressTrackRef.current.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const target = Math.round(ratio * (count - 1)); + jumpTo(target); + }, + [totalWords, saved.totalWords, jumpTo] + ); + + const handlePointerDown = (e: React.PointerEvent) => { + isDraggingRef.current = true; + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch {} + seekByClientX(e.clientX); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDraggingRef.current) return; + seekByClientX(e.clientX); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!isDraggingRef.current) return; + isDraggingRef.current = false; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch {} + }; + + const handlePointerCancel = (e: React.PointerEvent) => { + isDraggingRef.current = false; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch {} + }; + return (
- The Well of Ascension + {BOOK_TITLE} {/* Quiet Gear Button */} @@ -239,53 +347,53 @@ export default function App() { - {/* Main RSVPs Central Stream */} + {/* Main RSVPs Central Stream (Instant 0ms display, zero loading mask) */}
- {isLoading ? ( -
-
- - Loading Focus... - -
- ) : ( - { - resetInactivity(); - togglePlay(); - }} - /> - )} + { + resetInactivity(); + togglePlay(); + }} + />
- {/* Bottom Ultra-Thin Progress & Speed HUD */} + {/* Bottom Fixed-Height Progress & Speed HUD */}
- {/* Ultra-thin progress line */} + {/* Comfortable 32px hit-box progress bar with fixed 8px inner track */}
{ - const rect = e.currentTarget.getBoundingClientRect(); - const clickPos = (e.clientX - rect.left) / rect.width; - const target = Math.round(clickPos * (totalWords - 1)); - jumpTo(target); - }} - className="relative w-full h-1 bg-zinc-800/80 hover:h-2 rounded-full cursor-pointer transition-all group overflow-hidden" - title="点击调整进度" + ref={progressTrackRef} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerCancel} + className="relative w-full h-8 flex items-center cursor-pointer select-none touch-none group" + title="点击或拖拽跳转进度" > -
+ {/* Inner track: strictly fixed 8px height, rounded-full, zero hover jitter */} +
+ {/* Filled progress bar */} +
+ + {/* High-contrast current position thumb marker */} +
+
{/* Status text */}
- {(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '} + {(effectiveIndex + 1).toLocaleString()} / {effectiveTotal.toLocaleString()}{' '} ({progressPercent.toFixed(1)}%) @@ -308,12 +416,9 @@ export default function App() { onChangeWordsPerChunk={handleWordsPerChunkChange} fontSize={fontSize} onChangeFontSize={handleFontSizeChange} - wordIndex={currentIndex} - totalWords={totalWords} - onJumpTo={jumpTo} onResetProgress={() => { restart(); - saveState({ wordIndex: 0 }); + persistCurrentState(0); }} />
diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index d94f379..e3f4a1a 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -12,9 +12,6 @@ interface SettingsDrawerProps { onChangeWordsPerChunk: (chunk: number) => void; fontSize: FontSize; onChangeFontSize: (size: FontSize) => void; - wordIndex: number; - totalWords: number; - onJumpTo: (index: number) => void; onResetProgress: () => void; } @@ -36,18 +33,11 @@ export const SettingsDrawer: FC = ({ onChangeWordsPerChunk, fontSize, onChangeFontSize, - wordIndex, - totalWords, - onJumpTo, onResetProgress, }) => { if (!isOpen) return null; const wpm = calculateWpm(flashesPerMinute, wordsPerChunk); - const progressPercent = - totalWords > 0 - ? Math.min(100, Math.max(0, (wordIndex / (totalWords - 1 || 1)) * 100)) - : 0; return (
@@ -180,31 +170,19 @@ export const SettingsDrawer: FC = ({
- {/* Progress Jump Scrubber */} -
-
- Progress - - {wordIndex.toLocaleString()} / {totalWords.toLocaleString()} ({progressPercent.toFixed(1)}%) - -
- onJumpTo(Number(e.target.value))} - className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-emerald-500 focus:outline-none" - /> -
- -
+ {/* Reading Progress Action */} +
+ + Reading Progress + +
{/* Shortcuts Reference */} @@ -236,11 +214,6 @@ export const SettingsDrawer: FC = ({
- - {/* Footer info */} -
- The Well of Ascension · Brandon Sanderson -
); diff --git a/src/db/bookCache.ts b/src/db/bookCache.ts new file mode 100644 index 0000000..39fe4ab --- /dev/null +++ b/src/db/bookCache.ts @@ -0,0 +1,90 @@ +const DB_NAME = 'focus_tokens_db'; +const STORE_NAME = 'tokens_cache'; +const DB_VERSION = 1; + +interface CachedBookRecord { + bookKey: string; + tokens: string[]; + textLength: number; + updatedAt: number; +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + return reject(new Error('IndexedDB not supported')); + } + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'bookKey' }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** + * Retrieve cached token array and metadata from IndexedDB. + */ +export async function getCachedTokens( + bookKey: string +): Promise<{ tokens: string[]; textLength: number } | null> { + try { + const db = await openDatabase(); + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(bookKey); + + req.onsuccess = () => { + const res = req.result as CachedBookRecord | undefined; + if (res && Array.isArray(res.tokens) && res.tokens.length > 0) { + resolve({ + tokens: res.tokens, + textLength: typeof res.textLength === 'number' ? res.textLength : 0, + }); + } else { + resolve(null); + } + }; + + req.onerror = () => resolve(null); + }); + } catch (err) { + console.warn('Failed to read tokens from IndexedDB:', err); + return null; + } +} + +/** + * Persist parsed tokens and content length into IndexedDB. + */ +export async function saveCachedTokens( + bookKey: string, + tokens: string[], + textLength: number +): Promise { + try { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const record: CachedBookRecord = { + bookKey, + tokens, + textLength, + updatedAt: Date.now(), + }; + const req = store.put(record); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch (err) { + console.warn('Failed to save tokens to IndexedDB:', err); + } +} diff --git a/src/db/index.ts b/src/db/index.ts index 0b2aa9a..60fc007 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -7,6 +7,8 @@ export const DEFAULT_STATE: FocusState = { wordsPerChunk: 2, flashesPerMinute: 250, fontSize: 'xl', + savedChunkText: '', + totalWords: 0, }; export function loadSavedState(): FocusState { @@ -36,6 +38,10 @@ export function loadSavedState(): FocusState { ) ), fontSize: parsed.fontSize || 'xl', + savedChunkText: + typeof parsed.savedChunkText === 'string' ? parsed.savedChunkText : '', + totalWords: + typeof parsed.totalWords === 'number' ? parsed.totalWords : 0, }; } } catch (err) { diff --git a/src/types/index.ts b/src/types/index.ts index d2f8488..61f7693 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,4 +11,6 @@ export interface FocusState { wordsPerChunk: number; flashesPerMinute: number; fontSize: FontSize; + savedChunkText?: string; + totalWords?: number; }