refactor: streamline RSVP reader for The Well of Ascension single-book focus
- Clean up legacy multi-format/CJK modules (orp, epub, samples, modals) - Replace with lightweight pure English tokenizer and chunking engine - Add SettingsDrawer, auto-hiding HUD, and PWA icons - Update manifest and touch icons Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
15
index.html
15
index.html
@@ -1,15 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<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>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Focus" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="description" content="Focus — 极速词流专注器" />
|
||||
<title>Focus</title>
|
||||
</head>
|
||||
<body class="bg-black select-none">
|
||||
<div id="root"></div>
|
||||
<body class="bg-[#09090b] text-[#f4f4f5] select-none antialiased overflow-hidden">
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,14 +10,12 @@
|
||||
"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",
|
||||
|
||||
96
pnpm-lock.yaml
generated
96
pnpm-lock.yaml
generated
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
jszip:
|
||||
specifier: ^3.10.2
|
||||
version: 3.10.2
|
||||
lucide-react:
|
||||
specifier: ^1.59.0
|
||||
version: 1.59.0(@types/react@19.3.0)(react@19.3.1)
|
||||
@@ -24,9 +21,6 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0))
|
||||
'@types/jszip':
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
'@types/node':
|
||||
specifier: ^24.19.2
|
||||
version: 24.19.2
|
||||
@@ -370,10 +364,6 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@types/jszip@3.4.1':
|
||||
resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==}
|
||||
deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/node@24.19.2':
|
||||
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
||||
|
||||
@@ -521,9 +511,6 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -552,25 +539,10 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jszip@3.10.2:
|
||||
resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -741,9 +713,6 @@ packages:
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -755,9 +724,6 @@ packages:
|
||||
resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
react-dom@19.3.1:
|
||||
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
|
||||
peerDependencies:
|
||||
@@ -767,30 +733,18 @@ packages:
|
||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
rolldown@1.2.14:
|
||||
resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
scheduler@0.28.0:
|
||||
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
tailwindcss@4.3.3:
|
||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||
|
||||
@@ -810,9 +764,6 @@ packages:
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
vite@8.3.4:
|
||||
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1051,10 +1002,6 @@ snapshots:
|
||||
tailwindcss: 4.3.3
|
||||
vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0)
|
||||
|
||||
'@types/jszip@3.4.1':
|
||||
dependencies:
|
||||
jszip: 3.10.2
|
||||
|
||||
'@types/node@24.19.2':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
@@ -1132,8 +1079,6 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0)
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -1152,25 +1097,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jszip@3.10.2:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -1303,8 +1231,6 @@ snapshots:
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.89.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.89.0
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
@@ -1315,8 +1241,6 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
react-dom@19.3.1(react@19.3.1):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
@@ -1324,16 +1248,6 @@ snapshots:
|
||||
|
||||
react@19.3.1: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
rolldown@1.2.14:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.154.0
|
||||
@@ -1355,18 +1269,10 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.14
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.14
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
scheduler@0.28.0: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
tailwindcss@4.3.3: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
@@ -1401,8 +1307,6 @@ snapshots:
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
19027
public/books/The Well of Ascension.txt
Executable file
19027
public/books/The Well of Ascension.txt
Executable file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,44 @@
|
||||
<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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<!-- Obsidian background radial gradient -->
|
||||
<radialGradient id="obsidianGrad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#141c17" />
|
||||
<stop offset="70%" stop-color="#0c100e" />
|
||||
<stop offset="100%" stop-color="#09090b" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Chlorophyll core glow -->
|
||||
<radialGradient id="coreGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#a7f3d0" stop-opacity="0.9" />
|
||||
<stop offset="40%" stop-color="#34d399" stop-opacity="0.6" />
|
||||
<stop offset="70%" stop-color="#10b981" stop-opacity="0.2" />
|
||||
<stop offset="100%" stop-color="#059669" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Emerald outer blur glow -->
|
||||
<filter id="softGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Obsidian Deep Canvas -->
|
||||
<rect width="512" height="512" rx="128" fill="url(#obsidianGrad)" />
|
||||
|
||||
<!-- Outermost Organic Halo -->
|
||||
<circle cx="256" cy="256" r="190" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.25" stroke-dasharray="12 8" />
|
||||
|
||||
<!-- Outer Chlorophyll Focus Ring -->
|
||||
<circle cx="256" cy="256" r="150" fill="none" stroke="#059669" stroke-width="4" stroke-opacity="0.45" />
|
||||
|
||||
<!-- Middle Jade Concentric Ring -->
|
||||
<circle cx="256" cy="256" r="110" fill="none" stroke="#10b981" stroke-width="6" stroke-opacity="0.75" filter="url(#softGlow)" />
|
||||
|
||||
<!-- Inner Mint Aperture Ring -->
|
||||
<circle cx="256" cy="256" r="70" fill="none" stroke="#34d399" stroke-width="8" stroke-linecap="round" stroke-dasharray="180 30" />
|
||||
|
||||
<!-- Center Chlorophyll Vitality Core -->
|
||||
<circle cx="256" cy="256" r="50" fill="url(#coreGlow)" />
|
||||
<circle cx="256" cy="256" r="22" fill="#34d399" filter="url(#softGlow)" />
|
||||
<circle cx="256" cy="256" r="10" fill="#ecfdf5" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 2.0 KiB |
23
public/manifest.json
Normal file
23
public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Focus",
|
||||
"short_name": "Focus",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#09090b",
|
||||
"theme_color": "#09090b",
|
||||
"description": "极速词流专注器",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/pwa-192.png
Normal file
BIN
public/pwa-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/pwa-512.png
Normal file
BIN
public/pwa-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
530
src/App.tsx
530
src/App.tsx
@@ -1,410 +1,296 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChapterDrawer } from './components/ChapterDrawer';
|
||||
import { Controls } from './components/Controls';
|
||||
import { Settings as SettingsIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FocusDisplay } from './components/FocusDisplay';
|
||||
import { Header } from './components/Header';
|
||||
import { LibraryModal } from './components/LibraryModal';
|
||||
import { PasteModal } from './components/PasteModal';
|
||||
import { SettingsModal } from './components/SettingsModal';
|
||||
import { ShortcutsModal } from './components/ShortcutsModal';
|
||||
import {
|
||||
deleteBook,
|
||||
getActiveBookId,
|
||||
getAllBooksMeta,
|
||||
getBook,
|
||||
loadSettings,
|
||||
saveBook,
|
||||
saveSettings,
|
||||
setActiveBookId,
|
||||
} from './db';
|
||||
import { getBuiltinSampleBooks } from './db/samples';
|
||||
import { SettingsDrawer } from './components/SettingsDrawer';
|
||||
import { calculateWpm } from './core/timing';
|
||||
import { tokenizeEnglishText } from './core/tokenizer';
|
||||
import { loadSavedState, saveState } from './db';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import { useReaderEngine } from './hooks/useReaderEngine';
|
||||
import type { Book, BookMeta, Chapter, ReaderSettings, ThemeId } from './types';
|
||||
|
||||
const THEME_ORDER: ThemeId[] = ['oled', 'dark', 'slate', 'sepia', 'nord', 'paper'];
|
||||
import type { FontSize, FocusState } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [settings, setSettings] = useState<ReaderSettings>(() => loadSettings());
|
||||
const [booksMeta, setBooksMeta] = useState<BookMeta[]>([]);
|
||||
const [activeBook, setActiveBook] = useState<Book | null>(null);
|
||||
const [saved] = useState<FocusState>(() => loadSavedState());
|
||||
const [flashesPerMinute, setFlashesPerMinute] = useState<number>(
|
||||
saved.flashesPerMinute
|
||||
);
|
||||
const [wordsPerChunk, setWordsPerChunk] = useState<number>(saved.wordsPerChunk);
|
||||
const [fontSize, setFontSize] = useState<FontSize>(saved.fontSize);
|
||||
const [words, setWords] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Modals
|
||||
const [isLibraryOpen, setIsLibraryOpen] = useState<boolean>(false);
|
||||
const [isPasteOpen, setIsPasteOpen] = useState<boolean>(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState<boolean>(false);
|
||||
const [isShortcutsOpen, setIsShortcutsOpen] = useState<boolean>(false);
|
||||
const [isChaptersOpen, setIsChaptersOpen] = useState<boolean>(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
|
||||
// HUD inactivity fading
|
||||
const [isUserActive, setIsUserActive] = useState<boolean>(true);
|
||||
const inactivityTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Reader engine
|
||||
const {
|
||||
currentIndex,
|
||||
currentToken,
|
||||
previousToken,
|
||||
nextToken,
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
jumpTo,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
jumpToChapter,
|
||||
restart,
|
||||
totalTokens,
|
||||
} = useReaderEngine({
|
||||
book: activeBook,
|
||||
settings,
|
||||
onBookProgressUpdate: (bookId, progress) => {
|
||||
setBooksMeta((prev) =>
|
||||
prev.map((b) => (b.id === bookId ? { ...b, currentProgress: progress } : b))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Track fullscreen changes
|
||||
// Load built-in book text
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
console.warn('Fullscreen request failed:', err);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().catch((err) => {
|
||||
console.warn('Exit fullscreen failed:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load of books and defaults
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
async function loadBook() {
|
||||
try {
|
||||
let metaList = await getAllBooksMeta();
|
||||
|
||||
if (metaList.length === 0) {
|
||||
// First visit: inject curated built-in samples
|
||||
const samples = getBuiltinSampleBooks();
|
||||
for (const s of samples) {
|
||||
await saveBook(s);
|
||||
}
|
||||
metaList = await getAllBooksMeta();
|
||||
}
|
||||
|
||||
setBooksMeta(metaList);
|
||||
|
||||
// Load active book
|
||||
const savedActiveId = getActiveBookId();
|
||||
const targetId = savedActiveId || metaList[0]?.id;
|
||||
|
||||
if (targetId) {
|
||||
const loadedBook = await getBook(targetId);
|
||||
if (loadedBook) {
|
||||
setActiveBook(loadedBook);
|
||||
setActiveBookId(loadedBook.id);
|
||||
}
|
||||
const res = await fetch(
|
||||
encodeURI('/books/The Well of Ascension.txt')
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load book: ${res.statusText}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
const parsedWords = tokenizeEnglishText(text);
|
||||
setWords(parsedWords);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize reader database:', err);
|
||||
console.error('Failed to load book:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
loadBook();
|
||||
}, []);
|
||||
|
||||
// Update settings handler
|
||||
const handleUpdateSettings = (newSettings: Partial<ReaderSettings>) => {
|
||||
setSettings((prev) => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
});
|
||||
// Sync state changes to localStorage
|
||||
const handleFpmChange = (newFpm: number) => {
|
||||
const clamped = Math.min(600, Math.max(60, newFpm));
|
||||
setFlashesPerMinute(clamped);
|
||||
saveState({ flashesPerMinute: clamped });
|
||||
};
|
||||
|
||||
// Theme cycle
|
||||
const handleCycleTheme = () => {
|
||||
const currentIdx = THEME_ORDER.indexOf(settings.theme);
|
||||
const nextIdx = (currentIdx + 1) % THEME_ORDER.length;
|
||||
handleUpdateSettings({ theme: THEME_ORDER[nextIdx] });
|
||||
const handleWordsPerChunkChange = (newChunk: number) => {
|
||||
const clamped = Math.min(4, Math.max(1, newChunk));
|
||||
setWordsPerChunk(clamped);
|
||||
saveState({ wordsPerChunk: clamped });
|
||||
};
|
||||
|
||||
// Book selection
|
||||
const handleSelectBook = async (bookId: string) => {
|
||||
const book = await getBook(bookId);
|
||||
if (book) {
|
||||
setActiveBook(book);
|
||||
setActiveBookId(book.id);
|
||||
const handleFontSizeChange = (newSize: FontSize) => {
|
||||
setFontSize(newSize);
|
||||
saveState({ fontSize: newSize });
|
||||
};
|
||||
|
||||
// Throttled persistence of wordIndex to localStorage
|
||||
const lastSavedIndexRef = useRef<number>(saved.wordIndex);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const handleWordIndexUpdate = useCallback((index: number) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
// Save new book
|
||||
const handleSaveNewBook = async (newBook: Book) => {
|
||||
await saveBook(newBook);
|
||||
const updatedMeta = await getAllBooksMeta();
|
||||
setBooksMeta(updatedMeta);
|
||||
};
|
||||
|
||||
// Delete book
|
||||
const handleDeleteBook = async (bookId: string) => {
|
||||
await deleteBook(bookId);
|
||||
const updatedMeta = await getAllBooksMeta();
|
||||
setBooksMeta(updatedMeta);
|
||||
|
||||
if (activeBook?.id === bookId) {
|
||||
if (updatedMeta.length > 0) {
|
||||
handleSelectBook(updatedMeta[0].id);
|
||||
} else {
|
||||
setActiveBook(null);
|
||||
saveTimeoutRef.current = window.setTimeout(() => {
|
||||
if (index !== lastSavedIndexRef.current) {
|
||||
lastSavedIndexRef.current = index;
|
||||
saveState({ wordIndex: index });
|
||||
}
|
||||
}
|
||||
};
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
// Calculate current chapter based on currentIndex
|
||||
const currentChapter = useMemo(() => {
|
||||
if (!activeBook || !activeBook.chapters || activeBook.chapters.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const chapters = activeBook.chapters;
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
if (currentIndex >= chapters[i].startIndex) {
|
||||
return chapters[i];
|
||||
}
|
||||
}
|
||||
return chapters[0];
|
||||
}, [activeBook, currentIndex]);
|
||||
// Save immediately on unload
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
saveState({
|
||||
wordIndex: lastSavedIndexRef.current,
|
||||
flashesPerMinute,
|
||||
wordsPerChunk,
|
||||
fontSize,
|
||||
});
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [flashesPerMinute, wordsPerChunk, fontSize]);
|
||||
|
||||
// Jump to next/prev chapter
|
||||
const handlePrevChapter = () => {
|
||||
if (!activeBook || !activeBook.chapters) return;
|
||||
const chapters = activeBook.chapters;
|
||||
const currentIdx = currentChapter
|
||||
? chapters.findIndex((c) => c.id === currentChapter.id)
|
||||
: 0;
|
||||
if (currentIdx > 0) {
|
||||
jumpToChapter(chapters[currentIdx - 1]);
|
||||
} else {
|
||||
jumpTo(0);
|
||||
}
|
||||
};
|
||||
// Core reading engine
|
||||
const {
|
||||
currentIndex,
|
||||
currentChunkText,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
pause,
|
||||
jumpTo,
|
||||
jumpBy,
|
||||
restart,
|
||||
totalWords,
|
||||
} = useReaderEngine({
|
||||
words,
|
||||
flashesPerMinute,
|
||||
wordsPerChunk,
|
||||
initialWordIndex: saved.wordIndex,
|
||||
onWordIndexChange: (idx) => {
|
||||
lastSavedIndexRef.current = idx;
|
||||
handleWordIndexUpdate(idx);
|
||||
},
|
||||
});
|
||||
|
||||
const handleNextChapter = () => {
|
||||
if (!activeBook || !activeBook.chapters) return;
|
||||
const chapters = activeBook.chapters;
|
||||
const currentIdx = currentChapter
|
||||
? chapters.findIndex((c) => c.id === currentChapter.id)
|
||||
: 0;
|
||||
if (currentIdx < chapters.length - 1) {
|
||||
jumpToChapter(chapters[currentIdx + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse/touch/key activity detection for HUD fading
|
||||
// Reset inactivity timer
|
||||
const resetInactivity = useCallback(() => {
|
||||
setIsUserActive(true);
|
||||
if (inactivityTimerRef.current) {
|
||||
window.clearTimeout(inactivityTimerRef.current);
|
||||
}
|
||||
if (isPlaying && settings.autoHideControls) {
|
||||
if (isPlaying) {
|
||||
inactivityTimerRef.current = window.setTimeout(() => {
|
||||
setIsUserActive(false);
|
||||
}, 3000);
|
||||
}, 2500);
|
||||
}
|
||||
}, [isPlaying, settings.autoHideControls]);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !settings.autoHideControls) {
|
||||
if (!isPlaying) {
|
||||
if (inactivityTimerRef.current) {
|
||||
window.clearTimeout(inactivityTimerRef.current);
|
||||
inactivityTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const handleInteraction = () => {
|
||||
resetInactivity();
|
||||
};
|
||||
const onActivity = () => resetInactivity();
|
||||
window.addEventListener('mousemove', onActivity);
|
||||
window.addEventListener('keydown', onActivity);
|
||||
window.addEventListener('touchstart', onActivity);
|
||||
|
||||
window.addEventListener('mousemove', handleInteraction);
|
||||
window.addEventListener('keydown', handleInteraction);
|
||||
window.addEventListener('touchstart', handleInteraction);
|
||||
window.addEventListener('pointerdown', handleInteraction);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
inactivityTimerRef.current = window.setTimeout(() => {
|
||||
setIsUserActive(false);
|
||||
}, 3000);
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('mousemove', handleInteraction);
|
||||
window.removeEventListener('keydown', handleInteraction);
|
||||
window.removeEventListener('touchstart', handleInteraction);
|
||||
window.removeEventListener('pointerdown', handleInteraction);
|
||||
window.removeEventListener('mousemove', onActivity);
|
||||
window.removeEventListener('keydown', onActivity);
|
||||
window.removeEventListener('touchstart', onActivity);
|
||||
if (inactivityTimerRef.current) {
|
||||
window.clearTimeout(inactivityTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, settings.autoHideControls, resetInactivity]);
|
||||
}, [isPlaying, resetInactivity]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const anyModalOpen =
|
||||
isLibraryOpen ||
|
||||
isPasteOpen ||
|
||||
isSettingsOpen ||
|
||||
isShortcutsOpen ||
|
||||
isChaptersOpen;
|
||||
// Fullscreen toggle
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboardShortcuts({
|
||||
togglePlay,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
changeWpm: (delta) =>
|
||||
handleUpdateSettings({
|
||||
wpm: Math.max(100, Math.min(1200, settings.wpm + delta)),
|
||||
}),
|
||||
changeFpm: (delta) => handleFpmChange(flashesPerMinute + delta),
|
||||
toggleFullscreen,
|
||||
restart,
|
||||
prevChapter: handlePrevChapter,
|
||||
nextChapter: handleNextChapter,
|
||||
onEscape: () => {
|
||||
if (anyModalOpen) {
|
||||
setIsLibraryOpen(false);
|
||||
setIsPasteOpen(false);
|
||||
if (isSettingsOpen) {
|
||||
setIsSettingsOpen(false);
|
||||
setIsShortcutsOpen(false);
|
||||
setIsChaptersOpen(false);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
},
|
||||
isModalOpen: anyModalOpen,
|
||||
isDrawerOpen: isSettingsOpen,
|
||||
});
|
||||
|
||||
const themeClass = `theme-${settings.theme}`;
|
||||
const isHudDimmed = isPlaying && !isUserActive && settings.autoHideControls;
|
||||
const hudClasses = isHudDimmed
|
||||
? 'opacity-0 pointer-events-none transition-opacity duration-500'
|
||||
: 'opacity-100 pointer-events-auto transition-opacity duration-300';
|
||||
const progressPercent =
|
||||
totalWords > 0
|
||||
? Math.min(100, Math.max(0, (currentIndex / (totalWords - 1 || 1)) * 100))
|
||||
: 0;
|
||||
|
||||
const wpm = calculateWpm(flashesPerMinute, wordsPerChunk);
|
||||
const hudOpacity =
|
||||
isPlaying && !isUserActive
|
||||
? 'opacity-0 pointer-events-none'
|
||||
: 'opacity-100 pointer-events-auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseMove={resetInactivity}
|
||||
onTouchStart={resetInactivity}
|
||||
className={`relative w-full h-full flex flex-col justify-between overflow-hidden transition-colors duration-300 font-sans ${themeClass}`}
|
||||
className="relative w-full h-full flex flex-col justify-between bg-[#09090b] text-[#f4f4f5] font-sans overflow-hidden select-none"
|
||||
>
|
||||
{/* Top Header HUD */}
|
||||
<div className={`w-full z-20 shrink-0 ${hudClasses}`}>
|
||||
<Header
|
||||
book={activeBook}
|
||||
currentChapter={currentChapter}
|
||||
settings={settings}
|
||||
isFullscreen={isFullscreen}
|
||||
onOpenLibrary={() => setIsLibraryOpen(true)}
|
||||
onOpenPaste={() => setIsPasteOpen(true)}
|
||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||
onOpenShortcuts={() => setIsShortcutsOpen(true)}
|
||||
onOpenChapters={() => setIsChaptersOpen(true)}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCycleTheme={handleCycleTheme}
|
||||
/>
|
||||
</div>
|
||||
{/* Top Subtle Bar */}
|
||||
<header
|
||||
className={`w-full flex items-center justify-between px-6 py-4 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`}
|
||||
>
|
||||
<span className="text-[11px] font-mono tracking-widest text-zinc-500 uppercase">
|
||||
The Well of Ascension
|
||||
</span>
|
||||
|
||||
{/* Main RSVP Focus Arena */}
|
||||
<main className="flex-1 min-h-0 flex flex-col items-center justify-center px-2 sm:px-4 z-10 w-full overflow-hidden my-auto">
|
||||
{/* Quiet Gear Button */}
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
title="Settings [Esc]"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Main RSVPs Central Stream */}
|
||||
<main className="flex-1 min-h-0 flex items-center justify-center w-full z-10 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center space-y-3 text-neutral-400">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-rose-500 border-t-transparent animate-spin" />
|
||||
<span className="text-sm font-mono tracking-wider">正在加载 Focus 阅读器...</span>
|
||||
<div className="flex flex-col items-center space-y-3 text-zinc-500">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
|
||||
<span className="text-xs font-mono tracking-wider">
|
||||
Loading Focus...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<FocusDisplay
|
||||
token={currentToken}
|
||||
previousToken={previousToken}
|
||||
nextToken={nextToken}
|
||||
fontSize={settings.fontSize}
|
||||
fontFamily={settings.fontFamily}
|
||||
orpColor={settings.orpColor}
|
||||
showGuideLines={settings.showGuideLines}
|
||||
showContextPreview={settings.showContextPreview}
|
||||
chunkText={currentChunkText}
|
||||
fontSize={fontSize}
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={togglePlay}
|
||||
onTogglePlay={() => {
|
||||
resetInactivity();
|
||||
togglePlay();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Bottom Controls HUD */}
|
||||
<div className={`w-full pb-2 sm:pb-6 short:pb-1 z-20 shrink-0 ${hudClasses}`}>
|
||||
<Controls
|
||||
currentIndex={currentIndex}
|
||||
totalTokens={totalTokens}
|
||||
tokens={activeBook?.tokens || []}
|
||||
chapters={activeBook?.chapters || []}
|
||||
isPlaying={isPlaying}
|
||||
settings={settings}
|
||||
onTogglePlay={togglePlay}
|
||||
onJumpTo={jumpTo}
|
||||
onJumpBy={jumpBy}
|
||||
onJumpToPreviousSentence={jumpToPreviousSentence}
|
||||
onJumpToNextSentence={jumpToNextSentence}
|
||||
onRestart={restart}
|
||||
onChangeWpm={(newWpm) => handleUpdateSettings({ wpm: newWpm })}
|
||||
/>
|
||||
</div>
|
||||
{/* Bottom Ultra-Thin Progress & Speed HUD */}
|
||||
<footer
|
||||
className={`w-full flex flex-col space-y-2 pb-4 px-6 z-20 shrink-0 transition-opacity duration-500 ${hudOpacity}`}
|
||||
>
|
||||
{/* Ultra-thin progress line */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickPos = (e.clientX - rect.left) / rect.width;
|
||||
const target = Math.round(clickPos * (totalWords - 1));
|
||||
jumpTo(target);
|
||||
}}
|
||||
className="relative w-full h-1 bg-zinc-800/80 hover:h-2 rounded-full cursor-pointer transition-all group overflow-hidden"
|
||||
title="点击调整进度"
|
||||
>
|
||||
<div
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
className="h-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)] transition-all duration-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modals & Drawers */}
|
||||
<LibraryModal
|
||||
isOpen={isLibraryOpen}
|
||||
books={booksMeta}
|
||||
activeBookId={activeBook?.id || null}
|
||||
onClose={() => setIsLibraryOpen(false)}
|
||||
onSelectBook={handleSelectBook}
|
||||
onSaveNewBook={handleSaveNewBook}
|
||||
onDeleteBook={handleDeleteBook}
|
||||
/>
|
||||
{/* Status text */}
|
||||
<div className="flex items-center justify-between text-[11px] font-mono text-zinc-500">
|
||||
<span>
|
||||
{(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '}
|
||||
<span className="opacity-70">({progressPercent.toFixed(1)}%)</span>
|
||||
</span>
|
||||
|
||||
<PasteModal
|
||||
isOpen={isPasteOpen}
|
||||
wpm={settings.wpm}
|
||||
onClose={() => setIsPasteOpen(false)}
|
||||
onSaveAndPlay={async (newBook) => {
|
||||
await handleSaveNewBook(newBook);
|
||||
setActiveBook(newBook);
|
||||
setActiveBookId(newBook.id);
|
||||
play();
|
||||
}}
|
||||
/>
|
||||
<span className="text-zinc-400 font-semibold">
|
||||
{wpm} WPM{' '}
|
||||
<span className="font-normal text-zinc-500 text-[10px]">
|
||||
({flashesPerMinute} FPM × {wordsPerChunk})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<SettingsModal
|
||||
{/* Slide-out Settings Drawer */}
|
||||
<SettingsDrawer
|
||||
isOpen={isSettingsOpen}
|
||||
settings={settings}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
/>
|
||||
|
||||
<ShortcutsModal
|
||||
isOpen={isShortcutsOpen}
|
||||
onClose={() => setIsShortcutsOpen(false)}
|
||||
/>
|
||||
|
||||
<ChapterDrawer
|
||||
isOpen={isChaptersOpen}
|
||||
chapters={activeBook?.chapters || []}
|
||||
currentChapterId={currentChapter?.id}
|
||||
onClose={() => setIsChaptersOpen(false)}
|
||||
onSelectChapter={(chapter: Chapter) => jumpToChapter(chapter)}
|
||||
flashesPerMinute={flashesPerMinute}
|
||||
onChangeFpm={handleFpmChange}
|
||||
wordsPerChunk={wordsPerChunk}
|
||||
onChangeWordsPerChunk={handleWordsPerChunkChange}
|
||||
fontSize={fontSize}
|
||||
onChangeFontSize={handleFontSizeChange}
|
||||
wordIndex={currentIndex}
|
||||
totalWords={totalWords}
|
||||
onJumpTo={jumpTo}
|
||||
onResetProgress={() => {
|
||||
restart();
|
||||
saveState({ wordIndex: 0 });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
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-2 sm:space-y-4 short:space-y-1 px-3 sm:px-4 select-none shrink-0">
|
||||
{/* Progress & Timeline Bar */}
|
||||
{settings.showProgressBar && (
|
||||
<div className="flex flex-col space-y-0.5 sm:space-y-1.5">
|
||||
<div className="flex justify-between items-center text-[10px] sm: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-0.5 sm: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 sm: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 short:space-x-2">
|
||||
{/* Restart */}
|
||||
<button
|
||||
onClick={onRestart}
|
||||
title="重新开始 [R]"
|
||||
className="p-1.5 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-1.5 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-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
{/* Previous Sentence */}
|
||||
<button
|
||||
onClick={onJumpToPreviousSentence}
|
||||
title="上一句 [Shift + ←]"
|
||||
className="p-1.5 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-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
{/* Primary Play / Pause Button */}
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
title="播放 / 暂停 [空格键]"
|
||||
className="btn-play-primary w-10 h-10 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-4 h-4 sm:w-5 sm:h-5 fill-current" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 sm:w-5 sm:h-5 fill-current ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Next Sentence */}
|
||||
<button
|
||||
onClick={onJumpToNextSentence}
|
||||
title="下一句 [Shift + →]"
|
||||
className="p-1.5 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-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
{/* Forward 10 */}
|
||||
<button
|
||||
onClick={() => onJumpBy(10)}
|
||||
title="前进 10 词 [→]"
|
||||
className="p-1.5 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-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
{/* Quick Speed fine-tuning */}
|
||||
<div className="flex items-center space-x-1 pl-1.5 sm: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 sm: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-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
<span className="text-[11px] sm:text-xs font-mono font-semibold w-9 sm: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 sm: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-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset WPM Chips (Hidden on short screens to prevent overflow) */}
|
||||
<div className="flex items-center justify-center flex-wrap gap-1.5 pt-0.5 sm:pt-1 text-xs short:hidden">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,165 +1,55 @@
|
||||
import React from 'react';
|
||||
import type { FontFamily, FontSize, OrpColor, Token } from '../types';
|
||||
import type { FC } from 'react';
|
||||
import type { FontSize } from '../types';
|
||||
|
||||
interface FocusDisplayProps {
|
||||
token: Token | null;
|
||||
previousToken: Token | null;
|
||||
nextToken: Token | null;
|
||||
chunkText: string;
|
||||
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 short:h-20 short:text-2xl',
|
||||
base: 'text-4xl sm:text-5xl h-28 sm:h-32 short:h-20 short:text-3xl',
|
||||
lg: 'text-5xl sm:text-6xl h-32 sm:h-36 short:h-24 short:text-3xl',
|
||||
xl: 'text-6xl sm:text-7xl h-36 sm:h-44 short:h-24 short:text-4xl',
|
||||
'2xl': 'text-7xl sm:text-8xl h-44 sm:h-52 short:h-24 short:text-4xl',
|
||||
'3xl': 'text-8xl sm:text-9xl h-52 sm:h-64 short:h-24 short:text-4xl',
|
||||
sm: 'text-3xl sm:text-4xl md:text-5xl',
|
||||
md: 'text-4xl sm:text-5xl md:text-6xl',
|
||||
lg: 'text-5xl sm:text-6xl md:text-7xl',
|
||||
xl: 'text-6xl sm:text-7xl md:text-8xl',
|
||||
'2xl': 'text-7xl sm:text-8xl md:text-9xl',
|
||||
};
|
||||
|
||||
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,
|
||||
export const FocusDisplay: FC<FocusDisplayProps> = ({
|
||||
chunkText,
|
||||
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;
|
||||
const sizeClass = FONT_SIZE_MAP[fontSize] || FONT_SIZE_MAP.xl;
|
||||
|
||||
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-2 sm:py-6 short:py-1 transition-colors flex-1 min-h-0"
|
||||
title="点击屏幕或按空格键 暂停/继续"
|
||||
className="relative w-full h-full flex flex-col items-center justify-center cursor-pointer select-none px-4"
|
||||
title="点击播放 / 暂停"
|
||||
>
|
||||
{/* Contextual Peripheral Ghost Words (Top line) */}
|
||||
{showContextPreview && (
|
||||
<div className="h-5 sm:h-7 mb-1 sm:mb-2 short:hidden flex items-center justify-center space-x-4 sm:space-x-6 text-xs sm:text-sm text-neutral-400 dark:text-neutral-500 opacity-60 transition-opacity shrink-0">
|
||||
<span className="truncate max-w-[100px] sm:max-w-[140px] text-right">
|
||||
{previousToken ? previousToken.text : ''}
|
||||
<div className="relative max-w-5xl w-full flex items-center justify-center min-h-[140px] sm:min-h-[200px]">
|
||||
{chunkText ? (
|
||||
<span
|
||||
className={`font-sans font-semibold tracking-normal text-[#f4f4f5] text-center leading-tight transition-all duration-75 ${sizeClass}`}
|
||||
>
|
||||
{chunkText}
|
||||
</span>
|
||||
<span className="w-1 h-1 rounded-full bg-neutral-600/40" />
|
||||
<span className="truncate max-w-[100px] sm:max-w-[140px] text-left">
|
||||
{nextToken ? nextToken.text : ''}
|
||||
) : (
|
||||
<span className="text-xl sm:text-2xl text-zinc-500 font-sans tracking-wide">
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtle pause hint when not playing */}
|
||||
{!isPlaying && chunkText && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 text-xs font-mono tracking-widest text-zinc-500/80 uppercase pointer-events-none">
|
||||
PAUSED · SPACE TO PLAY
|
||||
</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-2 sm: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% with slot smoothing */}
|
||||
<span
|
||||
className={`relative inline-block min-w-[1.1ch] text-center 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-1 sm:mt-2 short:hidden flex items-center justify-center shrink-0">
|
||||
{token?.isParagraphEnd && (
|
||||
<span className="text-[11px] font-mono tracking-widest text-neutral-400 dark:text-neutral-500 uppercase">
|
||||
¶ 段落停顿
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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-3 sm:px-4 py-1.5 sm:py-3 short:py-1 border-b border-neutral-200/50 dark:border-neutral-800/80 bg-white/50 dark:bg-black/30 backdrop-blur-md select-none shrink-0">
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
@@ -1,338 +0,0 @@
|
||||
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.rawChapters;
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
@@ -1,197 +0,0 @@
|
||||
import { AlertCircle, Clipboard, Play, X } from 'lucide-react';
|
||||
import React, { useRef, 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);
|
||||
const [clipboardError, setClipboardError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
setIsReadingClipboard(true);
|
||||
setClipboardError(null);
|
||||
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
||||
throw new Error('Clipboard API not supported');
|
||||
}
|
||||
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && text.trim()) {
|
||||
setContent(text);
|
||||
if (!title) {
|
||||
const firstLine = text.trim().split('\n')[0].substring(0, 30);
|
||||
setTitle(firstLine || '剪贴板快读');
|
||||
}
|
||||
} else {
|
||||
setClipboardError('剪贴板中未检测到有效文本,请直接在下方文本框手动粘贴 (Ctrl+V)');
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Clipboard auto-read failed:', err);
|
||||
setClipboardError('无法自动读取剪贴板,请直接在文本框粘贴 (Ctrl+V)');
|
||||
textareaRef.current?.focus();
|
||||
} 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('');
|
||||
setClipboardError(null);
|
||||
};
|
||||
|
||||
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">
|
||||
{clipboardError && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/25 text-amber-700 dark:text-amber-400 text-xs flex items-center justify-between animate-fade-in">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-500" />
|
||||
<span>{clipboardError}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setClipboardError(null)}
|
||||
className="text-amber-600 dark:text-amber-400 hover:opacity-75 text-sm font-bold ml-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
ref={textareaRef}
|
||||
rows={8}
|
||||
placeholder="在此粘贴任意中英文文本..."
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
if (clipboardError) setClipboardError(null);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
};
|
||||
247
src/components/SettingsDrawer.tsx
Normal file
247
src/components/SettingsDrawer.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { Minus, Plus, RotateCcw, X } from 'lucide-react';
|
||||
import type { FC } from 'react';
|
||||
import { calculateWpm } from '../core/timing';
|
||||
import type { FontSize } from '../types';
|
||||
|
||||
interface SettingsDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
flashesPerMinute: number;
|
||||
onChangeFpm: (fpm: number) => void;
|
||||
wordsPerChunk: number;
|
||||
onChangeWordsPerChunk: (chunk: number) => void;
|
||||
fontSize: FontSize;
|
||||
onChangeFontSize: (size: FontSize) => void;
|
||||
wordIndex: number;
|
||||
totalWords: number;
|
||||
onJumpTo: (index: number) => void;
|
||||
onResetProgress: () => void;
|
||||
}
|
||||
|
||||
const FPM_PRESETS = [150, 200, 250, 300, 400, 500];
|
||||
const FONT_SIZES: { id: FontSize; label: string }[] = [
|
||||
{ id: 'sm', label: 'S' },
|
||||
{ id: 'md', label: 'M' },
|
||||
{ id: 'lg', label: 'L' },
|
||||
{ id: 'xl', label: 'XL' },
|
||||
{ id: '2xl', label: '2XL' },
|
||||
];
|
||||
|
||||
export const SettingsDrawer: FC<SettingsDrawerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
flashesPerMinute,
|
||||
onChangeFpm,
|
||||
wordsPerChunk,
|
||||
onChangeWordsPerChunk,
|
||||
fontSize,
|
||||
onChangeFontSize,
|
||||
wordIndex,
|
||||
totalWords,
|
||||
onJumpTo,
|
||||
onResetProgress,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const wpm = calculateWpm(flashesPerMinute, wordsPerChunk);
|
||||
const progressPercent =
|
||||
totalWords > 0
|
||||
? Math.min(100, Math.max(0, (wordIndex / (totalWords - 1 || 1)) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/60 backdrop-blur-sm select-none animate-in fade-in duration-200">
|
||||
{/* Backdrop click to close */}
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
{/* Slide-out Drawer Panel */}
|
||||
<div className="relative w-full max-w-sm h-full bg-[#0e0e11] border-l border-zinc-800/80 p-6 flex flex-col justify-between overflow-y-auto text-zinc-200 shadow-2xl z-10">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-zinc-800/80">
|
||||
<h2 className="text-sm font-semibold tracking-wider uppercase text-zinc-100 font-mono">
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 transition-colors"
|
||||
title="Close [Esc]"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed: FPM & Linked WPM */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-zinc-400">
|
||||
Speed (FPM)
|
||||
</span>
|
||||
<div className="flex items-baseline space-x-1.5">
|
||||
<span className="text-base font-bold font-mono text-emerald-400">
|
||||
{wpm} <span className="text-xs font-normal text-zinc-400">WPM</span>
|
||||
</span>
|
||||
<span className="text-xs font-mono text-zinc-500">
|
||||
({flashesPerMinute} × {wordsPerChunk})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => onChangeFpm(Math.max(60, flashesPerMinute - 10))}
|
||||
className="p-2 rounded-lg bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 hover:text-white transition-colors"
|
||||
title="-10 FPM [↓]"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={60}
|
||||
max={600}
|
||||
step={10}
|
||||
value={flashesPerMinute}
|
||||
onChange={(e) => onChangeFpm(Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-emerald-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onChangeFpm(Math.min(600, flashesPerMinute + 10))}
|
||||
className="p-2 rounded-lg bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 hover:text-white transition-colors"
|
||||
title="+10 FPM [↑]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick FPM Presets */}
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{FPM_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => onChangeFpm(preset)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-mono transition-colors ${
|
||||
flashesPerMinute === preset
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-semibold'
|
||||
: 'bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-zinc-200 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Words Per Chunk (1 ~ 4) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-zinc-400">
|
||||
Words Per Chunk
|
||||
</span>
|
||||
<span className="text-xs font-mono text-zinc-400 font-semibold">
|
||||
{wordsPerChunk} {wordsPerChunk === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[1, 2, 3, 4].map((chunk) => (
|
||||
<button
|
||||
key={chunk}
|
||||
onClick={() => onChangeWordsPerChunk(chunk)}
|
||||
className={`py-2 rounded-lg font-mono text-sm font-semibold transition-colors ${
|
||||
wordsPerChunk === chunk
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||
: 'bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-zinc-200 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{chunk}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-zinc-400">
|
||||
Font Size
|
||||
</span>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{FONT_SIZES.map((sz) => (
|
||||
<button
|
||||
key={sz.id}
|
||||
onClick={() => onChangeFontSize(sz.id)}
|
||||
className={`py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors ${
|
||||
fontSize === sz.id
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||
: 'bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-zinc-200 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{sz.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Jump Scrubber */}
|
||||
<div className="space-y-2 pt-2 border-t border-zinc-800/80">
|
||||
<div className="flex justify-between items-center text-xs font-mono">
|
||||
<span className="text-zinc-400 uppercase tracking-wider">Progress</span>
|
||||
<span className="text-zinc-300">
|
||||
{wordIndex.toLocaleString()} / {totalWords.toLocaleString()} ({progressPercent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, totalWords - 1)}
|
||||
value={wordIndex}
|
||||
onChange={(e) => onJumpTo(Number(e.target.value))}
|
||||
className="w-full h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-emerald-500 focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
onClick={onResetProgress}
|
||||
className="flex items-center space-x-1.5 text-xs font-mono text-zinc-400 hover:text-rose-400 transition-colors py-1 px-2 rounded hover:bg-zinc-900"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Reset to Start</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts Reference */}
|
||||
<div className="space-y-2 pt-3 border-t border-zinc-800/80 text-xs">
|
||||
<span className="font-mono uppercase tracking-wider text-zinc-500 text-[11px]">
|
||||
Shortcuts
|
||||
</span>
|
||||
<div className="space-y-1.5 text-zinc-400 font-mono text-[11px]">
|
||||
<div className="flex justify-between">
|
||||
<span>Space</span>
|
||||
<span className="text-zinc-500">Play / Pause</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>← / →</span>
|
||||
<span className="text-zinc-500">Rewind / Forward 10</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>↑ / ↓</span>
|
||||
<span className="text-zinc-500">Speed (±10 FPM)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>F</span>
|
||||
<span className="text-zinc-500">Toggle Fullscreen</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Esc</span>
|
||||
<span className="text-zinc-500">Close Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="pt-4 border-t border-zinc-800/60 text-center text-[11px] font-mono text-zinc-600">
|
||||
The Well of Ascension · Brandon Sanderson
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,265 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
158
src/core/epub.ts
158
src/core/epub.ts
@@ -1,158 +0,0 @@
|
||||
export interface EpubChapterItem {
|
||||
id: string;
|
||||
title: string;
|
||||
charOffset: number;
|
||||
}
|
||||
|
||||
export interface ParsedEpub {
|
||||
title: string;
|
||||
author: string;
|
||||
fullText: string;
|
||||
rawChapters: EpubChapterItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side EPUB parser with dynamic JSZip import to reduce initial bundle size.
|
||||
*/
|
||||
export async function parseEpubFile(file: File | Blob): Promise<ParsedEpub> {
|
||||
// Dynamic import JSZip to split ~100KB off the initial load bundle
|
||||
const { default: JSZip } = await import('jszip');
|
||||
|
||||
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) {
|
||||
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 rawChapters: EpubChapterItem[] = [];
|
||||
let fullText = '';
|
||||
let chapterIndex = 1;
|
||||
|
||||
for (const idref of spineItemIds) {
|
||||
const relativeHref = manifestMap.get(idref);
|
||||
if (!relativeHref) continue;
|
||||
|
||||
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;
|
||||
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) {
|
||||
const startCharOffset = fullText.length;
|
||||
rawChapters.push({
|
||||
id: `chap-${chapterIndex}`,
|
||||
title: chapterTitle,
|
||||
charOffset: startCharOffset,
|
||||
});
|
||||
|
||||
fullText += (fullText ? '\n\n' : '') + chapterText;
|
||||
chapterIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullText.trim()) {
|
||||
throw new Error('未能从 EPUB 提取到可读文本');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
author,
|
||||
fullText,
|
||||
rawChapters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('/');
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Optimal Recognition Point (ORP) Calculation for Spritz RSVP Reading.
|
||||
* Fixates the eye at ~30%-35% into the word where human ocular fixation naturally rests.
|
||||
* Uses unicode-safe code point segmentation (Array.from) to eliminate surrogate pair
|
||||
* and emoji splitting defects.
|
||||
*/
|
||||
|
||||
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.
|
||||
* Based on unicode code-point length (Array.from(cleanWord).length).
|
||||
*/
|
||||
export function calculateOrpIndex(cleanWord: string): number {
|
||||
const chars = Array.from(cleanWord);
|
||||
const len = chars.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.
|
||||
* Operates purely on unicode code points to prevent corrupting surrogate pairs.
|
||||
*/
|
||||
export function splitOrp(
|
||||
coreWord: string,
|
||||
leadingPunct: string = '',
|
||||
trailingPunct: string = ''
|
||||
): SplitWord {
|
||||
if (!coreWord) {
|
||||
return {
|
||||
prefix: leadingPunct,
|
||||
pivot: '',
|
||||
suffix: trailingPunct,
|
||||
orpIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const chars = Array.from(coreWord);
|
||||
const orpIndex = calculateOrpIndex(coreWord);
|
||||
const prefix = leadingPunct + chars.slice(0, orpIndex).join('');
|
||||
const pivot = chars[orpIndex] || '';
|
||||
const suffix = chars.slice(orpIndex + 1).join('') + trailingPunct;
|
||||
|
||||
return {
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +1,35 @@
|
||||
import type { ReaderSettings, Token } from '../types';
|
||||
import { isCJKWord } from './orp';
|
||||
/**
|
||||
* Calculate linked WPM: WPM = flashesPerMinute * wordsPerChunk
|
||||
*/
|
||||
export function calculateWpm(flashesPerMinute: number, wordsPerChunk: number): number {
|
||||
return Math.round(flashesPerMinute * wordsPerChunk);
|
||||
}
|
||||
|
||||
const SENTENCE_END_REGEX = /[.!?]["'”’)]?$/;
|
||||
const CLAUSE_END_REGEX = /[,;:]["'”’)]?$/;
|
||||
|
||||
/**
|
||||
* Calculates the display duration (in milliseconds) for a single token,
|
||||
* applying the physiological breathing rhythm algorithm.
|
||||
* Calculates display duration in ms for a chunk.
|
||||
* Base duration is strictly 60000 / flashesPerMinute,
|
||||
* supplemented with subtle natural punctuation pauses.
|
||||
*/
|
||||
export function calculateTokenDelay(
|
||||
token: Token,
|
||||
settings: ReaderSettings
|
||||
export function calculateChunkDelay(
|
||||
lastWordInChunk: string,
|
||||
flashesPerMinute: number
|
||||
): number {
|
||||
const baseDelay = Math.max(20, Math.round(60000 / Math.max(50, settings.wpm)));
|
||||
const baseDelay = 60000 / Math.max(1, flashesPerMinute);
|
||||
|
||||
if (!settings.breathingPunctuation) {
|
||||
return baseDelay;
|
||||
if (!lastWordInChunk) return Math.round(baseDelay);
|
||||
|
||||
// Subtle natural pause for sentence ends (. ! ?)
|
||||
if (SENTENCE_END_REGEX.test(lastWordInChunk)) {
|
||||
return Math.round(baseDelay * 1.3);
|
||||
}
|
||||
|
||||
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;
|
||||
// Very slight breath pause for clause ends (, ; :)
|
||||
if (CLAUSE_END_REGEX.test(lastWordInChunk)) {
|
||||
return Math.round(baseDelay * 1.15);
|
||||
}
|
||||
|
||||
// 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}小时`;
|
||||
return Math.round(baseDelay);
|
||||
}
|
||||
|
||||
@@ -1,328 +1,11 @@
|
||||
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 = /[,;:,;:、—–]/;
|
||||
|
||||
// Emoji / Extended pictographic Unicode matcher
|
||||
const EMOJI_REGEX = /^\p{Extended_Pictographic}+$/u;
|
||||
|
||||
// Chapter detection regex patterns for raw text / markdown
|
||||
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 ChapterInputHint {
|
||||
id?: string;
|
||||
title: string;
|
||||
charOffset?: number;
|
||||
startIndex?: number;
|
||||
}
|
||||
|
||||
export interface TokenizeResult {
|
||||
tokens: Token[];
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export function tokenizeText(
|
||||
rawText: string,
|
||||
existingChapters?: ChapterInputHint[]
|
||||
): TokenizeResult {
|
||||
if (!rawText || !rawText.trim()) {
|
||||
return { tokens: [], chapters: [] };
|
||||
}
|
||||
|
||||
// Determine chapter source
|
||||
const hasExternalHints = existingChapters && existingChapters.length > 0;
|
||||
const chapterHints: { id: string; title: string; charOffset: number }[] = [];
|
||||
|
||||
if (hasExternalHints) {
|
||||
existingChapters.forEach((c, idx) => {
|
||||
chapterHints.push({
|
||||
id: c.id || `chap-${idx + 1}`,
|
||||
title: c.title,
|
||||
charOffset: c.charOffset ?? c.startIndex ?? 0,
|
||||
});
|
||||
});
|
||||
chapterHints.sort((a, b) => a.charOffset - b.charOffset);
|
||||
}
|
||||
|
||||
const autoDetectChapters = !hasExternalHints;
|
||||
|
||||
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 chapters: Chapter[] = [];
|
||||
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;
|
||||
|
||||
// Align chapters based on character offset -> exact Token index
|
||||
if (hasExternalHints) {
|
||||
while (
|
||||
nextChapterIdx < chapterHints.length &&
|
||||
index >= chapterHints[nextChapterIdx].charOffset
|
||||
) {
|
||||
const hint = chapterHints[nextChapterIdx];
|
||||
currentChapterIndex = chapters.length;
|
||||
chapters.push({
|
||||
id: hint.id,
|
||||
title: hint.title,
|
||||
startIndex: tokens.length, // True token index
|
||||
});
|
||||
nextChapterIdx++;
|
||||
if (tokens.length > 0) {
|
||||
tokens[tokens.length - 1].isParagraphEnd = true;
|
||||
tokens[tokens.length - 1].isSentenceEnd = true;
|
||||
}
|
||||
}
|
||||
} else 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, // True token index
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// Emoji compatibility: Extended_Pictographic symbols stand as full tokens
|
||||
if (EMOJI_REGEX.test(trimmed)) {
|
||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(
|
||||
trimmed,
|
||||
pendingLeading,
|
||||
''
|
||||
);
|
||||
tokens.push({
|
||||
id: tokens.length,
|
||||
text: prefix + pivot + suffix,
|
||||
cleanWord: trimmed,
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
isParagraphEnd: newlineCount >= 2,
|
||||
isSentenceEnd: false,
|
||||
isClauseEnd: false,
|
||||
isLongWord: false,
|
||||
chapterIndex: currentChapterIndex,
|
||||
});
|
||||
pendingLeading = '';
|
||||
newlineCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LEADING_PUNCT_REGEX.test(trimmed)) {
|
||||
// Attaches to the NEXT token's prefix
|
||||
pendingLeading += trimmed;
|
||||
} else if (tokens.length > 0) {
|
||||
// Trailing punctuation attached to the PREVIOUS token
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
// Leading punctuation or symbols at the start of text (don't discard)
|
||||
pendingLeading += trimmed;
|
||||
}
|
||||
|
||||
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)
|
||||
? Array.from(coreWord).length >= 4
|
||||
: Array.from(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 {
|
||||
// Fallback regex loop with Emoji support
|
||||
const wordPattern = /\p{Extended_Pictographic}+|[一-龥]|[a-zA-Z0-9_À-ɏ]+|[^\s\w]/gu;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = wordPattern.exec(rawText)) !== null) {
|
||||
const str = match[0];
|
||||
if (EMOJI_REGEX.test(str) || /[一-龥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: Array.from(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;
|
||||
} else {
|
||||
pendingLeading += str;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If text only consisted of pendingLeading symbols and no words
|
||||
if (tokens.length === 0 && pendingLeading.trim().length > 0) {
|
||||
const trimmed = pendingLeading.trim();
|
||||
const { prefix, pivot, suffix, orpIndex } = splitOrp(trimmed, '', '');
|
||||
tokens.push({
|
||||
id: 0,
|
||||
text: trimmed,
|
||||
cleanWord: trimmed,
|
||||
prefix,
|
||||
pivot,
|
||||
suffix,
|
||||
orpIndex,
|
||||
isParagraphEnd: false,
|
||||
isSentenceEnd: false,
|
||||
isClauseEnd: false,
|
||||
isLongWord: false,
|
||||
chapterIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// If no chapters detected, 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 };
|
||||
/**
|
||||
* Pure and efficient English word tokenization.
|
||||
* Splits by whitespace/newlines, preserving native attached punctuation.
|
||||
*/
|
||||
export function tokenizeEnglishText(rawText: string): string[] {
|
||||
if (!rawText) return [];
|
||||
const trimmed = rawText.trim();
|
||||
if (!trimmed) return [];
|
||||
// Split on any whitespace sequence (spaces, tabs, newlines)
|
||||
return trimmed.split(/\s+/).filter((word) => word.length > 0);
|
||||
}
|
||||
|
||||
318
src/db/index.ts
318
src/db/index.ts
@@ -1,293 +1,55 @@
|
||||
import type { Book, BookMeta, ReaderSettings } from '../types';
|
||||
import type { FocusState } from '../types';
|
||||
|
||||
const DB_NAME = 'focus_rsvp_db';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_BOOKS = 'books';
|
||||
const STORE_META = 'books_meta';
|
||||
const STORAGE_KEY = 'focus_reader_state';
|
||||
|
||||
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,
|
||||
export const DEFAULT_STATE: FocusState = {
|
||||
wordIndex: 0,
|
||||
wordsPerChunk: 2,
|
||||
flashesPerMinute: 250,
|
||||
fontSize: 'xl',
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = 'focus_reader_settings';
|
||||
const ACTIVE_BOOK_KEY = 'focus_active_book_id';
|
||||
|
||||
// Global In-Memory Fallback Map (for private browsing, restricted sandbox, or quota limits)
|
||||
const memoryBooks = new Map<string, Book>();
|
||||
const memoryMeta = new Map<string, BookMeta>();
|
||||
let useMemoryFallback = false;
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
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);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getDB(): Promise<IDBDatabase | null> {
|
||||
if (useMemoryFallback) return null;
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
useMemoryFallback = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadSavedState(): FocusState {
|
||||
try {
|
||||
return await openDB();
|
||||
} catch (err) {
|
||||
console.warn('IndexedDB unavailable, engaging in-memory fallback store:', err);
|
||||
useMemoryFallback = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBook(book: Book): Promise<void> {
|
||||
const { tokens: _tokens, ...meta } = book;
|
||||
|
||||
// Always sync to memory fallback
|
||||
memoryBooks.set(book.id, book);
|
||||
memoryMeta.set(book.id, meta);
|
||||
|
||||
const db = await getDB();
|
||||
if (!db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readwrite');
|
||||
const booksStore = tx.objectStore(STORE_BOOKS);
|
||||
const metaStore = tx.objectStore(STORE_META);
|
||||
|
||||
booksStore.put(book);
|
||||
metaStore.put(meta);
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = (err) => {
|
||||
console.warn('IndexedDB saveBook transaction error, cached in memory:', err);
|
||||
resolve();
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('saveBook error:', err);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBook(id: string): Promise<Book | undefined> {
|
||||
const db = await getDB();
|
||||
if (!db) {
|
||||
return memoryBooks.get(id);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction([STORE_BOOKS, STORE_META], 'readonly');
|
||||
const booksStore = tx.objectStore(STORE_BOOKS);
|
||||
const metaStore = tx.objectStore(STORE_META);
|
||||
|
||||
let bookResult: Book | undefined;
|
||||
let metaResult: BookMeta | undefined;
|
||||
|
||||
const bookReq = booksStore.get(id);
|
||||
bookReq.onsuccess = () => {
|
||||
bookResult = bookReq.result as Book | undefined;
|
||||
};
|
||||
|
||||
const metaReq = metaStore.get(id);
|
||||
metaReq.onsuccess = () => {
|
||||
metaResult = metaReq.result as BookMeta | undefined;
|
||||
};
|
||||
|
||||
tx.oncomplete = () => {
|
||||
if (bookResult) {
|
||||
// Overlay latest progress & lastReadAt from STORE_META
|
||||
if (metaResult) {
|
||||
bookResult.currentProgress = metaResult.currentProgress;
|
||||
bookResult.lastReadAt = metaResult.lastReadAt;
|
||||
}
|
||||
memoryBooks.set(id, bookResult);
|
||||
if (metaResult) memoryMeta.set(id, metaResult);
|
||||
}
|
||||
resolve(bookResult || memoryBooks.get(id));
|
||||
};
|
||||
|
||||
tx.onerror = () => {
|
||||
resolve(memoryBooks.get(id));
|
||||
};
|
||||
} catch {
|
||||
resolve(memoryBooks.get(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllBooksMeta(): Promise<BookMeta[]> {
|
||||
const db = await getDB();
|
||||
if (!db) {
|
||||
const list = Array.from(memoryMeta.values());
|
||||
list.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||
return list;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
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[]) || [];
|
||||
list.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||
// Sync to memory
|
||||
list.forEach((m) => memoryMeta.set(m.id, m));
|
||||
resolve(list);
|
||||
};
|
||||
request.onerror = () => {
|
||||
const fallback = Array.from(memoryMeta.values());
|
||||
fallback.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||
resolve(fallback);
|
||||
};
|
||||
} catch {
|
||||
const fallback = Array.from(memoryMeta.values());
|
||||
fallback.sort((a, b) => b.lastReadAt - a.lastReadAt);
|
||||
resolve(fallback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim Progress Update:
|
||||
* Updates ONLY STORE_META (a lightweight record < 1KB), avoiding full serialization
|
||||
* and deserialization of megabyte-sized token arrays.
|
||||
*/
|
||||
export async function updateBookProgress(id: string, progress: number): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
// Update in-memory stores immediately
|
||||
const m = memoryMeta.get(id);
|
||||
if (m) {
|
||||
m.currentProgress = progress;
|
||||
m.lastReadAt = now;
|
||||
}
|
||||
const b = memoryBooks.get(id);
|
||||
if (b) {
|
||||
b.currentProgress = progress;
|
||||
b.lastReadAt = now;
|
||||
}
|
||||
|
||||
const db = await getDB();
|
||||
if (!db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// ONLY readwrite STORE_META! Zero serialization overhead.
|
||||
const tx = db.transaction(STORE_META, 'readwrite');
|
||||
const metaStore = tx.objectStore(STORE_META);
|
||||
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 = (err) => {
|
||||
console.warn('Failed to update meta progress in IndexedDB:', err);
|
||||
resolve();
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('updateBookProgress error:', err);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBook(id: string): Promise<void> {
|
||||
memoryBooks.delete(id);
|
||||
memoryMeta.delete(id);
|
||||
|
||||
const db = await getDB();
|
||||
if (!db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
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 = (err) => {
|
||||
console.warn('Failed to delete book in IndexedDB:', err);
|
||||
resolve();
|
||||
};
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function loadSettings(): ReaderSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
wordIndex: Math.max(
|
||||
0,
|
||||
typeof parsed.wordIndex === 'number' ? parsed.wordIndex : 0
|
||||
),
|
||||
wordsPerChunk: Math.min(
|
||||
4,
|
||||
Math.max(
|
||||
1,
|
||||
typeof parsed.wordsPerChunk === 'number' ? parsed.wordsPerChunk : 2
|
||||
)
|
||||
),
|
||||
flashesPerMinute: Math.min(
|
||||
600,
|
||||
Math.max(
|
||||
60,
|
||||
typeof parsed.flashesPerMinute === 'number'
|
||||
? parsed.flashesPerMinute
|
||||
: 250
|
||||
)
|
||||
),
|
||||
fontSize: parsed.fontSize || 'xl',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load settings from localStorage', err);
|
||||
console.warn('Failed to load state from localStorage:', err);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: ReaderSettings): void {
|
||||
export function saveState(state: Partial<FocusState>): void {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
const current = loadSavedState();
|
||||
const merged = { ...current, ...state };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
||||
} 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);
|
||||
console.warn('Failed to save state to localStorage:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3,33 +3,22 @@ import { useEffect } from 'react';
|
||||
interface KeyboardShortcutHandlers {
|
||||
togglePlay: () => void;
|
||||
jumpBy: (delta: number) => void;
|
||||
jumpToPreviousSentence: () => void;
|
||||
jumpToNextSentence: () => void;
|
||||
changeWpm: (delta: number) => void;
|
||||
changeFpm: (delta: number) => void;
|
||||
toggleFullscreen: () => void;
|
||||
onEscape?: () => void;
|
||||
restart: () => void;
|
||||
prevChapter?: () => void;
|
||||
nextChapter?: () => void;
|
||||
isModalOpen?: boolean;
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
togglePlay,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
changeWpm,
|
||||
changeFpm,
|
||||
toggleFullscreen,
|
||||
onEscape,
|
||||
restart,
|
||||
prevChapter,
|
||||
nextChapter,
|
||||
isModalOpen = false,
|
||||
isDrawerOpen = 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 &&
|
||||
@@ -52,8 +41,8 @@ export function useKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
// If a modal is open, don't trigger playback controls
|
||||
if (isModalOpen) return;
|
||||
// If drawer is open, keep drawer navigation active
|
||||
if (isDrawerOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ': // Space
|
||||
@@ -63,30 +52,22 @@ export function useKeyboardShortcuts({
|
||||
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
jumpToPreviousSentence();
|
||||
} else {
|
||||
jumpBy(-10);
|
||||
}
|
||||
jumpBy(-10);
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
jumpToNextSentence();
|
||||
} else {
|
||||
jumpBy(10);
|
||||
}
|
||||
jumpBy(10);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
changeWpm(e.shiftKey ? 50 : 25);
|
||||
changeFpm(e.shiftKey ? 25 : 10);
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
changeWpm(e.shiftKey ? -50 : -25);
|
||||
changeFpm(e.shiftKey ? -25 : -10);
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
@@ -95,24 +76,6 @@ export function useKeyboardShortcuts({
|
||||
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;
|
||||
}
|
||||
@@ -123,14 +86,9 @@ export function useKeyboardShortcuts({
|
||||
}, [
|
||||
togglePlay,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
changeWpm,
|
||||
changeFpm,
|
||||
toggleFullscreen,
|
||||
onEscape,
|
||||
restart,
|
||||
prevChapter,
|
||||
nextChapter,
|
||||
isModalOpen,
|
||||
isDrawerOpen,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,79 @@
|
||||
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';
|
||||
import { calculateChunkDelay } from '../core/timing';
|
||||
|
||||
interface UseReaderEngineProps {
|
||||
book: Book | null;
|
||||
settings: ReaderSettings;
|
||||
onBookProgressUpdate?: (bookId: string, progress: number) => void;
|
||||
words: string[];
|
||||
flashesPerMinute: number;
|
||||
wordsPerChunk: number;
|
||||
initialWordIndex: number;
|
||||
onWordIndexChange?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function useReaderEngine({
|
||||
book,
|
||||
settings,
|
||||
onBookProgressUpdate,
|
||||
words,
|
||||
flashesPerMinute,
|
||||
wordsPerChunk,
|
||||
initialWordIndex,
|
||||
onWordIndexChange,
|
||||
}: 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 [currentIndex, setCurrentIndex] = useState<number>(() =>
|
||||
Math.max(0, initialWordIndex || 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);
|
||||
}
|
||||
// Sync initialWordIndex when words load
|
||||
const hasInitializedWords = useRef<boolean>(false);
|
||||
useEffect(() => {
|
||||
if (!hasInitializedWords.current && words.length > 0) {
|
||||
hasInitializedWords.current = true;
|
||||
const clamped = Math.min(
|
||||
Math.max(0, initialWordIndex || 0),
|
||||
Math.max(0, words.length - 1)
|
||||
);
|
||||
setCurrentIndex(clamped);
|
||||
}
|
||||
}, [words, initialWordIndex]);
|
||||
|
||||
// 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;
|
||||
// Derived chunk text
|
||||
const currentChunkText = useMemo(() => {
|
||||
if (words.length === 0) return '';
|
||||
const slice = words.slice(currentIndex, currentIndex + wordsPerChunk);
|
||||
return slice.join(' ');
|
||||
}, [words, currentIndex, wordsPerChunk]);
|
||||
|
||||
// Refs for timer and clock synchronization
|
||||
const settingsRef = useRef<ReaderSettings>(settings);
|
||||
const isPlayingRef = useRef<boolean>(isPlaying);
|
||||
// Refs for timer execution
|
||||
const wordsRef = useRef<string[]>(words);
|
||||
const fpmRef = useRef<number>(flashesPerMinute);
|
||||
const chunkRef = useRef<number>(wordsPerChunk);
|
||||
const currentIndexRef = useRef<number>(currentIndex);
|
||||
const tokensRef = useRef<Token[]>(tokens);
|
||||
const isPlayingRef = useRef<boolean>(isPlaying);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const expectedTickTimeRef = useRef<number | null>(null);
|
||||
const lastSavedIndexRef = useRef<number>(currentIndex);
|
||||
const lastSaveTimestampRef = useRef<number>(0);
|
||||
const scheduleNextRef = useRef<((fromIdx: number) => void) | null>(null);
|
||||
|
||||
// Synchronize refs
|
||||
// Keep refs synchronized
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings;
|
||||
}, [settings]);
|
||||
wordsRef.current = words;
|
||||
}, [words]);
|
||||
|
||||
useEffect(() => {
|
||||
fpmRef.current = flashesPerMinute;
|
||||
}, [flashesPerMinute]);
|
||||
|
||||
useEffect(() => {
|
||||
chunkRef.current = wordsPerChunk;
|
||||
}, [wordsPerChunk]);
|
||||
|
||||
useEffect(() => {
|
||||
currentIndexRef.current = currentIndex;
|
||||
onWordIndexChange?.(currentIndex);
|
||||
}, [currentIndex, onWordIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
currentIndexRef.current = currentIndex;
|
||||
}, [currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
}, [tokens]);
|
||||
|
||||
// Periodic throttled persistence to IndexedDB (every 4s or immediately on pause)
|
||||
useEffect(() => {
|
||||
if (!book) return;
|
||||
|
||||
const now = Date.now();
|
||||
const isPaused = !isPlaying;
|
||||
const timeSinceLastSave = now - lastSaveTimestampRef.current;
|
||||
const shouldSave = isPaused || timeSinceLastSave >= 4000;
|
||||
|
||||
if (shouldSave && currentIndex !== lastSavedIndexRef.current) {
|
||||
lastSavedIndexRef.current = currentIndex;
|
||||
lastSaveTimestampRef.current = now;
|
||||
updateBookProgress(book.id, currentIndex);
|
||||
onBookProgressUpdate?.(book.id, currentIndex);
|
||||
}
|
||||
}, [currentIndex, isPlaying, book, onBookProgressUpdate]);
|
||||
|
||||
// Setup recursion handler with clock drift compensation
|
||||
// Clock-drift compensated animation runner
|
||||
useEffect(() => {
|
||||
scheduleNextRef.current = (fromIndex: number) => {
|
||||
if (timerRef.current) {
|
||||
@@ -98,21 +83,23 @@ export function useReaderEngine({
|
||||
|
||||
if (!isPlayingRef.current) return;
|
||||
|
||||
const curTokens = tokensRef.current;
|
||||
if (!curTokens || curTokens.length === 0) {
|
||||
const currentWords = wordsRef.current;
|
||||
if (!currentWords || currentWords.length === 0) {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromIndex >= curTokens.length - 1) {
|
||||
if (fromIndex >= currentWords.length - 1) {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
expectedTickTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const tok = curTokens[fromIndex];
|
||||
const nominalDelay = calculateTokenDelay(tok, settingsRef.current);
|
||||
const chunkCount = chunkRef.current;
|
||||
const lastWordIdx = Math.min(currentWords.length - 1, fromIndex + chunkCount - 1);
|
||||
const lastWord = currentWords[lastWordIdx] || '';
|
||||
const nominalDelay = calculateChunkDelay(lastWord, fpmRef.current);
|
||||
const now = performance.now();
|
||||
|
||||
// Adaptive clock drift compensation
|
||||
@@ -131,20 +118,27 @@ export function useReaderEngine({
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!isPlayingRef.current) return;
|
||||
|
||||
const nextIndex = fromIndex + 1;
|
||||
const nextIndex = fromIndex + chunkRef.current;
|
||||
if (nextIndex >= currentWords.length) {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
expectedTickTimeRef.current = null;
|
||||
setCurrentIndex(currentWords.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentIndex(nextIndex);
|
||||
currentIndexRef.current = nextIndex;
|
||||
|
||||
scheduleNextRef.current?.(nextIndex);
|
||||
}, compensatedDelay);
|
||||
};
|
||||
});
|
||||
|
||||
const play = useCallback(() => {
|
||||
const curTokens = tokensRef.current;
|
||||
if (!curTokens || curTokens.length === 0) return;
|
||||
const curWords = wordsRef.current;
|
||||
if (!curWords || curWords.length === 0) return;
|
||||
|
||||
if (currentIndexRef.current >= curTokens.length - 1) {
|
||||
if (currentIndexRef.current >= curWords.length - 1) {
|
||||
setCurrentIndex(0);
|
||||
currentIndexRef.current = 0;
|
||||
}
|
||||
@@ -165,19 +159,6 @@ export function useReaderEngine({
|
||||
isPlayingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Background visibility change auto-pause
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden && isPlayingRef.current) {
|
||||
pause();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [pause]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlayingRef.current) {
|
||||
pause();
|
||||
@@ -187,8 +168,9 @@ export function useReaderEngine({
|
||||
}, [play, pause]);
|
||||
|
||||
const jumpTo = useCallback((index: number) => {
|
||||
const curTokens = tokensRef.current;
|
||||
const target = Math.max(0, Math.min(curTokens.length - 1, index));
|
||||
const curWords = wordsRef.current;
|
||||
if (curWords.length === 0) return;
|
||||
const target = Math.max(0, Math.min(curWords.length - 1, index));
|
||||
setCurrentIndex(target);
|
||||
currentIndexRef.current = target;
|
||||
expectedTickTimeRef.current = null;
|
||||
@@ -209,47 +191,22 @@ export function useReaderEngine({
|
||||
[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
|
||||
// Auto-pause when page is hidden
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.hidden && isPlayingRef.current) {
|
||||
pause();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, [pause]);
|
||||
|
||||
// Clean unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
@@ -260,19 +217,14 @@ export function useReaderEngine({
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
currentToken,
|
||||
previousToken,
|
||||
nextToken,
|
||||
currentChunkText,
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
jumpTo,
|
||||
jumpBy,
|
||||
jumpToPreviousSentence,
|
||||
jumpToNextSentence,
|
||||
jumpToChapter,
|
||||
restart,
|
||||
totalTokens,
|
||||
totalWords: words.length,
|
||||
};
|
||||
}
|
||||
|
||||
138
src/index.css
138
src/index.css
@@ -3,150 +3,46 @@
|
||||
@layer base {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
/* Range slider styling with emerald accent */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background-color: #f43f5e;
|
||||
background-color: #10b981;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background-color: #f43f5e;
|
||||
background-color: #10b981;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Short / Landscape screen optimizations (< 520px viewport height) */
|
||||
@media (max-height: 520px) {
|
||||
.btn-play-primary {
|
||||
width: 2.25rem !important; /* 36px */
|
||||
height: 2.25rem !important;
|
||||
}
|
||||
|
||||
.short\:w-9 {
|
||||
width: 2.25rem !important;
|
||||
}
|
||||
.short\:h-9 {
|
||||
height: 2.25rem !important;
|
||||
}
|
||||
.short\:py-0\.5 {
|
||||
padding-top: 0.125rem !important;
|
||||
padding-bottom: 0.125rem !important;
|
||||
}
|
||||
.short\:py-1 {
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
}
|
||||
.short\:py-2 {
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
}
|
||||
.short\:pb-1 {
|
||||
padding-bottom: 0.25rem !important;
|
||||
}
|
||||
.short\:my-0 {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.short\:mb-1 {
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
.short\:mt-0\.5 {
|
||||
margin-top: 0.125rem !important;
|
||||
}
|
||||
.short\:h-20 {
|
||||
height: 5rem !important;
|
||||
}
|
||||
.short\:h-24 {
|
||||
height: 6rem !important;
|
||||
}
|
||||
.short\:h-5 {
|
||||
height: 1.25rem !important;
|
||||
}
|
||||
.short\:h-3 {
|
||||
height: 0.75rem !important;
|
||||
}
|
||||
.short\:text-2xl {
|
||||
font-size: 1.5rem !important;
|
||||
line-height: 2rem !important;
|
||||
}
|
||||
.short\:text-3xl {
|
||||
font-size: 1.875rem !important;
|
||||
line-height: 2.25rem !important;
|
||||
}
|
||||
.short\:text-4xl {
|
||||
font-size: 2.25rem !important;
|
||||
line-height: 2.5rem !important;
|
||||
}
|
||||
.short\:text-xs {
|
||||
font-size: 0.75rem !important;
|
||||
line-height: 1rem !important;
|
||||
}
|
||||
.short\:space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: 0.25rem !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.short\:space-y-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: 0.5rem !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.short\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,14 @@
|
||||
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 type FontSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
|
||||
export interface FocusSettings {
|
||||
wordsPerChunk: number; // 1 ~ 4, default 2
|
||||
flashesPerMinute: number; // 60 ~ 600, default 250
|
||||
fontSize: FontSize; // default 'xl'
|
||||
}
|
||||
|
||||
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
|
||||
export interface FocusState {
|
||||
wordIndex: number;
|
||||
wordsPerChunk: number;
|
||||
flashesPerMinute: number;
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user