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:
2026-07-08 16:38:54 +08:00
parent fe6e411d2c
commit c10be87d18
13 changed files with 653 additions and 2 deletions
@@ -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 5pending + 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 36 |
| ReadModeOverlay | Task 2 |
| 驾驶舱大纲任务 | Task 7 |
| E2E-READ / E2E-PDF | Task 8 |
+85
View File
@@ -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()
})
})
+13 -1
View File
@@ -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"
} }
+13 -1
View File
@@ -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": "朗读"
} }
+2
View File
@@ -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>
+24
View File
@@ -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) })
}))
+3
View File
@@ -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())
+101
View File
@@ -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;
}
+23
View File
@@ -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
}
+3
View File
@@ -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 {