refactor: streamline RSVP reader for The Well of Ascension single-book focus

- Clean up legacy multi-format/CJK modules (orp, epub, samples, modals)
- Replace with lightweight pure English tokenizer and chunking engine
- Add SettingsDrawer, auto-hiding HUD, and PWA icons
- Update manifest and touch icons

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-20 11:42:57 +08:00
parent 06df8744a0
commit 14f1956bbf
29 changed files with 19791 additions and 3282 deletions

View File

@@ -1,15 +1,20 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="description" content="Focus — 极致极速 RSVP 闪屏阅读器,基于 Spritz ORP 中心锚定与自适应呼吸节奏算法,眼球完全静止,秒进专注流。" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<title>Focus — 极速 RSVP 闪屏专注阅读器</title> <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> </head>
<body class="bg-black select-none"> <body class="bg-[#09090b] text-[#f4f4f5] select-none antialiased overflow-hidden">
<div id="root"></div> <div id="root" class="h-full w-full"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -10,14 +10,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"jszip": "^3.10.2",
"lucide-react": "^1.59.0", "lucide-react": "^1.59.0",
"react": "^19.3.1", "react": "^19.3.1",
"react-dom": "^19.3.1" "react-dom": "^19.3.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/jszip": "^3.4.1",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",

96
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
jszip:
specifier: ^3.10.2
version: 3.10.2
lucide-react: lucide-react:
specifier: ^1.59.0 specifier: ^1.59.0
version: 1.59.0(@types/react@19.3.0)(react@19.3.1) version: 1.59.0(@types/react@19.3.0)(react@19.3.1)
@@ -24,9 +21,6 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3(vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0)) 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': '@types/node':
specifier: ^24.19.2 specifier: ^24.19.2
version: 24.19.2 version: 24.19.2
@@ -370,10 +364,6 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 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': '@types/node@24.19.2':
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
@@ -521,9 +511,6 @@ packages:
oxc-transform-react: oxc-transform-react:
optional: true optional: true
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -552,25 +539,10 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 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: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true 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: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -741,9 +713,6 @@ packages:
vite-plus: vite-plus:
optional: true optional: true
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -755,9 +724,6 @@ packages:
resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==}
engines: {node: ^10 || ^12 || >=14} 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: react-dom@19.3.1:
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
peerDependencies: peerDependencies:
@@ -767,30 +733,18 @@ packages:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
rolldown@1.2.14: rolldown@1.2.14:
resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
scheduler@0.28.0: scheduler@0.28.0:
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} 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: source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -810,9 +764,6 @@ packages:
undici-types@7.24.6: undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@8.3.4: vite@8.3.4:
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1051,10 +1002,6 @@ snapshots:
tailwindcss: 4.3.3 tailwindcss: 4.3.3
vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) 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': '@types/node@24.19.2':
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
@@ -1132,8 +1079,6 @@ snapshots:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0) vite: 8.3.4(@types/node@24.19.2)(jiti@2.7.0)
core-util-is@1.0.3: {}
csstype@3.2.3: {} csstype@3.2.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@@ -1152,25 +1097,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
immediate@3.0.6: {}
inherits@2.0.4: {}
isarray@1.0.0: {}
jiti@2.7.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: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@@ -1303,8 +1231,6 @@ snapshots:
'@oxlint/binding-win32-ia32-msvc': 1.89.0 '@oxlint/binding-win32-ia32-msvc': 1.89.0
'@oxlint/binding-win32-x64-msvc': 1.89.0 '@oxlint/binding-win32-x64-msvc': 1.89.0
pako@1.0.11: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.7: {} picomatch@4.0.7: {}
@@ -1315,8 +1241,6 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.2 source-map-js: 1.2.2
process-nextick-args@2.0.1: {}
react-dom@19.3.1(react@19.3.1): react-dom@19.3.1(react@19.3.1):
dependencies: dependencies:
react: 19.3.1 react: 19.3.1
@@ -1324,16 +1248,6 @@ snapshots:
react@19.3.1: {} 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: rolldown@1.2.14:
dependencies: dependencies:
'@oxc-project/types': 0.154.0 '@oxc-project/types': 0.154.0
@@ -1355,18 +1269,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.14 '@rolldown/binding-win32-arm64-msvc': 1.2.14
'@rolldown/binding-win32-x64-msvc': 1.2.14 '@rolldown/binding-win32-x64-msvc': 1.2.14
safe-buffer@5.1.2: {}
scheduler@0.28.0: {} scheduler@0.28.0: {}
setimmediate@1.0.5: {}
source-map-js@1.2.2: {} source-map-js@1.2.2: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {} tapable@2.3.3: {}
@@ -1401,8 +1307,6 @@ snapshots:
undici-types@7.24.6: {} undici-types@7.24.6: {}
util-deprecate@1.0.2: {}
vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0): vite@8.3.4(@types/node@24.19.2)(jiti@2.7.0):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="64" height="64" rx="16" fill="#09090b" /> <defs>
<circle cx="32" cy="32" r="20" fill="none" stroke="#27272a" stroke-width="2.5" /> <!-- Obsidian background radial gradient -->
<line x1="32" y1="6" x2="32" y2="16" stroke="#f43f5e" stroke-width="3" stroke-linecap="round" /> <radialGradient id="obsidianGrad" cx="50%" cy="50%" r="50%">
<line x1="32" y1="48" x2="32" y2="58" stroke="#f43f5e" stroke-width="3" stroke-linecap="round" /> <stop offset="0%" stop-color="#141c17" />
<line x1="6" y1="32" x2="16" y2="32" stroke="#71717a" stroke-width="2" stroke-linecap="round" /> <stop offset="70%" stop-color="#0c100e" />
<line x1="48" y1="32" x2="58" y2="32" stroke="#71717a" stroke-width="2" stroke-linecap="round" /> <stop offset="100%" stop-color="#09090b" />
<circle cx="32" cy="32" r="4.5" fill="#f43f5e" /> </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> </svg>

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 2.0 KiB

