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 { 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 { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal' 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 bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId) const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter) const updateChapterLocal = useBookStore((s) => s.updateChapterLocal) 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 [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 useEffect(() => { if (selectedChapterId && (!target || target.kind === 'chapter')) { setTarget({ kind: 'chapter', id: selectedChapterId }) } }, [selectedChapterId, setTarget]) 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 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 [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') return (
{compareMode ? ( ) : ( <>
{documentTitle}
{saveStatus}{lastSaved ? ` ยท ${lastSaved.toLocaleTimeString()}` : ''} {showStockWarning && ( {t('stock.warning', { count: cockpitSummary!.stockReadyCount, threshold: cockpitSummary!.stockThreshold })} )} {isChapterTarget ? ( <> {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() }} />
) }