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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user