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:
399
src/App.tsx
Normal file
399
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user