perf(ux): instant 0ms reader resume, fixed height progress scrubber, and settings drawer slim down

- Persist parsed book tokens in IndexedDB with background silent verification
- Eliminate loading mask completely with instant 0ms state restoration
- Upgrade progress bar to fixed 32px touch/click target with 8px track and pointer scrub
- Remove duplicate progress slider and author footer from settings drawer
- Dynamically display book title without author prefix

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-20 12:48:06 +08:00
parent 966a58413a
commit 2c9dc6d2ee
5 changed files with 287 additions and 111 deletions

View File

@@ -5,10 +5,14 @@ import { SettingsDrawer } from './components/SettingsDrawer';
import { calculateWpm } from './core/timing'; import { calculateWpm } from './core/timing';
import { tokenizeEnglishText } from './core/tokenizer'; import { tokenizeEnglishText } from './core/tokenizer';
import { loadSavedState, saveState } from './db'; import { loadSavedState, saveState } from './db';
import { getCachedTokens, saveCachedTokens } from './db/bookCache';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useReaderEngine } from './hooks/useReaderEngine'; import { useReaderEngine } from './hooks/useReaderEngine';
import type { FontSize, FocusState } from './types'; import type { FontSize, FocusState } from './types';
const BOOK_FILENAME = 'The Well of Ascension.txt';
const BOOK_TITLE = BOOK_FILENAME.replace(/\.[^/.]+$/, '');
export default function App() { export default function App() {
const [saved] = useState<FocusState>(() => loadSavedState()); const [saved] = useState<FocusState>(() => loadSavedState());
const [flashesPerMinute, setFlashesPerMinute] = useState<number>( const [flashesPerMinute, setFlashesPerMinute] = useState<number>(
@@ -17,31 +21,61 @@ export default function App() {
const [wordsPerChunk, setWordsPerChunk] = useState<number>(saved.wordsPerChunk); const [wordsPerChunk, setWordsPerChunk] = useState<number>(saved.wordsPerChunk);
const [fontSize, setFontSize] = useState<FontSize>(saved.fontSize); const [fontSize, setFontSize] = useState<FontSize>(saved.fontSize);
const [words, setWords] = useState<string[]>([]); const [words, setWords] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false); const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
const [isUserActive, setIsUserActive] = useState<boolean>(true); const [isUserActive, setIsUserActive] = useState<boolean>(true);
const inactivityTimerRef = useRef<number | null>(null); const inactivityTimerRef = useRef<number | null>(null);
// Load built-in book text // Fast-path IndexedDB restore with silent background verification
useEffect(() => { useEffect(() => {
async function loadBook() { 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 { try {
const res = await fetch( const res = await fetch(encodeURI(`/books/${BOOK_FILENAME}`));
encodeURI('/books/The Well of Ascension.txt') if (!res.ok || isCancelled) return;
);
if (!res.ok) {
throw new Error(`Failed to load book: ${res.statusText}`);
}
const text = await res.text(); const text = await res.text();
const parsedWords = tokenizeEnglishText(text); if (isCancelled) return;
setWords(parsedWords);
// 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) { } catch (err) {
console.error('Failed to load book:', err); console.warn('Silent sync error:', 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 // Sync state changes to localStorage
@@ -67,6 +101,11 @@ export default function App() {
const fpmRef = useRef<number>(flashesPerMinute); const fpmRef = useRef<number>(flashesPerMinute);
const chunkRef = useRef<number>(wordsPerChunk); const chunkRef = useRef<number>(wordsPerChunk);
const fontSizeRef = useRef<FontSize>(fontSize); const fontSizeRef = useRef<FontSize>(fontSize);
const wordsRef = useRef<string[]>(words);
useEffect(() => {
wordsRef.current = words;
}, [words]);
useEffect(() => { useEffect(() => {
fpmRef.current = flashesPerMinute; fpmRef.current = flashesPerMinute;
@@ -80,19 +119,31 @@ export default function App() {
fontSizeRef.current = fontSize; fontSizeRef.current = fontSize;
}, [fontSize]); }, [fontSize]);
const persistCurrentState = useCallback((targetIndex?: number) => { const persistCurrentState = useCallback(
(targetIndex?: number) => {
const idx = const idx =
typeof targetIndex === 'number' typeof targetIndex === 'number'
? targetIndex ? targetIndex
: lastSavedIndexRef.current; : lastSavedIndexRef.current;
lastSavedIndexRef.current = idx; lastSavedIndexRef.current = idx;
const curWords = wordsRef.current;
let chunkStr = '';
if (curWords.length > 0) {
chunkStr = curWords.slice(idx, idx + chunkRef.current).join(' ');
}
saveState({ saveState({
wordIndex: idx, wordIndex: idx,
flashesPerMinute: fpmRef.current, flashesPerMinute: fpmRef.current,
wordsPerChunk: chunkRef.current, wordsPerChunk: chunkRef.current,
fontSize: fontSizeRef.current, fontSize: fontSizeRef.current,
savedChunkText: chunkStr,
totalWords: curWords.length || saved.totalWords || 0,
}); });
}, []); },
[saved.totalWords]
);
// Autosave periodically every 3 seconds // Autosave periodically every 3 seconds
useEffect(() => { useEffect(() => {
@@ -204,17 +255,74 @@ export default function App() {
isDrawerOpen: isSettingsOpen, isDrawerOpen: isSettingsOpen,
}); });
// Zero-millisecond instantaneous display metrics
const effectiveTotal =
totalWords > 0 ? totalWords : (saved.totalWords || 0);
const effectiveIndex = currentIndex;
const progressPercent = const progressPercent =
totalWords > 0 effectiveTotal > 0
? Math.min(100, Math.max(0, (currentIndex / (totalWords - 1 || 1)) * 100)) ? Math.min(
100,
Math.max(0, (effectiveIndex / (effectiveTotal - 1 || 1)) * 100)
)
: 0; : 0;
const activeChunkText = currentChunkText || saved.savedChunkText || '';
const wpm = calculateWpm(flashesPerMinute, wordsPerChunk); const wpm = calculateWpm(flashesPerMinute, wordsPerChunk);
const hudOpacity = const hudOpacity =
isPlaying && !isUserActive isPlaying && !isUserActive
? 'opacity-0 pointer-events-none' ? 'opacity-0 pointer-events-none'
: 'opacity-100 pointer-events-auto'; : 'opacity-100 pointer-events-auto';
// Smooth, jitter-free fixed height progress bar interactions
const progressTrackRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef<boolean>(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<HTMLDivElement>) => {
isDraggingRef.current = true;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
seekByClientX(e.clientX);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingRef.current) return;
seekByClientX(e.clientX);
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
};
const handlePointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {
isDraggingRef.current = false;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
};
return ( return (
<div <div
onMouseMove={resetInactivity} onMouseMove={resetInactivity}
@@ -226,7 +334,7 @@ export default function App() {
className={`w-full flex items-center justify-between px-6 py-4 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`} className={`w-full flex items-center justify-between px-6 py-4 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`}
> >
<span className="text-[11px] font-mono tracking-widest text-zinc-500 uppercase"> <span className="text-[11px] font-mono tracking-widest text-zinc-500 uppercase">
The Well of Ascension {BOOK_TITLE}
</span> </span>
{/* Quiet Gear Button */} {/* Quiet Gear Button */}
@@ -239,18 +347,10 @@ export default function App() {
</button> </button>
</header> </header>
{/* Main RSVPs Central Stream */} {/* Main RSVPs Central Stream (Instant 0ms display, zero loading mask) */}
<main className="flex-1 min-h-0 flex items-center justify-center w-full z-10 overflow-hidden"> <main className="flex-1 min-h-0 flex items-center justify-center w-full z-10 overflow-hidden">
{isLoading ? (
<div className="flex flex-col items-center space-y-3 text-zinc-500">
<div className="w-6 h-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
<span className="text-xs font-mono tracking-wider">
Loading Focus...
</span>
</div>
) : (
<FocusDisplay <FocusDisplay
chunkText={currentChunkText} chunkText={activeChunkText}
fontSize={fontSize} fontSize={fontSize}
isPlaying={isPlaying} isPlaying={isPlaying}
onTogglePlay={() => { onTogglePlay={() => {
@@ -258,34 +358,42 @@ export default function App() {
togglePlay(); togglePlay();
}} }}
/> />
)}
</main> </main>
{/* Bottom Ultra-Thin Progress & Speed HUD */} {/* Bottom Fixed-Height Progress & Speed HUD */}
<footer <footer
className={`w-full flex flex-col space-y-2 pb-4 px-6 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`} className={`w-full flex flex-col space-y-2 pb-4 px-6 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`}
> >
{/* Ultra-thin progress line */} {/* Comfortable 32px hit-box progress bar with fixed 8px inner track */}
<div <div
onClick={(e) => { ref={progressTrackRef}
const rect = e.currentTarget.getBoundingClientRect(); onPointerDown={handlePointerDown}
const clickPos = (e.clientX - rect.left) / rect.width; onPointerMove={handlePointerMove}
const target = Math.round(clickPos * (totalWords - 1)); onPointerUp={handlePointerUp}
jumpTo(target); onPointerCancel={handlePointerCancel}
}} className="relative w-full h-8 flex items-center cursor-pointer select-none touch-none group"
className="relative w-full h-1 bg-zinc-800/80 hover:h-2 rounded-full cursor-pointer transition-all group overflow-hidden" title="点击或拖拽跳转进度"
title="点击调整进度"
> >
{/* Inner track: strictly fixed 8px height, rounded-full, zero hover jitter */}
<div className="relative w-full h-2 bg-zinc-800/90 rounded-full">
{/* Filled progress bar */}
<div <div
style={{ width: `${progressPercent}%` }} style={{ width: `${progressPercent}%` }}
className="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)] transition-all duration-100" className="h-full bg-emerald-500 rounded-full shadow-[0_0_8px_rgba(16,185,129,0.7)]"
/> />
{/* High-contrast current position thumb marker */}
<div
style={{ left: `${progressPercent}%` }}
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 bg-white border-2 border-emerald-400 rounded-full shadow-[0_0_8px_rgba(16,185,129,0.9)] pointer-events-none group-hover:scale-110 transition-transform duration-100"
/>
</div>
</div> </div>
{/* Status text */} {/* Status text */}
<div className="flex items-center justify-between text-[11px] font-mono text-zinc-500"> <div className="flex items-center justify-between text-[11px] font-mono text-zinc-500">
<span> <span>
{(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '} {(effectiveIndex + 1).toLocaleString()} / {effectiveTotal.toLocaleString()}{' '}
<span className="opacity-70">({progressPercent.toFixed(1)}%)</span> <span className="opacity-70">({progressPercent.toFixed(1)}%)</span>
</span> </span>
@@ -308,12 +416,9 @@ export default function App() {
onChangeWordsPerChunk={handleWordsPerChunkChange} onChangeWordsPerChunk={handleWordsPerChunkChange}
fontSize={fontSize} fontSize={fontSize}
onChangeFontSize={handleFontSizeChange} onChangeFontSize={handleFontSizeChange}
wordIndex={currentIndex}
totalWords={totalWords}
onJumpTo={jumpTo}
onResetProgress={() => { onResetProgress={() => {
restart(); restart();
saveState({ wordIndex: 0 }); persistCurrentState(0);
}} }}
/> />
</div> </div>

View File

@@ -12,9 +12,6 @@ interface SettingsDrawerProps {
onChangeWordsPerChunk: (chunk: number) => void; onChangeWordsPerChunk: (chunk: number) => void;
fontSize: FontSize; fontSize: FontSize;
onChangeFontSize: (size: FontSize) => void; onChangeFontSize: (size: FontSize) => void;
wordIndex: number;
totalWords: number;
onJumpTo: (index: number) => void;
onResetProgress: () => void; onResetProgress: () => void;
} }
@@ -36,18 +33,11 @@ export const SettingsDrawer: FC<SettingsDrawerProps> = ({
onChangeWordsPerChunk, onChangeWordsPerChunk,
fontSize, fontSize,
onChangeFontSize, onChangeFontSize,
wordIndex,
totalWords,
onJumpTo,
onResetProgress, onResetProgress,
}) => { }) => {
if (!isOpen) return null; if (!isOpen) return null;
const wpm = calculateWpm(flashesPerMinute, wordsPerChunk); const wpm = calculateWpm(flashesPerMinute, wordsPerChunk);
const progressPercent =
totalWords > 0
? Math.min(100, Math.max(0, (wordIndex / (totalWords - 1 || 1)) * 100))
: 0;
return ( return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/60 backdrop-blur-sm select-none animate-in fade-in duration-200"> <div className="fixed inset-0 z-50 flex justify-end bg-black/60 backdrop-blur-sm select-none animate-in fade-in duration-200">
@@ -180,32 +170,20 @@ export const SettingsDrawer: FC<SettingsDrawerProps> = ({
</div> </div>
</div> </div>
{/* Progress Jump Scrubber */} {/* Reading Progress Action */}
<div className="space-y-2 pt-2 border-t border-zinc-800/80"> <div className="pt-2 border-t border-zinc-800/80 flex items-center justify-between">
<div className="flex justify-between items-center text-xs font-mono"> <span className="text-xs font-mono uppercase tracking-wider text-zinc-400">
<span className="text-zinc-400 uppercase tracking-wider">Progress</span> Reading Progress
<span className="text-zinc-300">
{wordIndex.toLocaleString()} / {totalWords.toLocaleString()} ({progressPercent.toFixed(1)}%)
</span> </span>
</div>
<input
type="range"
min={0}
max={Math.max(0, totalWords - 1)}
value={wordIndex}
onChange={(e) => 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"
/>
<div className="flex justify-end pt-1">
<button <button
onClick={onResetProgress} onClick={onResetProgress}
className="flex items-center space-x-1.5 text-xs font-mono text-zinc-400 hover:text-rose-400 transition-colors py-1 px-2 rounded hover:bg-zinc-900" className="flex items-center space-x-1.5 text-xs font-mono text-zinc-400 hover:text-rose-400 transition-colors py-1.5 px-3 rounded-lg border border-zinc-800 hover:border-zinc-700 bg-zinc-900"
title="Reset reading position to start"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
<span>Reset to Start</span> <span>Reset to Start</span>
</button> </button>
</div> </div>
</div>
{/* Shortcuts Reference */} {/* Shortcuts Reference */}
<div className="space-y-2 pt-3 border-t border-zinc-800/80 text-xs"> <div className="space-y-2 pt-3 border-t border-zinc-800/80 text-xs">
@@ -236,11 +214,6 @@ export const SettingsDrawer: FC<SettingsDrawerProps> = ({
</div> </div>
</div> </div>
</div> </div>
{/* Footer info */}
<div className="pt-4 border-t border-zinc-800/60 text-center text-[11px] font-mono text-zinc-600">
The Well of Ascension · Brandon Sanderson
</div>
</div> </div>
</div> </div>
); );

90
src/db/bookCache.ts Normal file
View File

@@ -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<IDBDatabase> {
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<void> {
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);
}
}

View File

@@ -7,6 +7,8 @@ export const DEFAULT_STATE: FocusState = {
wordsPerChunk: 2, wordsPerChunk: 2,
flashesPerMinute: 250, flashesPerMinute: 250,
fontSize: 'xl', fontSize: 'xl',
savedChunkText: '',
totalWords: 0,
}; };
export function loadSavedState(): FocusState { export function loadSavedState(): FocusState {
@@ -36,6 +38,10 @@ export function loadSavedState(): FocusState {
) )
), ),
fontSize: parsed.fontSize || 'xl', fontSize: parsed.fontSize || 'xl',
savedChunkText:
typeof parsed.savedChunkText === 'string' ? parsed.savedChunkText : '',
totalWords:
typeof parsed.totalWords === 'number' ? parsed.totalWords : 0,
}; };
} }
} catch (err) { } catch (err) {

View File

@@ -11,4 +11,6 @@ export interface FocusState {
wordsPerChunk: number; wordsPerChunk: number;
flashesPerMinute: number; flashesPerMinute: number;
fontSize: FontSize; fontSize: FontSize;
savedChunkText?: string;
totalWords?: number;
} }