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>
This commit is contained in:
2026-10-20 11:42:57 +08:00
parent 06df8744a0
commit 14f1956bbf
29 changed files with 19791 additions and 3282 deletions

View File

@@ -1,410 +1,296 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChapterDrawer } from './components/ChapterDrawer';
import { Controls } from './components/Controls';
import { Settings as SettingsIcon } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FocusDisplay } from './components/FocusDisplay';
import { Header } from './components/Header';
import { LibraryModal } from './components/LibraryModal';
import { PasteModal } from './components/PasteModal';
import { SettingsModal } from './components/SettingsModal';
import { ShortcutsModal } from './components/ShortcutsModal';
import {
deleteBook,
getActiveBookId,
getAllBooksMeta,
getBook,
loadSettings,
saveBook,
saveSettings,
setActiveBookId,
} from './db';
import { getBuiltinSampleBooks } from './db/samples';
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 { Book, BookMeta, Chapter, ReaderSettings, ThemeId } from './types';
const THEME_ORDER: ThemeId[] = ['oled', 'dark', 'slate', 'sepia', 'nord', 'paper'];
import type { FontSize, FocusState } from './types';
export default function App() {
const [settings, setSettings] = useState<ReaderSettings>(() => loadSettings());
const [booksMeta, setBooksMeta] = useState<BookMeta[]>([]);
const [activeBook, setActiveBook] = useState<Book | null>(null);
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);
// Modals
const [isLibraryOpen, setIsLibraryOpen] = useState<boolean>(false);
const [isPasteOpen, setIsPasteOpen] = useState<boolean>(false);
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
const [isShortcutsOpen, setIsShortcutsOpen] = useState<boolean>(false);
const [isChaptersOpen, setIsChaptersOpen] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
// HUD inactivity fading
const [isUserActive, setIsUserActive] = useState<boolean>(true);
const inactivityTimerRef = useRef<number | null>(null);
// Reader engine
const {
currentIndex,
currentToken,
previousToken,
nextToken,
isPlaying,
play,
pause,
togglePlay,
jumpTo,
jumpBy,
jumpToPreviousSentence,
jumpToNextSentence,
jumpToChapter,
restart,
totalTokens,
} = useReaderEngine({
book: activeBook,
settings,
onBookProgressUpdate: (bookId, progress) => {
setBooksMeta((prev) =>
prev.map((b) => (b.id === bookId ? { ...b, currentProgress: progress } : b))
);
},
});
// Track fullscreen changes
// Load built-in book text
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () =>
document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn('Fullscreen request failed:', err);
});
} else {
document.exitFullscreen().catch((err) => {
console.warn('Exit fullscreen failed:', err);
});
}
};
// Initial load of books and defaults
useEffect(() => {
async function init() {
async function loadBook() {
try {
let metaList = await getAllBooksMeta();
if (metaList.length === 0) {
// First visit: inject curated built-in samples
const samples = getBuiltinSampleBooks();
for (const s of samples) {
await saveBook(s);
}
metaList = await getAllBooksMeta();
}
setBooksMeta(metaList);
// Load active book
const savedActiveId = getActiveBookId();
const targetId = savedActiveId || metaList[0]?.id;
if (targetId) {
const loadedBook = await getBook(targetId);
if (loadedBook) {
setActiveBook(loadedBook);
setActiveBookId(loadedBook.id);
}
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 initialize reader database:', err);
console.error('Failed to load book:', err);
} finally {
setIsLoading(false);
}
}
init();
loadBook();
}, []);
// Update settings handler
const handleUpdateSettings = (newSettings: Partial<ReaderSettings>) => {
setSettings((prev) => {
const updated = { ...prev, ...newSettings };
saveSettings(updated);
return updated;
});
// Sync state changes to localStorage
const handleFpmChange = (newFpm: number) => {
const clamped = Math.min(600, Math.max(60, newFpm));
setFlashesPerMinute(clamped);
saveState({ flashesPerMinute: clamped });
};
// Theme cycle
const handleCycleTheme = () => {
const currentIdx = THEME_ORDER.indexOf(settings.theme);
const nextIdx = (currentIdx + 1) % THEME_ORDER.length;
handleUpdateSettings({ theme: THEME_ORDER[nextIdx] });
const handleWordsPerChunkChange = (newChunk: number) => {
const clamped = Math.min(4, Math.max(1, newChunk));
setWordsPerChunk(clamped);
saveState({ wordsPerChunk: clamped });
};
// Book selection
const handleSelectBook = async (bookId: string) => {
const book = await getBook(bookId);
if (book) {
setActiveBook(book);
setActiveBookId(book.id);
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);
}
};
// Save new book
const handleSaveNewBook = async (newBook: Book) => {
await saveBook(newBook);
const updatedMeta = await getAllBooksMeta();
setBooksMeta(updatedMeta);
};
// Delete book
const handleDeleteBook = async (bookId: string) => {
await deleteBook(bookId);
const updatedMeta = await getAllBooksMeta();
setBooksMeta(updatedMeta);
if (activeBook?.id === bookId) {
if (updatedMeta.length > 0) {
handleSelectBook(updatedMeta[0].id);
} else {
setActiveBook(null);
saveTimeoutRef.current = window.setTimeout(() => {
if (index !== lastSavedIndexRef.current) {
lastSavedIndexRef.current = index;
saveState({ wordIndex: index });
}
}
};
}, 400);
}, []);
// Calculate current chapter based on currentIndex
const currentChapter = useMemo(() => {
if (!activeBook || !activeBook.chapters || activeBook.chapters.length === 0) {
return undefined;
}
const chapters = activeBook.chapters;
for (let i = chapters.length - 1; i >= 0; i--) {
if (currentIndex >= chapters[i].startIndex) {
return chapters[i];
}
}
return chapters[0];
}, [activeBook, currentIndex]);
// 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]);
// Jump to next/prev chapter
const handlePrevChapter = () => {
if (!activeBook || !activeBook.chapters) return;
const chapters = activeBook.chapters;
const currentIdx = currentChapter
? chapters.findIndex((c) => c.id === currentChapter.id)
: 0;
if (currentIdx > 0) {
jumpToChapter(chapters[currentIdx - 1]);
} else {
jumpTo(0);
}
};
// 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);
},
});
const handleNextChapter = () => {
if (!activeBook || !activeBook.chapters) return;
const chapters = activeBook.chapters;
const currentIdx = currentChapter
? chapters.findIndex((c) => c.id === currentChapter.id)
: 0;
if (currentIdx < chapters.length - 1) {
jumpToChapter(chapters[currentIdx + 1]);
}
};
// Mouse/touch/key activity detection for HUD fading
// Reset inactivity timer
const resetInactivity = useCallback(() => {
setIsUserActive(true);
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
}
if (isPlaying && settings.autoHideControls) {
if (isPlaying) {
inactivityTimerRef.current = window.setTimeout(() => {
setIsUserActive(false);
}, 3000);
}, 2500);
}
}, [isPlaying, settings.autoHideControls]);
}, [isPlaying]);
useEffect(() => {
if (!isPlaying || !settings.autoHideControls) {
if (!isPlaying) {
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
inactivityTimerRef.current = null;
}
return;
}
const handleInteraction = () => {
resetInactivity();
};
const onActivity = () => resetInactivity();
window.addEventListener('mousemove', onActivity);
window.addEventListener('keydown', onActivity);
window.addEventListener('touchstart', onActivity);
window.addEventListener('mousemove', handleInteraction);
window.addEventListener('keydown', handleInteraction);
window.addEventListener('touchstart', handleInteraction);
window.addEventListener('pointerdown', handleInteraction);
const timer = window.setTimeout(() => {
inactivityTimerRef.current = window.setTimeout(() => {
setIsUserActive(false);
}, 3000);
}, 2500);
return () => {
window.clearTimeout(timer);
window.removeEventListener('mousemove', handleInteraction);
window.removeEventListener('keydown', handleInteraction);
window.removeEventListener('touchstart', handleInteraction);
window.removeEventListener('pointerdown', handleInteraction);
window.removeEventListener('mousemove', onActivity);
window.removeEventListener('keydown', onActivity);
window.removeEventListener('touchstart', onActivity);
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
}
};
}, [isPlaying, settings.autoHideControls, resetInactivity]);
}, [isPlaying, resetInactivity]);
// Keyboard shortcuts
const anyModalOpen =
isLibraryOpen ||
isPasteOpen ||
isSettingsOpen ||
isShortcutsOpen ||
isChaptersOpen;
// Fullscreen toggle
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
};
// Keyboard navigation
useKeyboardShortcuts({
togglePlay,
jumpBy,
jumpToPreviousSentence,
jumpToNextSentence,
changeWpm: (delta) =>
handleUpdateSettings({
wpm: Math.max(100, Math.min(1200, settings.wpm + delta)),
}),
changeFpm: (delta) => handleFpmChange(flashesPerMinute + delta),
toggleFullscreen,
restart,
prevChapter: handlePrevChapter,
nextChapter: handleNextChapter,
onEscape: () => {
if (anyModalOpen) {
setIsLibraryOpen(false);
setIsPasteOpen(false);
if (isSettingsOpen) {
setIsSettingsOpen(false);
setIsShortcutsOpen(false);
setIsChaptersOpen(false);
} else {
pause();
}
},
isModalOpen: anyModalOpen,
isDrawerOpen: isSettingsOpen,
});
const themeClass = `theme-${settings.theme}`;
const isHudDimmed = isPlaying && !isUserActive && settings.autoHideControls;
const hudClasses = isHudDimmed
? 'opacity-0 pointer-events-none transition-opacity duration-500'
: 'opacity-100 pointer-events-auto transition-opacity duration-300';
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 overflow-hidden transition-colors duration-300 font-sans ${themeClass}`}
className="relative w-full h-full flex flex-col justify-between bg-[#09090b] text-[#f4f4f5] font-sans overflow-hidden select-none"
>
{/* Top Header HUD */}
<div className={`w-full z-20 shrink-0 ${hudClasses}`}>
<Header
book={activeBook}
currentChapter={currentChapter}
settings={settings}
isFullscreen={isFullscreen}
onOpenLibrary={() => setIsLibraryOpen(true)}
onOpenPaste={() => setIsPasteOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenShortcuts={() => setIsShortcutsOpen(true)}
onOpenChapters={() => setIsChaptersOpen(true)}
onToggleFullscreen={toggleFullscreen}
onCycleTheme={handleCycleTheme}
/>
</div>
{/* 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>
{/* Main RSVP Focus Arena */}
<main className="flex-1 min-h-0 flex flex-col items-center justify-center px-2 sm:px-4 z-10 w-full overflow-hidden my-auto">
{/* 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-neutral-400">
<div className="w-8 h-8 rounded-full border-2 border-rose-500 border-t-transparent animate-spin" />
<span className="text-sm font-mono tracking-wider">正在加载 Focus 阅读器...</span>
<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
token={currentToken}
previousToken={previousToken}
nextToken={nextToken}
fontSize={settings.fontSize}
fontFamily={settings.fontFamily}
orpColor={settings.orpColor}
showGuideLines={settings.showGuideLines}
showContextPreview={settings.showContextPreview}
chunkText={currentChunkText}
fontSize={fontSize}
isPlaying={isPlaying}
onTogglePlay={togglePlay}
onTogglePlay={() => {
resetInactivity();
togglePlay();
}}
/>
)}
</main>
{/* Bottom Controls HUD */}
<div className={`w-full pb-2 sm:pb-6 short:pb-1 z-20 shrink-0 ${hudClasses}`}>
<Controls
currentIndex={currentIndex}
totalTokens={totalTokens}
tokens={activeBook?.tokens || []}
chapters={activeBook?.chapters || []}
isPlaying={isPlaying}
settings={settings}
onTogglePlay={togglePlay}
onJumpTo={jumpTo}
onJumpBy={jumpBy}
onJumpToPreviousSentence={jumpToPreviousSentence}
onJumpToNextSentence={jumpToNextSentence}
onRestart={restart}
onChangeWpm={(newWpm) => handleUpdateSettings({ wpm: newWpm })}
/>
</div>
{/* 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>
{/* Modals & Drawers */}
<LibraryModal
isOpen={isLibraryOpen}
books={booksMeta}
activeBookId={activeBook?.id || null}
onClose={() => setIsLibraryOpen(false)}
onSelectBook={handleSelectBook}
onSaveNewBook={handleSaveNewBook}
onDeleteBook={handleDeleteBook}
/>
{/* 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>
<PasteModal
isOpen={isPasteOpen}
wpm={settings.wpm}
onClose={() => setIsPasteOpen(false)}
onSaveAndPlay={async (newBook) => {
await handleSaveNewBook(newBook);
setActiveBook(newBook);
setActiveBookId(newBook.id);
play();
}}
/>
<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>
<SettingsModal
{/* Slide-out Settings Drawer */}
<SettingsDrawer
isOpen={isSettingsOpen}
settings={settings}
onClose={() => setIsSettingsOpen(false)}
onUpdateSettings={handleUpdateSettings}
/>
<ShortcutsModal
isOpen={isShortcutsOpen}
onClose={() => setIsShortcutsOpen(false)}
/>
<ChapterDrawer
isOpen={isChaptersOpen}
chapters={activeBook?.chapters || []}
currentChapterId={currentChapter?.id}
onClose={() => setIsChaptersOpen(false)}
onSelectChapter={(chapter: Chapter) => jumpToChapter(chapter)}
flashesPerMinute={flashesPerMinute}
onChangeFpm={handleFpmChange}
wordsPerChunk={wordsPerChunk}
onChangeWordsPerChunk={handleWordsPerChunkChange}
fontSize={fontSize}
onChangeFontSize={handleFontSizeChange}
wordIndex={currentIndex}
totalWords={totalWords}
onJumpTo={jumpTo}
onResetProgress={() => {
restart();
saveState({ wordIndex: 0 });
}}
/>
</div>
);