23
public/manifest.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

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

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,165 +1,55 @@
import React from 'react'; import type { FC } from 'react';
import type { FontFamily, FontSize, OrpColor, Token } from '../types'; import type { FontSize } from '../types';
interface FocusDisplayProps { interface FocusDisplayProps {
token: Token | null; chunkText: string;
previousToken: Token | null;
nextToken: Token | null;
fontSize: FontSize; fontSize: FontSize;
fontFamily: FontFamily;
orpColor: OrpColor;
showGuideLines: boolean;
showContextPreview: boolean;
isPlaying: boolean; isPlaying: boolean;
onTogglePlay: () => void; 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> = { const FONT_SIZE_MAP: Record<FontSize, string> = {
sm: 'text-3xl sm:text-4xl h-24 sm:h-28 short:h-20 short:text-2xl', sm: 'text-3xl sm:text-4xl md:text-5xl',
base: 'text-4xl sm:text-5xl h-28 sm:h-32 short:h-20 short:text-3xl', md: 'text-4xl sm:text-5xl md:text-6xl',
lg: 'text-5xl sm:text-6xl h-32 sm:h-36 short:h-24 short:text-3xl', lg: 'text-5xl sm:text-6xl md:text-7xl',
xl: 'text-6xl sm:text-7xl h-36 sm:h-44 short:h-24 short:text-4xl', xl: 'text-6xl sm:text-7xl md:text-8xl',
'2xl': 'text-7xl sm:text-8xl h-44 sm:h-52 short:h-24 short:text-4xl', '2xl': 'text-7xl sm:text-8xl md:text-9xl',
'3xl': 'text-8xl sm:text-9xl h-52 sm:h-64 short:h-24 short:text-4xl',
}; };
const FONT_FAMILY_MAP: Record<FontFamily, string> = { export const FocusDisplay: FC<FocusDisplayProps> = ({
mono: 'font-mono tracking-tight', chunkText,
sans: 'font-sans tracking-normal font-medium',
serif: 'font-serif tracking-normal',
};
export const FocusDisplay: React.FC<FocusDisplayProps> = ({
token,
previousToken,
nextToken,
fontSize, fontSize,
fontFamily,
orpColor,
showGuideLines,
showContextPreview,
isPlaying, isPlaying,
onTogglePlay, onTogglePlay,
}) => { }) => {
const colorConfig = ORP_COLOR_MAP[orpColor] || ORP_COLOR_MAP.rose; const sizeClass = FONT_SIZE_MAP[fontSize] || FONT_SIZE_MAP.xl;
const sizeClasses = FONT_SIZE_MAP[fontSize] || FONT_SIZE_MAP.xl;
const familyClasses = FONT_FAMILY_MAP[fontFamily] || FONT_FAMILY_MAP.sans;
return ( return (
<div <div
onClick={onTogglePlay} 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" className="relative w-full h-full flex flex-col items-center justify-center cursor-pointer select-none px-4"
title="点击屏幕或按空格键 暂停/继续" title="点击播放 / 暂停"
> >
{/* Contextual Peripheral Ghost Words (Top line) */} <div className="relative max-w-5xl w-full flex items-center justify-center min-h-[140px] sm:min-h-[200px]">
{showContextPreview && ( {chunkText ? (
<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 : ''}
</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>
</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 <span
className={`relative inline-block min-w-[1.1ch] text-center font-bold ${colorConfig.text} ${colorConfig.glow} px-[0.02em]`} className={`font-sans font-semibold tracking-normal text-[#f4f4f5] text-center leading-tight transition-all duration-75 ${sizeClass}`}
> >
{token.pivot || ' '} {chunkText}
</span> </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"> <span className="text-xl sm:text-2xl text-zinc-500 font-sans tracking-wide">
请载入文本或选择样篇 Ready
</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> </span>
)} )}
</div> </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>
)}
</div> </div>
); );
}; };

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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('/');
}

