feat(w3): add continuous read mode and Wave 3 plan
Introduce ReadModeOverlay with device width and font theme persistence, sidebar and volume menu entry, plus E2E-READ-01 through 03. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
# 笔临 Wave 3 导出阅读 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 交付 v1.4.0:导出矩阵(txt/md/docx/pdf)、连续阅读模式、驾驶舱补全、专注环境音。
|
||||||
|
|
||||||
|
**Architecture:** `ExportMatrixService` 扩展 `ExportService` 模式;docx 用 `docx` 库;pdf 用 Electron `printToPDF` + HTML 模板;`ReadModeOverlay` 虚拟 3 章 + scroll-snap;阅读偏好写入 `GlobalSettings`。
|
||||||
|
|
||||||
|
**Tech Stack:** Electron 36、docx、TypeScript、Vitest、Playwright
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §7
|
||||||
|
|
||||||
|
**Master Program:** Wave 3/5 → v1.4.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/shared/export-matrix.ts` | ExportFormat/Scope/Options 类型 |
|
||||||
|
| `src/main/services/export-matrix.service.ts` | txt/md/docx/pdf 导出 |
|
||||||
|
| `src/main/export-matrix-bootstrap.ts` | 延迟加载 docx,注册 IPC |
|
||||||
|
| `src/main/ipc/handlers/export-matrix.handler.ts` | EXPORT_MATRIX_* |
|
||||||
|
| `src/renderer/components/read/ReadModeOverlay.tsx` | 连续阅读全屏 |
|
||||||
|
| `src/renderer/stores/useReadModeStore.ts` | 阅读模式状态 |
|
||||||
|
| `src/renderer/components/export/ExportModal.tsx` | 导出矩阵 UI |
|
||||||
|
| `src/renderer/components/cockpit/CockpitModal.tsx` | 今日大纲任务 |
|
||||||
|
| `src/renderer/components/focus/FocusModeOverlay.tsx` | 环境音效 |
|
||||||
|
| `e2e/read-mode.spec.ts` | E2E-READ-01~03 |
|
||||||
|
| `e2e/export-matrix.spec.ts` | E2E-PDF-01 等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 类型 + 阅读偏好
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/shared/export-matrix.ts`
|
||||||
|
- Modify: `src/shared/types.ts` — `readModeFontTheme`、`readModeDeviceWidth`、`enableFocusAmbientSound`
|
||||||
|
|
||||||
|
- [x] **Step 1:** export-matrix 类型定义
|
||||||
|
- [x] **Step 2:** GlobalSettings 阅读/专注字段
|
||||||
|
- [x] **Step 3:** settings store 默认值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 连续阅读模式
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/renderer/stores/useReadModeStore.ts`
|
||||||
|
- Create: `src/renderer/components/read/ReadModeOverlay.tsx`
|
||||||
|
- Modify: `EditorLayout.tsx`、`VolumeContextMenu.tsx`、`App.tsx`、`layout.css`
|
||||||
|
|
||||||
|
- [x] **Step 1:** ReadModeOverlay 全屏 + 虚拟 3 章 + scroll-snap
|
||||||
|
- [x] **Step 2:** 设备宽度 / 字体主题切换与持久化
|
||||||
|
- [x] **Step 3:** 章节侧栏 + 分卷右键入口
|
||||||
|
- [x] **Step 4:** i18n `readMode.*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: ExportMatrixService txt/md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/services/export-matrix.service.ts`
|
||||||
|
- Create: `src/main/ipc/handlers/export-matrix.handler.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 章/卷/全书范围解析
|
||||||
|
- [ ] **Step 2:** txt/md 导出 + 选项(批注/AI 标注)
|
||||||
|
- [ ] **Step 3:** UT 章级 txt 导出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: docx 导出
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` — `docx`
|
||||||
|
- Modify: `export-matrix.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** docx 目录与章节结构
|
||||||
|
- [ ] **Step 2:** IT-DOCX-01 含目录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: pdf 导出
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `export-matrix.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** HTML 模板 + printToPDF
|
||||||
|
- [ ] **Step 2:** A4 页眉页脚
|
||||||
|
- [ ] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: ExportModal 矩阵 UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ExportModal.tsx`、`preload`、`ipc-channels`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 格式/范围/选项选择
|
||||||
|
- [ ] **Step 2:** 保存对话框与 toast
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 驾驶舱 + 专注环境音
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CockpitModal.tsx`
|
||||||
|
- Modify: `FocusModeOverlay.tsx`、`SettingsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 今日大纲任务 Top 5(pending + expectedWordCount)
|
||||||
|
- [ ] **Step 2:** 专注模式环境音开关与播放
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: E2E + v1.4.0
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/read-mode.spec.ts`、`e2e/export-matrix.spec.ts`
|
||||||
|
- Modify: `package.json`、`README.md`、`SettingsPage.tsx`
|
||||||
|
|
||||||
|
- [x] **Step 1:** E2E-READ-01~03
|
||||||
|
- [ ] **Step 2:** E2E-PDF-01
|
||||||
|
- [ ] **Step 3:** 全量 test + 相关 E2E
|
||||||
|
- [ ] **Step 4:** 版本 **v1.4.0**
|
||||||
|
- [ ] **Step 5:** 勾选本 plan 全部 checkbox
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan 自检
|
||||||
|
|
||||||
|
| Spec §7 要求 | Task |
|
||||||
|
|-------------|------|
|
||||||
|
| ExportMatrix 四格式 | Task 3–6 |
|
||||||
|
| ReadModeOverlay | Task 2 |
|
||||||
|
| 驾驶舱大纲任务 | Task 7 |
|
||||||
|
| E2E-READ / E2E-PDF | Task 8 |
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
launchApp,
|
||||||
|
skipOnboarding,
|
||||||
|
createBookAndOpenEditor,
|
||||||
|
ensureChapterEditor,
|
||||||
|
dismissCockpitIfOpen
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
test.describe('Read mode', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-read-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-READ-01: open read mode shows merged chapter content', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createBookAndOpenEditor(page, '阅读模式测试')
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('这是连续阅读模式测试正文。')
|
||||||
|
await page.keyboard.press('Control+S')
|
||||||
|
|
||||||
|
await page.getByTestId('chapter-read-mode').click()
|
||||||
|
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(page.getByTestId('read-mode-content')).toContainText('连续阅读模式测试正文')
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-READ-02: switch device width to phone', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createBookAndOpenEditor(page, '阅读宽度测试')
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
await page.getByTestId('chapter-read-mode').click()
|
||||||
|
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
await page.getByTestId('read-device-width').selectOption('phone')
|
||||||
|
await expect(page.getByTestId('read-mode-content')).toHaveCSS('max-width', '360px')
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-READ-03: font theme persists after exit and re-enter', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createBookAndOpenEditor(page, '阅读主题测试')
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
await page.getByTestId('chapter-read-mode').click()
|
||||||
|
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('read-font-theme').selectOption('eye-care')
|
||||||
|
await expect(page.locator('.read-mode-font-eye-care')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByTestId('read-mode-close').click()
|
||||||
|
await expect(page.getByTestId('read-mode-overlay')).toBeHidden()
|
||||||
|
await page.getByTestId('chapter-read-mode').click()
|
||||||
|
await expect(page.locator('.read-mode-font-eye-care')).toBeVisible()
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -538,5 +538,17 @@
|
|||||||
"tts.started": "Reading aloud",
|
"tts.started": "Reading aloud",
|
||||||
"tts.stopped": "Stopped reading",
|
"tts.stopped": "Stopped reading",
|
||||||
"tts.noText": "Nothing to read",
|
"tts.noText": "Nothing to read",
|
||||||
"home.exportAllWave2": "Bulk export coming in a later release"
|
"home.exportAllWave2": "Bulk export coming in a later release",
|
||||||
|
"readMode.title": "Read mode",
|
||||||
|
"readMode.open": "Read mode",
|
||||||
|
"readMode.exit": "Exit reading",
|
||||||
|
"readMode.deviceWidth": "Device width",
|
||||||
|
"readMode.devicePhone": "Phone (360px)",
|
||||||
|
"readMode.deviceTablet": "Tablet (768px)",
|
||||||
|
"readMode.deviceDesktop": "Desktop (100%)",
|
||||||
|
"readMode.fontTheme": "Font theme",
|
||||||
|
"readMode.fontSerif": "Serif",
|
||||||
|
"readMode.fontSans": "Sans-serif",
|
||||||
|
"readMode.fontEyeCare": "Eye care",
|
||||||
|
"readMode.tts": "Read aloud"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -538,5 +538,17 @@
|
|||||||
"tts.started": "开始朗读",
|
"tts.started": "开始朗读",
|
||||||
"tts.stopped": "已停止朗读",
|
"tts.stopped": "已停止朗读",
|
||||||
"tts.noText": "没有可朗读的内容",
|
"tts.noText": "没有可朗读的内容",
|
||||||
"home.exportAllWave2": "批量导出将在后续版本提供"
|
"home.exportAllWave2": "批量导出将在后续版本提供",
|
||||||
|
"readMode.title": "阅读模式",
|
||||||
|
"readMode.open": "阅读模式",
|
||||||
|
"readMode.exit": "退出阅读",
|
||||||
|
"readMode.deviceWidth": "设备宽度",
|
||||||
|
"readMode.devicePhone": "手机 (360px)",
|
||||||
|
"readMode.deviceTablet": "平板 (768px)",
|
||||||
|
"readMode.deviceDesktop": "桌面 (100%)",
|
||||||
|
"readMode.fontTheme": "字体主题",
|
||||||
|
"readMode.fontSerif": "衬线",
|
||||||
|
"readMode.fontSans": "无衬线",
|
||||||
|
"readMode.fontEyeCare": "护眼",
|
||||||
|
"readMode.tts": "朗读"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SearchModal } from '@renderer/components/search/SearchModal'
|
|||||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||||
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
||||||
|
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
|
||||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||||
@@ -206,6 +207,7 @@ function AppInner(): React.JSX.Element {
|
|||||||
{view === 'settings' && <SettingsPage />}
|
{view === 'settings' && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
<FocusModeOverlay />
|
<FocusModeOverlay />
|
||||||
|
<ReadModeOverlay />
|
||||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||||
<SearchModal />
|
<SearchModal />
|
||||||
<InspirationModal />
|
<InspirationModal />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeMod
|
|||||||
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
||||||
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
|
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
|
||||||
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
|
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
|
||||||
|
import { useReadModeStore } from '@renderer/stores/useReadModeStore'
|
||||||
import { ExportModal } from '@renderer/components/export/ExportModal'
|
import { ExportModal } from '@renderer/components/export/ExportModal'
|
||||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +125,7 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||||
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
|
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
|
||||||
|
const openReadMode = useReadModeStore((s) => s.openForVolume)
|
||||||
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
|
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@@ -497,6 +499,22 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
+ {t('template.quickNew')}
|
+ {t('template.quickNew')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chapter-read-mode"
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
disabled={!activeVolumeId}
|
||||||
|
onClick={() => {
|
||||||
|
if (!activeVolumeId) return
|
||||||
|
const idx = chapters
|
||||||
|
.filter((c) => c.volumeId === activeVolumeId)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.findIndex((c) => c.id === selectedChapterId)
|
||||||
|
openReadMode(activeVolumeId, Math.max(0, idx))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👁 {t('readMode.open')}
|
||||||
|
</button>
|
||||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||||
+ {t('editor.newVolume')}
|
+ {t('editor.newVolume')}
|
||||||
</button>
|
</button>
|
||||||
@@ -710,6 +728,7 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
|
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
|
||||||
x={volumeMenu.x}
|
x={volumeMenu.x}
|
||||||
y={volumeMenu.y}
|
y={volumeMenu.y}
|
||||||
|
onOpenReadMode={() => openReadMode(volumeMenuVolume.id, 0)}
|
||||||
onClose={() => setVolumeMenu(null)}
|
onClose={() => setVolumeMenu(null)}
|
||||||
onChanged={refreshChapters}
|
onChanged={refreshChapters}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { Chapter } from '@shared/types'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useReadModeStore } from '@renderer/stores/useReadModeStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
||||||
|
|
||||||
|
function htmlToPlain(html: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = html
|
||||||
|
return (div.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphsFromHtml(html: string): string[] {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = html
|
||||||
|
const blocks = div.querySelectorAll('p, h1, h2, h3, li')
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
const text = htmlToPlain(html)
|
||||||
|
return text ? text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean) : []
|
||||||
|
}
|
||||||
|
return Array.from(blocks)
|
||||||
|
.map((el) => (el.textContent ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChapterSection({
|
||||||
|
chapter,
|
||||||
|
chapterNumber
|
||||||
|
}: {
|
||||||
|
chapter: Chapter
|
||||||
|
chapterNumber: number
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const paragraphs = useMemo(() => paragraphsFromHtml(chapter.content), [chapter.content])
|
||||||
|
return (
|
||||||
|
<section className="read-mode-chapter" data-testid={`read-chapter-${chapter.id}`}>
|
||||||
|
<h2 className="read-mode-chapter-title">
|
||||||
|
{chapterNumber}. {chapter.title}
|
||||||
|
</h2>
|
||||||
|
{paragraphs.map((p, i) => (
|
||||||
|
<p key={i} className="read-mode-paragraph">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadModeOverlay(): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const bookName = useBookStore((s) => s.books.find((b) => b.id === s.currentBookId)?.name)
|
||||||
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const volumes = useBookStore((s) => s.volumes)
|
||||||
|
const open = useReadModeStore((s) => s.open)
|
||||||
|
const volumeId = useReadModeStore((s) => s.volumeId)
|
||||||
|
const chapterIndex = useReadModeStore((s) => s.chapterIndex)
|
||||||
|
const setChapterIndex = useReadModeStore((s) => s.setChapterIndex)
|
||||||
|
const close = useReadModeStore((s) => s.close)
|
||||||
|
const fontTheme = useSettingsStore((s) => s.readModeFontTheme ?? 'serif')
|
||||||
|
const deviceWidth = useSettingsStore((s) => s.readModeDeviceWidth ?? 'desktop')
|
||||||
|
const language = useSettingsStore((s) => s.language)
|
||||||
|
const updateSettings = useSettingsStore((s) => s.update)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const volumeChapters = useMemo(() => {
|
||||||
|
if (!volumeId) return []
|
||||||
|
return chapters
|
||||||
|
.filter((c) => c.volumeId === volumeId)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
}, [chapters, volumeId])
|
||||||
|
|
||||||
|
const volumeName = volumes.find((v) => v.id === volumeId)?.name ?? ''
|
||||||
|
|
||||||
|
const visibleIndices = useMemo(() => {
|
||||||
|
const indices = [chapterIndex - 1, chapterIndex, chapterIndex + 1].filter(
|
||||||
|
(i) => i >= 0 && i < volumeChapters.length
|
||||||
|
)
|
||||||
|
return indices
|
||||||
|
}, [chapterIndex, volumeChapters.length])
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
if (chapterIndex > 0) setChapterIndex(chapterIndex - 1)
|
||||||
|
}, [chapterIndex, setChapterIndex])
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (chapterIndex < volumeChapters.length - 1) setChapterIndex(chapterIndex + 1)
|
||||||
|
}, [chapterIndex, volumeChapters.length, setChapterIndex])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'PageUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
goPrev()
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, close, goPrev, goNext])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: 0 })
|
||||||
|
}, [chapterIndex])
|
||||||
|
|
||||||
|
const handleTts = (): void => {
|
||||||
|
const ch = volumeChapters[chapterIndex]
|
||||||
|
if (!ch) return
|
||||||
|
if (isSpeaking()) {
|
||||||
|
stopSpeaking()
|
||||||
|
showToast(t('tts.stopped'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = htmlToPlain(ch.content)
|
||||||
|
if (!text) {
|
||||||
|
showToast(t('tts.noText'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
speakText(text, language === 'en' ? 'en-US' : 'zh-CN')
|
||||||
|
showToast(t('tts.started'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open || !volumeId || volumeChapters.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`read-mode-overlay read-mode-font-${fontTheme}`}
|
||||||
|
data-testid="read-mode-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('readMode.title')}
|
||||||
|
>
|
||||||
|
<div className="read-mode-toolbar">
|
||||||
|
<span className="read-mode-toolbar-title">
|
||||||
|
{bookName} · {volumeName}
|
||||||
|
</span>
|
||||||
|
<div className="read-mode-toolbar-controls">
|
||||||
|
<select
|
||||||
|
className="form-control form-control--narrow"
|
||||||
|
data-testid="read-device-width"
|
||||||
|
value={deviceWidth}
|
||||||
|
onChange={(e) =>
|
||||||
|
void updateSettings({
|
||||||
|
readModeDeviceWidth: e.target.value as typeof deviceWidth
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t('readMode.deviceWidth')}
|
||||||
|
>
|
||||||
|
<option value="phone">{t('readMode.devicePhone')}</option>
|
||||||
|
<option value="tablet">{t('readMode.deviceTablet')}</option>
|
||||||
|
<option value="desktop">{t('readMode.deviceDesktop')}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="form-control form-control--narrow"
|
||||||
|
data-testid="read-font-theme"
|
||||||
|
value={fontTheme}
|
||||||
|
onChange={(e) =>
|
||||||
|
void updateSettings({
|
||||||
|
readModeFontTheme: e.target.value as typeof fontTheme
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t('readMode.fontTheme')}
|
||||||
|
>
|
||||||
|
<option value="serif">{t('readMode.fontSerif')}</option>
|
||||||
|
<option value="sans">{t('readMode.fontSans')}</option>
|
||||||
|
<option value="eye-care">{t('readMode.fontEyeCare')}</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn" data-testid="read-tts-btn" onClick={handleTts}>
|
||||||
|
🔊 {t('readMode.tts')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" data-testid="read-prev-chapter" onClick={goPrev} disabled={chapterIndex <= 0}>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
<span data-testid="read-chapter-indicator">
|
||||||
|
{chapterIndex + 1}/{volumeChapters.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="read-next-chapter"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={chapterIndex >= volumeChapters.length - 1}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" data-testid="read-mode-close" onClick={close}>
|
||||||
|
{t('readMode.exit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="read-mode-scroll" ref={scrollRef}>
|
||||||
|
<div
|
||||||
|
className={`read-mode-content read-mode-device-${deviceWidth}`}
|
||||||
|
data-testid="read-mode-content"
|
||||||
|
>
|
||||||
|
{visibleIndices.map((idx) => {
|
||||||
|
const ch = volumeChapters[idx]
|
||||||
|
return (
|
||||||
|
<ChapterSection key={ch.id} chapter={ch} chapterNumber={idx + 1} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ interface VolumeContextMenuProps {
|
|||||||
hasChapters: boolean
|
hasChapters: boolean
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
onOpenReadMode?: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onChanged: () => Promise<void>
|
onChanged: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -20,6 +21,7 @@ export function VolumeContextMenu({
|
|||||||
hasChapters,
|
hasChapters,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
onOpenReadMode,
|
||||||
onClose,
|
onClose,
|
||||||
onChanged
|
onChanged
|
||||||
}: VolumeContextMenuProps): React.JSX.Element {
|
}: VolumeContextMenuProps): React.JSX.Element {
|
||||||
@@ -74,6 +76,18 @@ export function VolumeContextMenu({
|
|||||||
<button type="button" onClick={() => void handleDescription()}>
|
<button type="button" onClick={() => void handleDescription()}>
|
||||||
{t('volume.editDescription')}
|
{t('volume.editDescription')}
|
||||||
</button>
|
</button>
|
||||||
|
{onOpenReadMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="volume-read-mode"
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
onOpenReadMode()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('readMode.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
||||||
{t('volume.delete')}
|
{t('volume.delete')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { GlobalSettings } from '@shared/types'
|
||||||
|
|
||||||
|
export type ReadDeviceWidth = NonNullable<GlobalSettings['readModeDeviceWidth']>
|
||||||
|
export type ReadFontTheme = NonNullable<GlobalSettings['readModeFontTheme']>
|
||||||
|
|
||||||
|
interface ReadModeState {
|
||||||
|
open: boolean
|
||||||
|
volumeId: string | null
|
||||||
|
chapterIndex: number
|
||||||
|
openForVolume: (volumeId: string, chapterIndex?: number) => void
|
||||||
|
close: () => void
|
||||||
|
setChapterIndex: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReadModeStore = create<ReadModeState>((set) => ({
|
||||||
|
open: false,
|
||||||
|
volumeId: null,
|
||||||
|
chapterIndex: 0,
|
||||||
|
openForVolume: (volumeId, chapterIndex = 0) =>
|
||||||
|
set({ open: true, volumeId, chapterIndex: Math.max(0, chapterIndex) }),
|
||||||
|
close: () => set({ open: false, volumeId: null, chapterIndex: 0 }),
|
||||||
|
setChapterIndex: (chapterIndex) => set({ chapterIndex: Math.max(0, chapterIndex) })
|
||||||
|
}))
|
||||||
@@ -32,6 +32,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
chapterTemplates: [],
|
chapterTemplates: [],
|
||||||
submissionPresets: [],
|
submissionPresets: [],
|
||||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||||
|
readModeFontTheme: 'serif',
|
||||||
|
readModeDeviceWidth: 'desktop',
|
||||||
|
enableFocusAmbientSound: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||||
|
|||||||
@@ -2346,3 +2346,104 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.read-mode-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10001;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-toolbar-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-toolbar-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-snap-type: y proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-content {
|
||||||
|
width: 100%;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-device-phone {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-device-tablet {
|
||||||
|
max-width: 768px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-device-desktop {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-chapter {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-chapter-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-paragraph {
|
||||||
|
line-height: 1.9;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-font-serif .read-mode-paragraph {
|
||||||
|
font-family: Georgia, 'Noto Serif SC', 'Songti SC', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-font-sans .read-mode-paragraph {
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-font-eye-care {
|
||||||
|
background: #ebe4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-font-eye-care .read-mode-content {
|
||||||
|
background: #f5f0e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-mode-font-eye-care .read-mode-paragraph,
|
||||||
|
.read-mode-font-eye-care .read-mode-chapter-title {
|
||||||
|
color: #3d3428;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type ExportFormat = 'txt' | 'md' | 'docx' | 'pdf'
|
||||||
|
export type ExportScope = 'chapter' | 'volume' | 'book'
|
||||||
|
|
||||||
|
export interface ExportMatrixOptions {
|
||||||
|
includeAnnotations?: boolean
|
||||||
|
markAiContent?: boolean
|
||||||
|
exportHistoryVersions?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportMatrixParams {
|
||||||
|
bookId: string
|
||||||
|
scope: ExportScope
|
||||||
|
scopeId?: string
|
||||||
|
format: ExportFormat
|
||||||
|
options?: ExportMatrixOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportMatrixChapter {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
contentHtml: string
|
||||||
|
chapterNumber: number
|
||||||
|
}
|
||||||
@@ -289,6 +289,9 @@ export interface GlobalSettings {
|
|||||||
chapterTemplates?: ChapterTemplate[]
|
chapterTemplates?: ChapterTemplate[]
|
||||||
submissionPresets?: SubmissionPreset[]
|
submissionPresets?: SubmissionPreset[]
|
||||||
defaultSubmissionPresetId?: string
|
defaultSubmissionPresetId?: string
|
||||||
|
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
|
||||||
|
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
|
||||||
|
enableFocusAmbientSound?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookMeta {
|
export interface BookMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user