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,233 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
const LARGE_FILE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function ImportBookModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.importBookModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setImportBookModalOpen)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { loadBooks, openBook } = useBookStore()
|
||||
const { openBookTab } = useTabStore()
|
||||
|
||||
const [filePath, setFilePath] = useState('')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [bookName, setBookName] = useState('')
|
||||
const [category, setCategory] = useState('玄幻')
|
||||
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
|
||||
const [customRegex, setCustomRegex] = useState('')
|
||||
const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setFilePath('')
|
||||
setFileName('')
|
||||
setBookName('')
|
||||
setCategory('玄幻')
|
||||
setSplitMode('auto')
|
||||
setCustomRegex('')
|
||||
setPreview(null)
|
||||
setProgress(null)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const unsub = window.electronAPI.onImportProgress((payload) => {
|
||||
setProgress(payload)
|
||||
})
|
||||
return unsub
|
||||
}, [open])
|
||||
|
||||
const handlePickFile = async (): Promise<void> => {
|
||||
const result = await ipcCall(() => window.electronAPI.import.pickFile())
|
||||
if (result.canceled || !result.filePath) return
|
||||
setFilePath(result.filePath)
|
||||
const name = result.filePath.split(/[/\\]/).pop() ?? ''
|
||||
setFileName(name)
|
||||
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
|
||||
if (!bookName.trim()) setBookName(baseName)
|
||||
}
|
||||
|
||||
const handlePreview = async (): Promise<void> => {
|
||||
if (!filePath) return
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
|
||||
)
|
||||
setPreview(result)
|
||||
}
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
if (!filePath || !bookName.trim()) return
|
||||
if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
|
||||
const ok = confirm(
|
||||
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
setImporting(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
if (!preview) await handlePreview()
|
||||
const bookId = await ipcCall(() =>
|
||||
window.electronAPI.import.execute({
|
||||
filePath,
|
||||
bookName: bookName.trim(),
|
||||
category,
|
||||
splitMode,
|
||||
customRegex: customRegex || undefined
|
||||
})
|
||||
)
|
||||
await loadBooks()
|
||||
const meta = useBookStore.getState().books.find((b) => b.id === bookId)
|
||||
await openBook(bookId)
|
||||
openBookTab(bookId, meta?.name ?? bookName.trim())
|
||||
setView('editor')
|
||||
showToast(t('import.success'))
|
||||
setOpen(false)
|
||||
} catch {
|
||||
showToast(t('import.failed'))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="importModal"
|
||||
data-testid="import-book-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('import.title')}
|
||||
>
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('import.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="import-file-row">
|
||||
<input
|
||||
className="form-control"
|
||||
readOnly
|
||||
value={fileName || t('import.noFile')}
|
||||
data-testid="import-file-path"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="import-pick-file"
|
||||
onClick={() => void handlePickFile()}
|
||||
>
|
||||
{t('import.pickFile')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-name"
|
||||
value={bookName}
|
||||
onChange={(e) => setBookName(e.target.value)}
|
||||
/>
|
||||
<label className="form-label">{t('dialog.newBook.category')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<label className="form-label">{t('import.splitMode')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-split-mode"
|
||||
value={splitMode}
|
||||
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
|
||||
>
|
||||
<option value="auto">{t('import.splitAuto')}</option>
|
||||
<option value="paragraph">{t('import.splitParagraph')}</option>
|
||||
<option value="regex">{t('import.splitRegex')}</option>
|
||||
</select>
|
||||
{splitMode === 'regex' && (
|
||||
<>
|
||||
<label className="form-label">{t('import.customRegex')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-custom-regex"
|
||||
value={customRegex}
|
||||
onChange={(e) => setCustomRegex(e.target.value)}
|
||||
placeholder="^第.+章"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="import-preview-btn"
|
||||
disabled={!filePath}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
{t('import.preview')}
|
||||
</button>
|
||||
{preview && (
|
||||
<div className="import-preview" data-testid="import-preview">
|
||||
<p>
|
||||
{t('import.chapterCount', { count: preview.chapters.length })}
|
||||
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
|
||||
</p>
|
||||
<ul className="import-preview-list">
|
||||
{previewTitles.map((title, i) => (
|
||||
<li key={i}>{title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{progress && (
|
||||
<div className="import-progress" data-testid="import-progress-bar">
|
||||
<div
|
||||
className="import-progress-fill"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
<span>{t('import.progress', { current: progress.current, total: progress.total })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="import-execute-btn"
|
||||
disabled={!filePath || !bookName.trim() || importing}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{importing ? t('import.importing') : t('import.start')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user