feat: 实现笔临 P0/P1 Electron 桌面应用 v0.1.0
交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { RightPanel } from '@renderer/components/layout/RightPanel'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
lastSavedAtAtom,
|
||||
wordCountAtom
|
||||
} from '@renderer/atoms/editorAtoms'
|
||||
|
||||
export function EditorLayout(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const sidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const activePanel = useAppStore((s) => s.sidebarPanel)
|
||||
const {
|
||||
currentBookId,
|
||||
volumes,
|
||||
chapters,
|
||||
selectedChapterId,
|
||||
activeVolumeId,
|
||||
setSelectedChapter,
|
||||
setActiveVolume,
|
||||
refreshChapters
|
||||
} = useBookStore()
|
||||
|
||||
const dirty = useAtomValue(editorDirtyAtom)
|
||||
const saving = useAtomValue(editorSavingAtom)
|
||||
const lastSaved = useAtomValue(lastSavedAtAtom)
|
||||
const wordCount = useAtomValue(wordCountAtom)
|
||||
|
||||
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
|
||||
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||
await flushEditorSave()
|
||||
setSelectedChapter(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)
|
||||
}
|
||||
|
||||
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 saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||
|
||||
return (
|
||||
<div id="editor-layout">
|
||||
<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"
|
||||
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
||||
onClick={() => sidebarPanel(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"
|
||||
onClick={() => setActiveVolume(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 ${selectedChapterId === ch.id ? 'active' : ''}`}
|
||||
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>
|
||||
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{activePanel !== 'chapters' && (
|
||||
<div className="sidebar-panel active">
|
||||
<div className="placeholder-box" style={{ padding: 20 }}>
|
||||
{t('feature.comingSoon')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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 id="editor-area">
|
||||
<div id="editor-toolbar">
|
||||
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
|
||||
📎
|
||||
</button>
|
||||
<span className="editor-label">
|
||||
{currentChapter ? currentChapter.title : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
|
||||
<div id="editor-statusbar">
|
||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<RightPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user