From c10be87d1823cd641880c753c8ec2e6a456a586a Mon Sep 17 00:00:00 2001 From: kun1h Date: Wed, 8 Jul 2026 16:38:54 +0800 Subject: [PATCH] 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 --- .../plans/2026-07-08-bilin-w3-export-read.md | 137 +++++++++++ e2e/read-mode.spec.ts | 85 +++++++ public/locales/en/translation.json | 14 +- public/locales/zh-CN/translation.json | 14 +- src/renderer/App.tsx | 2 + .../components/layout/EditorLayout.tsx | 19 ++ .../components/read/ReadModeOverlay.tsx | 216 ++++++++++++++++++ .../components/volume/VolumeContextMenu.tsx | 14 ++ src/renderer/stores/useReadModeStore.ts | 24 ++ src/renderer/stores/useSettingsStore.ts | 3 + src/renderer/styles/layout.css | 101 ++++++++ src/shared/export-matrix.ts | 23 ++ src/shared/types.ts | 3 + 13 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md create mode 100644 e2e/read-mode.spec.ts create mode 100644 src/renderer/components/read/ReadModeOverlay.tsx create mode 100644 src/renderer/stores/useReadModeStore.ts create mode 100644 src/shared/export-matrix.ts diff --git a/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md b/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md new file mode 100644 index 0000000..924beaf --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md @@ -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 | diff --git a/e2e/read-mode.spec.ts b/e2e/read-mode.spec.ts new file mode 100644 index 0000000..e1e2e53 --- /dev/null +++ b/e2e/read-mode.spec.ts @@ -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() + }) +}) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index bc37c9a..12f1833 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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" } diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index c397087..dd8c8f5 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -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": "朗读" } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 854a61e..37a1d98 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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' && } + {showOnboarding && } diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index c8c4b31..a86a44e 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -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')} + @@ -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} /> diff --git a/src/renderer/components/read/ReadModeOverlay.tsx b/src/renderer/components/read/ReadModeOverlay.tsx new file mode 100644 index 0000000..329a05f --- /dev/null +++ b/src/renderer/components/read/ReadModeOverlay.tsx @@ -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 ( +
+

+ {chapterNumber}. {chapter.title} +

+ {paragraphs.map((p, i) => ( +

+ {p} +

+ ))} +
+ ) +} + +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(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 ( +
+
+ + {bookName} · {volumeName} + +
+ + + + + + {chapterIndex + 1}/{volumeChapters.length} + + + +
+
+
+
+ {visibleIndices.map((idx) => { + const ch = volumeChapters[idx] + return ( + + ) + })} +
+
+
+ ) +} diff --git a/src/renderer/components/volume/VolumeContextMenu.tsx b/src/renderer/components/volume/VolumeContextMenu.tsx index e71ad9b..9170f4f 100644 --- a/src/renderer/components/volume/VolumeContextMenu.tsx +++ b/src/renderer/components/volume/VolumeContextMenu.tsx @@ -10,6 +10,7 @@ interface VolumeContextMenuProps { hasChapters: boolean x: number y: number + onOpenReadMode?: () => void onClose: () => void onChanged: () => Promise } @@ -20,6 +21,7 @@ export function VolumeContextMenu({ hasChapters, x, y, + onOpenReadMode, onClose, onChanged }: VolumeContextMenuProps): React.JSX.Element { @@ -74,6 +76,18 @@ export function VolumeContextMenu({ + {onOpenReadMode && ( + + )} diff --git a/src/renderer/stores/useReadModeStore.ts b/src/renderer/stores/useReadModeStore.ts new file mode 100644 index 0000000..dfb6977 --- /dev/null +++ b/src/renderer/stores/useReadModeStore.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' +import type { GlobalSettings } from '@shared/types' + +export type ReadDeviceWidth = NonNullable +export type ReadFontTheme = NonNullable + +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((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) }) +})) diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index b61d620..5692da3 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -32,6 +32,9 @@ export const useSettingsStore = create((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()) diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index f64b2e8..614d2ae 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -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; +} diff --git a/src/shared/export-matrix.ts b/src/shared/export-matrix.ts new file mode 100644 index 0000000..8ca92c4 --- /dev/null +++ b/src/shared/export-matrix.ts @@ -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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index b5a4c23..01ba0ba 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 {