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 { 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<FocusState>(() => loadSavedState());
const [flashesPerMinute, setFlashesPerMinute] = useState<number>(
@@ -17,31 +21,61 @@ export default function App() {
const [wordsPerChunk, setWordsPerChunk] = useState<number>(saved.wordsPerChunk);
const [fontSize, setFontSize] = useState<FontSize>(saved.fontSize);
const [words, setWords] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
const [isUserActive, setIsUserActive] = useState<boolean>(true);
const inactivityTimerRef = useRef<number | null>(null);
// Load built-in book text
// Fast-path IndexedDB restore with silent background verification
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 {
const res = await fetch(
encodeURI('/books/The Well of Ascension.txt')
);
if (!res.ok) {
throw new Error(`Failed to load book: ${res.statusText}`);
}
const res = await fetch(encodeURI(`/books/${BOOK_FILENAME}`));
if (!res.ok || isCancelled) return;
const text = await res.text();
const parsedWords = tokenizeEnglishText(text);
setWords(parsedWords);
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.error('Failed to load book:', err);
} finally {
setIsLoading(false);
console.warn('Silent sync error:', err);
}
};
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<number>(flashesPerMinute);
const chunkRef = useRef<number>(wordsPerChunk);
const fontSizeRef = useRef<FontSize>(fontSize);
const wordsRef = useRef<string[]>(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 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<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 (
<div
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}`}
>
<span className="text-[11px] font-mono tracking-widest text-zinc-500 uppercase">
The Well of Ascension
{BOOK_TITLE}
</span>
{/* Quiet Gear Button */}
@@ -239,18 +347,10 @@ export default function App() {
</button>
</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">
{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
chunkText={currentChunkText}
chunkText={activeChunkText}
fontSize={fontSize}
isPlaying={isPlaying}
onTogglePlay={() => {
@@ -258,34 +358,42 @@ export default function App() {
togglePlay();
}}
/>
)}
</main>
{/* Bottom Ultra-Thin Progress & Speed HUD */}
{/* Bottom Fixed-Height Progress & Speed HUD */}
<footer
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
onClick={(e) => {
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 */}
<div className="relative w-full h-2 bg-zinc-800/90 rounded-full">
{/* Filled progress bar */}
<div
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>
{/* Status text */}
<div className="flex items-center justify-between text-[11px] font-mono text-zinc-500">
<span>
{(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '}
{(effectiveIndex + 1).toLocaleString()} / {effectiveTotal.toLocaleString()}{' '}
<span className="opacity-70">({progressPercent.toFixed(1)}%)</span>
</span>
@@ -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);
}}
/>
</div>

View File

@@ -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<SettingsDrawerProps> = ({
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 (
<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>
{/* Progress Jump Scrubber */}
<div className="space-y-2 pt-2 border-t border-zinc-800/80">
<div className="flex justify-between items-center text-xs font-mono">
<span className="text-zinc-400 uppercase tracking-wider">Progress</span>
<span className="text-zinc-300">
{wordIndex.toLocaleString()} / {totalWords.toLocaleString()} ({progressPercent.toFixed(1)}%)
{/* Reading Progress Action */}
<div className="pt-2 border-t border-zinc-800/80 flex items-center justify-between">
<span className="text-xs font-mono uppercase tracking-wider text-zinc-400">
Reading Progress
</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
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" />
<span>Reset to Start</span>
</button>
</div>
</div>
{/* Shortcuts Reference */}
<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>
{/* 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>
);

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,
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) {

View File

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