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:
247
src/App.tsx
247
src/App.tsx
@@ -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;
|
||||||
try {
|
|
||||||
const res = await fetch(
|
async function loadBookData() {
|
||||||
encodeURI('/books/The Well of Ascension.txt')
|
// 1. Instantly read parsed tokens from IndexedDB
|
||||||
);
|
const cached = await getCachedTokens(BOOK_FILENAME);
|
||||||
if (!res.ok) {
|
if (!isCancelled && cached && cached.tokens.length > 0) {
|
||||||
throw new Error(`Failed to load book: ${res.statusText}`);
|
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);
|
if (cached && cached.tokens.length > 0) {
|
||||||
} catch (err) {
|
if ('requestIdleCallback' in window) {
|
||||||
console.error('Failed to load book:', err);
|
window.requestIdleCallback(() => runSilentSync(), { timeout: 3000 });
|
||||||
} finally {
|
} else {
|
||||||
setIsLoading(false);
|
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(
|
||||||
const idx =
|
(targetIndex?: number) => {
|
||||||
typeof targetIndex === 'number'
|
const idx =
|
||||||
? targetIndex
|
typeof targetIndex === 'number'
|
||||||
: lastSavedIndexRef.current;
|
? targetIndex
|
||||||
lastSavedIndexRef.current = idx;
|
: lastSavedIndexRef.current;
|
||||||
saveState({
|
lastSavedIndexRef.current = idx;
|
||||||
wordIndex: idx,
|
|
||||||
flashesPerMinute: fpmRef.current,
|
const curWords = wordsRef.current;
|
||||||
wordsPerChunk: chunkRef.current,
|
let chunkStr = '';
|
||||||
fontSize: fontSizeRef.current,
|
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
|
// 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,53 +347,53 @@ 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 ? (
|
<FocusDisplay
|
||||||
<div className="flex flex-col items-center space-y-3 text-zinc-500">
|
chunkText={activeChunkText}
|
||||||
<div className="w-6 h-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
|
fontSize={fontSize}
|
||||||
<span className="text-xs font-mono tracking-wider">
|
isPlaying={isPlaying}
|
||||||
Loading Focus...
|
onTogglePlay={() => {
|
||||||
</span>
|
resetInactivity();
|
||||||
</div>
|
togglePlay();
|
||||||
) : (
|
}}
|
||||||
<FocusDisplay
|
/>
|
||||||
chunkText={currentChunkText}
|
|
||||||
fontSize={fontSize}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
onTogglePlay={() => {
|
|
||||||
resetInactivity();
|
|
||||||
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="点击调整进度"
|
|
||||||
>
|
>
|
||||||
<div
|
{/* Inner track: strictly fixed 8px height, rounded-full, zero hover jitter */}
|
||||||
style={{ width: `${progressPercent}%` }}
|
<div className="relative w-full h-2 bg-zinc-800/90 rounded-full">
|
||||||
className="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)] transition-all duration-100"
|
{/* Filled progress bar */}
|
||||||
/>
|
<div
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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,31 +170,19 @@ 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">
|
</span>
|
||||||
{wordIndex.toLocaleString()} / {totalWords.toLocaleString()} ({progressPercent.toFixed(1)}%)
|
<button
|
||||||
</span>
|
onClick={onResetProgress}
|
||||||
</div>
|
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"
|
||||||
<input
|
title="Reset reading position to start"
|
||||||
type="range"
|
>
|
||||||
min={0}
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
max={Math.max(0, totalWords - 1)}
|
<span>Reset to Start</span>
|
||||||
value={wordIndex}
|
</button>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
|
||||||
<span>Reset to Start</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shortcuts Reference */}
|
{/* Shortcuts Reference */}
|
||||||
@@ -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
90
src/db/bookCache.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ export interface FocusState {
|
|||||||
wordsPerChunk: number;
|
wordsPerChunk: number;
|
||||||
flashesPerMinute: number;
|
flashesPerMinute: number;
|
||||||
fontSize: FontSize;
|
fontSize: FontSize;
|
||||||
|
savedChunkText?: string;
|
||||||
|
totalWords?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user