fe127ec3ed
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>
721 lines
29 KiB
TypeScript
721 lines
29 KiB
TypeScript
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>
|
||
)
|
||
}
|