feat: initial implementation of Focus RSVP speed reader

- Pure frontend SPA (Vite + React 19 + TypeScript + Tailwind CSS v4)
- Spritz ORP engine with absolute center crosshair gaze fixation
- Multilingual hybrid segmentation via Intl.Segmenter with punctuation attachment
- Adaptive breathing rhythm delay algorithm for natural comprehension
- IndexedDB local bookshelf, progress persistence, and client-side EPUB/TXT/MD parsing
- Curated sci-fi, philosophy, and classic speech samples
- Dark/OLED/Sepia/Slate/Nord themes and rich keyboard controls

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-20 08:28:20 +08:00
commit c6f541ed94
30 changed files with 4756 additions and 0 deletions

399
src/App.tsx Normal file
View File

@@ -0,0 +1,399 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChapterDrawer } from './components/ChapterDrawer';
import { Controls } from './components/Controls';
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 { 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'];
export default function App() {
const [settings, setSettings] = useState<ReaderSettings>(() => loadSettings());
const [booksMeta, setBooksMeta] = useState<BookMeta[]>([]);
const [activeBook, setActiveBook] = useState<Book | null>(null);
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
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() {
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);
}
}
} catch (err) {
console.error('Failed to initialize reader database:', err);
} finally {
setIsLoading(false);
}
}
init();
}, []);
// Update settings handler
const handleUpdateSettings = (newSettings: Partial<ReaderSettings>) => {
setSettings((prev) => {
const updated = { ...prev, ...newSettings };
saveSettings(updated);
return updated;
});
};
// Theme cycle
const handleCycleTheme = () => {
const currentIdx = THEME_ORDER.indexOf(settings.theme);
const nextIdx = (currentIdx + 1) % THEME_ORDER.length;
handleUpdateSettings({ theme: THEME_ORDER[nextIdx] });
};
// Book selection
const handleSelectBook = async (bookId: string) => {
const book = await getBook(bookId);
if (book) {
setActiveBook(book);
setActiveBookId(book.id);
}
};
// 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);
}
}
};
// 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]);
// 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);
}
};
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 activity detection for HUD fading
const resetInactivity = useCallback(() => {
setIsUserActive(true);
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
}
if (isPlaying && settings.autoHideControls) {
inactivityTimerRef.current = window.setTimeout(() => {
setIsUserActive(false);
}, 2200);
}
}, [isPlaying, settings.autoHideControls]);
useEffect(() => {
if (!isPlaying || !settings.autoHideControls) {
if (inactivityTimerRef.current) {
window.clearTimeout(inactivityTimerRef.current);
}
return;
}
const timer = window.setTimeout(() => {
setIsUserActive(false);
}, 2200);
return () => window.clearTimeout(timer);
}, [isPlaying, settings.autoHideControls]);
// Keyboard shortcuts
const anyModalOpen =
isLibraryOpen ||
isPasteOpen ||
isSettingsOpen ||
isShortcutsOpen ||
isChaptersOpen;
useKeyboardShortcuts({
togglePlay,
jumpBy,
jumpToPreviousSentence,
jumpToNextSentence,
changeWpm: (delta) =>
handleUpdateSettings({
wpm: Math.max(100, Math.min(1200, settings.wpm + delta)),
}),
toggleFullscreen,
restart,
prevChapter: handlePrevChapter,
nextChapter: handleNextChapter,
onEscape: () => {
if (anyModalOpen) {
setIsLibraryOpen(false);
setIsPasteOpen(false);
setIsSettingsOpen(false);
setIsShortcutsOpen(false);
setIsChaptersOpen(false);
} else {
pause();
}
},
isModalOpen: anyModalOpen,
});
const themeClass = `theme-${settings.theme}`;
const isHudDimmed = isPlaying && !isUserActive && settings.autoHideControls;
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}`}
>
{/* Top Header HUD */}
<div
className={`w-full z-20 transition-opacity duration-500 ${
isHudDimmed ? 'opacity-0 hover:opacity-100' : 'opacity-100'
}`}
>
<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>
{/* Main RSVP Focus Arena */}
<main className="flex-1 flex flex-col items-center justify-center px-4 z-10 w-full my-auto">
{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>
) : (
<FocusDisplay
token={currentToken}
previousToken={previousToken}
nextToken={nextToken}
fontSize={settings.fontSize}
fontFamily={settings.fontFamily}
orpColor={settings.orpColor}
showGuideLines={settings.showGuideLines}
showContextPreview={settings.showContextPreview}
isPlaying={isPlaying}
onTogglePlay={togglePlay}
/>
)}
</main>
{/* Bottom Controls HUD */}
<div
className={`w-full pb-6 z-20 transition-opacity duration-500 ${
isHudDimmed ? 'opacity-10 hover:opacity-100' : 'opacity-100'
}`}
>
<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>
{/* Modals & Drawers */}
<LibraryModal
isOpen={isLibraryOpen}
books={booksMeta}
activeBookId={activeBook?.id || null}
onClose={() => setIsLibraryOpen(false)}
onSelectBook={handleSelectBook}
onSaveNewBook={handleSaveNewBook}
onDeleteBook={handleDeleteBook}
/>
<PasteModal
isOpen={isPasteOpen}
wpm={settings.wpm}
onClose={() => setIsPasteOpen(false)}
onSaveAndPlay={async (newBook) => {
await handleSaveNewBook(newBook);
setActiveBook(newBook);
setActiveBookId(newBook.id);
play();
}}
/>
<SettingsModal
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)}
/>
</div>
);
}