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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user