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(null) const [renameDraft, setRenameDraft] = useState('') const [cockpitSummary, setCockpitSummary] = useState(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 => { 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 => { 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 => { setSelectedChapter(chapterId) await switchTarget({ kind: 'chapter', id: chapterId }) } const handleNewChapter = async (): Promise => { 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 => { 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 => { 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 => { 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(null) const handleChapterDropOnItem = async ( draggedId: string, targetVolumeId: string, targetChapterId: string ): Promise => { 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 => { 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 => { 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 (
{compareMode ? ( ) : ( <>
{documentTitle}
{!pomodoroRunning ? ( ) : ( <> {formatPomodoroTime(pomodoroRemaining)} )}
{saveStatus}{lastSaved ? ` ยท ${lastSaved.toLocaleTimeString()}` : ''} {dailyWordGoal > 0 && writingStats && ( = 1 ? 'goal-met' : writingStats.goalProgress >= 0.8 ? 'goal-near' : '' }`} data-testid="status-daily-goal" > {t('status.dailyGoal', { current: writingStats.todayWords, goal: dailyWordGoal })} )} {showStockWarning && ( {t('stock.warning', { count: cockpitSummary!.stockReadyCount, threshold: cockpitSummary!.stockThreshold })} )} {isChapterTarget ? ( <> {currentChapter?.publishStatus === 'published' && currentChapter.publishedAt && ( {t('chapterMeta.publishedAt', { date: new Date(currentChapter.publishedAt).toLocaleString('zh-CN') })} )} {t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })} {t('status.volume')}: {t('editor.words', { count: wordCount.volume })} {t('status.book')}: {t('editor.words', { count: wordCount.book })} ) : ( {t('editor.words', { count: wordCount.chapter })} )}
)}
{!referenceOpen && } setVersionModalOpen(false)} chapterId={chapterId} /> setLandmarksModalOpen(false)} /> { useCockpitStore.getState().openBridgeForChapter(chapterId) setBridgeOpen(true) }} /> { setBridgeOpen(false) clearBridgeChapter() }} /> setTemplateModalOpen(false)} /> setChapterMetaOpen(false)} /> {volumeMenu && volumeMenuVolume && currentBookId && ( c.volumeId === volumeMenuVolume.id)} x={volumeMenu.x} y={volumeMenu.y} onClose={() => setVolumeMenu(null)} onChanged={refreshChapters} /> )}
) }