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
+14
-2
@@ -10,7 +10,12 @@ export const schemas: Record<string, any> = {
|
||||
title: 'Page',
|
||||
required: ['title'],
|
||||
properties: { title: { type: 'string' }, path: { type: 'string' }, ...lifecycle },
|
||||
'x-graph': { children: ['section', 'heading', 'text', 'button', 'footer', 'layout'], group: 'Pages', label: 'title' },
|
||||
'x-graph': {
|
||||
children: ['section', 'heading', 'text', 'button', 'footer', 'layout'],
|
||||
group: 'Pages',
|
||||
label: 'title',
|
||||
connections: { hero: { to: ['section', 'heading'], kind: 'single', description: 'Hero slot' } },
|
||||
},
|
||||
},
|
||||
// Mirrors the real footer schema, plus a map, an enum set, a oneOf and a $ref - exercises every form widget.
|
||||
footer: {
|
||||
@@ -93,7 +98,12 @@ export const schemas: Record<string, any> = {
|
||||
padded: { type: 'boolean' },
|
||||
meta: { type: 'object', properties: { anchor: { type: 'string' }, order: { type: 'integer', minimum: 0 } } },
|
||||
},
|
||||
'x-graph': { children: ['heading', 'text', 'button', 'section', 'layout'], group: 'Layout', label: 'title' },
|
||||
'x-graph': {
|
||||
children: ['heading', 'text', 'button', 'section', 'layout'],
|
||||
group: 'Layout',
|
||||
label: 'title',
|
||||
connections: { sidebar: { to: ['text', 'button', 'footer'], kind: 'multi', description: 'Side content' } },
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
type: 'object',
|
||||
@@ -134,6 +144,8 @@ export function seedGraph(): Graph {
|
||||
{ id: 'l1', dataType: 'layout', data: { flow: 'row', columns: 2, gap: 'md', padding: 'sm', paddingTop: 'lg', margin: 'sm', label: 'Two columns' } },
|
||||
{ id: 'x1', dataType: 'text', data: { body: 'Left column.' } },
|
||||
{ id: 'x2', dataType: 'text', data: { body: 'Right column.' } },
|
||||
{ id: 'orphan1', dataType: 'text', data: { body: 'Orphaned block about sparrows' } },
|
||||
{ id: 'orphan2', dataType: 'button', data: { label: 'Buy sparrow seed', href: '#seed' } },
|
||||
{
|
||||
id: 'f1',
|
||||
dataType: 'footer',
|
||||
|
||||
@@ -159,6 +159,19 @@ export class Graph {
|
||||
this.place(root, root.sort)
|
||||
return { result: { edge: root.id, node: root.to }, inv: [{ op: 'delete', edge: root.id }], touch: [root.from] }
|
||||
}
|
||||
case 'graft': {
|
||||
// Link an existing node (tree.link) or create a subtree. The editor uses `tree: { link }` to attach
|
||||
// searched nodes into slots.
|
||||
if (op.tree?.link) {
|
||||
this.node(op.parent)
|
||||
const target = this.node(op.tree.link)
|
||||
const edge: MEdge = { id: this.id('e'), from: op.parent, to: target.id, type: 'child', handle: op.handle ?? op.tree.handle, sort: 0 }
|
||||
this.edges.set(edge.id, edge)
|
||||
this.place(edge, op.index)
|
||||
return { result: { node: target.id, edge: edge.id }, inv: [{ op: 'delete', edge: edge.id }], touch: [op.parent] }
|
||||
}
|
||||
throw new HttpError(400, 'graft without tree.link is not implemented by the mock')
|
||||
}
|
||||
case 'duplicate': {
|
||||
const e = this.edge(op.edge)
|
||||
const map = new Map<string, string>()
|
||||
|
||||
@@ -74,6 +74,18 @@ export function createMockCms(opts: MockOptions = {}) {
|
||||
const s = schemas[decodeURIComponent(r[1])]
|
||||
return s ? json(200, s) : json(404, { error: 'unknown type' })
|
||||
}
|
||||
if (path === '/api/nodes' && m === 'GET') {
|
||||
const type = url.searchParams.get('type')
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||
const limit = Math.max(0, Number(url.searchParams.get('limit') ?? 50))
|
||||
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0))
|
||||
let items = [...graph.nodes.values()]
|
||||
if (type) items = items.filter((n) => n.dataType === type)
|
||||
if (q) items = items.filter((n) => n.id.toLowerCase().includes(q) || JSON.stringify(n.data).toLowerCase().includes(q))
|
||||
const total = items.length
|
||||
items = limit === 0 ? items.slice(offset) : items.slice(offset, offset + limit)
|
||||
return json(200, { items: items.map((n) => ({ id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version })), total, offset, limit })
|
||||
}
|
||||
if ((r = /^\/api\/nodes\/([^/]+)\/edges$/.exec(path))) {
|
||||
graph.node(r[1])
|
||||
return json(200, graph.edgesOf(r[1]))
|
||||
|
||||
Reference in new issue
Block a user