Single panel: outline tree with slots, node search with type filter
The outline and blocks are no longer tabs: one panel shows the outline tree, the new-block palette and a search over existing nodes. Outline rows drag to reorder (same snap engine, explicit gap targets), palette blocks and searched nodes drop onto the outline gaps as well as the page. Named slots (x-graph.connections, e.g. a pdp's info-section / 3d-accessories / model) render as groups under their block with allowed types, live counts and a slot-limited insert picker. Drops that fit no plain-children rule resolve to the first accepting slot; existing nodes link via graft tree.link. Adds Api.searchNodes, schema connections helpers, handle-aware controller ops (linkExisting, insertIntoSlot, dropExisting) and mock support for /api/nodes listing and graft links. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
a4ed99f13e
commit
33bd5269fd
16 files changed
+852
-110
No files matched your search
+425
-65
@@ -1,90 +1,311 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Blueprint, TypeInfo } from '../api'
|
||||
import { OTHER_GROUP, allowedChildren, sortGroups, typeName } from '../schema'
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { InsertPicker } from '../editors/builtin'
|
||||
import type { CmsNode, TypeInfo } from '../api'
|
||||
import { allowedChildren, connectionsOf, labelKey, sortGroups, typeName } from '../schema'
|
||||
import type { DropTarget } from '../page/dnd'
|
||||
import { Icon, TypeIcon } from './Icon'
|
||||
import { Summary } from './Summary'
|
||||
import { useCtrl, useEditorState } from './hooks'
|
||||
import type { Block, Controller } from '../controller'
|
||||
|
||||
/** Left rail: the page outline, and the palette of blocks you can drag onto the page. */
|
||||
/**
|
||||
* Left panel: outline + blocks + nodes in one place (no tabs).
|
||||
*
|
||||
* - Outline is a tree with its slots (e.g. a pdp's `info-section` /
|
||||
* `3d-accessories` connections): drag any row by its grip to reorder, drop
|
||||
* palette blocks and searched nodes onto the gaps between rows or into slots.
|
||||
* - Blocks are the new types that fit this page (drag onto the page or the outline).
|
||||
* - Nodes finds *existing* nodes (type filter + text) to link into the page.
|
||||
*/
|
||||
export function Rail() {
|
||||
const ctrl = useCtrl()
|
||||
const rail = useEditorState((s) => s.rail)
|
||||
if (!rail) return null
|
||||
const open = useEditorState((s) => s.rail)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// Types carry every schema (titles, groups, slots): one request seeds them all.
|
||||
void ctrl.api
|
||||
.types()
|
||||
.then((ts) => {
|
||||
ctrl.ingestTypes(ts)
|
||||
void ctrl.loadSchemas(ts.map(typeName))
|
||||
})
|
||||
.catch(() => {})
|
||||
void ctrl.fillHandles()
|
||||
}, [open, ctrl])
|
||||
if (!open) return null
|
||||
return (
|
||||
<aside className="cms-rail" aria-label={rail === 'outline' ? 'Outline' : 'Blocks'}>
|
||||
<aside className="cms-rail" aria-label="Page panel">
|
||||
<header>
|
||||
<div className="cms-tabs" role="tablist">
|
||||
{(['outline', 'blocks'] as const).map((t) => (
|
||||
<button key={t} role="tab" aria-selected={rail === t} className={rail === t ? 'is-on' : ''} onClick={() => ctrl.store.set({ rail: t })}>
|
||||
<Icon name={t} size={16} /> {t === 'outline' ? 'Outline' : 'Blocks'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="cms-ico" onClick={() => ctrl.store.set({ rail: null })} aria-label="Close panel">
|
||||
<span className="cms-rail__title">
|
||||
<Icon name="outline" size={16} /> Page
|
||||
</span>
|
||||
<button className="cms-ico" onClick={() => ctrl.store.set({ rail: false })} aria-label="Close panel">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
{rail === 'outline' ? <Outline /> : <Palette />}
|
||||
<div className="cms-rail__body">
|
||||
<OutlineTree />
|
||||
<Palette />
|
||||
<NodeSearch />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/** Page structure from the DOM scan: no API calls, always in sync with what is rendered. */
|
||||
function Outline() {
|
||||
// --- outline -----------------------------------------------------------------
|
||||
|
||||
type Gap = { parentId: string; handle?: string; index: number }
|
||||
|
||||
function OutlineTree() {
|
||||
const ctrl = useCtrl()
|
||||
const { blocks, selected } = useEditorState((s) => ({ blocks: s.blocks, selected: s.selected }))
|
||||
const blocks = useEditorState((s) => s.blocks)
|
||||
const selected = useEditorState((s) => s.selected)
|
||||
const drag = useEditorState((s) => s.drag)
|
||||
useEditorState((s) => s.tick)
|
||||
useEditorState((s) => s.schemasVersion)
|
||||
useEditorState((s) => s.labelsVersion)
|
||||
if (!blocks.length) return <p className="cms-muted cms-pad">No editable blocks on this page.</p>
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
const [over, setOver] = useState<Gap | null>(null)
|
||||
const dragging = !!drag
|
||||
|
||||
// Our pointerup runs before the page engine's (registered at mount, theirs at
|
||||
// drag start): a drop on a gap commits to the outline, anywhere else on the
|
||||
// panel just cancels the page ghost.
|
||||
useEffect(() => {
|
||||
const onUp = (e: PointerEvent) => {
|
||||
const d = ctrl.store.get().drag
|
||||
if (!d) return
|
||||
const path = e.composedPath() as Element[]
|
||||
const inRail = path.some((el) => el instanceof Element && el.classList?.contains('cms-rail'))
|
||||
if (!inRail) return
|
||||
e.stopImmediatePropagation()
|
||||
e.preventDefault()
|
||||
const gapEl = path.find((el) => el instanceof Element && el.hasAttribute?.('data-ogap'))
|
||||
const target: DropTarget | null = gapEl
|
||||
? {
|
||||
parentId: gapEl.getAttribute('data-parent') ?? '',
|
||||
handle: gapEl.getAttribute('data-handle') || undefined,
|
||||
index: Number(gapEl.getAttribute('data-index') ?? 0),
|
||||
line: { x: 0, y: 0, w: 0, h: 0 },
|
||||
}
|
||||
: null
|
||||
setOver(null)
|
||||
ctrl.cancelDrag()
|
||||
if (!target) return
|
||||
if (d.source === 'block' && d.id) void ctrl.dropBlock(d.id, target)
|
||||
else if (d.source === 'existing' && d.nodeId) void ctrl.dropExisting(target, d.nodeId)
|
||||
else if (d.source === 'palette' && d.type && !d.blueprint) void ctrl.dropNew(target, { type: d.type })
|
||||
else if (d.source === 'palette' && d.blueprint && !target.handle) void ctrl.dropNew(target, { blueprint: d.blueprint })
|
||||
else if (d.source === 'palette' && d.blueprint && target.handle) ctrl.toast('info', 'Blueprints cannot go straight into a slot — drop it on the page first, then move it')
|
||||
}
|
||||
ctrl.doc.addEventListener('pointerup', onUp, true)
|
||||
return () => ctrl.doc.removeEventListener('pointerup', onUp, true)
|
||||
}, [ctrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!drag) setOver(null)
|
||||
}, [drag])
|
||||
|
||||
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
||||
if (!blocks.length) return null
|
||||
return (
|
||||
<ul className="cms-outline">
|
||||
{blocks.map((b, i) => (
|
||||
<li key={`${b.id}-${i}`}>
|
||||
<button
|
||||
className={b.id === selected ? 'is-sel' : ''}
|
||||
style={{ paddingLeft: 8 + b.depth * 14 }}
|
||||
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
|
||||
onMouseLeave={() => ctrl.store.set({ hovered: null })}
|
||||
onClick={() => {
|
||||
ctrl.setMode('edit')
|
||||
ctrl.select(b.id, { scroll: true })
|
||||
}}
|
||||
>
|
||||
<TypeIcon type={b.type} size={15} />
|
||||
{/* title (x-graph.label) when the type has one, otherwise the type name and a text snippet */}
|
||||
{(() => {
|
||||
const title = ctrl.labelFor(b.id)
|
||||
return title ? (
|
||||
<>
|
||||
<span className="cms-outline__type">{title}</span>
|
||||
<span className="cms-outline__text">{b.type}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="cms-outline__type">{b.type || '?'}</span>
|
||||
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<section aria-label="Outline">
|
||||
<h4 className="cms-rail__sec">Outline</h4>
|
||||
<ul className="cms-outline cms-outline--tree">
|
||||
{roots.map((b) => (
|
||||
<OutlineNode key={b.id} block={b} level={0} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
||||
))}
|
||||
</ul>
|
||||
{dragging && <OutlineGap parentId="" index={roots.length} over={over} setOver={setOver} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function OutlineGap({ parentId, handle, index, over, setOver }: { parentId: string; handle?: string; index: number; over: Gap | null; setOver: (g: Gap | null) => void }) {
|
||||
const ctrl = useCtrl()
|
||||
const drag = useEditorState((s) => s.drag)
|
||||
if (!drag) return null
|
||||
const active = over?.parentId === parentId && (over?.handle ?? '') === (handle ?? '') && over?.index === index
|
||||
// Blueprints cannot land in slots; slot gaps refuse them.
|
||||
const refused = !!drag.blueprint && !!handle
|
||||
// Page drops onto a type the slot does not take are refused too.
|
||||
const typeRefused = !!drag.type && !drag.blueprint && parentId !== '' && !ctrl.accepts(parentId, drag.type) && !(handle ? ctrl.acceptsInSlot(parentId, handle, drag.type) : ctrl.handleForDrop(parentId, drag.type))
|
||||
return (
|
||||
<div
|
||||
className={`cms-ogap${active && !refused ? ' is-over' : ''}${refused || typeRefused ? ' is-refused' : ''}`}
|
||||
data-ogap=""
|
||||
data-parent={parentId}
|
||||
data-handle={handle ?? ''}
|
||||
data-index={index}
|
||||
onPointerEnter={() => (refused ? setOver(null) : setOver({ parentId, handle, index }))}
|
||||
onPointerLeave={() => setOver(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OutlineNode({ block: b, level, collapsed, setCollapsed, over, setOver, dragging, selected }: {
|
||||
block: Block
|
||||
level: number
|
||||
collapsed: Set<string>
|
||||
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
|
||||
over: Gap | null
|
||||
setOver: (g: Gap | null) => void
|
||||
dragging: boolean
|
||||
selected: string | null
|
||||
}) {
|
||||
const ctrl = useCtrl()
|
||||
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === b.id && !ctrl.handleOf(x.id)))
|
||||
const slots = useMemo(() => ctrl.slotsFor(b.id), [ctrl, b.id, b.schema, kids.length])
|
||||
useEditorState((s) => s.tick)
|
||||
const isCollapsed = collapsed.has(b.id)
|
||||
const isSel = b.id === selected
|
||||
const toggle = () => setCollapsed((s) => {
|
||||
const n = new Set(s)
|
||||
if (n.has(b.id)) n.delete(b.id)
|
||||
else n.add(b.id)
|
||||
return n
|
||||
})
|
||||
const title = ctrl.labelFor(b.id)
|
||||
const hasKids = kids.length > 0 || slots.some((sl) => ctrl.slotChildren(b.id, sl.name).length > 0)
|
||||
return (
|
||||
<li>
|
||||
<div className={`cms-orow${isSel ? ' is-sel' : ''}`} style={{ paddingLeft: 4 + level * 14 }}>
|
||||
{(b.container || slots.length > 0) && hasKids ? (
|
||||
<button className="cms-ico cms-orow__chev" onClick={toggle} aria-label={isCollapsed ? 'Expand' : 'Collapse'} title={isCollapsed ? 'Expand' : 'Collapse'}>
|
||||
<span className={`cms-chev${isCollapsed ? '' : ' is-open'}`} style={{ display: 'inline-flex' }}>
|
||||
<Icon name="chevron" size={14} />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="cms-orow__spacer" />
|
||||
)}
|
||||
<span
|
||||
className="cms-orow__grip"
|
||||
title="Drag to reorder"
|
||||
aria-label={`Drag ${title ?? b.type} to reorder`}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
const x0 = e.clientX
|
||||
const y0 = e.clientY
|
||||
const doc = e.currentTarget.ownerDocument
|
||||
const move = (m: PointerEvent) => {
|
||||
if (Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return
|
||||
doc.removeEventListener('pointermove', move, true)
|
||||
doc.removeEventListener('pointerup', stop, true)
|
||||
ctrl.setMode('edit')
|
||||
ctrl.beginDrag(b.id)
|
||||
}
|
||||
const stop = () => {
|
||||
doc.removeEventListener('pointermove', move, true)
|
||||
doc.removeEventListener('pointerup', stop, true)
|
||||
}
|
||||
doc.addEventListener('pointermove', move, true)
|
||||
doc.addEventListener('pointerup', stop, true)
|
||||
}}
|
||||
>
|
||||
<Icon name="grip" size={14} />
|
||||
</span>
|
||||
<button
|
||||
className="cms-orow__main"
|
||||
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
|
||||
onMouseLeave={() => ctrl.store.set({ hovered: null })}
|
||||
onClick={() => {
|
||||
ctrl.setMode('edit')
|
||||
ctrl.select(b.id, { scroll: true })
|
||||
}}
|
||||
title={`${b.type}${ctrl.handleOf(b.id) ? ` · in slot ${ctrl.handleOf(b.id)}` : ''}`}
|
||||
>
|
||||
<TypeIcon type={b.type} size={15} />
|
||||
{title ? (
|
||||
<>
|
||||
<span className="cms-outline__type">{title}</span>
|
||||
<span className="cms-outline__text">{b.type}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="cms-outline__type">{b.type || '?'}</span>
|
||||
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
|
||||
</>
|
||||
)}
|
||||
{ctrl.handleOf(b.id) && <em className="cms-oslot-tag">{ctrl.handleOf(b.id)}</em>}
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<ul className="cms-outline cms-outline--nested">
|
||||
{dragging && <OutlineGap parentId={b.id} index={0} over={over} setOver={setOver} />}
|
||||
{kids.map((k, i) => (
|
||||
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
||||
))}
|
||||
{dragging && kids.length > 0 && <OutlineGap parentId={b.id} index={kids.length} over={over} setOver={setOver} />}
|
||||
{slots.map((sl) => (
|
||||
<SlotGroup key={sl.name} parentId={b.id} level={level + 1} slot={sl} over={over} setOver={setOver} dragging={dragging} collapsed={collapsed} setCollapsed={setCollapsed} selected={selected} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** A plain (non-slot) child: its own row, followed by the gap after it. */
|
||||
function OutlineNodeInner(props: { block: Block; index: number; level: number } & Omit<Parameters<typeof OutlineNode>[0], 'block' | 'level'>) {
|
||||
const { block, index, level, ...rest } = props
|
||||
return (
|
||||
<>
|
||||
<OutlineNode block={block} level={level} {...rest} />
|
||||
{rest.dragging && <OutlineGap parentId={block.parentId ?? ''} index={index + 1} over={rest.over} setOver={rest.setOver} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SlotGroup({ parentId, level, slot, over, setOver, dragging, collapsed, setCollapsed, selected }: {
|
||||
parentId: string
|
||||
level: number
|
||||
slot: { name: string; def: { to: string[]; kind: string; description?: string }; count: number; accepts: (t: string) => boolean }
|
||||
over: Gap | null
|
||||
setOver: (g: Gap | null) => void
|
||||
dragging: boolean
|
||||
collapsed: Set<string>
|
||||
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
|
||||
selected: string | null
|
||||
}) {
|
||||
const ctrl = useCtrl()
|
||||
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === parentId && ctrl.handleOf(x.id) === slot.name))
|
||||
const add = () => {
|
||||
let key = 0
|
||||
key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) })
|
||||
}
|
||||
return (
|
||||
<li className="cms-oslot" style={{ marginLeft: level * 14 }}>
|
||||
<div className="cms-oslot__head" title={slot.def.description ?? `Slot: ${slot.name}`}>
|
||||
<Icon name="shape" size={13} />
|
||||
<span>{slot.name}</span>
|
||||
<span className="cms-muted">{slot.def.to.length ? slot.def.to.join(', ') : 'any'}</span>
|
||||
<button className="cms-ico" onClick={add} title={`Add to ${slot.name}`} aria-label={`Add to ${slot.name}`}>
|
||||
<Icon name="add" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="cms-outline cms-outline--nested">
|
||||
{dragging && <OutlineGap parentId={parentId} handle={slot.name} index={0} over={over} setOver={setOver} />}
|
||||
{kids.map((k, i) => (
|
||||
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
||||
))}
|
||||
{dragging && kids.length > 0 && <OutlineGap parentId={parentId} handle={slot.name} index={kids.length} over={over} setOver={setOver} />}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// --- palette (new blocks) -------------------------------------------------------
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
type Item = { key: string; label: string; /** the type id, shown under the title when it differs */ sub?: string; type?: string; blueprint?: string; group: string; description?: string }
|
||||
type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string }
|
||||
|
||||
/**
|
||||
* Every block type (and blueprint) that fits somewhere on this page. Drag one onto the page - a ghost
|
||||
* shows where it will land, and containers that do not accept it refuse the drop - or click to add it
|
||||
* to the selection (inside a container, otherwise after the selected block, otherwise at the page end).
|
||||
*/
|
||||
function Palette() {
|
||||
const ctrl = useCtrl()
|
||||
const [types, setTypes] = useState<TypeInfo[] | null>(null)
|
||||
const [blueprints, setBlueprints] = useState<Blueprint[]>([])
|
||||
const [blueprints, setBlueprints] = useState<import('../api').Blueprint[]>([])
|
||||
const [q, setQ] = useState('')
|
||||
const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks }))
|
||||
const schemasVersion = useEditorState((s) => s.schemasVersion)
|
||||
@@ -108,6 +329,8 @@ function Palette() {
|
||||
const all = types.map(typeName).filter(Boolean)
|
||||
const allowed = new Set<string>()
|
||||
for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t))
|
||||
// Slots accept more than plain children (e.g. a pdp's info-section): include slot types too.
|
||||
for (const b of blocks) for (const h of Object.keys(connectionsOf(b.schema))) for (const t of connectionsOf(b.schema)[h]?.to ?? []) allowed.add(t)
|
||||
const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing
|
||||
return [
|
||||
...types
|
||||
@@ -131,13 +354,15 @@ function Palette() {
|
||||
try {
|
||||
let parent: string
|
||||
let index: number | undefined
|
||||
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type))) parent = sel.id
|
||||
let handle: string | undefined
|
||||
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type) || (handle = ctrl.handleForDrop(sel.id, item.type)))) parent = sel.id
|
||||
else if (sel) {
|
||||
parent = await ctrl.parentIdOf(sel.id)
|
||||
handle = ctrl.handleOf(sel.id)
|
||||
index = (await ctrl.position(sel.id)).index + 1
|
||||
} else parent = await ctrl.pageRoot()
|
||||
if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index)
|
||||
else if (item.type) await ctrl.insert(parent, item.type, index !== undefined ? { index } : {})
|
||||
else if (item.type) await ctrl.insert(parent, item.type, { ...(index !== undefined ? { index } : {}), ...(handle ? { handle } : {}) })
|
||||
} catch (e) {
|
||||
ctrl.toast('error', (e as Error).message)
|
||||
}
|
||||
@@ -164,10 +389,11 @@ function Palette() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cms-palette">
|
||||
<section aria-label="Blocks">
|
||||
<h4 className="cms-rail__sec">Blocks</h4>
|
||||
<div className="cms-pad">
|
||||
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<p className="cms-muted cms-hint">Drag onto the page, or click to add to the selection.</p>
|
||||
<p className="cms-muted cms-hint">Drag onto the page or the outline, or click to add to the selection.</p>
|
||||
</div>
|
||||
{!types && <p className="cms-muted cms-pad">Loading…</p>}
|
||||
{groups.map((g) => (
|
||||
@@ -199,6 +425,140 @@ function Palette() {
|
||||
</details>
|
||||
))}
|
||||
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- node search (existing nodes) -----------------------------------------------
|
||||
|
||||
function nodeTitle(ctrl: Controller, n: CmsNode): string | undefined {
|
||||
const key = labelKey(ctrl.schemaOf(n.dataType))
|
||||
const v = key ? (n.data as Record<string, unknown>)?.[key] : undefined
|
||||
const text = typeof v === 'string' || typeof v === 'number' ? String(v).replace(/\s+/g, ' ').trim() : ''
|
||||
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text
|
||||
const first = Object.values(n.data ?? {}).find((x) => typeof x === 'string' && x.trim())
|
||||
const fb = typeof first === 'string' ? first.trim().slice(0, 40) : ''
|
||||
return fb || undefined
|
||||
}
|
||||
|
||||
function NodeSearch() {
|
||||
const ctrl = useCtrl()
|
||||
const [typeNames, setTypeNames] = useState<string[]>([])
|
||||
const [type, setType] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [results, setResults] = useState<CmsNode[] | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const selected = useEditorState((s) => s.selected)
|
||||
useEditorState((s) => s.schemasVersion)
|
||||
|
||||
useEffect(() => {
|
||||
void ctrl.api
|
||||
.types()
|
||||
.then((ts) => {
|
||||
ctrl.ingestTypes(ts)
|
||||
setTypeNames(ts.map(typeName).filter(Boolean).sort())
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [ctrl])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setBusy(true)
|
||||
void ctrl.api
|
||||
.searchNodes({ type: type || undefined, q: q || undefined, limit: 20 })
|
||||
.then((r) => {
|
||||
setResults(r.items)
|
||||
setTotal(r.total)
|
||||
void ctrl.loadSchemas([...new Set(r.items.map((n) => n.dataType))])
|
||||
})
|
||||
.catch(() => setResults([]))
|
||||
.finally(() => setBusy(false))
|
||||
}, q ? 250 : 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [q, type, ctrl])
|
||||
|
||||
const linkToSelection = async (node: CmsNode) => {
|
||||
const sel = ctrl.block(selected)
|
||||
try {
|
||||
if (sel && sel.container && (ctrl.accepts(sel.id, node.dataType) || ctrl.handleForDrop(sel.id, node.dataType))) {
|
||||
await ctrl.linkExisting(sel.id, node.id, { handle: ctrl.handleForDrop(sel.id, node.dataType) })
|
||||
} else if (sel) {
|
||||
const parent = await ctrl.parentIdOf(sel.id)
|
||||
const { index } = await ctrl.position(sel.id)
|
||||
await ctrl.linkExisting(parent, node.id, { handle: ctrl.handleOf(sel.id), index: index + 1 })
|
||||
} else {
|
||||
await ctrl.linkExisting(await ctrl.pageRoot(), node.id, {})
|
||||
}
|
||||
} catch (e) {
|
||||
ctrl.toast('error', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent, node: CmsNode) => {
|
||||
if (e.button !== 0) return
|
||||
const x0 = e.clientX
|
||||
const y0 = e.clientY
|
||||
const doc = e.currentTarget.ownerDocument
|
||||
const move = (m: PointerEvent) => {
|
||||
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
|
||||
stop()
|
||||
ctrl.setMode('edit')
|
||||
ctrl.beginExistingDrag({ id: node.id, type: node.dataType, label: nodeTitle(ctrl, node) ?? node.dataType })
|
||||
}
|
||||
const stop = () => {
|
||||
doc.removeEventListener('pointermove', move, true)
|
||||
doc.removeEventListener('pointerup', stop, true)
|
||||
}
|
||||
doc.addEventListener('pointermove', move, true)
|
||||
doc.addEventListener('pointerup', stop, true)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Nodes">
|
||||
<h4 className="cms-rail__sec">Nodes</h4>
|
||||
<div className="cms-pad cms-nodefind">
|
||||
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select className="cms-input" aria-label="Filter by type" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All types</option>
|
||||
{typeNames.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{ctrl.typeTitle(t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="cms-muted cms-hint">Link a shared block into the page — drag it onto the outline, or click + to add to the selection.</p>
|
||||
</div>
|
||||
{busy && !results && <p className="cms-muted cms-pad">Searching…</p>}
|
||||
{results && !results.length && <p className="cms-muted cms-pad">No nodes match.</p>}
|
||||
{!!results?.length && (
|
||||
<ul className="cms-nodelist">
|
||||
{results.map((n) => (
|
||||
<li key={n.id} className="cms-noderow" onPointerDown={(e) => onPointerDown(e, n)} title={`${n.dataType} · ${n.id}`}>
|
||||
<TypeIcon type={n.dataType} size={15} />
|
||||
<span className="cms-noderow__main">
|
||||
<span className="cms-outline__type">{nodeTitle(ctrl, n) ?? n.dataType}</span>
|
||||
<span className="cms-outline__text">
|
||||
{n.dataType} · {n.id.slice(0, 8)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="cms-ico"
|
||||
aria-label={`Link ${nodeTitle(ctrl, n) ?? n.id}`}
|
||||
title="Link into the page"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void linkToSelection(n)
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="add" size={15} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!!results?.length && total > results.length && <p className="cms-muted cms-pad">Showing {results.length} of {total} — refine the search.</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user