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:
2026-07-06 14:13:27 +08:00
parent 91c93954df
commit aac51bf183
72 changed files with 5790 additions and 203 deletions
@@ -0,0 +1,92 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useEditStore } from '@renderer/stores/useEditStore'
export function ReferencePanel(): React.JSX.Element {
const { t } = useTranslation()
const open = useReferenceStore((s) => s.open)
const tab = useReferenceStore((s) => s.tab)
const query = useReferenceStore((s) => s.query)
const setTab = useReferenceStore((s) => s.setTab)
const setQuery = useReferenceStore((s) => s.setQuery)
const setOpen = useReferenceStore((s) => s.setOpen)
const switchTarget = useEditStore((s) => s.switchTarget)
const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings)
const inspirations = useBookStore((s) => s.inspirations)
const items = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (title: string, body: string) =>
!q || title.toLowerCase().includes(q) || body.toLowerCase().includes(q)
if (tab === 'setting') {
return settings
.filter((s) => match(s.name, s.description))
.map((s) => ({ id: s.id, kind: 'setting' as const, title: s.name, preview: s.description }))
}
if (tab === 'outline') {
return outlines
.filter((o) => match(o.title, o.description))
.map((o) => ({ id: o.id, kind: 'outline' as const, title: o.title, preview: o.description }))
}
return inspirations
.filter((i) => match(i.title, i.content))
.map((i) => ({ id: i.id, kind: 'inspiration' as const, title: i.title, preview: i.content }))
}, [tab, query, settings, outlines, inspirations])
if (!open) return <div id="reference-panel" />
return (
<div id="reference-panel" className="open" data-testid="reference-panel">
<div className="ref-header">
<span>{t('reference.title')}</span>
<button type="button" className="btn" onClick={() => setOpen(false)}>
</button>
</div>
<input
className="form-control form-control--wide"
placeholder={t('reference.search')}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="ref-tabs">
{(['setting', 'outline', 'inspiration'] as const).map((id) => (
<button
key={id}
type="button"
className={`ref-tab ${tab === id ? 'active' : ''}`}
onClick={() => setTab(id)}
>
{t(`reference.tab.${id}`)}
</button>
))}
</div>
<div className="ref-list">
{items.map((item) => (
<div
key={item.id}
className="ref-item"
draggable
onDragStart={(e) => {
const plain = item.preview.replace(/<[^>]+>/g, '').slice(0, 200)
e.dataTransfer.setData('text/plain', plain)
}}
onClick={() => void switchTarget({ kind: item.kind, id: item.id })}
role="button"
tabIndex={0}
data-testid={`ref-item-${item.id}`}
>
<div className="ref-item-title">{item.title}</div>
<div className="ref-item-preview">
{item.preview.replace(/<[^>]+>/g, '').slice(0, 40)}
</div>
</div>
))}
</div>
</div>
)
}