adf877861d
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>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
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>
|
|
)
|
|
}
|