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.stopped": "Stopped reading",
|
||||
"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.stopped": "已停止朗读",
|
||||
"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 { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
||||
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
|
||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
@@ -206,6 +207,7 @@ function AppInner(): React.JSX.Element {
|
||||
{view === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
<FocusModeOverlay />
|
||||
<ReadModeOverlay />
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeMod
|
||||
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
||||
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
|
||||
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
|
||||
import { useReadModeStore } from '@renderer/stores/useReadModeStore'
|
||||
import { ExportModal } from '@renderer/components/export/ExportModal'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import {
|
||||
@@ -124,6 +125,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
|
||||
const openReadMode = useReadModeStore((s) => s.openForVolume)
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
@@ -497,6 +499,22 @@ export function EditorLayout(): React.JSX.Element {
|
||||
>
|
||||
+ {t('template.quickNew')}
|
||||
</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()}>
|
||||
+ {t('editor.newVolume')}
|
||||
</button>
|
||||
@@ -710,6 +728,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
onOpenReadMode={() => openReadMode(volumeMenuVolume.id, 0)}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
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
|
||||
x: number
|
||||
y: number
|
||||
onOpenReadMode?: () => void
|
||||
onClose: () => void
|
||||
onChanged: () => Promise<void>
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export function VolumeContextMenu({
|
||||
hasChapters,
|
||||
x,
|
||||
y,
|
||||
onOpenReadMode,
|
||||
onClose,
|
||||
onChanged
|
||||
}: VolumeContextMenuProps): React.JSX.Element {
|
||||
@@ -74,6 +76,18 @@ export function VolumeContextMenu({
|
||||
<button type="button" onClick={() => void handleDescription()}>
|
||||
{t('volume.editDescription')}
|
||||
</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()}>
|
||||
{t('volume.delete')}
|
||||
</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: [],
|
||||
submissionPresets: [],
|
||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||
readModeFontTheme: 'serif',
|
||||
readModeDeviceWidth: 'desktop',
|
||||
enableFocusAmbientSound: false,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -2346,3 +2346,104 @@
|
||||
font-size: 12px;
|
||||
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[]
|
||||
submissionPresets?: SubmissionPreset[]
|
||||
defaultSubmissionPresetId?: string
|
||||
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
|
||||
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
|
||||
enableFocusAmbientSound?: boolean
|
||||
}
|
||||
|
||||
export interface BookMeta {
|
||||
|
||||
Reference in New Issue
Block a user