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:
+33
-2
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user