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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user