feat(w3): ship v1.4.0 export matrix, cockpit tasks, and focus ambient

Add ExportMatrixService for txt/md/docx/pdf, extend ExportModal, cockpit outline tasks, focus ambient sound, and E2E-PDF-01 with 134 passing unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 16:56:53 +08:00
parent c10be87d18
commit c5c0b7329b
23 changed files with 890 additions and 37 deletions
+10
View File
@@ -0,0 +1,10 @@
import { registerExportMatrixHandlers } from './ipc/handlers/export-matrix.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapExportMatrixHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerExportMatrixHandlers(registry, settings)
}
+2
View File
@@ -56,6 +56,8 @@ app.whenReady().then(async () => {
bootstrapImportHandlers(books)
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
bootstrapPackHandlers(books, settings)
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
bootstrapExportMatrixHandlers(books, settings)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ExportMatrixParams } from '../../../shared/export-matrix'
import { ExportMatrixService } from '../../services/export-matrix.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerExportMatrixHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const svc = new ExportMatrixService(registry, settings)
ipcMain.handle(IPC.EXPORT_MATRIX_EXPORT, (_e, params: ExportMatrixParams) =>
wrap(() => svc.pickAndExport(params))
)
}
+111
View File
@@ -0,0 +1,111 @@
import { stripHtml } from '../services/word-count'
import type { Chapter } from '../../shared/types'
import type { ExportMatrixOptions } from '../../shared/export-matrix'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
import type { SqliteDb } from '../db/types'
export interface ExportChapterBlock {
id: string
title: string
volumeName: string
paragraphs: string[]
annotations: string[]
hasAiContent: boolean
}
export function htmlToPlainParagraphs(html: string): string[] {
const trimmed = html.trim()
if (!trimmed) return []
const pMatches = trimmed.match(/<p[^>]*>([\s\S]*?)<\/p>/gi)
if (pMatches && pMatches.length > 0) {
return pMatches.map((p) => stripHtml(p).trim()).filter(Boolean)
}
const plain = stripHtml(trimmed)
if (!plain.trim()) return []
return plain
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean)
}
export function htmlToMarkdownParagraphs(html: string): string[] {
return htmlToPlainParagraphs(html)
}
export function resolveExportChapters(
db: SqliteDb,
scope: 'chapter' | 'volume' | 'book',
scopeId?: string
): Chapter[] {
const chapterRepo = new ChapterRepository(db)
const all = chapterRepo.list().sort((a, b) => a.sortOrder - b.sortOrder)
if (scope === 'chapter') {
if (!scopeId) throw new Error('Chapter scope requires scopeId')
const ch = chapterRepo.get(scopeId)
return ch ? [ch] : []
}
if (scope === 'volume') {
if (!scopeId) throw new Error('Volume scope requires scopeId')
return all.filter((c) => c.volumeId === scopeId)
}
return all
}
export function buildExportBlocks(
db: SqliteDb,
chapters: Chapter[],
options: ExportMatrixOptions = {}
): ExportChapterBlock[] {
const volumeRepo = new VolumeRepository(db)
const bookmarkRepo = new BookmarkRepository(db)
const snapshotRepo = new SnapshotRepository(db)
const volumes = new Map(volumeRepo.list().map((v) => [v.id, v.name]))
return chapters.map((ch) => {
const annotations =
options.includeAnnotations === true
? bookmarkRepo.listByChapter(ch.id).map((b) => `[${b.landmarkType}] ${b.label}`)
: []
const hasAiContent =
options.markAiContent === true && snapshotRepo.list(ch.id).some((s) => s.type === 'ai')
return {
id: ch.id,
title: ch.title,
volumeName: (ch.volumeId && volumes.get(ch.volumeId)) || '',
paragraphs: htmlToPlainParagraphs(ch.content),
annotations,
hasAiContent
}
})
}
export function buildTxtExport(blocks: ExportChapterBlock[]): string {
return blocks
.map((block) => {
const lines: string[] = [`# ${block.title}`]
if (block.hasAiContent) lines.push('[AI生成内容]', '')
lines.push(...block.paragraphs)
if (block.annotations.length > 0) {
lines.push('', '--- 批注 ---', ...block.annotations.map((a) => `- ${a}`))
}
return lines.join('\n')
})
.join('\n\n')
}
export function buildMdExport(blocks: ExportChapterBlock[]): string {
return blocks
.map((block) => {
const lines: string[] = [`# ${block.title}`]
if (block.hasAiContent) lines.push('', '> *AI 生成内容*', '')
lines.push(...block.paragraphs)
if (block.annotations.length > 0) {
lines.push('', '## 批注', ...block.annotations.map((a) => `- ${a}`))
}
return lines.join('\n')
})
.join('\n\n')
}
+217
View File
@@ -0,0 +1,217 @@
import { BrowserWindow, dialog } from 'electron'
import { writeFileSync } from 'fs'
import type { ExportMatrixParams } from '../../shared/export-matrix'
import type { ExportChapterBlock } from '../lib/export-content'
import {
buildExportBlocks,
buildMdExport,
buildTxtExport,
resolveExportChapters
} from '../lib/export-content'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
const FORMAT_EXT: Record<ExportMatrixParams['format'], string> = {
txt: 'txt',
md: 'md',
docx: 'docx',
pdf: 'pdf'
}
export class ExportMatrixService {
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService
) {}
collectBlocks(params: ExportMatrixParams): ExportChapterBlock[] {
const db = this.registry.getDb(params.bookId)
const chapters = resolveExportChapters(db, params.scope, params.scopeId)
if (chapters.length === 0) throw new Error('No chapters to export')
return buildExportBlocks(db, chapters, params.options)
}
async exportToPath(params: ExportMatrixParams, destPath: string): Promise<void> {
const blocks = this.collectBlocks(params)
const book = this.registry.list().find((b) => b.id === params.bookId)
const bookName = book?.name ?? 'export'
const { penName } = this.settings.get()
switch (params.format) {
case 'txt':
writeFileSync(destPath, buildTxtExport(blocks), 'utf8')
break
case 'md':
writeFileSync(destPath, buildMdExport(blocks), 'utf8')
break
case 'docx':
writeFileSync(destPath, await this.buildDocxBuffer(blocks, bookName))
break
case 'pdf':
await this.exportPdf(blocks, bookName, penName, destPath)
break
default:
throw new Error(`Unsupported format: ${params.format}`)
}
}
async pickAndExport(params: ExportMatrixParams): Promise<string | null> {
const ext = FORMAT_EXT[params.format]
let destPath: string | null = null
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_PDF_SAVE) {
destPath = process.env.BILIN_E2E_PDF_SAVE
} else {
const book = this.registry.list().find((b) => b.id === params.bookId)
const defaultPath = `${book?.name ?? 'export'}.${ext}`
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath,
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
})
if (canceled || !filePath) return null
destPath = filePath
}
await this.exportToPath(params, destPath)
return destPath
}
private async buildDocxBuffer(blocks: ExportChapterBlock[], bookName: string): Promise<Buffer> {
const docx = await import('docx')
const {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
TableOfContents,
AlignmentType,
PageBreak
} = docx
const children: InstanceType<typeof Paragraph>[] = [
new Paragraph({
text: bookName,
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER
}),
new TableOfContents('目录', { hyperlink: true, headingStyleRange: '1-1' }),
new Paragraph({ children: [new PageBreak()] })
]
for (const block of blocks) {
children.push(
new Paragraph({
text: block.title,
heading: HeadingLevel.HEADING_1
})
)
if (block.hasAiContent) {
children.push(
new Paragraph({
children: [new TextRun({ text: '[AI生成内容]', italics: true, color: '888888' })]
})
)
}
for (const p of block.paragraphs) {
children.push(new Paragraph({ children: [new TextRun(p)] }))
}
if (block.annotations.length > 0) {
children.push(
new Paragraph({
children: [new TextRun({ text: '批注', bold: true })]
})
)
for (const note of block.annotations) {
children.push(
new Paragraph({
children: [new TextRun({ text: `${note}`, size: 20 })]
})
)
}
}
}
const doc = new Document({
features: { updateFields: true },
sections: [{ children }]
})
return Packer.toBuffer(doc)
}
private buildPdfHtml(blocks: ExportChapterBlock[], bookName: string, penName: string): string {
const exportedAt = new Date().toLocaleDateString('zh-CN')
const chaptersHtml = blocks
.map((block, idx) => {
const ai = block.hasAiContent ? '<p class="ai-badge">[AI生成内容]</p>' : ''
const body = block.paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('')
const notes =
block.annotations.length > 0
? `<div class="annotations"><strong>批注</strong><ul>${block.annotations.map((a) => `<li>${escapeHtml(a)}</li>`).join('')}</ul></div>`
: ''
return `<section class="chapter"><h2>${idx + 1}. ${escapeHtml(block.title)}</h2>${ai}${body}${notes}</section>`
})
.join('')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<style>
@page { margin: 2cm 2cm 2.5cm 2cm; }
body { font-family: 'Noto Serif SC', Georgia, serif; font-size: 12pt; line-height: 1.8; color: #222; }
.print-header { font-size: 9pt; color: #666; border-bottom: 1px solid #ccc; padding-bottom: 6px; margin-bottom: 24px; display: flex; justify-content: space-between; }
.chapter { page-break-before: always; }
.chapter:first-of-type { page-break-before: auto; }
h2 { font-size: 16pt; margin: 0 0 12px; }
.ai-badge { font-size: 9pt; color: #888; }
.annotations { font-size: 9pt; color: #555; margin-top: 16px; border-top: 1px dashed #ccc; padding-top: 8px; }
.print-footer { font-size: 9pt; color: #666; margin-top: 32px; border-top: 1px solid #ccc; padding-top: 6px; display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="print-header">
<span>${escapeHtml(bookName)}</span>
<span>笔临导出</span>
<span></span>
</div>
${chaptersHtml}
<div class="print-footer">
<span>${escapeHtml(penName)}</span>
<span>${exportedAt}</span>
</div>
</body>
</html>`
}
private async exportPdf(
blocks: ExportChapterBlock[],
bookName: string,
penName: string,
destPath: string
): Promise<void> {
const html = this.buildPdfHtml(blocks, bookName, penName)
const win = new BrowserWindow({
show: false,
webPreferences: { offscreen: true }
})
try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
await new Promise<void>((resolve) => setTimeout(resolve, 300))
const pdf = await win.webContents.printToPDF({
printBackground: true,
pageSize: 'A4',
margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 }
})
writeFileSync(destPath, pdf)
} finally {
win.destroy()
}
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+5
View File
@@ -65,6 +65,7 @@ import type {
PackImportReport,
PackProgressEvent
} from '../shared/novel-pack'
import type { ExportMatrixParams } from '../shared/export-matrix'
const electronAPI = {
window: {
@@ -604,6 +605,10 @@ const electronAPI = {
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
},
exportMatrix: {
export: (params: ExportMatrixParams): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
},
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
@@ -25,6 +25,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const outlines = useBookStore((s) => s.outlines)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
const switchTarget = useEditStore((s) => s.switchTarget)
@@ -63,6 +64,11 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
close()
}
const todayOutlineTasks = outlines
.filter((o) => o.status === 'pending' && (o.expectedWordCount ?? 0) > 0)
.sort((a, b) => (b.expectedWordCount ?? 0) - (a.expectedWordCount ?? 0))
.slice(0, 5)
return (
<div className="modal-overlay" data-testid="cockpit-modal">
<div className="modal-content modal-content--wide">
@@ -124,6 +130,21 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
</div>
<CockpitAnalytics analytics={analytics} />
{todayOutlineTasks.length > 0 && (
<div className="cockpit-outline-tasks" data-testid="cockpit-outline-tasks">
<div className="cockpit-heatmap-label">{t('cockpit.todayOutlineTasks')}</div>
<ul className="cockpit-outline-task-list">
{todayOutlineTasks.map((item) => (
<li key={item.id} data-testid={`cockpit-outline-task-${item.id}`}>
<span>{item.title}</span>
<span className="cockpit-outline-task-words">
{t('cockpit.outlineTargetWords', { count: item.expectedWordCount ?? 0 })}
</span>
</li>
))}
</ul>
</div>
)}
<div className="cockpit-achievements-section">
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
<div className="cockpit-achievements" data-testid="cockpit-achievements">
+124 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mergeSubmissionPresets } from '@shared/builtin-templates'
import type { ExportFormat, ExportMatrixOptions, ExportScope } from '@shared/export-matrix'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
@@ -8,7 +9,7 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { ipcCall } from '@renderer/lib/ipc-client'
type ExportKind = 'submission' | 'novel'
type ExportKind = 'submission' | 'matrix' | 'novel'
export function ExportModal(): React.JSX.Element | null {
const { t } = useTranslation()
@@ -18,6 +19,7 @@ export function ExportModal(): React.JSX.Element | null {
const bookId = useBookStore((s) => s.currentBookId)
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
const chapters = useBookStore((s) => s.chapters)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const target = useEditStore((s) => s.target)
const customPresets = useSettingsStore((s) => s.submissionPresets)
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel')
@@ -28,14 +30,23 @@ export function ExportModal(): React.JSX.Element | null {
const [exportKind, setExportKind] = useState<ExportKind>('submission')
const [presetId, setPresetId] = useState(defaultPresetId)
const [matrixFormat, setMatrixFormat] = useState<ExportFormat>('txt')
const [matrixScope, setMatrixScope] = useState<ExportScope>('chapter')
const [matrixOptions, setMatrixOptions] = useState<ExportMatrixOptions>({
includeAnnotations: false,
markAiContent: false
})
const [preview, setPreview] = useState('')
const [loading, setLoading] = useState(false)
const [exportingNovel, setExportingNovel] = useState(false)
const [exporting, setExporting] = useState(false)
useEffect(() => {
if (!open) return
setExportKind('submission')
setPresetId(defaultPresetId)
setMatrixFormat('txt')
setMatrixScope('chapter')
setMatrixOptions({ includeAnnotations: false, markAiContent: false })
}, [open, defaultPresetId])
useEffect(() => {
@@ -50,6 +61,17 @@ export function ExportModal(): React.JSX.Element | null {
.finally(() => setLoading(false))
}, [open, exportKind, bookId, chapterId, presetId])
const resolveScopeId = (): string | undefined => {
if (matrixScope === 'chapter') return chapterId ?? undefined
if (matrixScope === 'volume') return activeVolumeId ?? undefined
return undefined
}
const matrixDisabled =
!bookId ||
(matrixScope === 'chapter' && !chapterId) ||
(matrixScope === 'volume' && !activeVolumeId)
const handleCopy = async (): Promise<void> => {
if (!bookId || !chapterId) return
const text = await ipcCall(() =>
@@ -66,8 +88,30 @@ export function ExportModal(): React.JSX.Element | null {
)
const defaultName = `${chapter.title}.txt`
const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName))
if (saved) {
showToast(t('export.saved', { path: saved }))
if (saved) showToast(t('export.saved', { path: saved }))
}
const handleMatrixExport = async (): Promise<void> => {
if (!bookId || matrixDisabled) return
setExporting(true)
try {
const saved = await ipcCall(() =>
window.electronAPI.exportMatrix.export({
bookId,
scope: matrixScope,
scopeId: resolveScopeId(),
format: matrixFormat,
options: matrixOptions
})
)
if (saved) {
showToast(t('export.matrixSaved', { path: saved }))
close()
}
} catch {
showToast(t('export.matrixFailed'))
} finally {
setExporting(false)
}
}
@@ -75,7 +119,7 @@ export function ExportModal(): React.JSX.Element | null {
if (!bookId) return
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
if (!destPath) return
setExportingNovel(true)
setExporting(true)
try {
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
showToast(t('pack.exportBookSuccess', { name: bookName ?? '' }))
@@ -83,7 +127,7 @@ export function ExportModal(): React.JSX.Element | null {
} catch {
showToast(t('pack.exportBookFailed'))
} finally {
setExportingNovel(false)
setExporting(false)
}
}
@@ -104,6 +148,7 @@ export function ExportModal(): React.JSX.Element | null {
{t('export.title')}
{exportKind === 'submission' && chapter ? `${chapter.title}` : ''}
{exportKind === 'novel' && bookName ? `${bookName}` : ''}
{exportKind === 'matrix' && bookName ? `${bookName}` : ''}
</h3>
<button type="button" className="modal-close" onClick={close}>
@@ -118,9 +163,11 @@ export function ExportModal(): React.JSX.Element | null {
onChange={(e) => setExportKind(e.target.value as ExportKind)}
>
<option value="submission">{t('export.kindSubmission')}</option>
<option value="matrix">{t('export.kindMatrix')}</option>
<option value="novel">{t('export.kindNovel')}</option>
</select>
{exportKind === 'submission' ? (
{exportKind === 'submission' && (
<>
<label className="form-label">{t('export.submissionPresets')}</label>
<select
@@ -140,7 +187,60 @@ export function ExportModal(): React.JSX.Element | null {
{loading ? t('export.loading') : preview || t('export.noPreview')}
</pre>
</>
) : (
)}
{exportKind === 'matrix' && (
<>
<label className="form-label">{t('export.matrixFormat')}</label>
<select
className="form-control form-control--wide"
data-testid="export-matrix-format"
value={matrixFormat}
onChange={(e) => setMatrixFormat(e.target.value as ExportFormat)}
>
<option value="txt">TXT</option>
<option value="md">Markdown</option>
<option value="docx">Word (.docx)</option>
<option value="pdf">PDF</option>
</select>
<label className="form-label">{t('export.matrixScope')}</label>
<select
className="form-control form-control--wide"
data-testid="export-matrix-scope"
value={matrixScope}
onChange={(e) => setMatrixScope(e.target.value as ExportScope)}
>
<option value="chapter">{t('export.scopeChapter')}</option>
<option value="volume">{t('export.scopeVolume')}</option>
<option value="book">{t('export.scopeBook')}</option>
</select>
<label className="form-label">{t('export.matrixOptions')}</label>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input
type="checkbox"
data-testid="export-matrix-annotations"
checked={matrixOptions.includeAnnotations === true}
onChange={(e) =>
setMatrixOptions((o) => ({ ...o, includeAnnotations: e.target.checked }))
}
/>
{t('export.optionAnnotations')}
</label>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="checkbox"
data-testid="export-matrix-ai-mark"
checked={matrixOptions.markAiContent === true}
onChange={(e) =>
setMatrixOptions((o) => ({ ...o, markAiContent: e.target.checked }))
}
/>
{t('export.optionAiMark')}
</label>
</>
)}
{exportKind === 'novel' && (
<p className="text-muted" style={{ lineHeight: 1.6 }}>
{t('export.novelDesc')}
</p>
@@ -150,7 +250,7 @@ export function ExportModal(): React.JSX.Element | null {
<button type="button" className="btn" onClick={close}>
{t('dialog.cancel')}
</button>
{exportKind === 'submission' ? (
{exportKind === 'submission' && (
<>
<button
type="button"
@@ -171,15 +271,27 @@ export function ExportModal(): React.JSX.Element | null {
{t('export.copyClipboard')}
</button>
</>
) : (
)}
{exportKind === 'matrix' && (
<button
type="button"
className="btn btn-primary"
data-testid="export-matrix-btn"
disabled={matrixDisabled || exporting}
onClick={() => void handleMatrixExport()}
>
{exporting ? t('export.exporting') : t('export.matrixStart')}
</button>
)}
{exportKind === 'novel' && (
<button
type="button"
className="btn btn-primary"
data-testid="export-novel-btn"
disabled={!bookId || exportingNovel}
disabled={!bookId || exporting}
onClick={() => void handleExportNovel()}
>
{exportingNovel ? t('pack.exporting') : t('export.exportNovel')}
{exporting ? t('pack.exporting') : t('export.exportNovel')}
</button>
)}
</div>
@@ -1,11 +1,40 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
function startAmbientLoop(ctx: AudioContext): () => void {
const gain = ctx.createGain()
gain.gain.value = 0.03
gain.connect(ctx.destination)
const osc1 = ctx.createOscillator()
osc1.type = 'sine'
osc1.frequency.value = 110
osc1.connect(gain)
const osc2 = ctx.createOscillator()
osc2.type = 'sine'
osc2.frequency.value = 164.81
osc2.connect(gain)
osc1.start()
osc2.start()
return () => {
osc1.stop()
osc2.stop()
gain.disconnect()
}
}
export function FocusModeOverlay(): React.JSX.Element | null {
const { t } = useTranslation()
const focusMode = useAppStore((s) => s.focusMode)
const setFocusMode = useAppStore((s) => s.setFocusMode)
const ambientEnabled = useSettingsStore((s) => s.enableFocusAmbientSound === true)
const stopRef = useRef<(() => void) | null>(null)
const ctxRef = useRef<AudioContext | null>(null)
useEffect(() => {
if (!focusMode) return
@@ -16,6 +45,25 @@ export function FocusModeOverlay(): React.JSX.Element | null {
return () => window.removeEventListener('keydown', onKey)
}, [focusMode, setFocusMode])
useEffect(() => {
if (!focusMode || !ambientEnabled) {
stopRef.current?.()
stopRef.current = null
void ctxRef.current?.close()
ctxRef.current = null
return
}
const ctx = new AudioContext()
ctxRef.current = ctx
stopRef.current = startAmbientLoop(ctx)
return () => {
stopRef.current?.()
stopRef.current = null
void ctx.close()
ctxRef.current = null
}
}, [focusMode, ambientEnabled])
if (!focusMode) return null
return (
@@ -234,6 +234,19 @@ export function SettingsPage(): React.JSX.Element {
{t('settings.goalNotifications')}
</label>
</div>
<div className="setting-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
data-testid="settings-focus-ambient"
checked={settings.enableFocusAmbientSound === true}
onChange={(e) =>
void settings.update({ enableFocusAmbientSound: e.target.checked })
}
/>
{t('settings.focusAmbientSound')}
</label>
</div>
<div className="setting-item">
<span>{t('settings.extractThreshold')}</span>
<input
@@ -263,7 +276,7 @@ export function SettingsPage(): React.JSX.Element {
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.3.0
{t('app.name')} v1.4.0
<br />
{t('app.tagline')}
</p>
+26
View File
@@ -2347,6 +2347,32 @@
margin-bottom: 8px;
}
.cockpit-outline-tasks {
margin-top: 16px;
}
.cockpit-outline-task-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
}
.cockpit-outline-task-list li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-secondary);
margin-bottom: 6px;
font-size: 13px;
}
.cockpit-outline-task-words {
color: var(--text-muted);
white-space: nowrap;
}
.read-mode-overlay {
position: fixed;
inset: 0;
+4
View File
@@ -60,6 +60,7 @@ import type {
ImportSplitMode
} from './types'
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
import type { ExportMatrixParams } from './export-matrix'
export interface ElectronAPI {
window: {
@@ -446,6 +447,9 @@ export interface ElectronAPI {
copyClipboard: (text: string) => Promise<IpcResult<void>>
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
}
exportMatrix: {
export: (params: ExportMatrixParams) => Promise<IpcResult<string | null>>
}
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
+1
View File
@@ -138,6 +138,7 @@ export const IPC = {
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
EXPORT_SAVE_TXT: 'export:saveTxt',
EXPORT_MATRIX_EXPORT: 'exportMatrix:export',
COCKPIT_SUMMARY: 'cockpit:summary',
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',