Files
bilin/src/renderer/components/layout/EditorLayout.tsx
T
bing b33d2e7b34 feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge
Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 23:03:11 +08:00

454 lines
18 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 { 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<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
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<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 [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')
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)}
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)}
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>
<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">
<button type="button" onClick={() => void handleNewChapter()}>
+ {t('editor.newChapter')}
</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('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">
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
{showStockWarning && (
<span className="status-stock-warning" data-testid="status-stock-warning">
{t('stock.warning', {
count: cockpitSummary!.stockReadyCount,
threshold: cockpitSummary!.stockThreshold
})}
</span>
)}
{isChapterTarget ? (
<>
<span>{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)
}}
/>
<ChapterBridgeModal
open={bridgeOpen}
chapterId={activeBridgeChapterId}
onClose={() => {
setBridgeOpen(false)
clearBridgeChapter()
}}
/>
</div>
)
}