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 { 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() {
|
||||
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<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 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<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,53 +347,53 @@ 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}
|
||||
fontSize={fontSize}
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={() => {
|
||||
resetInactivity();
|
||||
togglePlay();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FocusDisplay
|
||||
chunkText={activeChunkText}
|
||||
fontSize={fontSize}
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={() => {
|
||||
resetInactivity();
|
||||
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="点击或拖拽跳转进度"
|
||||
>
|
||||
<div
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
className="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)] transition-all duration-100"
|
||||
/>
|
||||
{/* 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 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>
|
||||
|
||||
@@ -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,31 +170,19 @@ 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)}%)
|
||||
</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"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Reset to Start</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* 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>
|
||||
<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.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>
|
||||
|
||||
{/* Shortcuts Reference */}
|
||||
@@ -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
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,
|
||||
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) {
|
||||
|
||||
@@ -11,4 +11,6 @@ export interface FocusState {
|
||||
wordsPerChunk: number;
|
||||
flashesPerMinute: number;
|
||||
fontSize: FontSize;
|
||||
savedChunkText?: string;
|
||||
totalWords?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user