cb6b4c3731
Co-authored-by: Cursor <cursoragent@cursor.com>
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
import { useMemo, useState } 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'
|
|
|
|
type RefItem = {
|
|
id: string
|
|
kind: 'setting' | 'outline' | 'inspiration'
|
|
title: string
|
|
preview: string
|
|
}
|
|
|
|
export function ReferencePanel(): React.JSX.Element {
|
|
const { t } = useTranslation()
|
|
const open = useReferenceStore((s) => s.open)
|
|
const pinned = useReferenceStore((s) => s.pinned)
|
|
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 setPinned = useReferenceStore((s) => s.setPinned)
|
|
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 [detailItem, setDetailItem] = useState<RefItem | null>(null)
|
|
|
|
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>
|
|
<div className="ref-header-actions">
|
|
<button
|
|
type="button"
|
|
className={`btn ref-pin-btn ${pinned ? 'active' : ''}`}
|
|
data-testid="reference-pin"
|
|
title={pinned ? t('reference.unpin') : t('reference.pin')}
|
|
onClick={() => setPinned(!pinned)}
|
|
>
|
|
📌
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
data-testid="reference-close"
|
|
onClick={() => {
|
|
if (pinned) setPinned(false)
|
|
else setOpen(false)
|
|
}}
|
|
>
|
|
{pinned ? t('reference.unpin') : '✕'}
|
|
</button>
|
|
</div>
|
|
</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 ${detailItem?.id === item.id ? 'active' : ''}`}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
const plain = item.preview.replace(/<[^>]+>/g, '').slice(0, 200)
|
|
e.dataTransfer.setData('text/plain', plain)
|
|
}}
|
|
onClick={() => setDetailItem(item)}
|
|
onDoubleClick={() => 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>
|
|
{detailItem && (
|
|
<div className="ref-detail-drawer" data-testid="reference-detail-drawer">
|
|
<div className="ref-detail-header">
|
|
<strong>{detailItem.title}</strong>
|
|
<button type="button" className="btn" onClick={() => setDetailItem(null)}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="ref-detail-body">
|
|
{detailItem.preview.replace(/<[^>]+>/g, '')}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => void switchTarget({ kind: detailItem.kind, id: detailItem.id })}
|
|
>
|
|
{t('reference.openInEditor')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|