View File

@@ -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,
};
}

View File

@@ -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, * Calculates display duration in ms for a chunk.
* applying the physiological breathing rhythm algorithm. * Base duration is strictly 60000 / flashesPerMinute,
* supplemented with subtle natural punctuation pauses.
*/ */
export function calculateTokenDelay( export function calculateChunkDelay(
token: Token, lastWordInChunk: string,
settings: ReaderSettings flashesPerMinute: number
): 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) { if (!lastWordInChunk) return Math.round(baseDelay);
return baseDelay;
// Subtle natural pause for sentence ends (. ! ?)
if (SENTENCE_END_REGEX.test(lastWordInChunk)) {
return Math.round(baseDelay * 1.3);
} }
let multiplier = 1.0; // Very slight breath pause for clause ends (, ; :)
if (CLAUSE_END_REGEX.test(lastWordInChunk)) {
if (token.isParagraphEnd) { return Math.round(baseDelay * 1.15);
multiplier = settings.paragraphEndMultiplier ?? 2.5;
} else if (token.isSentenceEnd) {
multiplier = settings.sentenceEndMultiplier ?? 2.2;
} else if (token.isClauseEnd) {
multiplier = settings.clauseEndMultiplier ?? 1.4;
} else if (token.isLongWord) {
multiplier = 1.2;
} else if (token.cleanWord.length <= 2 && !isCJKWord(token.cleanWord)) {
// Short function words (e.g. "a", "of", "in") flash slightly faster
multiplier = 0.9;
} }
// Minimum duration protection (never flash below 20ms) return Math.round(baseDelay);
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}小时`;
} }

View File

@@ -1,328 +1,11 @@
import type { Chapter, Token } from '../types'; /**
import { isCJKWord, splitOrp } from './orp'; * Pure and efficient English word tokenization.
* Splits by whitespace/newlines, preserving native attached punctuation.
// Leading brackets and quotes that should attach to the beginning of the next word */
const LEADING_PUNCT_REGEX = /^[“‘「『《〈(([<{<"'`]/; export function tokenizeEnglishText(rawText: string): string[] {
if (!rawText) return [];
// Sentence-ending punctuation const trimmed = rawText.trim();
const SENTENCE_PUNCT_REGEX = /[.!?。!?…\n]/; if (!trimmed) return [];
// Split on any whitespace sequence (spaces, tabs, newlines)
// Clause/pause punctuation return trimmed.split(/\s+/).filter((word) => word.length > 0);
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 };
} }

