feat: deliver P2 v0.2.0 with unified editor, search, and snapshots
Implement schema v2 migration, outline/setting/inspiration CRUD, unified TipTap editing, reference panel with mentions, FTS global search, chapter version history, writing landmarks, word frequency analysis, light theme contrast fixes, and full unit/E2E test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,22 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
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 { 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 {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
@@ -12,11 +24,40 @@ import {
|
||||
wordCountAtom
|
||||
} from '@renderer/atoms/editorAtoms'
|
||||
|
||||
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 sidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
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,
|
||||
@@ -27,18 +68,38 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setActiveVolume,
|
||||
refreshChapters
|
||||
} = useBookStore()
|
||||
const target = useEditStore((s) => s.target)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const setTarget = useEditStore((s) => s.setTarget)
|
||||
|
||||
const dirty = useAtomValue(editorDirtyAtom)
|
||||
const saving = useAtomValue(editorSavingAtom)
|
||||
const lastSaved = useAtomValue(lastSavedAtAtom)
|
||||
const wordCount = useAtomValue(wordCountAtom)
|
||||
const documentTitle = useDocumentTitle()
|
||||
|
||||
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? 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])
|
||||
|
||||
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||
await flushEditorSave()
|
||||
setSelectedChapter(chapterId)
|
||||
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||
}
|
||||
|
||||
const handleNewChapter = async (): Promise<void> => {
|
||||
@@ -49,6 +110,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
)
|
||||
await refreshChapters()
|
||||
setSelectedChapter(ch.id)
|
||||
await switchTarget({ kind: 'chapter', id: ch.id })
|
||||
}
|
||||
|
||||
const handleNewVolume = async (): Promise<void> => {
|
||||
@@ -60,10 +122,24 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setActiveVolume(vol.id)
|
||||
}
|
||||
|
||||
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
|
||||
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
|
||||
)
|
||||
showToast(t('landmark.created'))
|
||||
}
|
||||
|
||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||
|
||||
return (
|
||||
<div id="editor-layout">
|
||||
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
|
||||
<div id="left-sidebar">
|
||||
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
|
||||
<div className="sidebar-nav">
|
||||
@@ -71,8 +147,9 @@ export function EditorLayout(): React.JSX.Element {
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
data-testid={`sidebar-tab-${panel}`}
|
||||
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
||||
onClick={() => sidebarPanel(panel)}
|
||||
onClick={() => setSidebarPanel(panel)}
|
||||
>
|
||||
{panel === 'chapters' && t('sidebar.chapters')}
|
||||
{panel === 'outline' && t('sidebar.outline')}
|
||||
@@ -96,7 +173,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
.map((ch, idx) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
|
||||
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
|
||||
onClick={() => void handleSelectChapter(ch.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||
role="button"
|
||||
@@ -109,41 +186,90 @@ export function EditorLayout(): React.JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{activePanel !== 'chapters' && (
|
||||
<div className="sidebar-panel active">
|
||||
<div className="placeholder-box" style={{ padding: 20 }}>
|
||||
{t('feature.comingSoon')}
|
||||
{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-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 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">
|
||||
<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>
|
||||
{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>
|
||||
{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>
|
||||
<RightPanel />
|
||||
<ReferencePanel />
|
||||
{!referenceOpen && <RightPanel />}
|
||||
<VersionModal
|
||||
open={versionModalOpen}
|
||||
onClose={() => setVersionModalOpen(false)}
|
||||
chapterId={chapterId}
|
||||
/>
|
||||
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user