- 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>
298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
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>
|
||
);
|
||
}
|