View File

@@ -1,293 +1,55 @@
import type { Book, BookMeta, ReaderSettings } from '../types'; import type { FocusState } from '../types';
const DB_NAME = 'focus_rsvp_db'; const STORAGE_KEY = 'focus_reader_state';
const DB_VERSION = 1;
const STORE_BOOKS = 'books';
const STORE_META = 'books_meta';
export const DEFAULT_SETTINGS: ReaderSettings = { export const DEFAULT_STATE: FocusState = {
wpm: 350, wordIndex: 0,
fontSize: '2xl', wordsPerChunk: 2,
theme: 'oled', flashesPerMinute: 250,
fontFamily: 'sans', fontSize: 'xl',
orpColor: 'rose',
breathingPunctuation: true,
showGuideLines: true,
showContextPreview: true,
showProgressBar: true,
autoHideControls: true,
sentenceEndMultiplier: 2.2,
clauseEndMultiplier: 1.4,
paragraphEndMultiplier: 2.5,
}; };
const SETTINGS_KEY = 'focus_reader_settings'; export function loadSavedState(): FocusState {
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 { try {
const request = indexedDB.open(DB_NAME, DB_VERSION); const raw = localStorage.getItem(STORAGE_KEY);
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;
}
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);
if (raw) { 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) { } 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 { try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); const current = loadSavedState();
const merged = { ...current, ...state };
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch (err) { } catch (err) {
console.warn('Failed to save settings to localStorage', err); console.warn('Failed to save state 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);
} }
} }

View File

@@ -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,
};
});
}

View File

