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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="description" content="Focus — 极致极速 RSVP 闪屏阅读器,基于 Spritz ORP 中心锚定与自适应呼吸节奏算法,眼球完全静止,秒进专注流。" />
|
||||
<title>Focus — 极速 RSVP 闪屏专注阅读器</title>
|
||||
</head>
|
||||
<body class="bg-black select-none">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "focus",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.2",
|
||||
"lucide-react": "^1.59.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/jszip": "^3.4.1",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"oxlint": "^1.88.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.4"
|
||||
}
|
||||
}
|
||||
1416
pnpm-lock.yaml
generated
Normal file
1416
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect width="64" height="64" rx="16" fill="#09090b" />
|
||||
<circle cx="32" cy="32" r="20" fill="none" stroke="#27272a" stroke-width="2.5" />
|
||||
<line x1="32" y1="6" x2="32" y2="16" stroke="#f43f5e" stroke-width="3" stroke-linecap="round" />
|
||||
<line x1="32" y1="48" x2="32" y2="58" stroke="#f43f5e" stroke-width="3" stroke-linecap="round" />
|
||||
<line x1="6" y1="32" x2="16" y2="32" stroke="#71717a" stroke-width="2" stroke-linecap="round" />
|
||||
<line x1="48" y1="32" x2="58" y2="32" stroke="#71717a" stroke-width="2" stroke-linecap="round" />
|
||||
<circle cx="32" cy="32" r="4.5" fill="#f43f5e" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
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>
|
||||
);
|
||||
}
|
||||
78
src/components/ChapterDrawer.tsx
Normal file
78
src/components/ChapterDrawer.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Check, List, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type { Chapter } from '../types';
|
||||
|
||||
interface ChapterDrawerProps {
|
||||
isOpen: boolean;
|
||||
chapters: Chapter[];
|
||||
currentChapterId?: string;
|
||||
onClose: () => void;
|
||||
onSelectChapter: (chapter: Chapter) => void;
|
||||
}
|
||||
|
||||
export const ChapterDrawer: React.FC<ChapterDrawerProps> = ({
|
||||
isOpen,
|
||||
chapters,
|
||||
currentChapterId,
|
||||
onClose,
|
||||
onSelectChapter,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center sm:justify-start p-4 sm:p-0 bg-black/60 backdrop-blur-sm animate-fade-in select-none">
|
||||
<div className="relative w-full max-w-sm sm:h-full bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-800 shadow-2xl flex flex-col rounded-2xl sm:rounded-none overflow-hidden max-h-[80vh] sm:max-h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
<List className="w-5 h-5 text-rose-500" />
|
||||
<h2 className="text-base font-bold text-neutral-900 dark:text-neutral-100">
|
||||
章节目录 ({chapters.length})
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chapter List */}
|
||||
<div className="p-4 space-y-1.5 flex-1 overflow-y-auto">
|
||||
{chapters.length === 0 ? (
|
||||
<div className="text-center py-8 text-neutral-400 text-sm">
|
||||
无多章节信息
|
||||
</div>
|
||||
) : (
|
||||
chapters.map((chap, idx) => {
|
||||
const isActive = chap.id === currentChapterId;
|
||||
return (
|
||||
<button
|
||||
key={chap.id}
|
||||
onClick={() => {
|
||||
onSelectChapter(chap);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-xl text-left text-sm transition-all ${
|
||||
isActive
|
||||
? 'bg-rose-500/10 text-rose-500 font-semibold border border-rose-500/30'
|
||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 truncate mr-2">
|
||||
<span className="text-xs font-mono text-neutral-400">
|
||||
{idx + 1}.
|
||||
</span>
|
||||
<span className="truncate">{chap.title}</span>
|
||||
</div>
|
||||
{isActive && <Check className="w-4 h-4 shrink-0 text-rose-500" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
src/components/Controls.tsx
Normal file
210
src/components/Controls.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Minus,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { estimateRemainingSeconds, formatDuration } from '../core/timing';
|
||||
import type { Chapter, ReaderSettings, Token } from '../types';
|
||||
|
||||
interface ControlsProps {
|
||||
currentIndex: number;
|
||||
totalTokens: number;
|
||||
tokens: Token[];
|
||||
chapters: Chapter[];
|
||||
isPlaying: boolean;
|
||||
settings: ReaderSettings;
|
||||
onTogglePlay: () => void;
|
||||
onJumpTo: (index: number) => void;
|
||||
onJumpBy: (delta: number) => void;
|
||||
onJumpToPreviousSentence: () => void;
|
||||
onJumpToNextSentence: () => void;
|
||||
onRestart: () => void;
|
||||
onChangeWpm: (wpm: number) => void;
|
||||
}
|
||||
|
||||
const WPM_PRESETS = [200, 300, 400, 500, 650, 800, 1000];
|
||||
|
||||
export const Controls: React.FC<ControlsProps> = ({
|
||||
currentIndex,
|
||||
totalTokens,
|
||||
tokens,
|
||||
chapters,
|
||||
isPlaying,
|
||||
settings,
|
||||
onTogglePlay,
|
||||
onJumpTo,
|
||||
onJumpBy,
|
||||
onJumpToPreviousSentence,
|
||||
onJumpToNextSentence,
|
||||
onRestart,
|
||||
onChangeWpm,
|
||||
}) => {
|
||||
const percent =
|
||||
totalTokens > 0
|
||||
? Math.min(100, Math.max(0, (currentIndex / (totalTokens - 1 || 1)) * 100))
|
||||
: 0;
|
||||
|
||||
const remainingSeconds = estimateRemainingSeconds(
|
||||
tokens,
|
||||
currentIndex,
|
||||
settings
|
||||
);
|
||||
const remainingText = formatDuration(remainingSeconds);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto flex flex-col space-y-4 px-4 select-none">
|
||||
{/* Progress & Timeline Bar */}
|
||||
{settings.showProgressBar && (
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs font-mono text-neutral-400 dark:text-neutral-500">
|
||||
<span>
|
||||
{currentIndex + 1} / {totalTokens}{' '}
|
||||
<span className="opacity-75">({percent.toFixed(1)}%)</span>
|
||||
</span>
|
||||
<span className="text-neutral-400">
|
||||
剩余 ~{remainingText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center group py-1 cursor-pointer">
|
||||
{/* Chapter tick marks on the track */}
|
||||
{chapters.length > 1 &&
|
||||
chapters.map((chap, idx) => {
|
||||
if (idx === 0) return null;
|
||||
const pos = (chap.startIndex / (totalTokens || 1)) * 100;
|
||||
return (
|
||||
<div
|
||||
key={chap.id}
|
||||
title={chap.title}
|
||||
style={{ left: `${pos}%` }}
|
||||
className="absolute top-1 bottom-1 w-0.5 bg-neutral-500/40 z-10 pointer-events-none"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, totalTokens - 1)}
|
||||
value={currentIndex}
|
||||
onChange={(e) => onJumpTo(Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-rose-500 transition-all focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Playback Buttons */}
|
||||
<div className="flex items-center justify-between sm:justify-center sm:space-x-4">
|
||||
{/* Restart */}
|
||||
<button
|
||||
onClick={onRestart}
|
||||
title="重新开始 [R]"
|
||||
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Rewind 10 */}
|
||||
<button
|
||||
onClick={() => onJumpBy(-10)}
|
||||
title="后退 10 词 [←]"
|
||||
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronsLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Previous Sentence */}
|
||||
<button
|
||||
onClick={onJumpToPreviousSentence}
|
||||
title="上一句 [Shift + ←]"
|
||||
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Primary Play / Pause Button */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
title="播放 / 暂停 [空格键]"
|
||||
className="w-12 h-12 sm:w-14 sm:h-14 rounded-full bg-neutral-900 hover:bg-black dark:bg-neutral-100 dark:hover:bg-white text-white dark:text-black flex items-center justify-center shadow-lg active:scale-95 transition-all"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-5 h-5 fill-current" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Next Sentence */}
|
||||
<button
|
||||
onClick={onJumpToNextSentence}
|
||||
title="下一句 [Shift + →]"
|
||||
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Forward 10 */}
|
||||
<button
|
||||
onClick={() => onJumpBy(10)}
|
||||
title="前进 10 词 [→]"
|
||||
className="p-2 sm:p-2.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronsRight className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Quick Speed fine-tuning */}
|
||||
<div className="flex items-center space-x-1 pl-2 border-l border-neutral-300 dark:border-neutral-800">
|
||||
<button
|
||||
onClick={() => onChangeWpm(Math.max(100, settings.wpm - 25))}
|
||||
title="减速 25 WPM [↓]"
|
||||
className="p-1.5 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-mono font-semibold w-12 text-center text-neutral-700 dark:text-neutral-300">
|
||||
{settings.wpm}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChangeWpm(Math.min(1200, settings.wpm + 25))}
|
||||
title="加速 25 WPM [↑]"
|
||||
className="p-1.5 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset WPM Chips */}
|
||||
<div className="flex items-center justify-center flex-wrap gap-1.5 pt-1 text-xs">
|
||||
<span className="text-neutral-400 text-[11px] mr-1 hidden sm:inline">
|
||||
预设语速:
|
||||
</span>
|
||||
{WPM_PRESETS.map((preset) => {
|
||||
const isActive = settings.wpm === preset;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => onChangeWpm(preset)}
|
||||
className={`px-2.5 py-1 rounded-full font-mono text-xs transition-all ${
|
||||
isActive
|
||||
? 'bg-rose-500 text-white font-semibold shadow-sm'
|
||||
: 'bg-neutral-200/60 dark:bg-neutral-800/60 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-300 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
src/components/FocusDisplay.tsx
Normal file
165
src/components/FocusDisplay.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import type { FontFamily, FontSize, OrpColor, Token } from '../types';
|
||||
|
||||
interface FocusDisplayProps {
|
||||
token: Token | null;
|
||||
previousToken: Token | null;
|
||||
nextToken: Token | null;
|
||||
fontSize: FontSize;
|
||||
fontFamily: FontFamily;
|
||||
orpColor: OrpColor;
|
||||
showGuideLines: boolean;
|
||||
showContextPreview: boolean;
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
}
|
||||
|
||||
const ORP_COLOR_MAP: Record<OrpColor, { text: string; bg: string; glow: string }> = {
|
||||
rose: {
|
||||
text: 'text-rose-500',
|
||||
bg: 'bg-rose-500',
|
||||
glow: 'drop-shadow-[0_0_12px_rgba(244,63,94,0.45)]',
|
||||
},
|
||||
amber: {
|
||||
text: 'text-amber-500',
|
||||
bg: 'bg-amber-500',
|
||||
glow: 'drop-shadow-[0_0_12px_rgba(245,158,11,0.45)]',
|
||||
},
|
||||
emerald: {
|
||||
text: 'text-emerald-400',
|
||||
bg: 'bg-emerald-400',
|
||||
glow: 'drop-shadow-[0_0_12px_rgba(52,211,153,0.45)]',
|
||||
},
|
||||
cyan: {
|
||||
text: 'text-cyan-400',
|
||||
bg: 'bg-cyan-400',
|
||||
glow: 'drop-shadow-[0_0_12px_rgba(34,211,238,0.45)]',
|
||||
},
|
||||
violet: {
|
||||
text: 'text-purple-400',
|
||||
bg: 'bg-purple-400',
|
||||
glow: 'drop-shadow-[0_0_12px_rgba(192,132,252,0.45)]',
|
||||
},
|
||||
};
|
||||
|
||||
const FONT_SIZE_MAP: Record<FontSize, string> = {
|
||||
sm: 'text-3xl sm:text-4xl h-24 sm:h-28',
|
||||
base: 'text-4xl sm:text-5xl h-28 sm:h-32',
|
||||
lg: 'text-5xl sm:text-6xl h-32 sm:h-36',
|
||||
xl: 'text-6xl sm:text-7xl h-36 sm:h-44',
|
||||
'2xl': 'text-7xl sm:text-8xl h-44 sm:h-52',
|
||||
'3xl': 'text-8xl sm:text-9xl h-52 sm:h-64',
|
||||
};
|
||||
|
||||
const FONT_FAMILY_MAP: Record<FontFamily, string> = {
|
||||
mono: 'font-mono tracking-tight',
|
||||
sans: 'font-sans tracking-normal font-medium',
|
||||
serif: 'font-serif tracking-normal',
|
||||
};
|
||||
|
||||
export const FocusDisplay: React.FC<FocusDisplayProps> = ({
|
||||
token,
|
||||
previousToken,
|
||||
nextToken,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
orpColor,
|
||||
showGuideLines,
|
||||
showContextPreview,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
}) => {
|
||||
const colorConfig = ORP_COLOR_MAP[orpColor] || ORP_COLOR_MAP.rose;
|
||||
const sizeClasses = FONT_SIZE_MAP[fontSize] || FONT_SIZE_MAP.xl;
|
||||
const familyClasses = FONT_FAMILY_MAP[fontFamily] || FONT_FAMILY_MAP.sans;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onTogglePlay}
|
||||
className="relative w-full max-w-4xl mx-auto flex flex-col items-center justify-center cursor-pointer select-none py-6 sm:py-10 transition-colors"
|
||||
title="点击屏幕或按空格键 暂停/继续"
|
||||
>
|
||||
{/* Contextual Peripheral Ghost Words (Top line) */}
|
||||
{showContextPreview && (
|
||||
<div className="h-7 mb-3 flex items-center justify-center space-x-6 text-sm text-neutral-400 dark:text-neutral-500 opacity-60 transition-opacity">
|
||||
<span className="truncate max-w-[140px] text-right">
|
||||
{previousToken ? previousToken.text : ''}
|
||||
</span>
|
||||
<span className="w-1 h-1 rounded-full bg-neutral-600/40" />
|
||||
<span className="truncate max-w-[140px] text-left">
|
||||
{nextToken ? nextToken.text : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main RSVP Focal Chamber */}
|
||||
<div
|
||||
className={`relative w-full flex items-center justify-center border-y border-neutral-700/30 dark:border-neutral-800/60 bg-neutral-900/10 dark:bg-black/20 rounded-xl px-4 ${sizeClasses} ${familyClasses}`}
|
||||
>
|
||||
{/* Top & Bottom ORP Crosshair Guide Notches */}
|
||||
{showGuideLines && (
|
||||
<>
|
||||
{/* Top tick mark */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 flex flex-col items-center pointer-events-none">
|
||||
<div
|
||||
className={`w-0.5 sm:w-1 h-3 sm:h-4 ${colorConfig.bg} rounded-b-sm shadow-sm`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom tick mark */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 flex flex-col items-center pointer-events-none">
|
||||
<div
|
||||
className={`w-0.5 sm:w-1 h-3 sm:h-4 ${colorConfig.bg} rounded-t-sm shadow-sm`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtle horizontal baseline guides */}
|
||||
<div className="absolute left-6 right-6 top-1/2 -translate-y-1/2 h-[1px] bg-gradient-to-r from-transparent via-neutral-500/10 dark:via-neutral-400/5 to-transparent pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Word Display with Spritz Absolute Alignment */}
|
||||
{token ? (
|
||||
<div className="relative flex items-center justify-center leading-none">
|
||||
{/* Prefix: anchored to the left of the Pivot */}
|
||||
<span className="absolute right-full top-1/2 -translate-y-1/2 whitespace-pre text-right text-neutral-800 dark:text-neutral-200">
|
||||
{token.prefix}
|
||||
</span>
|
||||
|
||||
{/* Pivot (ORP): strictly mathematically centered at 50% */}
|
||||
<span
|
||||
className={`relative font-bold ${colorConfig.text} ${colorConfig.glow} px-[0.02em]`}
|
||||
>
|
||||
{token.pivot || ' '}
|
||||
</span>
|
||||
|
||||
{/* Suffix: anchored to the right of the Pivot */}
|
||||
<span className="absolute left-full top-1/2 -translate-y-1/2 whitespace-pre text-left text-neutral-800 dark:text-neutral-200">
|
||||
{token.suffix}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xl sm:text-2xl text-neutral-400/60 font-sans tracking-wide">
|
||||
请载入文本或选择样篇
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play/Pause state watermarks */}
|
||||
{!isPlaying && token && (
|
||||
<div className="absolute bottom-2 right-4 text-xs font-mono tracking-wider text-neutral-500/60 uppercase">
|
||||
已暂停 [空格继续]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Punctuation / Pause indicator dot */}
|
||||
<div className="h-4 mt-2 flex items-center justify-center">
|
||||
{token?.isParagraphEnd && (
|
||||
<span className="text-[11px] font-mono tracking-widest text-neutral-400 dark:text-neutral-500 uppercase">
|
||||
¶ 段落停顿
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
src/components/Header.tsx
Normal file
147
src/components/Header.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardPaste,
|
||||
HelpCircle,
|
||||
List,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Moon,
|
||||
Settings as SettingsIcon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type { Book, Chapter, ReaderSettings } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
book: Book | null;
|
||||
currentChapter?: Chapter;
|
||||
settings: ReaderSettings;
|
||||
isFullscreen: boolean;
|
||||
onOpenLibrary: () => void;
|
||||
onOpenPaste: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
onOpenChapters: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onCycleTheme: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
book,
|
||||
currentChapter,
|
||||
settings,
|
||||
isFullscreen,
|
||||
onOpenLibrary,
|
||||
onOpenPaste,
|
||||
onOpenSettings,
|
||||
onOpenShortcuts,
|
||||
onOpenChapters,
|
||||
onToggleFullscreen,
|
||||
onCycleTheme,
|
||||
}) => {
|
||||
return (
|
||||
<header className="w-full flex items-center justify-between px-4 py-3 border-b border-neutral-200/50 dark:border-neutral-800/80 bg-white/50 dark:bg-black/30 backdrop-blur-md select-none">
|
||||
{/* Left: App Brand & Current Book Info */}
|
||||
<div className="flex items-center space-x-3 truncate">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center space-x-2 font-black tracking-widest text-sm uppercase text-neutral-900 dark:text-neutral-100 shrink-0">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-rose-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-rose-500" />
|
||||
</div>
|
||||
<span className="hidden sm:inline">Focus</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-neutral-300 dark:bg-neutral-800 shrink-0" />
|
||||
|
||||
{/* Current Book & Chapter pill */}
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
<button
|
||||
onClick={onOpenLibrary}
|
||||
className="text-xs sm:text-sm font-medium text-neutral-800 dark:text-neutral-200 truncate hover:text-rose-500 transition-colors text-left"
|
||||
title="点击切换图书"
|
||||
>
|
||||
{book ? book.title : '未选择图书'}
|
||||
</button>
|
||||
|
||||
{book && book.chapters.length > 1 && (
|
||||
<button
|
||||
onClick={onOpenChapters}
|
||||
className="flex items-center space-x-1 px-2 py-0.5 rounded text-[11px] font-mono bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors shrink-0"
|
||||
title="切换章节"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
<span className="truncate max-w-[120px]">
|
||||
{currentChapter ? currentChapter.title : '目录'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Action Icons */}
|
||||
<div className="flex items-center space-x-1 sm:space-x-2">
|
||||
{/* Paste Instant Read */}
|
||||
<button
|
||||
onClick={onOpenPaste}
|
||||
title="粘贴文本立即开读"
|
||||
className="p-1.5 sm:p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Bookshelf */}
|
||||
<button
|
||||
onClick={onOpenLibrary}
|
||||
title="打开书架 / 上传文件"
|
||||
className="p-1.5 sm:p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={onCycleTheme}
|
||||
title={`当前主题:${settings.theme}(点击切换)`}
|
||||
className="p-1.5 sm:p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
{settings.theme === 'paper' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
title="设置 (字体/节奏/高亮)"
|
||||
className="p-1.5 sm:p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Shortcuts */}
|
||||
<button
|
||||
onClick={onOpenShortcuts}
|
||||
title="快捷键指引"
|
||||
className="p-1.5 sm:p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors hidden sm:inline-flex"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
title="全屏专注模式 [F]"
|
||||
className="p-1.5 sm:p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
338
src/components/LibraryModal.tsx
Normal file
338
src/components/LibraryModal.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { BookOpen, Sparkles, Trash2, Upload, X } from 'lucide-react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { parseEpubFile } from '../core/epub';
|
||||
import { tokenizeText } from '../core/tokenizer';
|
||||
import { getBuiltinSampleBooks } from '../db/samples';
|
||||
import type { Book, BookMeta } from '../types';
|
||||
|
||||
interface LibraryModalProps {
|
||||
isOpen: boolean;
|
||||
books: BookMeta[];
|
||||
activeBookId: string | null;
|
||||
onClose: () => void;
|
||||
onSelectBook: (bookId: string) => void;
|
||||
onSaveNewBook: (book: Book) => Promise<void>;
|
||||
onDeleteBook: (bookId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const LibraryModal: React.FC<LibraryModalProps> = ({
|
||||
isOpen,
|
||||
books,
|
||||
activeBookId,
|
||||
onClose,
|
||||
onSelectBook,
|
||||
onSaveNewBook,
|
||||
onDeleteBook,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'shelf' | 'upload' | 'samples'>('shelf');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
setIsProcessing(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const fileName = file.name;
|
||||
const lower = fileName.toLowerCase();
|
||||
let title = fileName.replace(/\.[^/.]+$/, '');
|
||||
let author = '未知作者';
|
||||
let fullText = '';
|
||||
let chapters = undefined;
|
||||
|
||||
if (lower.endsWith('.epub')) {
|
||||
const parsed = await parseEpubFile(file);
|
||||
title = parsed.title;
|
||||
author = parsed.author;
|
||||
fullText = parsed.fullText;
|
||||
chapters = parsed.chapters;
|
||||
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||
fullText = await file.text();
|
||||
} else {
|
||||
throw new Error('仅支持 .txt、.md 或 .epub 格式文件');
|
||||
}
|
||||
|
||||
if (!fullText.trim()) {
|
||||
throw new Error('文件内容为空');
|
||||
}
|
||||
|
||||
const { tokens, chapters: detectedChapters } = tokenizeText(
|
||||
fullText,
|
||||
chapters
|
||||
);
|
||||
|
||||
const newBook: Book = {
|
||||
id: `book-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
|
||||
title,
|
||||
author,
|
||||
format: lower.endsWith('.epub') ? 'epub' : lower.endsWith('.md') ? 'md' : 'txt',
|
||||
tokens,
|
||||
chapters: detectedChapters,
|
||||
currentProgress: 0,
|
||||
totalTokens: tokens.length,
|
||||
createdAt: Date.now(),
|
||||
lastReadAt: Date.now(),
|
||||
byteSize: file.size,
|
||||
};
|
||||
|
||||
await onSaveNewBook(newBook);
|
||||
onSelectBook(newBook.id);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
console.error('File import failed:', err);
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : '文件解析失败,请检查格式'
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSample = async (sampleBook: Book) => {
|
||||
await onSaveNewBook(sampleBook);
|
||||
onSelectBook(sampleBook.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const samples = getBuiltinSampleBooks();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
||||
<div className="relative w-full max-w-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BookOpen className="w-5 h-5 text-rose-500" />
|
||||
<h2 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
|
||||
书架与导入
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex border-b border-neutral-200 dark:border-neutral-800 px-6 pt-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('shelf')}
|
||||
className={`pb-2.5 px-3 text-sm font-medium border-b-2 transition-all ${
|
||||
activeTab === 'shelf'
|
||||
? 'border-rose-500 text-rose-500 font-semibold'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
我的书架 ({books.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={`pb-2.5 px-3 text-sm font-medium border-b-2 transition-all ${
|
||||
activeTab === 'upload'
|
||||
? 'border-rose-500 text-rose-500 font-semibold'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
上传文件 (TXT / MD / EPUB)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('samples')}
|
||||
className={`pb-2.5 px-3 text-sm font-medium border-b-2 transition-all ${
|
||||
activeTab === 'samples'
|
||||
? 'border-rose-500 text-rose-500 font-semibold'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
精选样篇
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{errorMessage && (
|
||||
<div className="p-3 text-sm rounded-lg bg-rose-500/10 text-rose-500 border border-rose-500/20">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 1: Bookshelf */}
|
||||
{activeTab === 'shelf' && (
|
||||
<div className="space-y-3">
|
||||
{books.length === 0 ? (
|
||||
<div className="text-center py-12 text-neutral-400">
|
||||
<BookOpen className="w-12 h-12 mx-auto stroke-[1.2] mb-3 opacity-40" />
|
||||
<p className="text-base font-medium">书架空空如也</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
点击上方“上传文件”或直接体验“精选样篇”
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
books.map((b) => {
|
||||
const isActive = b.id === activeBookId;
|
||||
const progressPct =
|
||||
b.totalTokens > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(b.currentProgress / (b.totalTokens - 1 || 1)) * 100
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
onClick={() => {
|
||||
onSelectBook(b.id);
|
||||
onClose();
|
||||
}}
|
||||
className={`group relative p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
isActive
|
||||
? 'border-rose-500/80 bg-rose-500/5 shadow-sm'
|
||||
: 'border-neutral-200 dark:border-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{b.title}
|
||||
</h3>
|
||||
{isActive && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-rose-500/20 text-rose-500 font-mono">
|
||||
正在阅读
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{b.author && b.author !== '未知作者'
|
||||
? `${b.author} · `
|
||||
: ''}
|
||||
共 {b.totalTokens.toLocaleString()} 词 · 格式:
|
||||
{b.format.toUpperCase()}
|
||||
</p>
|
||||
|
||||
{/* Progress bar inside card */}
|
||||
<div className="mt-3 flex items-center space-x-3">
|
||||
<div className="flex-1 h-1.5 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
style={{ width: `${progressPct}%` }}
|
||||
className="h-full bg-rose-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-neutral-500">
|
||||
{progressPct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-1 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`确定删除《${b.title}》吗?`)) {
|
||||
onDeleteBook(b.id);
|
||||
}
|
||||
}}
|
||||
title="删除书籍"
|
||||
className="p-1.5 rounded-lg text-neutral-400 hover:text-rose-500 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Upload Dropzone */}
|
||||
{activeTab === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-neutral-300 dark:border-neutral-700 hover:border-rose-500 dark:hover:border-rose-500 rounded-2xl p-8 text-center cursor-pointer transition-colors bg-neutral-50/50 dark:bg-neutral-800/10"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.epub"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Upload className="w-10 h-10 mx-auto text-neutral-400 group-hover:text-rose-500 mb-3" />
|
||||
<p className="text-base font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
点击选择文件 或 拖拽至此处
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
支持 TXT、Markdown (.md)、EPUB 电子书,浏览器内存级秒解
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="flex items-center justify-center space-x-2 py-4 text-sm text-rose-500">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-rose-500 border-t-transparent animate-spin" />
|
||||
<span>正在解析并生成 ORP 切词数据...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Curated Samples */}
|
||||
{activeTab === 'samples' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-neutral-500">
|
||||
点击任意样篇,立即开启无干扰的极速心流体验:
|
||||
</p>
|
||||
{samples.map((sample) => (
|
||||
<div
|
||||
key={sample.id}
|
||||
onClick={() => handleLoadSample(sample)}
|
||||
className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-rose-500/80 bg-neutral-50/50 dark:bg-neutral-800/20 cursor-pointer transition-all flex items-center justify-between"
|
||||
>
|
||||
<div className="min-w-0 pr-4">
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{sample.title}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
{sample.author} · {sample.totalTokens} 词
|
||||
</p>
|
||||
</div>
|
||||
<button className="flex items-center space-x-1 px-3 py-1 rounded-lg text-xs font-medium bg-rose-500 text-white shrink-0 hover:bg-rose-600 transition-colors">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>载入</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
164
src/components/PasteModal.tsx
Normal file
164
src/components/PasteModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Clipboard, Play, X } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { tokenizeText } from '../core/tokenizer';
|
||||
import type { Book } from '../types';
|
||||
|
||||
interface PasteModalProps {
|
||||
isOpen: boolean;
|
||||
wpm: number;
|
||||
onClose: () => void;
|
||||
onSaveAndPlay: (book: Book) => Promise<void>;
|
||||
}
|
||||
|
||||
export const PasteModal: React.FC<PasteModalProps> = ({
|
||||
isOpen,
|
||||
wpm,
|
||||
onClose,
|
||||
onSaveAndPlay,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [isReadingClipboard, setIsReadingClipboard] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
setIsReadingClipboard(true);
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
setContent(text);
|
||||
if (!title) {
|
||||
const firstLine = text.trim().split('\n')[0].substring(0, 30);
|
||||
setTitle(firstLine || '剪贴板快读');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Clipboard read failed', err);
|
||||
} finally {
|
||||
setIsReadingClipboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
let bookTitle = title.trim();
|
||||
if (!bookTitle) {
|
||||
const firstLine = content.trim().split('\n')[0].substring(0, 30);
|
||||
bookTitle = firstLine || '剪贴板阅读';
|
||||
}
|
||||
|
||||
const { tokens, chapters } = tokenizeText(content);
|
||||
const newBook: Book = {
|
||||
id: `paste-${Date.now()}`,
|
||||
title: bookTitle,
|
||||
author: '剪贴板投放',
|
||||
format: 'paste',
|
||||
tokens,
|
||||
chapters,
|
||||
currentProgress: 0,
|
||||
totalTokens: tokens.length,
|
||||
createdAt: Date.now(),
|
||||
lastReadAt: Date.now(),
|
||||
};
|
||||
|
||||
await onSaveAndPlay(newBook);
|
||||
onClose();
|
||||
setContent('');
|
||||
setTitle('');
|
||||
};
|
||||
|
||||
const charCount = content.length;
|
||||
const approxMinutes =
|
||||
charCount > 0 ? Math.max(1, Math.ceil((charCount / 2) / Math.max(1, wpm))) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
||||
<div className="relative w-full max-w-xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clipboard className="w-5 h-5 text-rose-500" />
|
||||
<h2 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
|
||||
剪贴板投放快读
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-neutral-500 mb-1">
|
||||
标题(选填)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="留空则自动提取首行作为标题"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-neutral-100 dark:bg-neutral-800 rounded-lg border border-neutral-300 dark:border-neutral-700 focus:outline-none focus:border-rose-500 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-semibold text-neutral-500">
|
||||
粘贴文章、新闻、网页或长篇内容
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasteFromClipboard}
|
||||
disabled={isReadingClipboard}
|
||||
className="text-xs text-rose-500 hover:text-rose-600 font-medium flex items-center space-x-1"
|
||||
>
|
||||
<span>从剪贴板一键填入</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={8}
|
||||
placeholder="在此粘贴任意中英文文本..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full p-3 text-sm bg-neutral-100 dark:bg-neutral-800 rounded-lg border border-neutral-300 dark:border-neutral-700 focus:outline-none focus:border-rose-500 text-neutral-900 dark:text-neutral-100 resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{content.length > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-neutral-500 font-mono bg-neutral-50 dark:bg-neutral-800/40 p-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<span>共计 {charCount.toLocaleString()} 字符</span>
|
||||
<span>
|
||||
当前语速预计耗时:约 {approxMinutes} 分钟
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={!content.trim()}
|
||||
className="flex items-center space-x-1.5 px-5 py-2 text-sm font-semibold rounded-xl bg-rose-500 hover:bg-rose-600 disabled:opacity-40 text-white transition-all shadow-md active:scale-95"
|
||||
>
|
||||
<Play className="w-4 h-4 fill-current ml-0.5" />
|
||||
<span>立即专注阅读</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
265
src/components/SettingsModal.tsx
Normal file
265
src/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Sliders, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type {
|
||||
FontFamily,
|
||||
FontSize,
|
||||
OrpColor,
|
||||
ReaderSettings,
|
||||
ThemeId,
|
||||
} from '../types';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
settings: ReaderSettings;
|
||||
onClose: () => void;
|
||||
onUpdateSettings: (newSettings: Partial<ReaderSettings>) => void;
|
||||
}
|
||||
|
||||
const THEMES: { id: ThemeId; label: string; previewBg: string; previewText: string }[] = [
|
||||
{ id: 'oled', label: '极致纯黑 (OLED)', previewBg: 'bg-black', previewText: 'text-white' },
|
||||
{ id: 'dark', label: '深色暗黑 (Dark)', previewBg: 'bg-zinc-900', previewText: 'text-zinc-200' },
|
||||
{ id: 'slate', label: '冷色蓝灰 (Slate)', previewBg: 'bg-slate-900', previewText: 'text-slate-200' },
|
||||
{ id: 'sepia', label: '护眼羊皮纸 (Sepia)', previewBg: 'bg-[#fbf0d9]', previewText: 'text-[#433422]' },
|
||||
{ id: 'nord', label: '极光冷调 (Nord)', previewBg: 'bg-[#2e3440]', previewText: 'text-[#eceff4]' },
|
||||
{ id: 'paper', label: '纸质亮白 (Paper)', previewBg: 'bg-white', previewText: 'text-neutral-900' },
|
||||
];
|
||||
|
||||
const ORP_COLORS: { id: OrpColor; label: string; bg: string }[] = [
|
||||
{ id: 'rose', label: '醒目玫瑰红', bg: 'bg-rose-500' },
|
||||
{ id: 'amber', label: '高亮琥珀橘', bg: 'bg-amber-500' },
|
||||
{ id: 'emerald', label: '清爽翡翠绿', bg: 'bg-emerald-400' },
|
||||
{ id: 'cyan', label: '科技极光青', bg: 'bg-cyan-400' },
|
||||
{ id: 'violet', label: '梦幻冷紫', bg: 'bg-purple-400' },
|
||||
];
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
isOpen,
|
||||
settings,
|
||||
onClose,
|
||||
onUpdateSettings,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in select-none">
|
||||
<div className="relative w-full max-w-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-2xl flex flex-col max-h-[88vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sliders className="w-5 h-5 text-rose-500" />
|
||||
<h2 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
|
||||
专注阅读设置
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings Body */}
|
||||
<div className="p-6 space-y-6 flex-1 overflow-y-auto text-sm">
|
||||
{/* Theme Selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 mb-2">
|
||||
视觉主题
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onUpdateSettings({ theme: t.id })}
|
||||
className={`p-2.5 rounded-xl border flex items-center space-x-2.5 transition-all text-left ${
|
||||
settings.theme === t.id
|
||||
? 'border-rose-500 ring-1 ring-rose-500/50 bg-rose-500/5'
|
||||
: 'border-neutral-200 dark:border-neutral-800 hover:border-neutral-400 dark:hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-md border border-neutral-400/30 ${t.previewBg} shadow-inner shrink-0`}
|
||||
/>
|
||||
<span className="text-xs font-medium text-neutral-800 dark:text-neutral-200 truncate">
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ORP Focus Highlight Color */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 mb-2">
|
||||
ORP 凝视点颜色
|
||||
</label>
|
||||
<div className="flex items-center space-x-3">
|
||||
{ORP_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onUpdateSettings({ orpColor: c.id })}
|
||||
title={c.label}
|
||||
className={`w-8 h-8 rounded-full ${c.bg} flex items-center justify-center transition-all ${
|
||||
settings.orpColor === c.id
|
||||
? 'ring-2 ring-offset-2 ring-offset-white dark:ring-offset-neutral-900 ring-rose-500 scale-110'
|
||||
: 'opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 mb-2">
|
||||
字体字形
|
||||
</label>
|
||||
<div className="flex rounded-xl bg-neutral-100 dark:bg-neutral-800 p-1 border border-neutral-200 dark:border-neutral-700">
|
||||
{(['sans', 'serif', 'mono'] as FontFamily[]).map((fam) => (
|
||||
<button
|
||||
key={fam}
|
||||
onClick={() => onUpdateSettings({ fontFamily: fam })}
|
||||
className={`flex-1 py-1.5 text-xs rounded-lg font-medium transition-all ${
|
||||
settings.fontFamily === fam
|
||||
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
{fam === 'sans' ? '无衬线' : fam === 'serif' ? '衬线' : '等宽'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 mb-2">
|
||||
字体大小
|
||||
</label>
|
||||
<div className="flex rounded-xl bg-neutral-100 dark:bg-neutral-800 p-1 border border-neutral-200 dark:border-neutral-700">
|
||||
{(['sm', 'base', 'lg', 'xl', '2xl'] as FontSize[]).map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
onClick={() => onUpdateSettings({ fontSize: sz })}
|
||||
className={`flex-1 py-1.5 text-xs rounded-lg font-mono font-medium transition-all ${
|
||||
settings.fontSize === sz
|
||||
? 'bg-white dark:bg-neutral-700 text-rose-500 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
>
|
||||
{sz.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breathing Rhythm Engine */}
|
||||
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-800/30 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
自适应呼吸节奏算法
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
句末与标点自适应延时,杜绝机械恒速造成的疲劳
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.breathingPunctuation}
|
||||
onChange={(e) =>
|
||||
onUpdateSettings({ breathingPunctuation: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded text-rose-500 focus:ring-rose-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.breathingPunctuation && (
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700/50 grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-neutral-500">句末停顿</span>
|
||||
<p className="font-mono font-semibold text-neutral-800 dark:text-neutral-200 mt-0.5">
|
||||
{settings.sentenceEndMultiplier}x
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-neutral-500">逗号分号</span>
|
||||
<p className="font-mono font-semibold text-neutral-800 dark:text-neutral-200 mt-0.5">
|
||||
{settings.clauseEndMultiplier}x
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-neutral-500">段落呼吸</span>
|
||||
<p className="font-mono font-semibold text-neutral-800 dark:text-neutral-200 mt-0.5">
|
||||
{settings.paragraphEndMultiplier}x
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<span className="text-neutral-800 dark:text-neutral-200 font-medium">
|
||||
中心参考标尺(视窗瞄准刻度)
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500">
|
||||
上下参考游标引导眼睛维持在最佳识别点 (ORP)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showGuideLines}
|
||||
onChange={(e) =>
|
||||
onUpdateSettings({ showGuideLines: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded text-rose-500 focus:ring-rose-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<span className="text-neutral-800 dark:text-neutral-200 font-medium">
|
||||
前后幽灵词伴读 (Context Preview)
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500">
|
||||
弱化淡化显示前后词,提供余光语义连贯性
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showContextPreview}
|
||||
onChange={(e) =>
|
||||
onUpdateSettings({ showContextPreview: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded text-rose-500 focus:ring-rose-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
<span className="text-neutral-800 dark:text-neutral-200 font-medium">
|
||||
底部进度条与时间预估
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500">
|
||||
展示当前阅读进度百分比与剩余时间预估
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showProgressBar}
|
||||
onChange={(e) =>
|
||||
onUpdateSettings({ showProgressBar: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded text-rose-500 focus:ring-rose-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
65
src/components/ShortcutsModal.tsx
Normal file
65
src/components/ShortcutsModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { HelpCircle, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ key: '空格键 (Space)', action: '播放 / 暂停' },
|
||||
{ key: '← / →', action: '快退 / 快进 10 个词' },
|
||||
{ key: 'Shift + ← / →', action: '跳转到 上一句 / 下一句' },
|
||||
{ key: '↑ / ↓', action: '微调阅读语速 ±25 WPM' },
|
||||
{ key: 'Shift + ↑ / ↓', action: '加速调节语速 ±50 WPM' },
|
||||
{ key: '[ / ] 或 PageUp/Down', action: '跳转到 上一章 / 下一章' },
|
||||
{ key: 'R', action: '从头重新开始阅读' },
|
||||
{ key: 'F', action: '切换全屏专注模式' },
|
||||
{ key: 'Esc', action: '关闭当前浮层 / 弹窗' },
|
||||
];
|
||||
|
||||
export const ShortcutsModal: React.FC<ShortcutsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in select-none">
|
||||
<div className="relative w-full max-w-md bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-2xl flex flex-col max-h-[85vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center space-x-2">
|
||||
<HelpCircle className="w-5 h-5 text-rose-500" />
|
||||
<h2 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
|
||||
键盘极速快捷键
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts list */}
|
||||
<div className="p-6 space-y-2.5 flex-1 overflow-y-auto">
|
||||
{SHORTCUTS.map((s, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between py-1.5 border-b border-neutral-100 dark:border-neutral-800/60 last:border-0"
|
||||
>
|
||||
<kbd className="px-2.5 py-1 text-xs font-mono font-semibold bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200 rounded-md border border-neutral-200 dark:border-neutral-700 shadow-sm">
|
||||
{s.key}
|
||||
</kbd>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{s.action}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
148
src/core/epub.ts
Normal file
148
src/core/epub.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import JSZip from 'jszip';
|
||||
import type { Chapter } from '../types';
|
||||
|
||||
export interface ParsedEpub {
|
||||
title: string;
|
||||
author: string;
|
||||
fullText: string;
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
// 1. Locate container.xml
|
||||
const containerXmlFile = zip.file('META-INF/container.xml');
|
||||
if (!containerXmlFile) {
|
||||
throw new Error('无效的 EPUB 格式:缺少 META-INF/container.xml');
|
||||
}
|
||||
|
||||
const containerXml = await containerXmlFile.async('text');
|
||||
const domParser = new DOMParser();
|
||||
const containerDoc = domParser.parseFromString(containerXml, 'application/xml');
|
||||
const rootfileEl = containerDoc.querySelector('rootfile');
|
||||
const opfPath = rootfileEl?.getAttribute('full-path');
|
||||
|
||||
if (!opfPath) {
|
||||
throw new Error('无法在 container.xml 中找到 OPF 元数据路径');
|
||||
}
|
||||
|
||||
// 2. Read OPF file
|
||||
const opfFile = zip.file(opfPath);
|
||||
if (!opfFile) {
|
||||
throw new Error(`无法找到 OPF 文件:${opfPath}`);
|
||||
}
|
||||
|
||||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
|
||||
const opfXml = await opfFile.async('text');
|
||||
const opfDoc = domParser.parseFromString(opfXml, 'application/xml');
|
||||
|
||||
// Metadata
|
||||
const title = opfDoc.querySelector('title, dc\\:title')?.textContent?.trim() || (file instanceof File ? file.name.replace(/\.epub$/i, '') : '未命名书籍');
|
||||
const author = opfDoc.querySelector('creator, dc\\:creator')?.textContent?.trim() || '未知作者';
|
||||
|
||||
// Manifest: id -> href
|
||||
const manifestMap = new Map<string, string>();
|
||||
opfDoc.querySelectorAll('manifest > item').forEach((item) => {
|
||||
const id = item.getAttribute('id');
|
||||
const href = item.getAttribute('href');
|
||||
if (id && href) {
|
||||
// Decode URL encoding
|
||||
manifestMap.set(id, decodeURIComponent(href));
|
||||
}
|
||||
});
|
||||
|
||||
// Spine: ordered list of itemrefs
|
||||
const spineItemIds: string[] = [];
|
||||
opfDoc.querySelectorAll('spine > itemref').forEach((itemref) => {
|
||||
const idref = itemref.getAttribute('idref');
|
||||
if (idref) {
|
||||
spineItemIds.push(idref);
|
||||
}
|
||||
});
|
||||
|
||||
const chapters: Chapter[] = [];
|
||||
let fullText = '';
|
||||
let chapterIndex = 1;
|
||||
|
||||
for (const idref of spineItemIds) {
|
||||
const relativeHref = manifestMap.get(idref);
|
||||
if (!relativeHref) continue;
|
||||
|
||||
// Resolve full path inside zip
|
||||
const filePath = opfDir ? resolvePath(opfDir, relativeHref) : relativeHref;
|
||||
const chapterFile = zip.file(filePath);
|
||||
if (!chapterFile) continue;
|
||||
|
||||
const contentHtml = await chapterFile.async('text');
|
||||
const chapterDoc = domParser.parseFromString(contentHtml, 'text/html');
|
||||
|
||||
// Extract title from headings or title tag
|
||||
const heading = chapterDoc.querySelector('h1, h2, h3, title');
|
||||
let chapterTitle = heading?.textContent?.trim() || '';
|
||||
if (!chapterTitle || chapterTitle.length > 50) {
|
||||
chapterTitle = `第 ${chapterIndex} 节`;
|
||||
}
|
||||
|
||||
// Extract text content cleanly
|
||||
const body = chapterDoc.body || chapterDoc.documentElement;
|
||||
// Remove scripts and styles
|
||||
body.querySelectorAll('script, style, svg').forEach((el) => el.remove());
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
const elements = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote');
|
||||
|
||||
if (elements.length > 0) {
|
||||
elements.forEach((el) => {
|
||||
const text = el.textContent?.trim();
|
||||
if (text) paragraphs.push(text);
|
||||
});
|
||||
} else {
|
||||
const text = body.textContent?.trim();
|
||||
if (text) paragraphs.push(text);
|
||||
}
|
||||
|
||||
const chapterText = paragraphs.join('\n\n');
|
||||
if (chapterText.trim().length > 0) {
|
||||
chapters.push({
|
||||
id: `chap-${chapterIndex}`,
|
||||
title: chapterTitle,
|
||||
startIndex: fullText.length,
|
||||
});
|
||||
|
||||
fullText += (fullText ? '\n\n' : '') + chapterText;
|
||||
chapterIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullText.trim()) {
|
||||
throw new Error('未能从 EPUB 提取到可读文本');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
fullText,
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative path against a base directory.
|
||||
*/
|
||||
function resolvePath(baseDir: string, relativePath: string): string {
|
||||
const parts = (baseDir + relativePath).split('/');
|
||||
const resolved: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '.' || part === '') continue;
|
||||
if (part === '..') {
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved.join('/');
|
||||
}
|
||||
74
src/core/orp.ts
Normal file
74
src/core/orp.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading.
|
||||
* Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests.
|
||||
*/
|
||||
|
||||
const CJK_REGEX = /[一-龥-ヿ가-]/;
|
||||
|
||||
export function isCJKChar(char: string): boolean {
|
||||
return CJK_REGEX.test(char);
|
||||
}
|
||||
|
||||
export function isCJKWord(word: string): boolean {
|
||||
return CJK_REGEX.test(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the 0-based index of the ORP character in cleanWord.
|
||||
*/
|
||||
export function calculateOrpIndex(cleanWord: string): number {
|
||||
const len = cleanWord.length;
|
||||
if (len <= 1) return 0;
|
||||
|
||||
if (isCJKWord(cleanWord)) {
|
||||
// CJK characters have high semantic density per glyph
|
||||
if (len === 2) return 0;
|
||||
if (len <= 4) return 1;
|
||||
if (len <= 6) return 2;
|
||||
return Math.min(len - 1, Math.floor((len - 1) * 0.35));
|
||||
}
|
||||
|
||||
// Standard Spritz ORP Table for alphabetic words
|
||||
if (len <= 5) return 1;
|
||||
if (len <= 9) return 2;
|
||||
if (len <= 13) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
export interface SplitWord {
|
||||
prefix: string;
|
||||
pivot: string;
|
||||
suffix: string;
|
||||
orpIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a token into prefix, pivot (ORP character), and suffix.
|
||||
* Handles leading punctuation (e.g. "“", "《", "(") and trailing punctuation.
|
||||
*/
|
||||
export function splitOrp(
|
||||
coreWord: string,
|
||||
leadingPunct: string = '',
|
||||
trailingPunct: string = ''
|
||||
): SplitWord {
|
||||
if (!coreWord) {
|
||||
return {
|
||||
prefix: leadingPunct,
|
||||
pivot: '',
|
||||
suffix: trailingPunct,
|
||||
orpIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const orpIndex = calculateOrpIndex(coreWord);
|
||||
const prefix = leadingPunct + coreWord.slice(0, orpIndex);
|
||||
const pivot = coreWord[orpIndex] || '';
|
||||
const suffix = coreWord.slice(orpIndex + 1) + trailingPunct;
|
||||
|
||||
return {
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
};
|
||||
}
|
||||
73
src/core/timing.ts
Normal file
73
src/core/timing.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ReaderSettings, Token } from '../types';
|
||||
import { isCJKWord } from './orp';
|
||||
|
||||
/**
|
||||
* Calculates the display duration (in milliseconds) for a single token,
|
||||
* applying the physiological breathing rhythm algorithm.
|
||||
*/
|
||||
export function calculateTokenDelay(
|
||||
token: Token,
|
||||
settings: ReaderSettings
|
||||
): number {
|
||||
const baseDelay = Math.max(20, Math.round(60000 / Math.max(50, settings.wpm)));
|
||||
|
||||
if (!settings.breathingPunctuation) {
|
||||
return baseDelay;
|
||||
}
|
||||
|
||||
let multiplier = 1.0;
|
||||
|
||||
if (token.isParagraphEnd) {
|
||||
multiplier = settings.paragraphEndMultiplier ?? 2.5;
|
||||
} else if (token.isSentenceEnd) {
|
||||
multiplier = settings.sentenceEndMultiplier ?? 2.2;
|
||||
} else if (token.isClauseEnd) {
|
||||
multiplier = settings.clauseEndMultiplier ?? 1.4;
|
||||
} else if (token.isLongWord) {
|
||||
multiplier = 1.2;
|
||||
} else if (token.cleanWord.length <= 2 && !isCJKWord(token.cleanWord)) {
|
||||
// Short function words (e.g. "a", "of", "in") flash slightly faster
|
||||
multiplier = 0.9;
|
||||
}
|
||||
|
||||
// Minimum duration protection (never flash below 20ms)
|
||||
return Math.max(20, Math.round(baseDelay * multiplier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the remaining reading time in seconds from current index to the end.
|
||||
*/
|
||||
export function estimateRemainingSeconds(
|
||||
tokens: Token[],
|
||||
currentIndex: number,
|
||||
settings: ReaderSettings
|
||||
): number {
|
||||
if (!tokens || currentIndex >= tokens.length) return 0;
|
||||
|
||||
const remainingTokens = tokens.length - currentIndex;
|
||||
// Fast approximate calculation considering ~15% punctuation overhead
|
||||
const baseDelay = 60000 / Math.max(50, settings.wpm);
|
||||
const factor = settings.breathingPunctuation ? 1.15 : 1.0;
|
||||
const totalMs = remainingTokens * baseDelay * factor;
|
||||
|
||||
return Math.ceil(totalMs / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds into human-readable duration (e.g. "3分20秒" or "45秒" or "1小时12分").
|
||||
*/
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return '0秒';
|
||||
if (totalSeconds < 60) return `${totalSeconds}秒`;
|
||||
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes < 60) {
|
||||
return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分钟`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return remMinutes > 0 ? `${hours}小时${remMinutes}分` : `${hours}小时`;
|
||||
}
|
||||
238
src/core/tokenizer.ts
Normal file
238
src/core/tokenizer.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Chapter, Token } from '../types';
|
||||
import { isCJKWord, splitOrp } from './orp';
|
||||
|
||||
// Leading brackets and quotes that should attach to the beginning of the next word
|
||||
const LEADING_PUNCT_REGEX = /^[“‘「『《〈(([<{<"'`]/;
|
||||
|
||||
// Sentence-ending punctuation
|
||||
const SENTENCE_PUNCT_REGEX = /[.!?。!?…\n]/;
|
||||
|
||||
// Clause/pause punctuation
|
||||
const CLAUSE_PUNCT_REGEX = /[,;:,;:、—–]/;
|
||||
|
||||
// Chapter detection regex patterns
|
||||
const CHAPTER_REGEXES = [
|
||||
/^(#{1,3}\s+.+)$/m, // # Markdown header
|
||||
/^(第[0-9一二三四五六七八九十百千万]+[章回卷节篇部集].*)$/m, // 第X章 ...
|
||||
/^(Chapter\s+[0-9IVXLCDM]+.*)$/im, // Chapter X ...
|
||||
/^(Part\s+[0-9IVXLCDM]+.*)$/im, // Part X ...
|
||||
/^(Book\s+[0-9IVXLCDM]+.*)$/im, // Book X ...
|
||||
/^(Act\s+[0-9IVXLCDM]+.*)$/im, // Act X ...
|
||||
];
|
||||
|
||||
export interface TokenizeResult {
|
||||
tokens: Token[];
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export function tokenizeText(
|
||||
rawText: string,
|
||||
existingChapters?: Chapter[]
|
||||
): TokenizeResult {
|
||||
if (!rawText || !rawText.trim()) {
|
||||
return { tokens: [], chapters: [] };
|
||||
}
|
||||
|
||||
const chapters: Chapter[] = existingChapters ? [...existingChapters] : [];
|
||||
const autoDetectChapters = !existingChapters || existingChapters.length === 0;
|
||||
|
||||
let segmenter: Intl.Segmenter | null = null;
|
||||
try {
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
segmenter = new Intl.Segmenter(['zh', 'en', 'ja', 'ko'], {
|
||||
granularity: 'word',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Intl.Segmenter not available, fallback to basic regex', err);
|
||||
}
|
||||
|
||||
// Pre-process chapter headers if auto detecting from plain text
|
||||
const detectedChapterPositions: { line: string; index: number }[] = [];
|
||||
if (autoDetectChapters) {
|
||||
let charIndex = 0;
|
||||
const lines = rawText.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.length > 0 && trimmedLine.length < 60) {
|
||||
for (const regex of CHAPTER_REGEXES) {
|
||||
if (regex.test(trimmedLine)) {
|
||||
const cleanTitle = trimmedLine.replace(/^#+\s*/, '').trim();
|
||||
detectedChapterPositions.push({
|
||||
line: cleanTitle,
|
||||
index: charIndex,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
charIndex += line.length + 1; // +1 for the newline
|
||||
}
|
||||
}
|
||||
|
||||
const tokens: Token[] = [];
|
||||
let pendingLeading = '';
|
||||
let newlineCount = 0;
|
||||
let nextChapterIdx = 0;
|
||||
let currentChapterIndex = 0;
|
||||
|
||||
if (segmenter) {
|
||||
const segments = segmenter.segment(rawText);
|
||||
|
||||
for (const seg of segments) {
|
||||
const { segment, isWordLike, index } = seg;
|
||||
|
||||
// Check if we reached a detected chapter boundary
|
||||
if (
|
||||
autoDetectChapters &&
|
||||
nextChapterIdx < detectedChapterPositions.length &&
|
||||
index >= detectedChapterPositions[nextChapterIdx].index
|
||||
) {
|
||||
const chapInfo = detectedChapterPositions[nextChapterIdx];
|
||||
currentChapterIndex = chapters.length;
|
||||
chapters.push({
|
||||
id: `chap-${chapters.length + 1}`,
|
||||
title: chapInfo.line,
|
||||
startIndex: tokens.length,
|
||||
});
|
||||
nextChapterIdx++;
|
||||
if (tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
tokens[tokens.length - 1].isSentenceEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isWordLike) {
|
||||
if (segment.includes('\n')) {
|
||||
newlineCount += (segment.match(/\n/g) || []).length;
|
||||
}
|
||||
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
// Whitespace only
|
||||
if (newlineCount >= 2 && tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LEADING_PUNCT_REGEX.test(trimmed)) {
|
||||
// Attaches to the NEXT token's prefix
|
||||
pendingLeading += trimmed;
|
||||
} else {
|
||||
// Trailing punctuation attached to the PREVIOUS token
|
||||
if (tokens.length > 0) {
|
||||
const prev = tokens[tokens.length - 1];
|
||||
prev.text += trimmed;
|
||||
prev.suffix += trimmed;
|
||||
if (SENTENCE_PUNCT_REGEX.test(trimmed)) {
|
||||
prev.isSentenceEnd = true;
|
||||
}
|
||||
if (CLAUSE_PUNCT_REGEX.test(trimmed)) {
|
||||
prev.isClauseEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newlineCount >= 2 && tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word segment
|
||||
const coreWord = segment.trim();
|
||||
if (!coreWord) continue;
|
||||
|
||||
const isLong = isCJKWord(coreWord)
|
||||
? coreWord.length >= 4
|
||||
: coreWord.length > 8;
|
||||
|
||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||
coreWord,
|
||||
pendingLeading,
|
||||
''
|
||||
);
|
||||
|
||||
const isParagraphBreak = newlineCount >= 2;
|
||||
|
||||
tokens.push({
|
||||
id: tokens.length,
|
||||
text: prefix + pivot + suffix,
|
||||
cleanWord: coreWord,
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
isParagraphEnd: isParagraphBreak,
|
||||
isSentenceEnd: false,
|
||||
isClauseEnd: false,
|
||||
isLongWord: isLong,
|
||||
chapterIndex: currentChapterIndex,
|
||||
});
|
||||
|
||||
pendingLeading = '';
|
||||
newlineCount = 0;
|
||||
}
|
||||
} else {
|
||||
// Regex fallback for environments without Intl.Segmenter
|
||||
const wordPattern = /[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = wordPattern.exec(rawText)) !== null) {
|
||||
const str = match[0];
|
||||
if (/[一-龥a-zA-Z0-9]/.test(str)) {
|
||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||
str,
|
||||
pendingLeading,
|
||||
''
|
||||
);
|
||||
tokens.push({
|
||||
id: tokens.length,
|
||||
text: prefix + pivot + suffix,
|
||||
cleanWord: str,
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
isParagraphEnd: newlineCount >= 2,
|
||||
isSentenceEnd: false,
|
||||
isClauseEnd: false,
|
||||
isLongWord: str.length > 8,
|
||||
chapterIndex: currentChapterIndex,
|
||||
});
|
||||
pendingLeading = '';
|
||||
newlineCount = 0;
|
||||
} else if (LEADING_PUNCT_REGEX.test(str)) {
|
||||
pendingLeading += str;
|
||||
} else {
|
||||
if (tokens.length > 0) {
|
||||
const prev = tokens[tokens.length - 1];
|
||||
prev.text += str;
|
||||
prev.suffix += str;
|
||||
if (SENTENCE_PUNCT_REGEX.test(str)) prev.isSentenceEnd = true;
|
||||
if (CLAUSE_PUNCT_REGEX.test(str)) prev.isClauseEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no chapters detected at all, supply default single chapter
|
||||
if (chapters.length === 0 && tokens.length > 0) {
|
||||
chapters.push({
|
||||
id: 'chap-1',
|
||||
title: '全文',
|
||||
startIndex: 0,
|
||||
tokenCount: tokens.length,
|
||||
});
|
||||
} else {
|
||||
// Fill in chapter token counts
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const nextStart =
|
||||
i + 1 < chapters.length ? chapters[i + 1].startIndex : tokens.length;
|
||||
chapters[i].tokenCount = Math.max(0, nextStart - chapters[i].startIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return { tokens, chapters };
|
||||
}
|
||||
173
src/db/index.ts
Normal file
173
src/db/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Book, BookMeta, ReaderSettings } from '../types';
|
||||
|
||||
const DB_NAME = 'focus_rsvp_db';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_BOOKS = 'books';
|
||||
const STORE_META = 'books_meta';
|
||||
|
||||
export const DEFAULT_SETTINGS: ReaderSettings = {
|
||||
wpm: 350,
|
||||
fontSize: '2xl',
|
||||
theme: 'oled',
|
||||
fontFamily: 'sans',
|
||||
orpColor: 'rose',
|
||||
breathingPunctuation: true,
|
||||
showGuideLines: true,
|
||||
showContextPreview: true,
|
||||
showProgressBar: true,
|
||||
autoHideControls: true,
|
||||
sentenceEndMultiplier: 2.2,
|
||||
clauseEndMultiplier: 1.4,
|
||||
paragraphEndMultiplier: 2.5,
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = 'focus_reader_settings';
|
||||
const ACTIVE_BOOK_KEY = 'focus_active_book_id';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_BOOKS)) {
|
||||
db.createObjectStore(STORE_BOOKS, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_META)) {
|
||||
const metaStore = db.createObjectStore(STORE_META, { keyPath: 'id' });
|
||||
metaStore.createIndex('lastReadAt', 'lastReadAt', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveBook(book: Book): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
||||
const booksStore = tx.objectStore(STORE_BOOKS);
|
||||
const metaStore = tx.objectStore(STORE_META);
|
||||
|
||||
const { tokens: _tokens, ...meta } = book;
|
||||
|
||||
booksStore.put(book);
|
||||
metaStore.put(meta);
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBook(id: string): Promise<Book | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_BOOKS, 'readonly');
|
||||
const store = tx.objectStore(STORE_BOOKS);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => resolve(request.result as Book | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllBooksMeta(): Promise<BookMeta[]> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_META, 'readonly');
|
||||
const store = tx.objectStore(STORE_META);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const list = (request.result as BookMeta[]) || [];
|
||||
// Sort newest read first
|
||||
list.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||
resolve(list);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBookProgress(id: string, progress: number): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
||||
const booksStore = tx.objectStore(STORE_BOOKS);
|
||||
const metaStore = tx.objectStore(STORE_META);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const getReq = booksStore.get(id);
|
||||
getReq.onsuccess = () => {
|
||||
const book = getReq.result as Book | undefined;
|
||||
if (book) {
|
||||
book.currentProgress = progress;
|
||||
book.lastReadAt = now;
|
||||
booksStore.put(book);
|
||||
}
|
||||
};
|
||||
|
||||
const getMetaReq = metaStore.get(id);
|
||||
getMetaReq.onsuccess = () => {
|
||||
const meta = getMetaReq.result as BookMeta | undefined;
|
||||
if (meta) {
|
||||
meta.currentProgress = progress;
|
||||
meta.lastReadAt = now;
|
||||
metaStore.put(meta);
|
||||
}
|
||||
};
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBook(id: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
||||
tx.objectStore(STORE_BOOKS).delete(id);
|
||||
tx.objectStore(STORE_META).delete(id);
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function loadSettings(): ReaderSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load settings from localStorage', err);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: ReaderSettings): void {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.warn('Failed to save settings to localStorage', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveBookId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_BOOK_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveBookId(id: string): void {
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_BOOK_KEY, id);
|
||||
} catch (err) {
|
||||
console.warn('Failed to set active book id', err);
|
||||
}
|
||||
}
|
||||
132
src/db/samples.ts
Normal file
132
src/db/samples.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { tokenizeText } from '../core/tokenizer';
|
||||
import type { Book } from '../types';
|
||||
|
||||
interface SampleSource {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SAMPLE_SOURCES: SampleSource[] = [
|
||||
{
|
||||
id: 'sample-three-body',
|
||||
title: '三体 II:黑暗森林(水滴降临)',
|
||||
author: '刘慈欣',
|
||||
content: `第一章 水滴的面纱
|
||||
“给岁月以文明,而不是给文明以岁月。”
|
||||
这是一句在危机纪元初期流行的话,后来逐渐被人们遗忘,但现在,当两个世纪的岁月走过,这名古老罗辑的思想却像幽灵一样在历史的天空中重新显现。
|
||||
|
||||
人类太空舰队的两千艘恒星级战舰在木星轨道上整齐列阵。那是一个无与伦比的宏伟阵列,每一艘战舰都像是一座漂浮在太空中的巍峨钢铁山脉。所有的推进器都在低功率运转,泛着幽蓝色的等离子光芒。
|
||||
|
||||
“水滴”探测器只有三点五米长,呈完美的泪滴形状。它的表面是绝对光滑的镜面,映照着银河系浩瀚的繁星,以及人类两千艘庞大战舰的倒影。物理学家丁仪乘坐穿梭机缓缓接近这枚来自四光年外三体舰队的信标。
|
||||
|
||||
第二章 绝对光滑与毁灭之舞
|
||||
丁仪伸出戴着轻型宇航手套的手指,轻轻触碰在水滴的表面。
|
||||
“没有缝隙,没有凸起,甚至没有分子间隙。”丁仪低声喃喃自语。在放大了一千万倍的电子显微镜视场中,水滴的表面依然光滑如初,反射着冷酷而纯粹的光芒。
|
||||
|
||||
“这不是普通的金属合金,”丁仪的声音在无线电通讯频道中颤抖起来,“这是由强互作用力紧密锁死的物质!它的原子核被死死钉在一起,没有任何震动。它的强度是太阳系中所有物质的上万倍!”
|
||||
|
||||
丁仪猛地转过头,对着身边的年轻军官嘶吼:“傻孩子们,快跑啊!这不是敬意,不是问候,这是一颗死神的子弹!”
|
||||
|
||||
话音未落,水滴的尖端骤然亮起刺目的蓝白强光。在绝对无声的太空中,它以无法理喻的加速度瞬间穿透了第一艘战舰“无限边疆”号的反应堆堆芯!火球在大阵中无声爆开,犹如宇宙深处悄然盛开的一朵炼狱之莲。`,
|
||||
},
|
||||
{
|
||||
id: 'sample-meditations',
|
||||
title: '沉思录:关于专注与当下',
|
||||
author: '马可·奥勒留',
|
||||
content: `第一节 专注你手头的事
|
||||
每一刻都要坚定地思考,像一个罗马人,像一个真正的人那样,怀着庄严、自然、关切、自由和正义的心情,去做好你手头正在做的事。抛弃一切旁鹜与幻想。如果你把生活中的每一个行动都当作最后一个行动来对待,免除一切轻浮、盲动,免除与理性指导原则相违背的情感,你就能够做到这一点。
|
||||
|
||||
看一看,一个人为了过上顺遂神圣的生活,所需把握的事情是多么微少。只要遵守这些法则,诸神便不会再强求你什么。
|
||||
|
||||
第二节 生命的本质与刹那
|
||||
不要让未来的事情扰乱你;因为如果需要的话,你将怀着同一种理性走向未来,正是这种理性引导着你处理当下的事务。
|
||||
|
||||
时间的河流是一股急流,是一股由万事万物汇聚而成的汹涌激流。某样事物刚刚出现在视线之中,转瞬就被冲走,另一样事物取而代之,而它也将被带走。
|
||||
|
||||
一个人失去的,不过是他此刻正在度过的生命;他拥有的,也唯有他正在度过的当下。因此,最漫长的生命与最短暂的生命在本质上是均等的。因为当下属于所有人,逝去的事物已不再属于我们。任何人都不可能失去他尚未拥有的过去,或者他尚未到达的未来。
|
||||
|
||||
回归你的内心。理性的统治力量天生是自足的,只要它行事公正,就能保持平静与不动摇的安宁。`,
|
||||
},
|
||||
{
|
||||
id: 'sample-jobs-stanford',
|
||||
title: 'Stay Hungry, Stay Foolish (Stanford 2005)',
|
||||
author: 'Steve Jobs',
|
||||
content: `Chapter 1 Connecting the Dots
|
||||
I am honored to be with you today at your commencement from one of the finest universities in the world. I never graduated from college. Truth be told, this is the closest I've ever gotten to a college graduation. Today I want to tell you three stories from my life. That's it. No big deal. Just three stories.
|
||||
|
||||
The first story is about connecting the dots.
|
||||
You can't connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future. You have to trust in something — your gut, destiny, life, karma, whatever. This approach has never let me down, and it has made all the difference in my life.
|
||||
|
||||
Chapter 2 Love and Loss
|
||||
My second story is about love and loss.
|
||||
I was lucky — I found what I loved to do early in life. Woz and I started Apple in my parents garage when I was 20. In 10 years Apple had grown from just the two of us into a 2 billion dollar company with over 4000 employees. And then I got fired. How can you get fired from a company you started?
|
||||
|
||||
Well, as Apple grew we hired someone who I thought was very talented to run the company with me, and for the first year or so things went well. But then our visions of the future began to diverge and eventually we had a falling out.
|
||||
|
||||
I didn't see it then, but it turned out that getting fired from Apple was the best thing that could have ever happened to me. The heaviness of being successful was replaced by the lightness of being a beginner again, less sure about everything. It freed me to enter one of the most creative periods of my life.
|
||||
|
||||
Chapter 3 Stay Hungry, Stay Foolish
|
||||
Your time is limited, so don't waste it living someone else's life. Don't be trapped by dogma — which is living with the results of other people's thinking. Don't let the noise of others' opinions drown out your own inner voice. And most important, have the courage to follow your heart and intuition. They somehow already know what you truly want to become. Everything else is secondary.
|
||||
|
||||
Stay Hungry. Stay Foolish.`,
|
||||
},
|
||||
{
|
||||
id: 'sample-foundation',
|
||||
title: '银河帝国:基地与心理史学',
|
||||
author: '艾萨克·阿西莫夫',
|
||||
content: `第一节 穹顶之下的预言者
|
||||
哈里·谢顿站在川陀帝国图书馆的高台上,俯瞰着这座横跨整颗星球的钢铁巨都会。两百亿人的喧嚣被隔绝在三重力场穹顶之外,群星在人造大气层外冰冷地闪烁。
|
||||
|
||||
“心理史学,”谢顿对年轻的数学家盖尔·多尼克说,“是研究人类社会统计学概率的科学。单个个体的行为不可预测,就像单个分子的布朗运动一样变幻莫测。但是,当个体的数量达到万亿这个量级时,群体的轨迹就遵循严密的数学定律。”
|
||||
|
||||
“帝国将会怎样,谢顿宗师?”多尼克问。
|
||||
|
||||
谢顿闭上双眼,声音平静而沉重:“帝国正在腐烂。川陀将在三百年内沦为废墟,银河系将坠入长达三万年的黑暗时代,战乱、瘟疫与野蛮将吞噬所有文明的火种。”
|
||||
|
||||
第二节 避难所的火种
|
||||
“三万年太漫长了,”多尼克的声音里带着惊恐,“难道数学无法改变命运吗?”
|
||||
|
||||
“无法阻止崩溃,但能缩短严冬。”谢顿睁开眼睛,目光中闪烁着睿智的光芒,“通过在银河系两端设立两座文明知识的避难所——第一基地与第二基地,我们能够将三万年的黑暗时代,压缩为短短的一千年。”
|
||||
|
||||
“知识本身并不危险,盖尔。最危险的,是那些手握毁灭力量却丧失了清醒理性的人。”`,
|
||||
},
|
||||
{
|
||||
id: 'sample-daodejing',
|
||||
title: '道德经:致虚极,守静笃',
|
||||
author: '老子',
|
||||
content: `第十六章 归根复命
|
||||
致虚极,守静笃。万物并作,吾以观复。
|
||||
夫物芸芸,各复归其根。归根曰静,静曰复命。复命曰常,知常曰明。不知常,妄作凶。
|
||||
知常容,容乃公,公乃全,全乃天,天乃道,道乃久,没身不殆。
|
||||
|
||||
第四十一章 大象无形
|
||||
上士闻道,勤而行之;中士闻道,若存若亡;下士闻道,大笑之。不笑不足以为道。
|
||||
故建言有之:明道若昧,进道若退,夷道若纇,上德若谷,大白若辱,广德若不足,建德若偷,质真若渝。
|
||||
大方无隅,大器晚成,大音希声,大象无形。道隐无名。夫唯道,善贷且成。
|
||||
|
||||
第四十八章 为学日益
|
||||
为学日益,为道日损。损之又损,以至于无为。
|
||||
无为而无不为。取天下常以无事,及其有事,不足以取天下。`,
|
||||
},
|
||||
];
|
||||
|
||||
export function getBuiltinSampleBooks(): Book[] {
|
||||
const now = Date.now();
|
||||
return SAMPLE_SOURCES.map((sample, index) => {
|
||||
const { tokens, chapters } = tokenizeText(sample.content);
|
||||
return {
|
||||
id: sample.id,
|
||||
title: sample.title,
|
||||
author: sample.author,
|
||||
format: 'sample',
|
||||
tokens,
|
||||
chapters,
|
||||
currentProgress: 0,
|
||||
totalTokens: tokens.length,
|
||||
createdAt: now - index * 60000,
|
||||
lastReadAt: index === 0 ? now : now - index * 60000,
|
||||
};
|
||||
});
|
||||
}
|
||||
136
src/hooks/useKeyboardShortcuts.ts
Normal file
136
src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface KeyboardShortcutHandlers {
|
||||
togglePlay: () => void;
|
||||
jumpBy: (delta: number) => void;
|
||||
jumpToPreviousSentence: () => void;
|
||||
jumpToNextSentence: () => void;
|
||||
changeWpm: (delta: number) => void;
|
||||
toggleFullscreen: () => void;
|
||||
onEscape?: () => void;
|
||||
restart: () => void;
|
||||
prevChapter?: () => void;
|
||||
nextChapter?: () => void;
|
||||
isModalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
togglePlay,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
changeWpm,
|
||||
toggleFullscreen,
|
||||
onEscape,
|
||||
restart,
|
||||
prevChapter,
|
||||
nextChapter,
|
||||
isModalOpen = false,
|
||||
}: KeyboardShortcutHandlers) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't intercept shortcuts when user is focused on an input or textarea
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
e.preventDefault();
|
||||
onEscape();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (onEscape) {
|
||||
e.preventDefault();
|
||||
onEscape();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If a modal is open, don't trigger playback controls
|
||||
if (isModalOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ': // Space
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
jumpToPreviousSentence();
|
||||
} else {
|
||||
jumpBy(-10);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
jumpToNextSentence();
|
||||
} else {
|
||||
jumpBy(10);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
changeWpm(e.shiftKey ? 50 : 25);
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
changeWpm(e.shiftKey ? -50 : -25);
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
case 'F':
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
|
||||
case 'r':
|
||||
case 'R':
|
||||
e.preventDefault();
|
||||
restart();
|
||||
break;
|
||||
|
||||
case '[':
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
prevChapter?.();
|
||||
break;
|
||||
|
||||
case ']':
|
||||
case 'PageDown':
|
||||
e.preventDefault();
|
||||
nextChapter?.();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
togglePlay,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
changeWpm,
|
||||
toggleFullscreen,
|
||||
onEscape,
|
||||
restart,
|
||||
prevChapter,
|
||||
nextChapter,
|
||||
isModalOpen,
|
||||
]);
|
||||
}
|
||||
239
src/hooks/useReaderEngine.ts
Normal file
239
src/hooks/useReaderEngine.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { calculateTokenDelay } from '../core/timing';
|
||||
import { updateBookProgress } from '../db';
|
||||
import type { Book, Chapter, ReaderSettings, Token } from '../types';
|
||||
|
||||
interface UseReaderEngineProps {
|
||||
book: Book | null;
|
||||
settings: ReaderSettings;
|
||||
onBookProgressUpdate?: (bookId: string, progress: number) => void;
|
||||
}
|
||||
|
||||
export function useReaderEngine({
|
||||
book,
|
||||
settings,
|
||||
onBookProgressUpdate,
|
||||
}: UseReaderEngineProps) {
|
||||
// Sync state when active book changes using standard React adjustment
|
||||
const [prevBookId, setPrevBookId] = useState<string | null>(book?.id || null);
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(() => {
|
||||
return book ? Math.max(0, book.currentProgress || 0) : 0;
|
||||
});
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
|
||||
if (book && book.id !== prevBookId) {
|
||||
setPrevBookId(book.id);
|
||||
const initialIndex = Math.min(
|
||||
Math.max(0, book.currentProgress || 0),
|
||||
Math.max(0, (book.tokens?.length || 1) - 1)
|
||||
);
|
||||
setCurrentIndex(initialIndex);
|
||||
setIsPlaying(false);
|
||||
} else if (!book && prevBookId !== null) {
|
||||
setPrevBookId(null);
|
||||
setCurrentIndex(0);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
// Derive tokens and current token safely during render
|
||||
const tokens = useMemo<Token[]>(() => book?.tokens || [], [book?.tokens]);
|
||||
const totalTokens = tokens.length;
|
||||
const currentToken = tokens.length > 0 ? tokens[currentIndex] || tokens[0] : null;
|
||||
const previousToken = currentIndex > 0 ? tokens[currentIndex - 1] || null : null;
|
||||
const nextToken =
|
||||
currentIndex < tokens.length - 1 ? tokens[currentIndex + 1] || null : null;
|
||||
|
||||
// Refs for timer and async execution
|
||||
const settingsRef = useRef<ReaderSettings>(settings);
|
||||
const isPlayingRef = useRef<boolean>(isPlaying);
|
||||
const currentIndexRef = useRef<number>(currentIndex);
|
||||
const tokensRef = useRef<Token[]>(tokens);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const lastSavedIndexRef = useRef<number>(currentIndex);
|
||||
const scheduleNextRef = useRef<((fromIdx: number) => void) | null>(null);
|
||||
|
||||
// Synchronize refs
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
currentIndexRef.current = currentIndex;
|
||||
}, [currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
}, [tokens]);
|
||||
|
||||
// Periodic throttled persistence to IndexedDB
|
||||
useEffect(() => {
|
||||
if (!book) return;
|
||||
|
||||
if (Math.abs(currentIndex - lastSavedIndexRef.current) >= 5 || !isPlaying) {
|
||||
lastSavedIndexRef.current = currentIndex;
|
||||
updateBookProgress(book.id, currentIndex);
|
||||
onBookProgressUpdate?.(book.id, currentIndex);
|
||||
}
|
||||
}, [currentIndex, isPlaying, book, onBookProgressUpdate]);
|
||||
|
||||
// Setup recursion handler in ref
|
||||
useEffect(() => {
|
||||
scheduleNextRef.current = (fromIndex: number) => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isPlayingRef.current) return;
|
||||
|
||||
const curTokens = tokensRef.current;
|
||||
if (!curTokens || curTokens.length === 0) {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromIndex >= curTokens.length - 1) {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const tok = curTokens[fromIndex];
|
||||
const delay = calculateTokenDelay(tok, settingsRef.current);
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!isPlayingRef.current) return;
|
||||
|
||||
const nextIndex = fromIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
currentIndexRef.current = nextIndex;
|
||||
|
||||
scheduleNextRef.current?.(nextIndex);
|
||||
}, delay);
|
||||
};
|
||||
});
|
||||
|
||||
const play = useCallback(() => {
|
||||
const curTokens = tokensRef.current;
|
||||
if (!curTokens || curTokens.length === 0) return;
|
||||
|
||||
if (currentIndexRef.current >= curTokens.length - 1) {
|
||||
setCurrentIndex(0);
|
||||
currentIndexRef.current = 0;
|
||||
}
|
||||
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
scheduleNextRef.current?.(currentIndexRef.current);
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlayingRef.current) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [play, pause]);
|
||||
|
||||
const jumpTo = useCallback((index: number) => {
|
||||
const curTokens = tokensRef.current;
|
||||
const target = Math.max(0, Math.min(curTokens.length - 1, index));
|
||||
setCurrentIndex(target);
|
||||
currentIndexRef.current = target;
|
||||
|
||||
if (isPlayingRef.current) {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
scheduleNextRef.current?.(target);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const jumpBy = useCallback(
|
||||
(delta: number) => {
|
||||
jumpTo(currentIndexRef.current + delta);
|
||||
},
|
||||
[jumpTo]
|
||||
);
|
||||
|
||||
const jumpToPreviousSentence = useCallback(() => {
|
||||
const curTokens = tokensRef.current;
|
||||
const cur = currentIndexRef.current;
|
||||
if (curTokens.length === 0 || cur === 0) return;
|
||||
|
||||
let idx = cur - 1;
|
||||
while (idx > 0 && curTokens[idx].isSentenceEnd) {
|
||||
idx--;
|
||||
}
|
||||
while (idx > 0 && !curTokens[idx].isSentenceEnd) {
|
||||
idx--;
|
||||
}
|
||||
const target = idx === 0 ? 0 : idx + 1;
|
||||
jumpTo(target);
|
||||
}, [jumpTo]);
|
||||
|
||||
const jumpToNextSentence = useCallback(() => {
|
||||
const curTokens = tokensRef.current;
|
||||
const cur = currentIndexRef.current;
|
||||
if (curTokens.length === 0 || cur >= curTokens.length - 1) return;
|
||||
|
||||
let idx = cur;
|
||||
while (idx < curTokens.length - 1 && !curTokens[idx].isSentenceEnd) {
|
||||
idx++;
|
||||
}
|
||||
const target = Math.min(curTokens.length - 1, idx + 1);
|
||||
jumpTo(target);
|
||||
}, [jumpTo]);
|
||||
|
||||
const jumpToChapter = useCallback(
|
||||
(chapter: Chapter) => {
|
||||
jumpTo(chapter.startIndex);
|
||||
},
|
||||
[jumpTo]
|
||||
);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
jumpTo(0);
|
||||
}, [jumpTo]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
currentToken,
|
||||
previousToken,
|
||||
nextToken,
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
jumpTo,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
jumpToChapter,
|
||||
restart,
|
||||
totalTokens,
|
||||
};
|
||||
}
|
||||
71
src/index.css
Normal file
71
src/index.css
Normal file
@@ -0,0 +1,71 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom theme container classes */
|
||||
.theme-oled {
|
||||
background-color: #000000;
|
||||
color: #f4f4f5;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.theme-dark {
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.theme-slate {
|
||||
background-color: #0f172a;
|
||||
color: #f1f5f9;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.theme-nord {
|
||||
background-color: #242933;
|
||||
color: #eceff4;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.theme-sepia {
|
||||
background-color: #fbf0d9;
|
||||
color: #433422;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.theme-paper {
|
||||
background-color: #ffffff;
|
||||
color: #18181b;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Range input thumb styling */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background-color: #f43f5e;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(244, 63, 94, 0.4);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background-color: #f43f5e;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(244, 63, 94, 0.4);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
63
src/types/index.ts
Normal file
63
src/types/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface Token {
|
||||
id: number;
|
||||
text: string; // Full display segment with punctuation (e.g. "“文明,”")
|
||||
cleanWord: string; // Core word without trailing punctuation (e.g. "文明")
|
||||
prefix: string; // Characters preceding the ORP character
|
||||
pivot: string; // The ORP character itself
|
||||
suffix: string; // Characters following the ORP character (including punctuation)
|
||||
orpIndex: number; // Index of pivot within cleanWord
|
||||
isParagraphEnd: boolean;// Followed by double newline
|
||||
isSentenceEnd: boolean; // Punctuation like 。!?.!?
|
||||
isClauseEnd: boolean; // Punctuation like ,、;:,;:
|
||||
isLongWord: boolean; // English > 8 chars or CJK >= 4 chars
|
||||
chapterIndex?: number; // Corresponding chapter index
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
title: string;
|
||||
startIndex: number;
|
||||
tokenCount?: number;
|
||||
}
|
||||
|
||||
export type BookFormat = 'txt' | 'md' | 'epub' | 'paste' | 'sample';
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
format: BookFormat;
|
||||
tokens: Token[];
|
||||
chapters: Chapter[];
|
||||
currentProgress: number; // Current token index
|
||||
totalTokens: number;
|
||||
lastReadAt: number; // Unix timestamp
|
||||
createdAt: number; // Unix timestamp
|
||||
byteSize?: number;
|
||||
}
|
||||
|
||||
export type BookMeta = Omit<Book, 'tokens'>;
|
||||
|
||||
export type ThemeId = 'oled' | 'dark' | 'sepia' | 'slate' | 'nord' | 'paper';
|
||||
|
||||
export type FontFamily = 'mono' | 'sans' | 'serif';
|
||||
|
||||
export type FontSize = 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl';
|
||||
|
||||
export type OrpColor = 'rose' | 'amber' | 'emerald' | 'cyan' | 'violet';
|
||||
|
||||
export interface ReaderSettings {
|
||||
wpm: number; // 100 - 1200
|
||||
fontSize: FontSize;
|
||||
theme: ThemeId;
|
||||
fontFamily: FontFamily;
|
||||
orpColor: OrpColor;
|
||||
breathingPunctuation: boolean; // Punctuation adaptive delay
|
||||
showGuideLines: boolean; // Center tick marks
|
||||
showContextPreview: boolean; // Subtle ghost text of previous/next words
|
||||
showProgressBar: boolean;
|
||||
autoHideControls: boolean; // Dim controls when reading
|
||||
sentenceEndMultiplier: number; // default 2.2
|
||||
clauseEndMultiplier: number; // default 1.4
|
||||
paragraphEndMultiplier: number; // default 2.5
|
||||
}
|
||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user