Files
focus/src/App.tsx
gkxk 14f1956bbf refactor: streamline RSVP reader for The Well of Ascension single-book focus
- Clean up legacy multi-format/CJK modules (orp, epub, samples, modals)
- Replace with lightweight pure English tokenizer and chunking engine
- Add SettingsDrawer, auto-hiding HUD, and PWA icons
- Update manifest and touch icons

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 11:42:57 +08:00

298 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Settings as SettingsIcon } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FocusDisplay } from './components/FocusDisplay';
import { SettingsDrawer } from './components/SettingsDrawer';
import { calculateWpm } from './core/timing';
import { tokenizeEnglishText } from './core/tokenizer';
import { loadSavedState, saveState } from './db';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useReaderEngine } from './hooks/useReaderEngine';
import type { FontSize, FocusState } from './types';
export default function App() {
const [saved] = useState<FocusState>(() => loadSavedState());
const [flashesPerMinute, setFlashesPerMinute] = useState<number>(
saved.flashesPerMinute
);
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
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}`);
}
const text = await res.text();
const parsedWords = tokenizeEnglishText(text);
setWords(parsedWords);
} catch (err) {
console.error('Failed to load book:', err);
} finally {
setIsLoading(false);
}
}
loadBook();
}, []);
// Sync state changes to localStorage
const handleFpmChange = (newFpm: number) => {
const clamped = Math.min(600, Math.max(60, newFpm));
setFlashesPerMinute(clamped);
saveState({ flashesPerMinute: clamped });
};
const handleWordsPerChunkChange = (newChunk: number) => {
const clamped = Math.min(4, Math.max(1, newChunk));
setWordsPerChunk(clamped);
saveState({ wordsPerChunk: clamped });
};
const handleFontSizeChange = (newSize: FontSize) => {
setFontSize(newSize);
saveState({ fontSize: newSize });
};
// Throttled persistence of wordIndex to localStorage
const lastSavedIndexRef = useRef<number>(saved.wordIndex);
const saveTimeoutRef = useRef<number | null>(null);
const handleWordIndexUpdate = useCallback((index: number) => {
if (saveTimeoutRef.current) {
window.clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = window.setTimeout(() => {
if (index !== lastSavedIndexRef.current) {
lastSavedIndexRef.current = index;
saveState({ wordIndex: index });
}
}, 400);
}, []);
// Save immediately on unload
useEffect(() => {
const handleBeforeUnload = () => {
saveState({
wordIndex: lastSavedIndexRef.current,
flashesPerMinute,
wordsPerChunk,
fontSize,
});
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [flashesPerMinute, wordsPerChunk, fontSize]);
// Core reading engine
const {
currentIndex,
currentChunkText,
isPlaying,
togglePlay,
pause,
jumpTo,
jumpBy,
restart,
totalWords,
} = useReaderEngine({
words,
flashesPerMinute,
wordsPerChunk,
initialWordIndex: saved.wordIndex,
onWordIndexChange: (idx) => {
lastSavedIndexRef.current = idx;
handleWordIndexUpdate(idx);
},
});
// Reset inactivity timer
const resetInactivity = useCallback(() => {
setIsUserActive(true);
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
}
if (isPlaying) {
inactivityTimerRef.current = window.setTimeout(() => {
setIsUserActive(false);
}, 2500);
}
}, [isPlaying]);
useEffect(() => {
if (!isPlaying) {
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
inactivityTimerRef.current = null;
}
return;
}
const onActivity = () => resetInactivity();
window.addEventListener('mousemove', onActivity);
window.addEventListener('keydown', onActivity);
window.addEventListener('touchstart', onActivity);
inactivityTimerRef.current = window.setTimeout(() => {
setIsUserActive(false);
}, 2500);
return () => {
window.removeEventListener('mousemove', onActivity);
window.removeEventListener('keydown', onActivity);
window.removeEventListener('touchstart', onActivity);
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
}
};
}, [isPlaying, resetInactivity]);
// Fullscreen toggle
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
};
// Keyboard navigation
useKeyboardShortcuts({
togglePlay,
jumpBy,
changeFpm: (delta) => handleFpmChange(flashesPerMinute + delta),
toggleFullscreen,
onEscape: () => {
if (isSettingsOpen) {
setIsSettingsOpen(false);
} else {
pause();
}
},
isDrawerOpen: isSettingsOpen,
});
const progressPercent =
totalWords > 0
? Math.min(100, Math.max(0, (currentIndex / (totalWords - 1 || 1)) * 100))
: 0;
const wpm = calculateWpm(flashesPerMinute, wordsPerChunk);
const hudOpacity =
isPlaying && !isUserActive
? 'opacity-0 pointer-events-none'
: 'opacity-100 pointer-events-auto';
return (
<div
onMouseMove={resetInactivity}
onTouchStart={resetInactivity}
className="relative w-full h-full flex flex-col justify-between bg-[#09090b] text-[#f4f4f5] font-sans overflow-hidden select-none"
>
{/* Top Subtle Bar */}
<header
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
</span>
{/* Quiet Gear Button */}
<button
onClick={() => setIsSettingsOpen(true)}
className="p-1.5 rounded-lg text-zinc-500 hover:text-zinc-200 transition-colors"
title="Settings [Esc]"
>
<SettingsIcon className="w-4 h-4" />
</button>
</header>
{/* Main RSVPs Central Stream */}
<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();
}}
/>
)}
</main>
{/* Bottom Ultra-Thin 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 */}
<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="点击调整进度"
>
<div
style={{ width: `${progressPercent}%` }}
className="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)] transition-all duration-100"
/>
</div>
{/* Status text */}
<div className="flex items-center justify-between text-[11px] font-mono text-zinc-500">
<span>
{(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '}
<span className="opacity-70">({progressPercent.toFixed(1)}%)</span>
</span>
<span className="text-zinc-400 font-semibold">
{wpm} WPM{' '}
<span className="font-normal text-zinc-500 text-[10px]">
({flashesPerMinute} FPM × {wordsPerChunk})
</span>
</span>
</div>
</footer>
{/* Slide-out Settings Drawer */}
<SettingsDrawer
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
flashesPerMinute={flashesPerMinute}
onChangeFpm={handleFpmChange}
wordsPerChunk={wordsPerChunk}
onChangeWordsPerChunk={handleWordsPerChunkChange}
fontSize={fontSize}
onChangeFontSize={handleFontSizeChange}
wordIndex={currentIndex}
totalWords={totalWords}
onJumpTo={jumpTo}
onResetProgress={() => {
restart();
saveState({ wordIndex: 0 });
}}
/>
</div>
);
}