@@ -3,33 +3,22 @@ import { useEffect } from 'react';
interface KeyboardShortcutHandlers { interface KeyboardShortcutHandlers {
togglePlay: () => void; togglePlay: () => void;
jumpBy: (delta: number) => void; jumpBy: (delta: number) => void;
jumpToPreviousSentence: () => void; changeFpm: (delta: number) => void;
jumpToNextSentence: () => void;
changeWpm: (delta: number) => void;
toggleFullscreen: () => void; toggleFullscreen: () => void;
onEscape?: () => void; onEscape?: () => void;
restart: () => void; isDrawerOpen?: boolean;
prevChapter?: () => void;
nextChapter?: () => void;
isModalOpen?: boolean;
} }
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
togglePlay, togglePlay,
jumpBy, jumpBy,
jumpToPreviousSentence, changeFpm,
jumpToNextSentence,
changeWpm,
toggleFullscreen, toggleFullscreen,
onEscape, onEscape,
restart, isDrawerOpen = false,
prevChapter,
nextChapter,
isModalOpen = false,
}: KeyboardShortcutHandlers) { }: KeyboardShortcutHandlers) {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Don't intercept shortcuts when user is focused on an input or textarea
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
target && target &&
@@ -52,8 +41,8 @@ export function useKeyboardShortcuts({
return; return;
} }
// If a modal is open, don't trigger playback controls // If drawer is open, keep drawer navigation active
if (isModalOpen) return; if (isDrawerOpen) return;
switch (e.key) { switch (e.key) {
case ' ': // Space case ' ': // Space
@@ -63,30 +52,22 @@ export function useKeyboardShortcuts({
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
if (e.shiftKey) {
jumpToPreviousSentence();
} else {
jumpBy(-10); jumpBy(-10);
}
break; break;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
if (e.shiftKey) {
jumpToNextSentence();
} else {
jumpBy(10); jumpBy(10);
}
break; break;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
changeWpm(e.shiftKey ? 50 : 25); changeFpm(e.shiftKey ? 25 : 10);
break; break;
case 'ArrowDown': case 'ArrowDown':
e.preventDefault(); e.preventDefault();
changeWpm(e.shiftKey ? -50 : -25); changeFpm(e.shiftKey ? -25 : -10);
break; break;
case 'f': case 'f':
@@ -95,24 +76,6 @@ export function useKeyboardShortcuts({
toggleFullscreen(); toggleFullscreen();
break; break;
case 'r':
case 'R':
e.preventDefault();
restart();
break;
case '[':
case 'PageUp':
e.preventDefault();
prevChapter?.();
break;
case ']':
case 'PageDown':
e.preventDefault();
nextChapter?.();
break;
default: default:
break; break;
} }
@@ -123,14 +86,9 @@ export function useKeyboardShortcuts({
}, [ }, [
togglePlay, togglePlay,
jumpBy, jumpBy,
jumpToPreviousSentence, changeFpm,
jumpToNextSentence,
changeWpm,
toggleFullscreen, toggleFullscreen,
onEscape, onEscape,
restart, isDrawerOpen,
prevChapter,
nextChapter,
isModalOpen,
]); ]);
} }

View File

@@ -1,94 +1,79 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { calculateTokenDelay } from '../core/timing'; import { calculateChunkDelay } from '../core/timing';
import { updateBookProgress } from '../db';
import type { Book, Chapter, ReaderSettings, Token } from '../types';
interface UseReaderEngineProps { interface UseReaderEngineProps {
book: Book | null; words: string[];
settings: ReaderSettings; flashesPerMinute: number;
onBookProgressUpdate?: (bookId: string, progress: number) => void; wordsPerChunk: number;
initialWordIndex: number;
onWordIndexChange?: (index: number) => void;
} }
export function useReaderEngine({ export function useReaderEngine({
book, words,
settings, flashesPerMinute,
onBookProgressUpdate, wordsPerChunk,
initialWordIndex,
onWordIndexChange,
}: UseReaderEngineProps) { }: UseReaderEngineProps) {
// Sync state when active book changes using standard React adjustment const [currentIndex, setCurrentIndex] = useState<number>(() =>
const [prevBookId, setPrevBookId] = useState<string | null>(book?.id || null); Math.max(0, initialWordIndex || 0)
const [currentIndex, setCurrentIndex] = useState<number>(() => { );
return book ? Math.max(0, book.currentProgress || 0) : 0;
});
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
if (book && book.id !== prevBookId) { // Sync initialWordIndex when words load
setPrevBookId(book.id); const hasInitializedWords = useRef<boolean>(false);
const initialIndex = Math.min( useEffect(() => {
Math.max(0, book.currentProgress || 0), if (!hasInitializedWords.current && words.length > 0) {
Math.max(0, (book.tokens?.length || 1) - 1) hasInitializedWords.current = true;
const clamped = Math.min(
Math.max(0, initialWordIndex || 0),
Math.max(0, words.length - 1)
); );
setCurrentIndex(initialIndex); setCurrentIndex(clamped);
setIsPlaying(false);
} else if (!book && prevBookId !== null) {
setPrevBookId(null);
setCurrentIndex(0);
setIsPlaying(false);
} }
}, [words, initialWordIndex]);
// Derive tokens and current token safely during render // Derived chunk text
const tokens = useMemo<Token[]>(() => book?.tokens || [], [book?.tokens]); const currentChunkText = useMemo(() => {
const totalTokens = tokens.length; if (words.length === 0) return '';
const currentToken = tokens.length > 0 ? tokens[currentIndex] || tokens[0] : null; const slice = words.slice(currentIndex, currentIndex + wordsPerChunk);
const previousToken = currentIndex > 0 ? tokens[currentIndex - 1] || null : null; return slice.join(' ');
const nextToken = }, [words, currentIndex, wordsPerChunk]);
currentIndex < tokens.length - 1 ? tokens[currentIndex + 1] || null : null;
// Refs for timer and clock synchronization // Refs for timer execution
const settingsRef = useRef<ReaderSettings>(settings); const wordsRef = useRef<string[]>(words);
const isPlayingRef = useRef<boolean>(isPlaying); const fpmRef = useRef<number>(flashesPerMinute);
const chunkRef = useRef<number>(wordsPerChunk);
const currentIndexRef = useRef<number>(currentIndex); const currentIndexRef = useRef<number>(currentIndex);
const tokensRef = useRef<Token[]>(tokens); const isPlayingRef = useRef<boolean>(isPlaying);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const expectedTickTimeRef = 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); const scheduleNextRef = useRef<((fromIdx: number) => void) | null>(null);
// Synchronize refs // Keep refs synchronized
useEffect(() => { useEffect(() => {
settingsRef.current = settings; wordsRef.current = words;
}, [settings]); }, [words]);
useEffect(() => {
fpmRef.current = flashesPerMinute;
}, [flashesPerMinute]);
useEffect(() => {
chunkRef.current = wordsPerChunk;
}, [wordsPerChunk]);
useEffect(() => {
currentIndexRef.current = currentIndex;
onWordIndexChange?.(currentIndex);
}, [currentIndex, onWordIndexChange]);
useEffect(() => { useEffect(() => {
isPlayingRef.current = isPlaying; isPlayingRef.current = isPlaying;
}, [isPlaying]); }, [isPlaying]);
useEffect(() => { // Clock-drift compensated animation runner
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
useEffect(() => { useEffect(() => {
scheduleNextRef.current = (fromIndex: number) => { scheduleNextRef.current = (fromIndex: number) => {
if (timerRef.current) { if (timerRef.current) {
@@ -98,21 +83,23 @@ export function useReaderEngine({
if (!isPlayingRef.current) return; if (!isPlayingRef.current) return;
const curTokens = tokensRef.current; const currentWords = wordsRef.current;
if (!curTokens || curTokens.length === 0) { if (!currentWords || currentWords.length === 0) {
setIsPlaying(false); setIsPlaying(false);
return; return;
} }
if (fromIndex >= curTokens.length - 1) { if (fromIndex >= currentWords.length - 1) {
setIsPlaying(false); setIsPlaying(false);
isPlayingRef.current = false; isPlayingRef.current = false;
expectedTickTimeRef.current = null; expectedTickTimeRef.current = null;
return; return;
} }
const tok = curTokens[fromIndex]; const chunkCount = chunkRef.current;
const nominalDelay = calculateTokenDelay(tok, settingsRef.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(); const now = performance.now();
// Adaptive clock drift compensation // Adaptive clock drift compensation
@@ -131,20 +118,27 @@ export function useReaderEngine({
timerRef.current = window.setTimeout(() => { timerRef.current = window.setTimeout(() => {
if (!isPlayingRef.current) return; 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); setCurrentIndex(nextIndex);
currentIndexRef.current = nextIndex; currentIndexRef.current = nextIndex;
scheduleNextRef.current?.(nextIndex); scheduleNextRef.current?.(nextIndex);
}, compensatedDelay); }, compensatedDelay);
}; };
}); });
const play = useCallback(() => { const play = useCallback(() => {
const curTokens = tokensRef.current; const curWords = wordsRef.current;
if (!curTokens || curTokens.length === 0) return; if (!curWords || curWords.length === 0) return;
if (currentIndexRef.current >= curTokens.length - 1) { if (currentIndexRef.current >= curWords.length - 1) {
setCurrentIndex(0); setCurrentIndex(0);
currentIndexRef.current = 0; currentIndexRef.current = 0;
} }
@@ -165,19 +159,6 @@ export function useReaderEngine({
isPlayingRef.current = false; 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(() => { const togglePlay = useCallback(() => {
if (isPlayingRef.current) { if (isPlayingRef.current) {
pause(); pause();
@@ -187,8 +168,9 @@ export function useReaderEngine({
}, [play, pause]); }, [play, pause]);
const jumpTo = useCallback((index: number) => { const jumpTo = useCallback((index: number) => {
const curTokens = tokensRef.current; const curWords = wordsRef.current;
const target = Math.max(0, Math.min(curTokens.length - 1, index)); if (curWords.length === 0) return;
const target = Math.max(0, Math.min(curWords.length - 1, index));
setCurrentIndex(target); setCurrentIndex(target);
currentIndexRef.current = target; currentIndexRef.current = target;
expectedTickTimeRef.current = null; expectedTickTimeRef.current = null;
@@ -209,47 +191,22 @@ export function useReaderEngine({
[jumpTo] [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(() => { const restart = useCallback(() => {
jumpTo(0); jumpTo(0);
}, [jumpTo]); }, [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(() => { useEffect(() => {
return () => { return () => {
if (timerRef.current) { if (timerRef.current) {
@@ -260,19 +217,14 @@ export function useReaderEngine({
return { return {
currentIndex, currentIndex,
currentToken, currentChunkText,
previousToken,
nextToken,
isPlaying, isPlaying,
play, play,
pause, pause,
togglePlay, togglePlay,
jumpTo, jumpTo,
jumpBy, jumpBy,
jumpToPreviousSentence,
jumpToNextSentence,
jumpToChapter,
restart, restart,
totalTokens, totalWords: words.length,
}; };
} }

View File

@@ -3,150 +3,46 @@
@layer base { @layer base {
html, body, #root { html, body, #root {
height: 100%; height: 100%;
width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #09090b;
color: #f4f4f5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
user-select: none; user-select: none;
overflow: hidden;
} }
} }
/* Custom theme container classes */ /* Range slider styling with emerald accent */
.theme-oled {
background-color: #000000;
color: #f4f4f5;
color-scheme: dark;
}
.theme-dark {
background-color: #09090b;
color: #f4f4f5;
color-scheme: dark;
}
.theme-slate {
background-color: #0f172a;
color: #f1f5f9;
color-scheme: dark;
}
.theme-nord {
background-color: #242933;
color: #eceff4;
color-scheme: dark;
}
.theme-sepia {
background-color: #fbf0d9;
color: #433422;
color-scheme: light;
}
.theme-paper {
background-color: #ffffff;
color: #18181b;
color-scheme: light;
}
/* Range input thumb styling */
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 9999px; border-radius: 9999px;
background-color: #f43f5e; background-color: #10b981;
cursor: pointer; 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 { input[type="range"]::-moz-range-thumb {
width: 14px; width: 14px;
height: 14px; height: 14px;
border-radius: 9999px; border-radius: 9999px;
background-color: #f43f5e; background-color: #10b981;
border: none; border: none;
cursor: pointer; 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) */ input[type="range"]::-moz-range-thumb:hover {
@media (max-height: 520px) { transform: scale(1.2);
.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;
}
} }

View File

@@ -1,63 +1,14 @@
export interface Token { export type FontSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
id: number;
text: string; // Full display segment with punctuation (e.g. "“文明,”") export interface FocusSettings {
cleanWord: string; // Core word without trailing punctuation (e.g. "文明") wordsPerChunk: number; // 1 ~ 4, default 2
prefix: string; // Characters preceding the ORP character flashesPerMinute: number; // 60 ~ 600, default 250
pivot: string; // The ORP character itself fontSize: FontSize; // default 'xl'
suffix: string; // Characters following the ORP character (including punctuation)
orpIndex: number; // Index of pivot within cleanWord
isParagraphEnd: boolean;// Followed by double newline
isSentenceEnd: boolean; // Punctuation like 。!?.!?
isClauseEnd: boolean; // Punctuation like ,、;:,;:
isLongWord: boolean; // English > 8 chars or CJK >= 4 chars
chapterIndex?: number; // Corresponding chapter index
} }
export interface Chapter { export interface FocusState {
id: string; wordIndex: number;
title: string; wordsPerChunk: number;
startIndex: number; flashesPerMinute: number;
tokenCount?: number;
}
export type BookFormat = 'txt' | 'md' | 'epub' | 'paste' | 'sample';
export interface Book {
id: string;
title: string;
author?: string;
format: BookFormat;
tokens: Token[];
chapters: Chapter[];
currentProgress: number; // Current token index
totalTokens: number;
lastReadAt: number; // Unix timestamp
createdAt: number; // Unix timestamp
byteSize?: number;
}
export type BookMeta = Omit<Book, 'tokens'>;
export type ThemeId = 'oled' | 'dark' | 'sepia' | 'slate' | 'nord' | 'paper';
export type FontFamily = 'mono' | 'sans' | 'serif';
export type FontSize = 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl';
export type OrpColor = 'rose' | 'amber' | 'emerald' | 'cyan' | 'violet';
export interface ReaderSettings {
wpm: number; // 100 - 1200
fontSize: FontSize; 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
} }