feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode

Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags,
AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 15:22:58 +08:00
parent 64da95f7e1
commit fe127ec3ed
57 changed files with 1963 additions and 139 deletions
+33 -2
View File
@@ -18,10 +18,14 @@ 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 { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { ipcCall } from '@renderer/lib/ipc-client'
@@ -30,6 +34,10 @@ function AppInner(): React.JSX.Element {
const { t } = useTranslation()
const view = useAppStore((s) => s.view)
const toast = useAppStore((s) => s.toast)
const focusMode = useAppStore((s) => s.focusMode)
const namingCheckOpen = useAppStore((s) => s.namingCheckOpen)
const setFocusMode = useAppStore((s) => s.setFocusMode)
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
@@ -158,27 +166,50 @@ function AppInner(): React.JSX.Element {
useExportStore.getState().openModal()
return
}
if (action === 'focusMode') {
if (view !== 'editor') return
setFocusMode(!useAppStore.getState().focusMode)
return
}
if (action === 'toggleTTS') {
if (view !== 'editor') return
if (isSpeaking()) {
stopSpeaking()
showToast(t('tts.stopped'))
return
}
const text = getEditorTextForTts()
if (!text.trim()) {
showToast(t('tts.noText'))
return
}
speakText(text)
showToast(t('tts.started'))
return
}
showToast(t('feature.comingSoon'))
})
return unsub
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
const handleOnboardingComplete = (): void => {
setShowOnboarding(false)
}
return (
<div id="app">
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
<TopBar />
<div id="main-area">
{view === 'home' && <HomePage />}
{view === 'editor' && <EditorLayout />}
{view === 'settings' && <SettingsPage />}
</div>
<FocusModeOverlay />
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
<ImportBookModal />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>}
</div>
)
+25 -6
View File
@@ -12,6 +12,7 @@ import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore'
@@ -75,7 +76,7 @@ export function AiPanel(): React.JSX.Element {
useEffect(() => {
if (bookId) {
void loadSessions(bookId)
void loadSessions(bookId, true)
void checkGate(bookId)
void checkAutoGate(bookId)
void checkWizardGate(bookId)
@@ -176,6 +177,10 @@ export function AiPanel(): React.JSX.Element {
)
}
const activeSessions = sessions.filter((s) => !s.archived)
const archivedSessions = sessions.filter((s) => s.archived)
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null
return (
<>
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
@@ -186,12 +191,26 @@ export function AiPanel(): React.JSX.Element {
onChange={(e) => void selectSession(e.target.value)}
>
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
{sessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
{activeSessions.length > 0 && (
<optgroup label={t('ai.session.active')}>
{activeSessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</optgroup>
)}
{archivedSessions.length > 0 && (
<optgroup label={t('ai.session.archived')}>
{archivedSessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</optgroup>
)}
</select>
<AiSessionMenu bookId={bookId} session={activeSession} />
<button
type="button"
className="btn"
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AiSession } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAiStore } from '@renderer/stores/useAiStore'
interface AiSessionMenuProps {
bookId: string
session: AiSession | null
}
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
const { t } = useTranslation()
const loadSessions = useAiStore((s) => s.loadSessions)
const selectSession = useAiStore((s) => s.selectSession)
const [open, setOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
useEffect(() => {
if (!open) return
const close = (): void => {
setOpen(false)
setExportOpen(false)
}
window.addEventListener('click', close)
return () => window.removeEventListener('click', close)
}, [open])
if (!session) return null
const handleRename = async (): Promise<void> => {
setOpen(false)
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
if (!title?.trim() || title.trim() === session.title) return
await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
)
await loadSessions(bookId, true)
}
const handleArchiveToggle = async (): Promise<void> => {
setOpen(false)
await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
)
await loadSessions(bookId, true)
}
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
setOpen(false)
setExportOpen(false)
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
}
const handleDelete = async (): Promise<void> => {
setOpen(false)
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
return
}
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
await loadSessions(bookId, true)
const next = useAiStore.getState().sessions[0]
if (next) await selectSession(next.id)
}
return (
<div className="ai-session-menu-wrap">
<button
type="button"
className="btn ai-session-menu-btn"
data-testid="ai-session-menu-btn"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
>
</button>
{open && (
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
<button type="button" onClick={() => void handleRename()}>
{t('ai.session.rename')}
</button>
<button type="button" onClick={() => void handleArchiveToggle()}>
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setExportOpen((v) => !v)
}}
>
{t('ai.session.export')}
</button>
{exportOpen && (
<div className="ai-session-export-submenu">
<button type="button" onClick={() => void handleExport('markdown')}>
{t('ai.session.exportMd')}
</button>
<button type="button" onClick={() => void handleExport('txt')}>
{t('ai.session.exportTxt')}
</button>
</div>
)}
<button type="button" className="danger" onClick={() => void handleDelete()}>
{t('ai.session.delete')}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useBookPrefsStore } from '@renderer/stores/useBookPrefsStore'
export function BookSettingsTab(): React.JSX.Element {
const { t } = useTranslation()
const setView = useAppStore((s) => s.setView)
const { currentBookId, books, loadBooks } = useBookStore()
const { alwaysShowCockpit, load, setAlwaysShowCockpit } = useBookPrefsStore()
const book = useMemo(
() => books.find((b) => b.id === currentBookId) ?? null,
[books, currentBookId]
)
const [name, setName] = useState('')
const [category, setCategory] = useState('玄幻')
const [targetWordCount, setTargetWordCount] = useState('')
const [synopsis, setSynopsis] = useState('')
const [coverPath, setCoverPath] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!book) return
setName(book.name)
setCategory(book.category || '玄幻')
setTargetWordCount(book.targetWordCount != null ? String(book.targetWordCount) : '')
setSynopsis(book.synopsis ?? '')
setCoverPath(book.coverPath ?? null)
}, [book])
useEffect(() => {
if (currentBookId) void load(currentBookId)
}, [currentBookId, load])
const handleSave = async (): Promise<void> => {
if (!currentBookId || !name.trim()) return
setSaving(true)
try {
const updated = await ipcCall(() =>
window.electronAPI.book.updateMeta(currentBookId, {
name: name.trim(),
category,
targetWordCount: targetWordCount ? Number(targetWordCount) : null,
synopsis: synopsis.trim(),
coverPath
})
)
setCoverPath(updated.coverPath ?? null)
await loadBooks()
} finally {
setSaving(false)
}
}
const handlePickCover = async (): Promise<void> => {
if (!currentBookId) return
const updated = await ipcCall(() => window.electronAPI.book.pickCover(currentBookId))
if (updated) {
setCoverPath(updated.coverPath ?? null)
await loadBooks()
}
}
const handleDelete = async (): Promise<void> => {
if (!currentBookId || !book) return
if (!window.confirm(t('bookSettings.deleteConfirm', { name: book.name }))) return
await ipcCall(() => window.electronAPI.book.delete(currentBookId))
await loadBooks()
setView('home')
}
if (!currentBookId || !book) {
return <div className="placeholder-box">{t('ai.noBook')}</div>
}
return (
<div className="book-settings-tab" data-testid="book-settings-tab">
<h3 className="settings-subheading">{t('bookSettings.title')}</h3>
<div className="form-group">
<label className="form-label">{t('dialog.newBook.name')}</label>
<input
className="form-control form-control--wide"
data-testid="book-settings-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">{t('dialog.newBook.category')}</label>
<select
className="form-control form-control--wide"
data-testid="book-settings-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="玄幻"></option>
<option value="仙侠"></option>
<option value="科幻"></option>
<option value="言情"></option>
</select>
</div>
<div className="form-group">
<label className="form-label">{t('dialog.newBook.target')}</label>
<input
type="number"
className="form-control form-control--wide"
data-testid="book-settings-target"
value={targetWordCount}
onChange={(e) => setTargetWordCount(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">{t('bookSettings.synopsis')}</label>
<textarea
className="form-control form-control--wide"
data-testid="book-settings-synopsis"
rows={4}
value={synopsis}
onChange={(e) => setSynopsis(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">{t('bookSettings.cover')}</label>
<div className="book-settings-cover-row">
<button
type="button"
className="btn btn-sm"
data-testid="book-settings-pick-cover"
onClick={() => void handlePickCover()}
>
{t('bookSettings.pickCover')}
</button>
<span className="book-settings-cover-path">
{coverPath ? coverPath.replace(/^.*[\\/]/, '') : t('bookSettings.noCover')}
</span>
</div>
</div>
<div className="form-group">
<label className="form-label checkbox-label">
<input
type="checkbox"
data-testid="book-settings-always-cockpit"
checked={alwaysShowCockpit}
onChange={(e) => void setAlwaysShowCockpit(currentBookId, e.target.checked)}
/>
{t('bookSettings.alwaysShowCockpit')}
</label>
</div>
<button
type="button"
className="btn btn-primary"
data-testid="book-settings-save"
disabled={!name.trim() || saving}
onClick={() => void handleSave()}
>
{t('dialog.save')}
</button>
<div className="book-settings-danger" data-testid="book-settings-danger">
<h4>{t('bookSettings.dangerZone')}</h4>
<button
type="button"
className="btn btn-danger"
data-testid="book-settings-delete"
onClick={() => void handleDelete()}
>
{t('bookSettings.deleteBook')}
</button>
</div>
</div>
)
}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Chapter, ChapterTag } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
interface ChapterMetaPanelProps {
open: boolean
chapter: Chapter | null
onClose: () => void
}
export function ChapterMetaPanel({
open,
chapter,
onClose
}: ChapterMetaPanelProps): React.JSX.Element | null {
const { t } = useTranslation()
const currentBookId = useBookStore((s) => s.currentBookId)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const [summary, setSummary] = useState('')
const [storyTime, setStoryTime] = useState('')
const [wordCountThreshold, setWordCountThreshold] = useState('')
const [tags, setTags] = useState<ChapterTag[]>([])
const [newTag, setNewTag] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open || !chapter || !currentBookId) return
setSummary(chapter.summary ?? '')
setStoryTime(chapter.storyTime ?? '')
setWordCountThreshold(
chapter.wordCountThreshold != null ? String(chapter.wordCountThreshold) : ''
)
void ipcCall(() =>
window.electronAPI.chapterTag.list(currentBookId, chapter.id)
).then(setTags)
}, [open, chapter, currentBookId])
const handleSave = async (): Promise<void> => {
if (!currentBookId || !chapter) return
setSaving(true)
try {
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({
bookId: currentBookId,
chapterId: chapter.id,
summary: summary.trim(),
storyTime: storyTime.trim() || null,
wordCountThreshold: wordCountThreshold ? Number(wordCountThreshold) : null
})
)
updateChapterLocal(updated)
onClose()
} finally {
setSaving(false)
}
}
const handleAddTag = async (): Promise<void> => {
const tag = newTag.trim()
if (!currentBookId || !chapter || !tag) return
await ipcCall(() =>
window.electronAPI.chapterTag.set(currentBookId, chapter.id, tag)
)
setTags((prev) => [...prev.filter((t) => t.tag !== tag), { chapterId: chapter.id, tag, color: '' }])
setNewTag('')
}
const handleRemoveTag = async (tag: string): Promise<void> => {
if (!currentBookId || !chapter) return
await ipcCall(() =>
window.electronAPI.chapterTag.remove(currentBookId, chapter.id, tag)
)
setTags((prev) => prev.filter((t) => t.tag !== tag))
}
if (!open || !chapter) return null
return (
<div className="modal-overlay" role="dialog" aria-modal="true" data-testid="chapter-meta-panel">
<div className="modal-content chapter-meta-modal">
<div className="modal-header">
<h3>{t('chapterMeta.title')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="modal-body">
<label className="form-label">{t('chapterMeta.summary')}</label>
<textarea
className="form-control form-control--wide"
data-testid="chapter-meta-summary"
rows={3}
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
<label className="form-label">{t('chapterMeta.storyTime')}</label>
<input
className="form-control form-control--wide"
data-testid="chapter-meta-story-time"
value={storyTime}
onChange={(e) => setStoryTime(e.target.value)}
/>
<label className="form-label">{t('chapterMeta.wordThreshold')}</label>
<input
type="number"
className="form-control form-control--wide"
data-testid="chapter-meta-threshold"
value={wordCountThreshold}
onChange={(e) => setWordCountThreshold(e.target.value)}
/>
{chapter.publishStatus === 'published' && chapter.publishedAt && (
<p className="chapter-meta-published" data-testid="chapter-meta-published">
{t('chapterMeta.publishedAt', {
date: new Date(chapter.publishedAt).toLocaleString('zh-CN')
})}
</p>
)}
<label className="form-label">{t('chapterMeta.tags')}</label>
<div className="chapter-meta-tags">
{tags.map((tag) => (
<span key={tag.tag} className="tag-chip" data-testid={`chapter-tag-${tag.tag}`}>
{tag.tag}
<button type="button" onClick={() => void handleRemoveTag(tag.tag)}>
×
</button>
</span>
))}
</div>
<div className="chapter-meta-tag-add">
<input
className="form-control"
data-testid="chapter-meta-tag-input"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('chapterMeta.tagPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleAddTag()
}}
/>
<button type="button" className="btn btn-sm" onClick={() => void handleAddTag()}>
{t('chapterMeta.addTag')}
</button>
</div>
</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="chapter-meta-save"
disabled={saving}
onClick={() => void handleSave()}
>
{t('dialog.save')}
</button>
</div>
</div>
</div>
)
}
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'
import { mergeChapterTemplates } from '@shared/builtin-templates'
import {
bodyPlainToChapterHtml,
replaceChapterTemplate,
type TemplateContext
buildTemplateContext,
replaceChapterTemplate
} from '@shared/chapter-template'
import type { ChapterTemplate } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
@@ -30,6 +30,9 @@ export function TemplateQuickCreateModal({
activeVolumeId,
volumes,
chapters,
outlines,
settings,
selectedChapterId,
refreshChapters,
setSelectedChapter
} = useBookStore()
@@ -52,33 +55,22 @@ export function TemplateQuickCreateModal({
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 ctx = buildTemplateContext({
chapterTitle: title,
penName,
volumes,
chapters,
outlines,
settings,
activeVolumeId,
selectedChapterId
})
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
@@ -13,7 +13,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
import { SettingMention } from '@renderer/extensions/mention'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert, registerEditorGetText } from '@renderer/lib/editor-commands'
function countPlain(text: string): number {
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
@@ -289,6 +289,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
}
}, [editor])
useEffect(() => {
if (!editor) {
registerEditorGetText(null)
return
}
registerEditorGetText(() => editor.getText())
return () => registerEditorGetText(null)
}, [editor])
useEffect(() => {
if (!editor || !bookId || !target || target.kind !== 'chapter') {
registerEditorInsert(null, () => {})
@@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
export function FocusModeOverlay(): React.JSX.Element | null {
const { t } = useTranslation()
const focusMode = useAppStore((s) => s.focusMode)
const setFocusMode = useAppStore((s) => s.setFocusMode)
useEffect(() => {
if (!focusMode) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setFocusMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [focusMode, setFocusMode])
if (!focusMode) return null
return (
<button
type="button"
className="focus-mode-exit"
data-testid="focus-mode-exit"
onClick={() => setFocusMode(false)}
>
{t('focus.exit')}
</button>
)
}
+7 -1
View File
@@ -59,7 +59,13 @@ export function HomePage(): React.JSX.Element {
>
{t('home.importBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="btn-large"
data-testid="home-export-all"
disabled
title={t('home.exportAllWave2')}
>
{t('home.exportAll')}
</button>
<button
+118 -2
View File
@@ -26,6 +26,8 @@ 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 { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
import { ExportModal } from '@renderer/components/export/ExportModal'
import { useExportStore } from '@renderer/stores/useExportStore'
import {
@@ -121,6 +123,12 @@ export function EditorLayout(): React.JSX.Element {
const [bridgeOpen, setBridgeOpen] = useState(false)
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
null
)
const [renamingChapterId, setRenamingChapterId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
@@ -237,6 +245,36 @@ export function EditorLayout(): React.JSX.Element {
setActiveVolume(vol.id)
}
const commitChapterRename = async (chapterId: string): Promise<void> => {
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
const title = renameDraft.trim()
setRenamingChapterId(null)
if (!ch || !title || title === ch.title) return
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({ bookId: currentBookId, chapterId, title })
)
updateChapterLocal(updated)
}
const handleDeleteChapter = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
if (!ch || !window.confirm(t('chapter.deleteConfirm', { title: ch.title }))) return
await flushEditorSave()
await ipcCall(() => window.electronAPI.chapter.delete(currentBookId, chapterId))
await refreshChapters()
if (selectedChapterId === chapterId) {
const remaining = chapters.filter((c) => c.id !== chapterId)
const next = remaining.find((c) => c.volumeId === ch.volumeId) ?? remaining[0]
if (next) {
setSelectedChapter(next.id)
await switchTarget({ kind: 'chapter', id: next.id })
}
}
}
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
const handleChapterDropOnItem = async (
@@ -309,6 +347,12 @@ export function EditorLayout(): React.JSX.Element {
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
const chapterOverThreshold =
currentChapter?.wordCountThreshold != null &&
wordCount.chapter > currentChapter.wordCountThreshold
const volumeMenuVolume = volumeMenu
? volumes.find((v) => v.id === volumeMenu.volumeId)
: null
return (
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
@@ -337,6 +381,10 @@ export function EditorLayout(): React.JSX.Element {
className="vol-header"
data-volume-id={vol.id}
onClick={() => setActiveVolume(vol.id)}
onContextMenu={(e) => {
e.preventDefault()
setVolumeMenu({ volumeId: vol.id, x: e.clientX, y: e.clientY })
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
@@ -371,12 +419,44 @@ export function EditorLayout(): React.JSX.Element {
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
}}
onClick={() => void handleSelectChapter(ch.id)}
onDoubleClick={(e) => {
e.stopPropagation()
setRenamingChapterId(ch.id)
setRenameDraft(ch.title)
}}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"
tabIndex={0}
>
<span>{idx + 1}.</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
{renamingChapterId === ch.id ? (
<input
className="chapter-rename-input"
data-testid={`chapter-rename-${ch.id}`}
value={renameDraft}
autoFocus
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={() => void commitChapterRename(ch.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void commitChapterRename(ch.id)
if (e.key === 'Escape') setRenamingChapterId(null)
}}
/>
) : (
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ch.title}
</span>
)}
<button
type="button"
className="ch-delete-btn"
title={t('chapter.delete')}
data-testid={`chapter-delete-${ch.id}`}
onClick={(e) => void handleDeleteChapter(ch.id, e)}
>
×
</button>
<button
type="button"
className="ch-publish-btn"
@@ -560,7 +640,27 @@ export function EditorLayout(): React.JSX.Element {
))}
</select>
</label>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
<button
type="button"
className="btn btn-sm status-meta-btn"
data-testid="chapter-meta-open"
onClick={() => setChapterMetaOpen(true)}
>
{t('chapterMeta.open')}
</button>
{currentChapter?.publishStatus === 'published' && currentChapter.publishedAt && (
<span className="status-published-at" data-testid="status-published-at">
{t('chapterMeta.publishedAt', {
date: new Date(currentChapter.publishedAt).toLocaleString('zh-CN')
})}
</span>
)}
<span
className={chapterOverThreshold ? 'status-word-over-threshold' : undefined}
data-testid="status-chapter-words"
>
{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}
</span>
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
</>
@@ -598,6 +698,22 @@ export function EditorLayout(): React.JSX.Element {
open={templateModalOpen}
onClose={() => setTemplateModalOpen(false)}
/>
<ChapterMetaPanel
open={chapterMetaOpen}
chapter={currentChapter}
onClose={() => setChapterMetaOpen(false)}
/>
{volumeMenu && volumeMenuVolume && currentBookId && (
<VolumeContextMenu
bookId={currentBookId}
volume={volumeMenuVolume}
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
onChanged={refreshChapters}
/>
)}
<ExportModal />
</div>
)
@@ -7,6 +7,7 @@ import { useWizardStore } from '@renderer/stores/useWizardStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
import { BookSettingsTab } from '@renderer/components/book/BookSettingsTab'
export function RightPanel(): React.JSX.Element {
const { t } = useTranslation()
@@ -54,7 +55,7 @@ export function RightPanel(): React.JSX.Element {
<WordFreqPanel />
</div>
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
<div className="placeholder-box">{t('feature.comingSoon')}</div>
<BookSettingsTab />
</div>
</div>
)
+38 -2
View File
@@ -1,18 +1,32 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
export function TopBar(): React.JSX.Element {
const { t } = useTranslation()
const setView = useAppStore((s) => s.setView)
const showToast = useAppStore((s) => s.showToast)
const focusMode = useAppStore((s) => s.focusMode)
const setFocusMode = useAppStore((s) => s.setFocusMode)
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
const currentBookId = useBookStore((s) => s.currentBookId)
const openCockpit = useCockpitStore((s) => s.openModal)
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const writingStats = useWritingStatsStore((s) => s.stats)
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
const goalProgress =
dailyWordGoal > 0 && writingStats ? Math.min(writingStats.goalProgress, 1) : 0
const progressClass =
goalProgress >= 1 ? 'goal-met' : goalProgress >= 0.8 ? 'goal-near' : ''
useEffect(() => {
if (currentBookId) void useWritingStatsStore.getState().refresh(currentBookId)
}, [currentBookId])
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
e.stopPropagation()
@@ -63,6 +77,21 @@ export function TopBar(): React.JSX.Element {
</button>
))}
</div>
{dailyWordGoal > 0 && currentBookId && (
<div
className={`top-daily-progress ${progressClass}`}
data-testid="top-daily-progress"
title={t('status.dailyGoal', {
current: writingStats?.todayWords ?? 0,
goal: dailyWordGoal
})}
>
<div
className="top-daily-progress-fill"
style={{ width: `${goalProgress * 100}%` }}
/>
</div>
)}
<div className="top-spacer" />
<button
type="button"
@@ -81,7 +110,14 @@ export function TopBar(): React.JSX.Element {
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
</button>
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="top-btn"
title={t('focus.toggle')}
data-testid="topbar-focus-mode"
disabled={!currentBookId}
onClick={() => setFocusMode(!focusMode)}
>
👁
</button>
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}></button>
@@ -252,7 +252,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.1.0
{t('app.name')} v1.2.0
<br />
{t('app.tagline')}
</p>
@@ -0,0 +1,82 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import type { Volume } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
interface VolumeContextMenuProps {
bookId: string
volume: Volume
hasChapters: boolean
x: number
y: number
onClose: () => void
onChanged: () => Promise<void>
}
export function VolumeContextMenu({
bookId,
volume,
hasChapters,
x,
y,
onClose,
onChanged
}: VolumeContextMenuProps): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
useEffect(() => {
const close = (): void => onClose()
window.addEventListener('click', close)
return () => window.removeEventListener('click', close)
}, [onClose])
const handleRename = async (): Promise<void> => {
const name = window.prompt(t('volume.renamePrompt'), volume.name)
onClose()
if (!name?.trim() || name.trim() === volume.name) return
await ipcCall(() => window.electronAPI.volume.update(bookId, volume.id, { name: name.trim() }))
await onChanged()
}
const handleDescription = async (): Promise<void> => {
const description = window.prompt(t('volume.descriptionPrompt'), volume.description ?? '')
onClose()
if (description === null) return
await ipcCall(() =>
window.electronAPI.volume.update(bookId, volume.id, { description: description.trim() })
)
await onChanged()
}
const handleDelete = async (): Promise<void> => {
onClose()
if (hasChapters) {
showToast(t('volume.deleteHasChapters'))
return
}
if (!window.confirm(t('volume.deleteConfirm', { name: volume.name }))) return
await ipcCall(() => window.electronAPI.volume.delete(bookId, volume.id))
await onChanged()
}
return (
<div
className="context-menu"
data-testid="volume-context-menu"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => void handleRename()}>
{t('volume.rename')}
</button>
<button type="button" onClick={() => void handleDescription()}>
{t('volume.editDescription')}
</button>
<button type="button" className="danger" onClick={() => void handleDelete()}>
{t('volume.delete')}
</button>
</div>
)
}
@@ -8,7 +8,7 @@ import { highlightTokenInEditor } from '@renderer/lib/editor-commands'
export function WordFreqPanel(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
const bookId = useBookStore((s) => s.currentBookId)
const chapters = useBookStore((s) => s.chapters)
const target = useEditStore((s) => s.target)
@@ -54,7 +54,8 @@ export function WordFreqPanel(): React.JSX.Element {
type="button"
className="btn"
style={{ marginTop: 12 }}
onClick={() => showToast(t('feature.comingSoon'))}
data-testid="wordfreq-naming-check"
onClick={() => setNamingCheckOpen(true)}
>
{t('wordfreq.aiNaming')}
</button>
+11
View File
@@ -13,6 +13,7 @@ let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
let highlightTokenFn: ((token: string) => void) | null = null
let editorInsertContext: EditorInsertContext | null = null
let insertTextFn: ((text: string) => void) | null = null
let getEditorTextFn: (() => string) | null = null
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
insertLandmarkFn = fn
@@ -30,6 +31,16 @@ export function registerEditorInsert(
insertTextFn = insert
}
export function registerEditorGetText(fn: (() => string) | null): void {
getEditorTextFn = fn
}
export function getEditorTextForTts(): string {
const selection = window.getSelection()?.toString().trim()
if (selection) return selection
return getEditorTextFn?.() ?? ''
}
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
insertLandmarkFn?.(payload)
}
+16
View File
@@ -0,0 +1,16 @@
export function speakText(text: string, lang = 'zh-CN'): void {
const trimmed = text.trim()
if (!trimmed) return
const u = new SpeechSynthesisUtterance(trimmed)
u.lang = lang
window.speechSynthesis.cancel()
window.speechSynthesis.speak(u)
}
export function stopSpeaking(): void {
window.speechSynthesis.cancel()
}
export function isSpeaking(): boolean {
return window.speechSynthesis.speaking
}
+6 -4
View File
@@ -19,7 +19,7 @@ interface AiStore {
listenersAttached: boolean
mount: () => void
unmount: () => void
loadSessions: (bookId: string) => Promise<void>
loadSessions: (bookId: string, includeArchived?: boolean) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
createSession: (bookId: string) => Promise<void>
sendMessage: (content: string, prompt?: string) => Promise<void>
@@ -63,8 +63,10 @@ export const useAiStore = create<AiStore>((set, get) => ({
set({ listenersAttached: false })
},
loadSessions: async (bookId) => {
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
loadSessions: async (bookId, includeArchived = true) => {
const sessions = await ipcCall(() =>
window.electronAPI.ai.sessionList(bookId, includeArchived)
)
const activeSessionId = get().activeSessionId
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
set({ sessions })
@@ -157,7 +159,7 @@ export const useAiStore = create<AiStore>((set, get) => ({
try {
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId, true))
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
} finally {
set({ sending: false })
+8
View File
@@ -10,6 +10,8 @@ interface AppState {
landmarksModalOpen: boolean
inspirationModalOpen: boolean
importBookModalOpen: boolean
focusMode: boolean
namingCheckOpen: boolean
toast: string | null
setView: (view: AppView) => void
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
@@ -18,6 +20,8 @@ interface AppState {
setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void
setImportBookModalOpen: (open: boolean) => void
setFocusMode: (on: boolean) => void
setNamingCheckOpen: (open: boolean) => void
showToast: (message: string) => void
clearToast: () => void
}
@@ -30,6 +34,8 @@ export const useAppStore = create<AppState>((set) => ({
landmarksModalOpen: false,
inspirationModalOpen: false,
importBookModalOpen: false,
focusMode: false,
namingCheckOpen: false,
toast: null,
setView: (view) => set({ view }),
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
@@ -38,6 +44,8 @@ export const useAppStore = create<AppState>((set) => ({
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
setFocusMode: (focusMode) => set({ focusMode }),
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
showToast: (toast) => {
set({ toast })
setTimeout(() => set({ toast: null }), 2500)
+28
View File
@@ -0,0 +1,28 @@
import { create } from 'zustand'
import { ipcCall } from '@renderer/lib/ipc-client'
interface BookPrefsState {
alwaysShowCockpit: boolean
loadedBookId: string | null
load: (bookId: string) => Promise<void>
setAlwaysShowCockpit: (bookId: string, value: boolean) => Promise<void>
}
export const useBookPrefsStore = create<BookPrefsState>((set, get) => ({
alwaysShowCockpit: false,
loadedBookId: null,
load: async (bookId) => {
const value = await ipcCall(() =>
window.electronAPI.bookPrefs.get(bookId, 'alwaysShowCockpit')
)
set({ alwaysShowCockpit: value === 'true', loadedBookId: bookId })
},
setAlwaysShowCockpit: async (bookId, value) => {
await ipcCall(() =>
window.electronAPI.bookPrefs.set(bookId, 'alwaysShowCockpit', value ? 'true' : 'false')
)
if (get().loadedBookId === bookId) {
set({ alwaysShowCockpit: value })
}
}
}))
+218
View File
@@ -67,6 +67,59 @@
-webkit-app-region: drag;
}
.top-daily-progress {
width: 72px;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.top-daily-progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
}
.top-daily-progress.goal-near .top-daily-progress-fill {
background: #e67e22;
}
.top-daily-progress.goal-met .top-daily-progress-fill {
background: var(--green);
}
#app.focus-mode #left-sidebar,
#app.focus-mode #right-panel,
#app.focus-mode #reference-panel.open {
display: none !important;
}
#app.focus-mode #topbar .tab-bar,
#app.focus-mode #topbar .top-btn:not([data-testid='topbar-focus-mode']) {
display: none;
}
.focus-mode-exit {
position: fixed;
top: 10px;
right: 10px;
z-index: 10001;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.focus-mode-exit:hover {
background: var(--bg-hover);
}
.top-btn {
width: 32px;
height: 32px;
@@ -1070,6 +1123,171 @@
opacity: 1;
}
.ch-delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 4px;
opacity: 0;
color: var(--red, #c44);
}
.chapter-item:hover .ch-delete-btn {
opacity: 0.7;
}
.ch-delete-btn:hover {
opacity: 1;
}
.chapter-rename-input {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 2px 4px;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
}
.context-menu {
position: fixed;
z-index: 10000;
min-width: 140px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 4px;
}
.context-menu button {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: none;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
border-radius: 4px;
}
.context-menu button:hover {
background: var(--bg-hover);
}
.context-menu button.danger {
color: var(--red, #c44);
}
.chapter-meta-modal .modal-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.chapter-meta-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background: var(--bg-tertiary);
font-size: 12px;
}
.tag-chip button {
border: none;
background: none;
cursor: pointer;
padding: 0;
line-height: 1;
}
.chapter-meta-tag-add {
display: flex;
gap: 8px;
align-items: center;
}
.chapter-meta-published,
.status-published-at {
font-size: 11px;
color: var(--text-secondary);
}
.status-word-over-threshold {
color: #e67e22;
font-weight: 600;
}
.status-meta-btn {
margin-left: 4px;
}
.ai-session-menu-wrap {
position: relative;
}
.ai-session-menu-btn {
padding: 4px 8px;
min-width: 32px;
}
.ai-session-menu {
right: 0;
left: auto;
top: 100%;
}
.ai-session-export-submenu {
padding-left: 8px;
}
.ai-session-export-submenu button {
font-size: 11px;
}
.book-settings-tab {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.book-settings-cover-row {
display: flex;
align-items: center;
gap: 8px;
}
.book-settings-cover-path {
font-size: 12px;
color: var(--text-secondary);
}
.book-settings-danger {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.btn-danger {
color: #fff;
background: var(--red, #c44);
}
.status-stock-warning {
color: var(--warning, #e6a23c);
margin-left: 12px;