feat: ship v1.1.0 writer toolkit with templates, import, export, and inspiration capture
Implement chapter templates, txt/md/docx import, submission export presets, and global inspiration shortcut. Split import handlers into a separate main bundle and fix EditorLayout setTarget loop that broke E2E. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function ExportModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useExportStore((s) => s.open)
|
||||
const close = useExportStore((s) => s.close)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
||||
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel')
|
||||
|
||||
const presets = useMemo(() => mergeSubmissionPresets(customPresets ?? []), [customPresets])
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : null
|
||||
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||
|
||||
const [presetId, setPresetId] = useState(defaultPresetId)
|
||||
const [preview, setPreview] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPresetId(defaultPresetId)
|
||||
}, [open, defaultPresetId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId || !chapterId) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
void ipcCall(() => window.electronAPI.export.formatChapter(bookId, chapterId, presetId))
|
||||
.then((text) => setPreview(text.slice(0, 500)))
|
||||
.catch(() => setPreview(''))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, bookId, chapterId, presetId])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId) return
|
||||
const text = await ipcCall(() =>
|
||||
window.electronAPI.export.formatChapter(bookId, chapterId, presetId)
|
||||
)
|
||||
await ipcCall(() => window.electronAPI.export.copyClipboard(text))
|
||||
showToast(t('export.copied'))
|
||||
}
|
||||
|
||||
const handleSaveTxt = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId || !chapter) return
|
||||
const text = await ipcCall(() =>
|
||||
window.electronAPI.export.formatChapter(bookId, chapterId, presetId)
|
||||
)
|
||||
const defaultName = `${chapter.title}.txt`
|
||||
const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName))
|
||||
if (saved) {
|
||||
showToast(t('export.saved', { path: saved }))
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="exportModal"
|
||||
data-testid="export-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('export.title')}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
{t('export.title')}
|
||||
{chapter ? ` — ${chapter.title}` : ''}
|
||||
</h3>
|
||||
<button type="button" className="modal-close" onClick={close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="export-preset-select"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label">{t('export.preview')}</label>
|
||||
<pre className="export-preview" data-testid="export-preview">
|
||||
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={close}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="export-save-txt"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleSaveTxt()}
|
||||
>
|
||||
{t('export.saveTxt')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-copy-clipboard"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t('export.copyClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user