Files
bilin/src/renderer/components/layout/EditorLayout.tsx
T
bing fe127ec3ed 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>
2026-07-08 15:22:58 +08:00

721 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import type { CockpitSummary, PublishStatus } from '@shared/types'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
import { usePomodoroStore, formatPomodoroTime } from '@renderer/stores/usePomodoroStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { RightPanel } from '@renderer/components/layout/RightPanel'
import { ReferencePanel } from '@renderer/components/reference/ReferencePanel'
import { OutlineTree } from '@renderer/components/outline/OutlineTree'
import { OutlineCompareView } from '@renderer/components/outline/OutlineCompareView'
import { SettingList } from '@renderer/components/setting/SettingList'
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
import { VersionModal } from '@renderer/components/version/VersionModal'
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
import { 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 {
editorDirtyAtom,
editorSavingAtom,
lastSavedAtAtom,
wordCountAtom
} from '@renderer/atoms/editorAtoms'
const PUBLISH_CYCLE: PublishStatus[] = ['draft', 'ready', 'published']
function nextPublishStatus(current: PublishStatus | undefined): PublishStatus {
const idx = PUBLISH_CYCLE.indexOf(current ?? 'draft')
return PUBLISH_CYCLE[(idx + 1) % PUBLISH_CYCLE.length]
}
function publishIcon(status: PublishStatus | undefined): string {
if (status === 'ready') return '📦'
if (status === 'published') return '✅'
return '📝'
}
function useDocumentTitle(): string {
const target = useEditStore((s) => s.target)
const chapters = useBookStore((s) => s.chapters)
const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings)
const inspirations = useBookStore((s) => s.inspirations)
return useMemo(() => {
if (!target) return '—'
if (target.kind === 'chapter') {
return chapters.find((c) => c.id === target.id)?.title ?? '—'
}
if (target.kind === 'outline') {
return outlines.find((o) => o.id === target.id)?.title ?? '—'
}
if (target.kind === 'setting') {
return settings.find((s) => s.id === target.id)?.name ?? '—'
}
return inspirations.find((i) => i.id === target.id)?.title ?? '—'
}, [target, chapters, outlines, settings, inspirations])
}
export function EditorLayout(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
const activePanel = useAppStore((s) => s.sidebarPanel)
const versionModalOpen = useAppStore((s) => s.versionModalOpen)
const landmarksModalOpen = useAppStore((s) => s.landmarksModalOpen)
const setVersionModalOpen = useAppStore((s) => s.setVersionModalOpen)
const setLandmarksModalOpen = useAppStore((s) => s.setLandmarksModalOpen)
const compareMode = useOutlineStore((s) => s.compareMode)
const referenceOpen = useReferenceStore((s) => s.open)
const setReferenceOpen = useReferenceStore((s) => s.setOpen)
const {
currentBookId,
volumes,
chapters,
selectedChapterId,
activeVolumeId,
setSelectedChapter,
setActiveVolume,
refreshChapters
} = useBookStore()
const target = useEditStore((s) => s.target)
const switchTarget = useEditStore((s) => s.switchTarget)
const setTarget = useEditStore((s) => s.setTarget)
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
const writingStats = useWritingStatsStore((s) => s.stats)
const refreshWritingStats = useWritingStatsStore((s) => s.refresh)
const pomodoroRunning = usePomodoroStore((s) => s.running)
const pomodoroPaused = usePomodoroStore((s) => s.paused)
const pomodoroRemaining = usePomodoroStore((s) => s.remainingSec)
const pomodoroBookId = usePomodoroStore((s) => s.bookId)
const pomodoroInit = usePomodoroStore((s) => s.init)
const pomodoroStart = usePomodoroStore((s) => s.start)
const pomodoroPause = usePomodoroStore((s) => s.pause)
const pomodoroResume = usePomodoroStore((s) => s.resume)
const pomodoroCancel = usePomodoroStore((s) => s.cancel)
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const settings = useBookStore((s) => s.settings)
const dirty = useAtomValue(editorDirtyAtom)
const saving = useAtomValue(editorSavingAtom)
const lastSaved = useAtomValue(lastSavedAtAtom)
const wordCount = useAtomValue(wordCountAtom)
const documentTitle = useDocumentTitle()
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)
const isChapterTarget = target?.kind === 'chapter'
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
const currentChapter = isChapterTarget && chapterId ? chapters.find((c) => c.id === chapterId) : null
const characterSettings = settings.filter((s) => s.type === 'character')
const handlePovChange = async (povCharacterId: string | null): Promise<void> => {
if (!currentBookId || !chapterId) return
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({
bookId: currentBookId,
chapterId,
povCharacterId
})
)
updateChapterLocal(updated)
}
useEffect(() => {
void pomodoroInit()
}, [pomodoroInit])
useEffect(() => {
if (!pomodoroRunning || !pomodoroBookId || !currentBookId) return
if (pomodoroBookId === currentBookId) return
void pomodoroCancel()
}, [currentBookId, pomodoroRunning, pomodoroBookId, pomodoroCancel])
useEffect(() => {
if (!selectedChapterId) return
if (target?.kind === 'chapter' && target.id === selectedChapterId) return
setTarget({ kind: 'chapter', id: selectedChapterId })
}, [selectedChapterId, setTarget, target?.kind, target?.id])
useEffect(() => {
if (!currentBookId || target?.kind !== 'chapter') return
const chapterId = target.id
void ipcCall(() => window.electronAPI.snapshot.startAutoSave(currentBookId, chapterId))
return () => {
void ipcCall(() => window.electronAPI.snapshot.stopAutoSave(currentBookId, chapterId))
}
}, [currentBookId, target?.kind, target?.id])
useEffect(() => {
if (!currentBookId) return
void refreshWritingStats(currentBookId)
}, [currentBookId, lastSaved, refreshWritingStats])
useEffect(() => {
if (!currentBookId) return
void ipcCall(() =>
window.electronAPI.cockpit.getSummary(currentBookId, activeVolumeId ?? undefined)
).then(setCockpitSummary)
}, [currentBookId, activeVolumeId, chapters])
useEffect(() => {
if (bridgeChapterId) setBridgeOpen(true)
}, [bridgeChapterId])
useEffect(() => {
if (!currentBookId || target?.kind !== 'chapter') return
const chapterId = target.id
void ipcCall(() => window.electronAPI.bridge.shouldPrompt(currentBookId, chapterId)).then(
(should) => {
if (should) setTimeout(() => setBridgeOpen(true), 500)
}
)
}, [currentBookId, target?.kind, target?.id])
const activeBridgeChapterId =
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
const showStockWarning =
updateSchedule !== 'none' &&
cockpitSummary != null &&
cockpitSummary.stockReadyCount < cockpitSummary.stockThreshold
const handlePublishCycle = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
if (!ch) return
const next = nextPublishStatus(ch.publishStatus)
const updated = await ipcCall(() =>
window.electronAPI.chapter.setPublishStatus(currentBookId, chapterId, next)
)
updateChapterLocal(updated)
}
const handleSelectChapter = async (chapterId: string): Promise<void> => {
setSelectedChapter(chapterId)
await switchTarget({ kind: 'chapter', id: chapterId })
}
const handleNewChapter = async (): Promise<void> => {
if (!currentBookId || !activeVolumeId) return
await flushEditorSave()
const ch = await ipcCall(() =>
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
)
await refreshChapters()
setSelectedChapter(ch.id)
await switchTarget({ kind: 'chapter', id: ch.id })
}
const handleNewVolume = async (): Promise<void> => {
if (!currentBookId) return
const vol = await ipcCall(() =>
window.electronAPI.volume.create(currentBookId, t('editor.newVolume'))
)
await refreshChapters()
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 (
draggedId: string,
targetVolumeId: string,
targetChapterId: string
): Promise<void> => {
if (!currentBookId || draggedId === targetChapterId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const volChapters = chapters
.filter((c) => c.volumeId === targetVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const targetIdx = volChapters.findIndex((c) => c.id === targetChapterId)
if (dragged.volumeId === targetVolumeId) {
const ids = volChapters.map((c) => c.id)
const from = ids.indexOf(draggedId)
if (from < 0) return
ids.splice(from, 1)
const insertAt = ids.indexOf(targetChapterId)
ids.splice(insertAt, 0, draggedId)
await ipcCall(() => window.electronAPI.chapter.reorder(currentBookId, targetVolumeId, ids))
} else {
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(
currentBookId,
draggedId,
targetVolumeId,
Math.max(0, targetIdx)
)
)
}
await refreshChapters()
}
const handleChapterDropOnVolume = async (draggedId: string, targetVolumeId: string): Promise<void> => {
if (!currentBookId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const targetLen = chapters.filter((c) => c.volumeId === targetVolumeId).length
if (dragged.volumeId === targetVolumeId) return
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(currentBookId, draggedId, targetVolumeId, targetLen)
)
await refreshChapters()
}
const handleInsertLandmark = async (): Promise<void> => {
if (!currentBookId || target?.kind !== 'chapter') {
showToast(t('landmark.chapterOnly'))
return
}
const label = prompt(t('landmark.prompt'))
if (!label?.trim()) return
const isForeshadow = confirm(t('landmark.foreshadowConfirm'))
const landmarkType = isForeshadow ? 'foreshadow' : 'todo'
const syncKnowledge = isForeshadow && confirm(t('knowledge.syncFromLandmark'))
insertLandmarkInEditor({ label: label.trim(), landmarkType })
const bm = await ipcCall(() =>
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), landmarkType)
)
if (syncKnowledge) {
await ipcCall(() => window.electronAPI.knowledge.createFromBookmark(currentBookId, bm.id))
showToast(t('knowledge.syncedFromLandmark'))
} else {
showToast(t('landmark.created'))
}
}
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 ?? ''}>
<div id="left-sidebar">
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
<div className="sidebar-nav">
{(['chapters', 'outline', 'setting', 'inspiration'] as const).map((panel) => (
<button
key={panel}
type="button"
data-testid={`sidebar-tab-${panel}`}
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
onClick={() => setSidebarPanel(panel)}
>
{panel === 'chapters' && t('sidebar.chapters')}
{panel === 'outline' && t('sidebar.outline')}
{panel === 'setting' && t('sidebar.settings')}
{panel === 'inspiration' && t('sidebar.inspiration')}
</button>
))}
</div>
<div className={`sidebar-panel ${activePanel === 'chapters' ? 'active' : ''}`}>
{volumes.map((vol) => (
<div key={vol.id}>
<div
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()
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnVolume(draggedId, vol.id)
}}
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
>
{vol.name}
</div>
{chapters
.filter((c) => c.volumeId === vol.id)
.map((ch, idx) => (
<div
key={ch.id}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
draggable
data-testid={`chapter-item-${ch.id}`}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
e.dataTransfer.effectAllowed = 'move'
}}
onDragOver={(e) => {
e.preventDefault()
setDragOverChapterId(ch.id)
}}
onDragLeave={() => setDragOverChapterId(null)}
onDrop={(e) => {
e.preventDefault()
setDragOverChapterId(null)
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
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>
{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"
title={t(`publish.status.${ch.publishStatus ?? 'draft'}`)}
data-testid={`chapter-publish-${ch.id}`}
onClick={(e) => void handlePublishCycle(ch.id, e)}
>
{publishIcon(ch.publishStatus)}
</button>
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
</div>
))}
</div>
))}
{activePanel === 'chapters' && (
<div className="sidebar-footer">
{selectedChapterId && currentBookId && (
<button
type="button"
data-testid="chapter-extract-knowledge"
onClick={() =>
void ipcCall(() =>
window.electronAPI.knowledge.extractChapter(currentBookId, selectedChapterId)
)
}
>
{t('knowledge.extractChapter')}
</button>
)}
<button type="button" onClick={() => void handleNewChapter()}>
+ {t('editor.newChapter')}
</button>
<button
type="button"
data-testid="chapter-quick-new"
style={{ marginTop: 6 }}
onClick={() => setTemplateModalOpen(true)}
>
+ {t('template.quickNew')}
</button>
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
+ {t('editor.newVolume')}
</button>
</div>
)}
</div>
<div className={`sidebar-panel ${activePanel === 'outline' ? 'active' : ''}`}>
{activePanel === 'outline' && <OutlineTree />}
</div>
<div className={`sidebar-panel ${activePanel === 'setting' ? 'active' : ''}`}>
{activePanel === 'setting' && <SettingList />}
</div>
<div className={`sidebar-panel ${activePanel === 'inspiration' ? 'active' : ''}`}>
{activePanel === 'inspiration' && <InspirationList />}
</div>
</div>
<div id="editor-area">
{compareMode ? (
<OutlineCompareView />
) : (
<>
<div id="editor-toolbar">
<button
type="button"
className="tool-btn"
title={t('export.title')}
data-testid="open-export"
disabled={!isChapterTarget}
onClick={() => useExportStore.getState().openModal()}
>
📦
</button>
<button
type="button"
className="tool-btn"
title={t('reference.title')}
data-testid="open-reference"
onClick={() => setReferenceOpen(true)}
>
📎
</button>
<button
type="button"
className="tool-btn"
title={t('version.title')}
data-testid="open-version"
onClick={() => setVersionModalOpen(true)}
>
📜
</button>
<button
type="button"
className="tool-btn"
title={t('landmark.insert')}
data-testid="insert-landmark"
onClick={() => void handleInsertLandmark()}
>
📌
</button>
<span className="editor-label">{documentTitle}</span>
</div>
<TipTapEditor
key={target ? `${target.kind}:${target.id}` : 'none'}
target={target}
bookId={currentBookId}
/>
<div id="editor-statusbar">
<div className="pomodoro-control" data-testid="pomodoro-control">
{!pomodoroRunning ? (
<button
type="button"
className="pomodoro-btn"
title={t('pomodoro.start')}
disabled={!currentBookId}
onClick={() => currentBookId && void pomodoroStart(currentBookId)}
>
</button>
) : (
<>
<button
type="button"
className="pomodoro-btn"
title={pomodoroPaused ? t('pomodoro.resume') : t('pomodoro.pause')}
onClick={() => void (pomodoroPaused ? pomodoroResume() : pomodoroPause())}
>
{pomodoroPaused ? '▶' : '⏸'}
</button>
<span className="pomodoro-time">{formatPomodoroTime(pomodoroRemaining)}</span>
<button
type="button"
className="pomodoro-btn"
title={t('pomodoro.cancel')}
onClick={() => void pomodoroCancel()}
>
</button>
</>
)}
</div>
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
{dailyWordGoal > 0 && writingStats && (
<span
className={`status-daily-goal ${
writingStats.goalProgress >= 1
? 'goal-met'
: writingStats.goalProgress >= 0.8
? 'goal-near'
: ''
}`}
data-testid="status-daily-goal"
>
{t('status.dailyGoal', {
current: writingStats.todayWords,
goal: dailyWordGoal
})}
</span>
)}
{showStockWarning && (
<span className="status-stock-warning" data-testid="status-stock-warning">
{t('stock.warning', {
count: cockpitSummary!.stockReadyCount,
threshold: cockpitSummary!.stockThreshold
})}
</span>
)}
{isChapterTarget ? (
<>
<label className="status-pov-label">
{t('chapter.pov')}
<select
data-testid="chapter-pov-select"
value={currentChapter?.povCharacterId ?? ''}
onChange={(e) => void handlePovChange(e.target.value || null)}
>
<option value="">{t('analytics.povUnassigned')}</option>
{characterSettings.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<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>
</>
) : (
<span>{t('editor.words', { count: wordCount.chapter })}</span>
)}
</div>
</>
)}
</div>
<ReferencePanel />
{!referenceOpen && <RightPanel />}
<VersionModal
open={versionModalOpen}
onClose={() => setVersionModalOpen(false)}
chapterId={chapterId}
/>
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
<CockpitModal
onOpenBridge={(chapterId) => {
useCockpitStore.getState().openBridgeForChapter(chapterId)
setBridgeOpen(true)
}}
/>
<CharacterGraphModal />
<ChapterBridgeModal
open={bridgeOpen}
chapterId={activeBridgeChapterId}
onClose={() => {
setBridgeOpen(false)
clearBridgeChapter()
}}
/>
<TemplateQuickCreateModal
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>
)
}