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:
2026-07-08 13:53:22 +08:00
parent ea4819847f
commit adf877861d
49 changed files with 2450 additions and 87 deletions
+8
View File
@@ -0,0 +1,8 @@
import type { BookRegistryService } from './services/book-registry'
import { registerImportHandlers } from './ipc/handlers/import.handler'
export { registerImportHandlers }
export function bootstrapImportHandlers(registry: BookRegistryService): void {
registerImportHandlers(registry)
}
+4 -2
View File
@@ -50,8 +50,10 @@ function createWindow(): void { mainWindow = new BrowserWindow({
getNetworkMonitor()?.start(() => mainWindow)
}
app.whenReady().then(() => {
registerIpc()
app.whenReady().then(async () => {
const { books } = registerIpc()
const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
bootstrapImportHandlers(books)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
+38
View File
@@ -0,0 +1,38 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
import { ExportService } from '../../services/export.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerExportHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const svc = new ExportService(registry, settings)
ipcMain.handle(
IPC.EXPORT_FORMAT_CHAPTER,
(
_e,
{
bookId,
chapterId,
presetId
}: { bookId: string; chapterId: string; presetId: string }
) => wrap(() => svc.formatChapter(bookId, chapterId, presetId))
)
ipcMain.handle(IPC.EXPORT_COPY_CLIPBOARD, (_e, { text }: { text: string }) =>
wrap(() => {
svc.copyToClipboard(text)
})
)
ipcMain.handle(
IPC.EXPORT_SAVE_TXT,
(_e, { text, defaultName }: { text: string; defaultName: string }) =>
wrap(() => svc.saveTxt(text, defaultName))
)
}
+35
View File
@@ -0,0 +1,35 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
import { ImportService } from '../../services/import.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerImportHandlers(registry: BookRegistryService): void {
const svc = new ImportService(registry)
ipcMain.handle(IPC.IMPORT_PICK_FILE, () => wrap(() => svc.pickFile()))
ipcMain.handle(
IPC.IMPORT_PREVIEW,
(
_e,
{
filePath,
splitMode,
customRegex
}: { filePath: string; splitMode: ImportSplitMode; customRegex?: string }
) => wrap(() => svc.preview(filePath, splitMode, customRegex))
)
ipcMain.handle(IPC.IMPORT_EXECUTE, (_e, params: ImportExecuteParams) =>
wrap(async () => {
const bookId = await svc.execute(params, (current, total) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.IMPORT_PROGRESS, { current, total })
}
})
return bookId
})
)
}
+2
View File
@@ -34,6 +34,7 @@ import { registerAchievementHandlers } from './handlers/achievement.handler'
import { registerGraphHandlers } from './handlers/graph.handler'
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { registerExportHandlers } from './handlers/export.handler'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -89,6 +90,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerBridgeHandlers(books, aiClient)
registerExportHandlers(books, settings)
return { settings, books, shortcuts: shortcutManager }
}
+80
View File
@@ -0,0 +1,80 @@
import type { ImportSplitMode } from '../../shared/types'
export interface SplitChapterPart {
title: string
body: string
}
const CH_DI = '\u7b2c'
const CH_ZHANG = '\u7ae0'
const MARKDOWN_HEADING = /^#\s*.+/gm
const CHINESE_CHAPTER = new RegExp(
'^' + CH_DI + '[\\d\\u4e00-\\u9fff]+' + CH_ZHANG + '[^\\n]*',
'gm'
)
const DEFAULT_TITLE = CH_DI + '\u4e00' + CH_ZHANG
function splitByRegex(text: string, regex: RegExp): SplitChapterPart[] {
const matches = [...text.matchAll(regex)]
if (matches.length === 0) return []
const parts: SplitChapterPart[] = []
for (let i = 0; i < matches.length; i++) {
const match = matches[i]!
const title = match[0].replace(/^#\s*/, '').trim()
const start = (match.index ?? 0) + match[0].length
const end = i + 1 < matches.length ? matches[i + 1]!.index! : text.length
const body = text.slice(start, end).trim()
parts.push({ title: title.slice(0, 50), body })
}
return parts
}
function splitByParagraph(text: string): SplitChapterPart[] {
const blocks = text.split(/\n\s*\n/).filter((b) => b.trim())
return blocks.map((block) => {
const lines = block.trim().split('\n')
const title = (lines[0] ?? 'Untitled').trim().slice(0, 50)
const body = (lines.slice(1).join('\n').trim() || lines[0]) ?? ''
return { title, body: body || title }
})
}
export function splitPlainText(
text: string,
mode: ImportSplitMode,
customRegex?: string
): SplitChapterPart[] {
const normalized = text.replace(/\r\n/g, '\n').trim()
if (!normalized) return []
if (mode === 'paragraph') {
return splitByParagraph(normalized)
}
if (mode === 'regex') {
if (!customRegex?.trim()) return []
try {
const regex = new RegExp(customRegex, 'gm')
return splitByRegex(normalized, regex)
} catch {
return []
}
}
let parts = splitByRegex(normalized, MARKDOWN_HEADING)
if (parts.length === 0) parts = splitByRegex(normalized, CHINESE_CHAPTER)
if (parts.length === 0) {
return [{ title: DEFAULT_TITLE, body: normalized }]
}
const firstMatch = normalized.match(MARKDOWN_HEADING) ?? normalized.match(CHINESE_CHAPTER)
if (firstMatch && (firstMatch.index ?? 0) > 0) {
const preamble = normalized.slice(0, firstMatch.index).trim()
if (preamble && parts[0]) {
parts[0].body = preamble + '\n\n' + parts[0].body
}
}
return parts
}
+28
View File
@@ -0,0 +1,28 @@
import { stripHtml } from '../services/word-count'
import type { SubmissionPreset } from '../../shared/types'
export interface FormatChapterInput {
chapterNumber: number
chapterTitle: string
contentHtml: string
preset: SubmissionPreset
}
export function formatChapterForSubmission(input: FormatChapterInput): string {
let body = input.contentHtml
.replace(/<\/p>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
body = stripHtml(body)
if (!input.preset.includeAuthorsNote) {
body = body.split(/\n\u2014\u2014\n/)[0] ?? body
}
const paragraphs = body.split(/\n+/).filter((p) => p.trim())
const indented = input.preset.indentParagraphs
? paragraphs.map((p) => '\u3000\u3000' + p.trim())
: paragraphs.map((p) => p.trim())
const gap = input.preset.paragraphGap === 'double' ? '\n\n' : '\n'
const title = input.preset.titleFormat
.replace('{chapterNumber}', String(input.chapterNumber))
.replace('{chapterTitle}', input.chapterTitle)
return `${title}\n\n${indented.join(gap)}`
}
+19
View File
@@ -0,0 +1,19 @@
import type { SubmissionPreset } from '../../shared/types'
export const MAIN_BUILTIN_SUBMISSION_PRESET: SubmissionPreset = {
id: 'builtin-webnovel',
name: 'builtin-webnovel',
titleFormat: '# \u7b2c{chapterNumber}\u7ae0 {chapterTitle}',
indentParagraphs: true,
includeAuthorsNote: true,
paragraphGap: 'double'
}
export function mergeSubmissionPresetsForMain(
custom: SubmissionPreset[] = []
): SubmissionPreset[] {
return [
MAIN_BUILTIN_SUBMISSION_PRESET,
...custom.filter((p) => p.id !== 'builtin-webnovel')
]
}
+55
View File
@@ -0,0 +1,55 @@
import { clipboard, dialog } from 'electron'
import { writeFileSync } from 'fs'
import {
mergeSubmissionPresetsForMain,
MAIN_BUILTIN_SUBMISSION_PRESET
} from '../lib/submission-presets'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { formatChapterForSubmission } from '../lib/submission-formatter'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
export class ExportService {
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService
) {}
formatChapter(bookId: string, chapterId: string, presetId: string): string {
const db = this.registry.getDb(bookId)
const chapterRepo = new ChapterRepository(db)
const chapter = chapterRepo.get(chapterId)
if (!chapter) throw new Error('Chapter not found')
const presets = mergeSubmissionPresetsForMain(this.settings.get().submissionPresets)
const preset =
presets.find((p) => p.id === presetId) ?? MAIN_BUILTIN_SUBMISSION_PRESET
const volumeChapters = chapterRepo
.list()
.filter((c) => c.volumeId === chapter.volumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volumeChapters.findIndex((c) => c.id === chapterId) + 1
return formatChapterForSubmission({
chapterNumber,
chapterTitle: chapter.title,
contentHtml: chapter.content,
preset
})
}
copyToClipboard(text: string): void {
clipboard.writeText(text)
}
async saveTxt(text: string, defaultName: string): Promise<string | null> {
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [{ name: 'Text', extensions: ['txt'] }]
})
if (canceled || !filePath) return null
writeFileSync(filePath, text, 'utf8')
return filePath
}
}
+7 -1
View File
@@ -35,7 +35,10 @@ function defaults(): GlobalSettings {
knowledgeExtractConfidenceThreshold: 0.8,
pomodoroDurationMinutes: 25,
enableSystemNotifications: false,
enableGoalNotifications: true
enableGoalNotifications: true,
chapterTemplates: [],
submissionPresets: [],
defaultSubmissionPresetId: 'builtin-webnovel'
}
}
@@ -64,6 +67,9 @@ export class GlobalSettingsService {
enableSystemNotifications: raw.enableSystemNotifications ?? false,
enableGoalNotifications: raw.enableGoalNotifications ?? true,
dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate,
chapterTemplates: raw.chapterTemplates ?? [],
submissionPresets: raw.submissionPresets ?? [],
defaultSubmissionPresetId: raw.defaultSubmissionPresetId ?? 'builtin-webnovel',
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
+119
View File
@@ -0,0 +1,119 @@
import { basename } from 'node:path'
import { readFileSync, statSync } from 'node:fs'
import { dialog } from 'electron'
import type {
ImportExecuteParams,
ImportPreviewResult,
ImportSplitMode
} from '../../shared/types'
import { splitPlainText } from '../lib/chapter-splitter'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { countWords, stripHtml } from './word-count'
import type { BookRegistryService } from './book-registry'
function plainTextToEditorHtml(text: string): string {
const trimmed = text.trim()
if (!trimmed) return '<p></p>'
return trimmed
.split(/\n{2,}/)
.map((p) => {
const safe = p
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
return '<p>' + safe + '</p>'
})
.join('')
}
async function readHtmlFromFile(filePath: string): Promise<string> {
const lower = filePath.toLowerCase()
if (lower.endsWith('.docx')) {
const mod = await import('mammoth')
return (await mod.default.convertToHtml({ path: filePath })).value
}
const raw = readFileSync(filePath, 'utf8')
if (lower.endsWith('.md')) {
const mod = await import('marked')
return mod.marked.parse(raw) as string
}
return plainTextToEditorHtml(raw)
}
export class ImportService {
constructor(private registry: BookRegistryService) {}
async pickFile(): Promise<{ canceled: boolean; filePath?: string }> {
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_IMPORT_FILE) {
return { canceled: false, filePath: process.env.BILIN_E2E_IMPORT_FILE }
}
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Import', extensions: ['txt', 'md', 'docx'] }]
})
if (canceled || filePaths.length === 0) return { canceled: true }
return { canceled: false, filePath: filePaths[0] }
}
async preview(
filePath: string,
splitMode: ImportSplitMode,
customRegex?: string
): Promise<ImportPreviewResult> {
const stat = statSync(filePath)
const html = await readHtmlFromFile(filePath)
const plain = stripHtml(html)
const parts = splitPlainText(plain, splitMode, customRegex)
const warnings: string[] = []
if (parts.length === 0) warnings.push('No chapters detected')
const chapters = parts.map((p) => {
const contentHtml = plainTextToEditorHtml(p.body)
return {
title: p.title || 'Untitled',
contentHtml,
wordCount: countWords(contentHtml)
}
})
return {
fileName: basename(filePath),
fileSizeBytes: stat.size,
chapters,
warnings
}
}
async execute(
params: ImportExecuteParams,
onProgress?: (current: number, total: number) => void
): Promise<string> {
const preview = await this.preview(params.filePath, params.splitMode, params.customRegex)
if (preview.chapters.length === 0) throw new Error('No chapters to import')
const meta = this.registry.create({
name: params.bookName,
category: params.category,
createSampleChapter: false
})
try {
const db = this.registry.getDb(meta.id)
const volId = new VolumeRepository(db).list()[0]!.id
const chapterRepo = new ChapterRepository(db)
for (let i = 0; i < preview.chapters.length; i++) {
const ch = preview.chapters[i]!
chapterRepo.create(volId, ch.title, i, ch.contentHtml)
onProgress?.(i + 1, preview.chapters.length)
}
return meta.id
} catch (e) {
this.registry.delete(meta.id)
throw e
}
}
}
+37 -1
View File
@@ -53,7 +53,10 @@ import type {
WritingAchievement,
CharacterGraphData,
CharacterRelationship,
WritingAnalyticsSummary
WritingAnalyticsSummary,
ImportPreviewResult,
ImportExecuteParams,
ImportSplitMode
} from '../shared/types'
const electronAPI = {
@@ -543,6 +546,30 @@ const electronAPI = {
getSummary: (bookId: string): Promise<IpcResult<WritingAnalyticsSummary>> =>
ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
},
import: {
pickFile: (): Promise<IpcResult<{ canceled: boolean; filePath?: string }>> =>
ipcRenderer.invoke(IPC.IMPORT_PICK_FILE),
preview: (
filePath: string,
splitMode: ImportSplitMode,
customRegex?: string
): Promise<IpcResult<ImportPreviewResult>> =>
ipcRenderer.invoke(IPC.IMPORT_PREVIEW, { filePath, splitMode, customRegex }),
execute: (params: ImportExecuteParams): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.IMPORT_EXECUTE, params)
},
export: {
formatChapter: (
bookId: string,
chapterId: string,
presetId: string
): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.EXPORT_FORMAT_CHAPTER, { bookId, chapterId, presetId }),
copyClipboard: (text: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.EXPORT_COPY_CLIPBOARD, { text }),
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
},
bridge: {
get: (
bookId: string,
@@ -609,6 +636,15 @@ const electronAPI = {
}
ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler)
return () => ipcRenderer.removeListener(IPC.GOAL_NOTIFICATION, handler)
},
onImportProgress: (
callback: (payload: { current: number; total: number }) => void
): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: { current: number; total: number }) => {
callback(payload)
}
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
}
}
+10 -4
View File
@@ -17,11 +17,13 @@ import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { ipcCall } from '@renderer/lib/ipc-client'
function AppInner(): React.JSX.Element {
@@ -57,8 +59,6 @@ function AppInner(): React.JSX.Element {
const openSearch = (): void => useSearchStore.getState().setOpen(true)
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
const openInspiration = (): void => {
if (useAppStore.getState().view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true)
}
const onInsertLandmark = (e: Event): void => {
@@ -148,11 +148,16 @@ function AppInner(): React.JSX.Element {
return
}
if (action === 'captureInspiration') {
if (view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true)
return
}
if (action === 'exportBook') {
if (view !== 'editor') return
const target = useEditStore.getState().target
if (target?.kind !== 'chapter') return
useExportStore.getState().openModal()
return
}
showToast(t('feature.comingSoon'))
})
return unsub
@@ -173,6 +178,7 @@ function AppInner(): React.JSX.Element {
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
<ImportBookModal />
{toast && <div className="toast">{toast}</div>}
</div>
)
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mergeChapterTemplates } from '@shared/builtin-templates'
import {
bodyPlainToChapterHtml,
replaceChapterTemplate,
type TemplateContext
} from '@shared/chapter-template'
import type { ChapterTemplate } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
interface TemplateQuickCreateModalProps {
open: boolean
onClose: () => void
}
export function TemplateQuickCreateModal({
open,
onClose
}: TemplateQuickCreateModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const penName = useSettingsStore((s) => s.penName)
const customTemplates = useSettingsStore((s) => s.chapterTemplates)
const {
currentBookId,
activeVolumeId,
volumes,
chapters,
refreshChapters,
setSelectedChapter
} = useBookStore()
const switchTarget = useEditStore((s) => s.switchTarget)
const templates = useMemo(
() => mergeChapterTemplates(customTemplates ?? []),
[customTemplates]
)
const [selectedId, setSelectedId] = useState<string>(templates[0]?.id ?? '')
const [chapterTitle, setChapterTitle] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
if (!open) return
setSelectedId(templates[0]?.id ?? '')
setChapterTitle('')
}, [open, templates])
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
const buildContext = (title: string): TemplateContext => {
const volChapters = chapters
.filter((c) => c.volumeId === activeVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volChapters.length + 1
const volumeName = volumes.find((v) => v.id === activeVolumeId)?.name ?? ''
const previousChapterTitle =
volChapters.length > 0 ? (volChapters[volChapters.length - 1]?.title ?? '') : ''
return {
chapterNumber,
chapterTitle: title,
penName,
date: new Date().toLocaleDateString('zh-CN'),
volumeName,
previousChapterTitle,
outlineItem: '',
characterList: ''
}
}
const handleCreate = async (): Promise<void> => {
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
setCreating(true)
try {
await flushEditorSave()
const title = chapterTitle.trim()
const ctx = buildContext(title)
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
const ch = await ipcCall(() =>
window.electronAPI.chapter.create(currentBookId, activeVolumeId, title)
)
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({
bookId: currentBookId,
chapterId: ch.id,
content: contentHtml,
...(selectedTemplate.defaultStatus ? { status: selectedTemplate.defaultStatus } : {})
})
)
await refreshChapters()
setSelectedChapter(updated.id)
await switchTarget({ kind: 'chapter', id: updated.id })
onClose()
} finally {
setCreating(false)
}
}
if (!open) return null
return (
<div
className="modal-overlay"
id="templateModal"
data-testid="template-quick-create-modal"
role="dialog"
aria-modal="true"
aria-label={t('template.quickNew')}
>
<div className="modal-content">
<div className="modal-header">
<h3>{t('template.quickNew')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="modal-body">
<label className="form-label">{t('template.selectTemplate')}</label>
<div className="template-grid">
{templates.map((tpl: ChapterTemplate) => (
<button
key={tpl.id}
type="button"
className={`template-card ${tpl.id === selectedId ? 'active' : ''}`}
data-testid={`template-card-${tpl.id}`}
onClick={() => setSelectedId(tpl.id)}
>
<span className="template-card-name">{tpl.name}</span>
{tpl.builtin && <span className="template-badge">{t('template.builtin')}</span>}
</button>
))}
</div>
<label className="form-label">{t('template.chapterTitle')}</label>
<input
className="form-control form-control--wide"
data-testid="template-chapter-title"
value={chapterTitle}
onChange={(e) => setChapterTitle(e.target.value)}
placeholder={t('template.chapterTitlePlaceholder')}
/>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="template-create-btn"
disabled={!chapterTitle.trim() || creating}
onClick={() => void handleCreate()}
>
{t('template.createChapter')}
</button>
</div>
</div>
</div>
)
}
@@ -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>
)
}
+6 -1
View File
@@ -51,7 +51,12 @@ export function HomePage(): React.JSX.Element {
<button type="button" className="btn-large primary" onClick={() => setShowDialog(true)}>
+ {t('home.newBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="btn-large"
data-testid="home-import-book"
onClick={() => useAppStore.getState().setImportBookModalOpen(true)}
>
{t('home.importBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
@@ -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>
)
}
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
type SpeechRecognitionCtor = new () => SpeechRecognition
@@ -19,24 +18,34 @@ export function InspirationModal(): React.JSX.Element | null {
const { t } = useTranslation()
const open = useAppStore((s) => s.inspirationModalOpen)
const setOpen = useAppStore((s) => s.setInspirationModalOpen)
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
const bookId = useBookStore((s) => s.currentBookId)
const showToast = useAppStore((s) => s.showToast)
const currentBookId = useBookStore((s) => s.currentBookId)
const books = useBookStore((s) => s.books)
const addInspirationLocal = useBookStore((s) => s.addInspirationLocal)
const switchTarget = useEditStore((s) => s.switchTarget)
const sortedBooks = useMemo(
() => [...books].sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()),
[books]
)
const [selectedBookId, setSelectedBookId] = useState<string>('')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [tags, setTags] = useState('')
const contentRef = useRef<HTMLTextAreaElement>(null)
const speechAvailable = getSpeechRecognition() !== null
const effectiveBookId = currentBookId ?? (selectedBookId || sortedBooks[0]?.id || '')
useEffect(() => {
if (!open) return
setTitle('')
setContent('')
setTags('')
setSelectedBookId(currentBookId ?? sortedBooks[0]?.id ?? '')
const timer = setTimeout(() => contentRef.current?.focus(), 100)
return () => clearTimeout(timer)
}, [open])
}, [open, currentBookId, sortedBooks])
const handleVoice = (): void => {
const SpeechRecognition = getSpeechRecognition()
@@ -51,6 +60,7 @@ export function InspirationModal(): React.JSX.Element | null {
}
const handleSave = async (): Promise<void> => {
const bookId = effectiveBookId
if (!bookId || !content.trim()) return
const tagList = tags
.split(/[,]/)
@@ -64,9 +74,10 @@ export function InspirationModal(): React.JSX.Element | null {
tagList
)
)
addInspirationLocal(item)
setSidebarPanel('inspiration')
await switchTarget({ kind: 'inspiration', id: item.id })
if (bookId === currentBookId) {
addInspirationLocal(item)
}
showToast(t('inspiration.saved'))
setOpen(false)
}
@@ -74,7 +85,7 @@ export function InspirationModal(): React.JSX.Element | null {
return (
<div
className="modal-overlay"
className="modal-overlay inspiration-modal--capture"
id="inspirationModal"
data-testid="inspiration-modal"
role="dialog"
@@ -89,6 +100,26 @@ export function InspirationModal(): React.JSX.Element | null {
</button>
</div>
<div className="modal-body inspiration-capture">
{!currentBookId && sortedBooks.length > 0 && (
<>
<label className="form-label">{t('inspiration.selectBook')}</label>
<select
className="form-control form-control--wide"
data-testid="inspiration-book-select"
value={effectiveBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
>
{sortedBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.name}
</option>
))}
</select>
</>
)}
{!currentBookId && sortedBooks.length === 0 && (
<p className="import-hint">{t('inspiration.noBooks')}</p>
)}
<input
className="form-control form-control--wide"
data-testid="inspiration-title-input"
@@ -132,7 +163,7 @@ export function InspirationModal(): React.JSX.Element | null {
type="button"
className="btn btn-primary"
data-testid="inspiration-save-btn"
disabled={!content.trim()}
disabled={!content.trim() || !effectiveBookId}
onClick={() => void handleSave()}
>
{t('inspiration.save')}
@@ -25,6 +25,9 @@ import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
import { ExportModal } from '@renderer/components/export/ExportModal'
import { useExportStore } from '@renderer/stores/useExportStore'
import {
editorDirtyAtom,
editorSavingAtom,
@@ -117,6 +120,7 @@ export function EditorLayout(): React.JSX.Element {
const documentTitle = useDocumentTitle()
const [bridgeOpen, setBridgeOpen] = useState(false)
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
@@ -148,10 +152,10 @@ export function EditorLayout(): React.JSX.Element {
}, [currentBookId, pomodoroRunning, pomodoroBookId, pomodoroCancel])
useEffect(() => {
if (selectedChapterId && (!target || target.kind === 'chapter')) {
setTarget({ kind: 'chapter', id: selectedChapterId })
}
}, [selectedChapterId, setTarget, target])
if (!selectedChapterId) return
if (target?.kind === 'chapter' && target.id === selectedChapterId) return
setTarget({ kind: 'chapter', id: selectedChapterId })
}, [selectedChapterId, setTarget, target?.kind, target?.id])
useEffect(() => {
if (!currentBookId || target?.kind !== 'chapter') return
@@ -405,6 +409,14 @@ export function EditorLayout(): React.JSX.Element {
<button type="button" onClick={() => void handleNewChapter()}>
+ {t('editor.newChapter')}
</button>
<button
type="button"
data-testid="chapter-quick-new"
style={{ marginTop: 6 }}
onClick={() => setTemplateModalOpen(true)}
>
+ {t('template.quickNew')}
</button>
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
+ {t('editor.newVolume')}
</button>
@@ -427,6 +439,16 @@ export function EditorLayout(): React.JSX.Element {
) : (
<>
<div id="editor-toolbar">
<button
type="button"
className="tool-btn"
title={t('export.title')}
data-testid="open-export"
disabled={!isChapterTarget}
onClick={() => useExportStore.getState().openModal()}
>
📦
</button>
<button
type="button"
className="tool-btn"
@@ -572,6 +594,11 @@ export function EditorLayout(): React.JSX.Element {
clearBridgeChapter()
}}
/>
<TemplateQuickCreateModal
open={templateModalOpen}
onClose={() => setTemplateModalOpen(false)}
/>
<ExportModal />
</div>
)
}
@@ -4,6 +4,8 @@ import type { ThemeId, Language, PomodoroDuration } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
const THEMES: { id: ThemeId; key: string }[] = [
{ id: 'default', key: 'theme.default' },
@@ -18,7 +20,9 @@ const THEMES: { id: ThemeId; key: string }[] = [
export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
const [section, setSection] = useState<'general' | 'ai' | 'shortcuts' | 'about'>('general')
const [section, setSection] = useState<
'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about'
>('general')
return (
<div id="settings-page">
@@ -40,6 +44,24 @@ export function SettingsPage(): React.JSX.Element {
>
{t('settings.ai')}
</div>
<div
className={`settings-nav-item ${section === 'templates' ? 'active' : ''}`}
onClick={() => setSection('templates')}
role="button"
tabIndex={0}
data-testid="settings-nav-templates"
>
{t('settings.templates')}
</div>
<div
className={`settings-nav-item ${section === 'submission' ? 'active' : ''}`}
onClick={() => setSection('submission')}
role="button"
tabIndex={0}
data-testid="settings-nav-submission"
>
{t('settings.submission')}
</div>
<div
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')}
@@ -225,10 +247,12 @@ export function SettingsPage(): React.JSX.Element {
</>
)}
{section === 'ai' && <AiSettingsPage />}
{section === 'templates' && <TemplateSettingsTab />}
{section === 'submission' && <SubmissionPresetTab />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.0.0
{t('app.name')} v1.1.0
<br />
{t('app.tagline')}
</p>
@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { mergeSubmissionPresets } from '@shared/builtin-templates'
import type { GlobalSettings, SubmissionPreset } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
const BUILTIN_ID = 'builtin-webnovel'
export function SubmissionPresetTab(): React.JSX.Element {
const { t } = useTranslation()
const customPresets = useSettingsStore((s) => s.submissionPresets ?? [])
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId)
const update = useSettingsStore((s) => s.update)
const allPresets = useMemo(() => mergeSubmissionPresets(customPresets), [customPresets])
const [editingId, setEditingId] = useState<string | null>(null)
const editing = customPresets.find((p) => p.id === editingId) ?? null
const handleAdd = (): void => {
const preset: SubmissionPreset = {
id: uuid(),
name: t('submission.newPreset'),
titleFormat: '# 第{chapterNumber}章 {chapterTitle}',
indentParagraphs: true,
includeAuthorsNote: true,
paragraphGap: 'double'
}
void update({ submissionPresets: [...customPresets, preset] })
setEditingId(preset.id)
}
const handleSaveEdit = (id: string, patch: Partial<SubmissionPreset>): void => {
void update({
submissionPresets: customPresets.map((p) => (p.id === id ? { ...p, ...patch } : p))
})
}
const handleDelete = (id: string): void => {
if (id === BUILTIN_ID) return
const next = customPresets.filter((p) => p.id !== id)
const patch: Partial<GlobalSettings> = { submissionPresets: next }
if (defaultPresetId === id) {
Object.assign(patch, { defaultSubmissionPresetId: BUILTIN_ID })
}
void update(patch)
if (editingId === id) setEditingId(null)
}
return (
<div data-testid="settings-submission-presets">
<h3 className="settings-subheading">{t('submission.title')}</h3>
<div className="template-settings-list">
{allPresets.map((preset) => (
<div key={preset.id} className="template-settings-item">
<div className="template-settings-header">
<strong>{preset.name}</strong>
{preset.id === BUILTIN_ID && (
<span className="template-badge">{t('template.builtin')}</span>
)}
</div>
<div className="submission-preset-meta">
<span>{t('submission.titleFormat')}: {preset.titleFormat}</span>
<span>
{preset.indentParagraphs ? t('submission.indentOn') : t('submission.indentOff')}
</span>
</div>
{preset.id !== BUILTIN_ID && (
<div className="template-settings-actions">
<button type="button" className="btn btn-sm" onClick={() => setEditingId(preset.id)}>
{t('dialog.edit')}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => handleDelete(preset.id)}
>
{t('dialog.delete')}
</button>
</div>
)}
</div>
))}
</div>
<button
type="button"
className="btn btn-primary"
data-testid="setting-submission-add"
style={{ marginTop: 12 }}
onClick={handleAdd}
>
+ {t('submission.add')}
</button>
{editing && (
<div className="template-edit-panel">
<label className="form-label">{t('submission.name')}</label>
<input
className="form-control form-control--wide"
value={editing.name}
onChange={(e) => handleSaveEdit(editing.id, { name: e.target.value })}
/>
<label className="form-label">{t('submission.titleFormat')}</label>
<input
className="form-control form-control--wide"
value={editing.titleFormat}
onChange={(e) => handleSaveEdit(editing.id, { titleFormat: e.target.value })}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
<input
type="checkbox"
checked={editing.indentParagraphs}
onChange={(e) => handleSaveEdit(editing.id, { indentParagraphs: e.target.checked })}
/>
{t('submission.indent')}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
<input
type="checkbox"
checked={editing.includeAuthorsNote}
onChange={(e) =>
handleSaveEdit(editing.id, { includeAuthorsNote: e.target.checked })
}
/>
{t('submission.includeAuthorsNote')}
</label>
<label className="form-label">{t('submission.paragraphGap')}</label>
<select
className="form-control"
value={editing.paragraphGap}
onChange={(e) =>
handleSaveEdit(editing.id, {
paragraphGap: e.target.value as SubmissionPreset['paragraphGap']
})
}
>
<option value="single">{t('submission.gapSingle')}</option>
<option value="double">{t('submission.gapDouble')}</option>
</select>
<label className="form-label" style={{ marginTop: 8 }}>
<input
type="radio"
name="defaultPreset"
checked={defaultPresetId === editing.id}
onChange={() => void update({ defaultSubmissionPresetId: editing.id })}
/>{' '}
{t('submission.setDefault')}
</label>
</div>
)}
</div>
)
}
@@ -0,0 +1,99 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { v4 as uuid } from 'uuid'
import { mergeChapterTemplates } from '@shared/builtin-templates'
import type { ChapterTemplate } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
export function TemplateSettingsTab(): React.JSX.Element {
const { t } = useTranslation()
const customTemplates = useSettingsStore((s) => s.chapterTemplates ?? [])
const update = useSettingsStore((s) => s.update)
const allTemplates = useMemo(() => mergeChapterTemplates(customTemplates), [customTemplates])
const [editingId, setEditingId] = useState<string | null>(null)
const editing = customTemplates.find((tpl) => tpl.id === editingId) ?? null
const handleAdd = (): void => {
const tpl: ChapterTemplate = {
id: uuid(),
name: t('template.newTemplate'),
body: '## 第{chapterNumber}章 {chapterTitle}\n\n'
}
void update({ chapterTemplates: [...customTemplates, tpl] })
setEditingId(tpl.id)
}
const handleSaveEdit = (id: string, patch: Partial<ChapterTemplate>): void => {
void update({
chapterTemplates: customTemplates.map((tpl) => (tpl.id === id ? { ...tpl, ...patch } : tpl))
})
}
const handleDelete = (id: string): void => {
void update({ chapterTemplates: customTemplates.filter((tpl) => tpl.id !== id) })
if (editingId === id) setEditingId(null)
}
return (
<div data-testid="settings-chapter-templates">
<h3 className="settings-subheading">{t('template.title')}</h3>
<div className="template-settings-list">
{allTemplates.map((tpl) => (
<div key={tpl.id} className="template-settings-item">
<div className="template-settings-header">
<strong>{tpl.name}</strong>
{tpl.builtin ? (
<span className="template-badge">{t('template.builtin')}</span>
) : (
<span className="template-badge template-badge--custom">{t('template.custom')}</span>
)}
</div>
{!tpl.builtin && (
<div className="template-settings-actions">
<button type="button" className="btn btn-sm" onClick={() => setEditingId(tpl.id)}>
{t('dialog.edit')}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => handleDelete(tpl.id)}
>
{t('dialog.delete')}
</button>
</div>
)}
<pre className="template-body-preview">{tpl.body}</pre>
</div>
))}
</div>
<button
type="button"
className="btn btn-primary"
data-testid="setting-template-add"
style={{ marginTop: 12 }}
onClick={handleAdd}
>
+ {t('template.add')}
</button>
{editing && (
<div className="template-edit-panel">
<label className="form-label">{t('template.name')}</label>
<input
className="form-control form-control--wide"
value={editing.name}
onChange={(e) => handleSaveEdit(editing.id, { name: e.target.value })}
/>
<label className="form-label">{t('template.body')}</label>
<textarea
className="form-control form-control--wide"
rows={6}
value={editing.body}
onChange={(e) => handleSaveEdit(editing.id, { body: e.target.value })}
/>
</div>
)}
</div>
)
}
+4
View File
@@ -9,6 +9,7 @@ interface AppState {
versionModalOpen: boolean
landmarksModalOpen: boolean
inspirationModalOpen: boolean
importBookModalOpen: boolean
toast: string | null
setView: (view: AppView) => void
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
@@ -16,6 +17,7 @@ interface AppState {
setVersionModalOpen: (open: boolean) => void
setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void
setImportBookModalOpen: (open: boolean) => void
showToast: (message: string) => void
clearToast: () => void
}
@@ -27,6 +29,7 @@ export const useAppStore = create<AppState>((set) => ({
versionModalOpen: false,
landmarksModalOpen: false,
inspirationModalOpen: false,
importBookModalOpen: false,
toast: null,
setView: (view) => set({ view }),
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
@@ -34,6 +37,7 @@ export const useAppStore = create<AppState>((set) => ({
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
showToast: (toast) => {
set({ toast })
setTimeout(() => set({ toast: null }), 2500)
+13
View File
@@ -0,0 +1,13 @@
import { create } from 'zustand'
interface ExportState {
open: boolean
openModal: () => void
close: () => void
}
export const useExportStore = create<ExportState>((set) => ({
open: false,
openModal: () => set({ open: true }),
close: () => set({ open: false })
}))
+3
View File
@@ -29,6 +29,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
pomodoroDurationMinutes: 25,
enableSystemNotifications: false,
enableGoalNotifications: true,
chapterTemplates: [],
submissionPresets: [],
defaultSubmissionPresetId: 'builtin-webnovel',
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
+141
View File
@@ -1987,3 +1987,144 @@
height: 100%;
border-radius: 3px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.template-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
cursor: pointer;
text-align: left;
}
.template-card.active {
border-color: var(--accent);
background: var(--bg-tertiary);
}
.template-badge {
font-size: 10px;
color: var(--text-muted);
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-tertiary);
}
.template-badge--custom {
color: var(--accent);
}
.template-settings-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.template-settings-item {
padding: 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
}
.template-body-preview {
font-size: 11px;
white-space: pre-wrap;
margin: 8px 0 0;
color: var(--text-secondary);
}
.template-edit-panel {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.settings-subheading {
font-size: 14px;
margin-bottom: 12px;
}
.import-file-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.import-file-row .form-control {
flex: 1;
}
.import-preview-list {
font-size: 12px;
max-height: 120px;
overflow: auto;
margin: 8px 0 0;
padding-left: 18px;
}
.import-progress {
margin-top: 12px;
height: 20px;
background: var(--bg-tertiary);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.import-progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.2s;
}
.import-progress span {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
}
.export-preview {
font-family: var(--font-mono, monospace);
font-size: 11px;
white-space: pre-wrap;
max-height: 200px;
overflow: auto;
padding: 10px;
background: var(--bg-tertiary);
border-radius: 4px;
margin-top: 8px;
}
.inspiration-modal--capture {
z-index: 10000;
}
.submission-preset-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
}
.import-hint {
color: var(--text-muted);
font-size: 12px;
margin-bottom: 8px;
}
+26
View File
@@ -0,0 +1,26 @@
{
"chapterTemplates": [
{
"id": "builtin-generic",
"name": "通用模板",
"body": "## 第{chapterNumber}章 {chapterTitle}\n\n",
"builtin": true
},
{
"id": "builtin-webnovel",
"name": "网文常用",
"body": "## 第{chapterNumber}章 {chapterTitle}\n\n\n\n——\n作者的话:\n",
"builtin": true
}
],
"submissionPresets": [
{
"id": "builtin-webnovel",
"name": "通用网文",
"titleFormat": "# 第{chapterNumber}章 {chapterTitle}",
"indentParagraphs": true,
"includeAuthorsNote": true,
"paragraphGap": "double"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
import type { ChapterTemplate, SubmissionPreset } from './types'
import builtinData from './builtin-templates.json'
export const BUILTIN_CHAPTER_TEMPLATES: ChapterTemplate[] = builtinData.chapterTemplates
export const BUILTIN_SUBMISSION_PRESETS: SubmissionPreset[] = builtinData.submissionPresets
export function mergeChapterTemplates(custom: ChapterTemplate[] = []): ChapterTemplate[] {
return [...BUILTIN_CHAPTER_TEMPLATES, ...custom.filter((t) => !t.builtin)]
}
export function mergeSubmissionPresets(custom: SubmissionPreset[] = []): SubmissionPreset[] {
const customFiltered = custom.filter((p) => p.id !== 'builtin-webnovel')
return [...BUILTIN_SUBMISSION_PRESETS, ...customFiltered]
}
+49
View File
@@ -0,0 +1,49 @@
export interface TemplateContext {
chapterNumber: number
chapterTitle: string
penName: string
date: string
volumeName: string
previousChapterTitle: string
outlineItem: string
characterList: string
}
const VARS = [
'chapterNumber',
'chapterTitle',
'penName',
'date',
'volumeName',
'previousChapterTitle',
'outlineItem',
'characterList'
] as const
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
let out = body
for (const key of VARS) {
out = out.split(`{${key}}`).join(String(ctx[key]))
}
return out
}
export function plainTextToEditorHtml(text: string): string {
const trimmed = text.trim()
if (!trimmed) return '<p></p>'
return trimmed
.split(/\n{2,}/)
.map((p) => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`)
.join('')
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
export function bodyPlainToChapterHtml(bodyPlain: string): string {
return plainTextToEditorHtml(bodyPlain)
}
+23 -1
View File
@@ -52,7 +52,10 @@ import type {
PomodoroDuration,
CharacterGraphData,
CharacterRelationship,
WritingAnalyticsSummary
WritingAnalyticsSummary,
ImportPreviewResult,
ImportExecuteParams,
ImportSplitMode
} from './types'
export interface ElectronAPI {
@@ -410,6 +413,24 @@ export interface ElectronAPI {
analytics: {
getSummary: (bookId: string) => Promise<IpcResult<WritingAnalyticsSummary>>
}
import: {
pickFile: () => Promise<IpcResult<{ canceled: boolean; filePath?: string }>>
preview: (
filePath: string,
splitMode: ImportSplitMode,
customRegex?: string
) => Promise<IpcResult<ImportPreviewResult>>
execute: (params: ImportExecuteParams) => Promise<IpcResult<string>>
}
export: {
formatChapter: (
bookId: string,
chapterId: string,
presetId: string
) => Promise<IpcResult<string>>
copyClipboard: (text: string) => Promise<IpcResult<void>>
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
}
bridge: {
get: (
bookId: string,
@@ -435,6 +456,7 @@ export interface ElectronAPI {
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
}
declare global {
+7
View File
@@ -130,6 +130,13 @@ export const IPC = {
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
IMPORT_PICK_FILE: 'import:pickFile',
IMPORT_PREVIEW: 'import:preview',
IMPORT_EXECUTE: 'import:execute',
IMPORT_PROGRESS: 'import:progress',
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
EXPORT_SAVE_TXT: 'export:saveTxt',
COCKPIT_SUMMARY: 'cockpit:summary',
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
+43
View File
@@ -198,6 +198,46 @@ export interface WritingAnalyticsSummary {
volumeDistribution: { volumeId: string; title: string; words: number }[]
}
export interface ChapterTemplate {
id: string
name: string
body: string
defaultStatus?: ChapterStatus
builtin?: boolean
}
export interface SubmissionPreset {
id: string
name: string
titleFormat: string
indentParagraphs: boolean
includeAuthorsNote: boolean
paragraphGap: 'single' | 'double'
}
export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'
export interface ImportChapterPreview {
title: string
contentHtml: string
wordCount: number
}
export interface ImportPreviewResult {
fileName: string
fileSizeBytes: number
chapters: ImportChapterPreview[]
warnings: string[]
}
export interface ImportExecuteParams {
filePath: string
bookName: string
category: string
splitMode: ImportSplitMode
customRegex?: string
}
export interface ChapterBridgeResult {
previousChapterId: string | null
previousTail: string
@@ -246,6 +286,9 @@ export interface GlobalSettings {
enableSystemNotifications?: boolean
enableGoalNotifications?: boolean
dailyGoalNotifiedDate?: string
chapterTemplates?: ChapterTemplate[]
submissionPresets?: SubmissionPreset[]
defaultSubmissionPresetId?: string
}
export interface BookMeta {