From 33bd5269fd6737baeeb224a4c842b03859a3bae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Fri, 6 Nov 2026 20:38:31 +0100 Subject: [PATCH] Single panel: outline tree with slots, node search with type filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 16 +- mock/fixtures.ts | 16 +- mock/graph.ts | 13 ++ mock/server.ts | 12 + src/api.ts | 10 + src/controller.ts | 145 ++++++++++-- src/editors/builtin.tsx | 19 +- src/page/dnd.ts | 2 + src/schema.ts | 27 +++ src/ui/Dock.tsx | 7 +- src/ui/Overlay.tsx | 2 +- src/ui/Rail.tsx | 490 ++++++++++++++++++++++++++++++++++------ src/ui/styles.css | 31 ++- test/onpage.test.tsx | 6 +- test/panel.test.tsx | 154 +++++++++++++ test/titles.test.tsx | 12 +- 16 files changed, 852 insertions(+), 110 deletions(-) create mode 100644 test/panel.test.tsx diff --git a/README.md b/README.md index 5cf2bc8..2e399b1 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,19 @@ the page URL with `X-CMS-Preview: 1`. Visitors who are not signed in are never t click a field again to edit. Ambiguous values (same text twice, one value on two properties) are skipped rather than guessed, and enums, markdown/html fields and read-only fields are never bound as text - those stay in Properties (or need a template marker, e.g. `data-cms-editor="markdown"` for the WYSIWYG editor). -- **Block palette** (dock ▸ Blocks): every type that fits somewhere on the page, plus blueprints. Drag onto the page - a ghost - "virtual element" and a drop line show where it lands, and containers whose `x-graph.children` does not accept the type refuse it - - or click to add to the selection. Existing blocks drag the same way from the toolbar grip, and **sort by snapping between their siblings** (root nodes included): faint markers show every position, the line snaps to the nearest, and the block is only nested into a container under the pointer while **Alt** is held. +- **Panel** (dock ▸ Page): outline, blocks and nodes in one place, no tabs. Drag palette blocks, searched nodes and + outline rows onto the page - a ghost "virtual element" and a drop line show where it lands, and containers whose + `x-graph.children` does not accept the type refuse it - or drop them onto the outline's gaps and slots instead. + Existing blocks drag the same way from the toolbar grip or the outline grip, and **sort by snapping between their + siblings** (root nodes included): faint markers show every position, the line snaps to the nearest, and the block is + only nested into a container under the pointer while **Alt** is held. Reordering inside the outline uses the same + engine: drop a row onto another row's gap (before / after / into). +- **Nodes**: search *existing* nodes (`GET /api/nodes?type=&q=`) with a type filter, and link a shared block into the page + (`graft` with `tree.link`: no copy, one node in many places) - drag the row onto the page or the outline, or click + + to add it next to the selection. +- **Slots / dropzones** (`x-graph.connections`, e.g. a pdp's `info-section`, `3d-accessories`, `model`): the outline shows + each named slot under its block with its allowed types, a count and an add button (the insert picker is limited to the + slot's types). Drops that fit no plain-children rule resolve to the first accepting slot automatically. - **Insertion slots**: a selected or hovered container shows "+" between its children (and a big "Add block" when empty). - **One dialog per thing**: opening Properties/Style/an attribute editor/an insert picker that is already open raises it, pulses it and focuses its first field (unsaved edits are kept) instead of opening a duplicate; clicking a dialog brings it forward and Escape closes the topmost. Custom dialogs opt in with `ctrl.openDialog({ id, ... })`. - The toolbar keeps the everyday actions; **JSON** and other developer tools live under **…** (`placement: 'menu'` on a diff --git a/mock/fixtures.ts b/mock/fixtures.ts index a1e5dd6..02aec0b 100644 --- a/mock/fixtures.ts +++ b/mock/fixtures.ts @@ -10,7 +10,12 @@ export const schemas: Record = { 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 = { 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', diff --git a/mock/graph.ts b/mock/graph.ts index 7d956fb..d177703 100644 --- a/mock/graph.ts +++ b/mock/graph.ts @@ -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() diff --git a/mock/server.ts b/mock/server.ts index d4677ce..44be178 100644 --- a/mock/server.ts +++ b/mock/server.ts @@ -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])) diff --git a/src/api.ts b/src/api.ts index 82cff81..3feeb75 100644 --- a/src/api.ts +++ b/src/api.ts @@ -137,6 +137,16 @@ export class Api { me = () => this.get<{ [k: string]: unknown }>('/api/auth/me') node = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}`) + /** Find existing nodes: `GET /api/nodes?type=&q=&limit=&offset=`. Empty filter lists everything (paged). */ + async searchNodes(opts: { type?: string; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: CmsNode[]; total: number }> { + const p = new URLSearchParams() + if (opts.type) p.set('type', opts.type) + if (opts.q) p.set('q', opts.q) + p.set('limit', String(opts.limit ?? 20)) + if (opts.offset) p.set('offset', String(opts.offset)) + const r = await this.get<{ items: CmsNode[]; total: number }>(`/api/nodes?${p}`) + return { items: r.items ?? [], total: r.total ?? (r.items ?? []).length } + } edges = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}/edges`) edit = (ops: Op[]) => this.post('/api/edit', { ops }) publish = (ids: string[]) => this.post<{ publishedAt?: string; results?: { id: string; error?: string }[] }>('/api/publish-roots', { ids }) diff --git a/src/controller.ts b/src/controller.ts index 89dc4d9..a768a88 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -13,7 +13,8 @@ import { blockEditors, editorsFor, inlineEditors } from './registry' import { dropTarget, moveTarget } from './page/dnd' import { INFERRED_ATTR, collectStrings, getIn, inferBindings, markInferred, parsePath, patchForPath } from './page/infer' import type { AttrBinding } from './page/infer' -import { OTHER_GROUP, acceptsChild, groupOf, labelKey, typeName } from './schema' +import { OTHER_GROUP, acceptsChild, acceptsHandle, connectionsOf, groupOf, labelKey, typeName } from './schema' +import type { ConnectionDef } from './schema' import { describeStyle, patchFor } from './style/model' import type { Breakpoint, StyleField } from './style/model' import { measureScale, probeMeasure } from './style/tokens' @@ -54,7 +55,7 @@ export interface EditorState { canRedo: boolean busy: number toast: { kind: 'info' | 'error'; text: string; n: number; action?: { label: string; run(): void } } | null - drag: { id: string | null; source: 'block' | 'palette'; type?: string; label: string; target: DropTarget | null; pointer?: { x: number; y: number }; /** Alt held: nesting into containers instead of sorting among siblings */ nesting?: boolean } | null + drag: { id: string | null; source: 'block' | 'palette' | 'existing'; type?: string; blueprint?: string; nodeId?: string; label: string; target: DropTarget | null; pointer?: { x: number; y: number }; /** Alt held: nesting into containers instead of sorting among siblings */ nesting?: boolean } | null /** Attribute-bound properties of the selected block (href, src, alt…), shown as chips on the page. */ bindings: AttrBinding[] /** Bumped when a schema finishes loading (editors that match on schema re-evaluate). */ @@ -63,8 +64,8 @@ export interface EditorState { labelsVersion: number /** Bumped after every applied edit/undo/redo (open panels re-read their node). */ editRev: number - /** Left rail: page outline or the block palette. */ - rail: 'outline' | 'blocks' | null + /** Left panel: outline + blocks + node search in one place (no tabs). */ + rail: boolean dev: boolean /** AI assistant panel open. */ assistant: boolean @@ -97,6 +98,7 @@ export class Controller { readonly host: HTMLElement private nodes = new Map>() private edgeCache = new Map>() + private handleCache = new Map() private inlineSession: (InlineSession & { id: string }) | null = null private cleanup: Array<() => void> = [] private dialogKey = 0 @@ -118,7 +120,7 @@ export class Controller { canRedo: false, busy: 0, toast: null, - rail: null, + rail: false, dev: false, assistant: false, inline: null, @@ -294,9 +296,10 @@ export class Controller { this.client.clear() this.nodes.clear() this.edgeCache.clear() + this.handleCache.clear() await this.softReload() // the page may have been the published one: swap to live preview this.scan() - void this.loadTree() + void this.loadTree().then(() => void this.fillHandles()) this.toast('info', `Signed in as ${this.auth.state.get().user}`) } @@ -495,13 +498,35 @@ export class Controller { this.snapAsked.add(n.id) this.remember(n) } - for (const c of t?.children ?? []) walk(c) + for (const c of t?.children ?? []) { + const cn = c?.node as CmsNode | undefined + const handle = (c?.edge as CmsEdge | undefined)?.handle + if (cn?.id && handle) this.handleCache.set(cn.id, handle) + walk(c) + } } walk(tree) } catch { /* labels then arrive lazily, per node */ } } + + /** Learn every rendered block's slot handle in the background (one edges call per parent). */ + async fillHandles(): Promise { + const parents = [...new Set(this.store.get().blocks.map((b) => b.parentId ?? ''))] + await Promise.all( + parents.map(async (p) => { + if (!p) return + try { + await this.childEdges(p) + } catch { + /* a slot count stays 0 until the edges load */ + } + }), + ) + // The handle cache lives outside the store: bump so the outline re-reads it. + this.store.set((st) => ({ tick: st.tick + 1 })) + } schema = (type: string): Promise => this.api.schema(type) private schemaCache = new Map() @@ -550,6 +575,44 @@ export class Controller { return !!b && acceptsChild(b.schema, type, this.typeList) } + /** Named slots of a block (x-graph.connections), with live occupant counts. */ + slotsFor(parentId: string): Array<{ name: string; def: ConnectionDef; count: number; accepts: (type: string) => boolean }> { + const b = this.block(parentId) + const conns = connectionsOf(b?.schema) + return Object.entries(conns).map(([name, def]) => ({ + name, + def, + count: this.store.get().blocks.filter((x) => x.parentId === parentId && this.handleCache.get(x.id) === name).length, + accepts: (type: string) => acceptsHandle(b?.schema, name, type), + })) + } + + /** Blocks living in a named slot (for the outline). */ + slotChildren(parentId: string, handle: string): Block[] { + return this.store.get().blocks.filter((x) => x.parentId === parentId && this.handleCache.get(x.id) === handle) + } + + /** Can `type` be linked into `handle` of `parentId`? */ + acceptsInSlot(parentId: string, handle: string, type: string): boolean { + const b = this.block(parentId) + if (!b) return parentId === '' && !handle + return acceptsHandle(b.schema, handle, type) + } + + /** The edge handle that placed this block in its parent ('' = plain child). Learned from edges/tree. */ + handleOf(id: string): string | undefined { + return this.handleCache.get(id) + } + + /** When dropping `type` onto `parentId`, pick the slot when plain children refuse it. */ + handleForDrop(parentId: string, type: string): string | undefined { + if (!parentId) return undefined + if (this.accepts(parentId, type)) return undefined + const b = this.block(parentId) + const names = Object.keys(connectionsOf(b?.schema)) + return names.find((h) => this.acceptsInSlot(parentId, h, type)) + } + /** The node type used to wrap blocks for styling: the schema tagged `x-style.wrapper`. */ wrapperType(): string | undefined { for (const [t, s] of this.schemaCache) if (s['x-style']?.wrapper) return t @@ -569,7 +632,14 @@ export class Controller { private childEdges(id: string): Promise { let p = this.edgeCache.get(`out:${id}`) if (!p) { - p = this.api.edges(id).then((r) => (r.from ?? []).filter((e) => !e.type || e.type === 'child').sort((a, b) => a.sort - b.sort)) + p = this.api.edges(id).then((r) => { + const kids = (r.from ?? []).filter((e) => !e.type || e.type === 'child').sort((a, b) => a.sort - b.sort) + for (const e of kids) { + if (e.handle) this.handleCache.set(e.to, e.handle) + else if (!this.handleCache.has(e.to)) this.handleCache.set(e.to, undefined) + } + return kids + }) p.catch(() => this.edgeCache.delete(`out:${id}`)) this.edgeCache.set(`out:${id}`, p) } @@ -583,6 +653,7 @@ export class Controller { const into = (await this.parentEdges(id)).filter((e) => !e.type || e.type === 'child') const hit = attr ? into.find((e) => e.id === attr) : undefined const edge = hit ?? into.find((e) => e.from === b?.parentId) ?? (b?.parentId ? undefined : into[0]) + if (edge) this.handleCache.set(id, edge.handle || undefined) if (!edge) throw new Error(`cannot resolve the edge of ${id} (parent ${b?.parentId ?? 'page root'})`) if (!b?.parentId && this.store.get().rootId !== edge.from) this.store.set({ rootId: edge.from }) return edge @@ -654,6 +725,25 @@ export class Controller { return res } + /** Attach an existing node (from node search) into a parent or slot. Shared blocks are linked, not copied. */ + async linkExisting(parent: string, nodeId: string, opts: { handle?: string; index?: number } = {}) { + try { + const res = await this.apply([{ op: 'graft', parent, tree: { link: nodeId }, ...(opts.handle ? { handle: opts.handle } : {}), ...(opts.index !== undefined ? { index: opts.index } : {}) }]) + if (opts.handle) this.handleCache.set(nodeId, opts.handle) + const created = res?.results[0]?.node + if (created) await this.selectCreated(created) + return res + } catch (e) { + this.fail(e) + return null + } + } + + /** Insert a new node of `type` straight into a named slot. */ + async insertIntoSlot(parent: string, handle: string, type: string, index?: number) { + return this.insert(parent, type, { handle, ...(index !== undefined ? { index } : {}) }) + } + async remove(id: string) { try { const edge = await this.edgeOf(id) @@ -766,6 +856,8 @@ export class Controller { } this.store.set((st) => ({ editRev: st.editRev + 1 })) this.edgeCache.clear() // structure may have changed + this.handleCache.clear() + void this.fillHandles() const active = this.doc.activeElement const report = applyPatches(this.doc.body, patches, { skip: (el) => !!this.inlineSession && el instanceof HTMLElement && el.isContentEditable && (el.contains(active) || el === active), @@ -912,10 +1004,15 @@ export class Controller { /** Start dragging a new block type (or blueprint) out of the palette. */ beginPaletteDrag(item: { type?: string; blueprint?: string; label: string }) { const type = item.type - this.startDrag({ id: null, source: 'palette', type, label: item.label }, (target) => void this.dropNew(target, item)) + this.startDrag({ id: null, source: 'palette', type, blueprint: item.blueprint, label: item.label }, (target) => void this.dropNew(target, item)) } - private startDrag(d: { id: string | null; source: 'block' | 'palette'; type?: string; label: string }, onDrop: (t: DropTarget) => void) { + /** Start dragging an existing node (from node search or the outline) to link it somewhere. */ + beginExistingDrag(node: { id: string; type: string; label: string }) { + this.startDrag({ id: null, source: 'existing', type: node.type, nodeId: node.id, label: node.label }, (target) => void this.dropExisting(target, node.id)) + } + + private startDrag(d: { id: string | null; source: 'block' | 'palette' | 'existing'; type?: string; blueprint?: string; nodeId?: string; label: string }, onDrop: (t: DropTarget) => void) { this.cancelDrag() const doc = this.doc this.store.set({ drag: { ...d, target: null } }) @@ -927,7 +1024,9 @@ export class Controller { const r = b.el.getBoundingClientRect() return { id: b.id, parentId: b.parentId, container: b.container, depth: b.depth, rect: { x: r.x, y: r.y, w: r.width, h: r.height } } }) - const accepts = d.type ? (parent: string) => this.accepts(parent, d.type!) : undefined + const accepts = d.type + ? (parent: string) => this.accepts(parent, d.type!) || this.handleForDrop(parent, d.type!) !== undefined + : undefined // Existing blocks sort among their siblings by default (snapping); Alt nests into the container under the pointer. if (d.source === 'block' && d.id) return moveTarget(blocks, { x, y }, { id: d.id, parentId: this.block(d.id)?.parentId }, { nest, accepts }) return dropTarget(blocks, { x, y }, d.id, accepts) @@ -969,15 +1068,17 @@ export class Controller { if (this.store.get().drag) this.store.set({ drag: null }) } - /** Commit a block move: one `move` op (re-parent and/or reorder). */ + /** Commit a block move: one `move` op (re-parent and/or reorder, slots included). */ async dropBlock(id: string, target: DropTarget) { try { const edge = await this.edgeOf(id) + const b = this.block(id) const parent = target.parentId || (await this.pageRoot()) - const sibs = await this.childEdges(edge.from) + const handle = target.handle ?? (b?.type ? this.handleForDrop(parent, b.type) : undefined) + const sibs = (await this.childEdges(edge.from)).filter((e) => (e.handle ?? undefined) === (edge.handle ?? undefined)) const cur = sibs.findIndex((e) => e.id === edge.id) - if (parent === edge.from && cur === target.index) return null // dropped where it was - return await this.apply([{ op: 'move', edge: edge.id, parent, index: target.index }]) + if (parent === edge.from && (edge.handle ?? undefined) === handle && cur === target.index) return null // dropped where it was + return await this.apply([{ op: 'move', edge: edge.id, parent, index: target.index, ...(handle ? { handle } : {}) }]) } catch (e) { this.fail(e) return null @@ -988,14 +1089,26 @@ export class Controller { async dropNew(target: DropTarget, item: { type?: string; blueprint?: string }) { try { const parent = target.parentId || (await this.pageRoot()) + const handle = target.handle ?? (item.type ? this.handleForDrop(parent, item.type) : undefined) if (item.blueprint) return await this.insertBlueprint(parent, item.blueprint, target.index) - if (item.type) return await this.insert(parent, item.type, { index: target.index }) + if (item.type) return await this.insert(parent, item.type, { index: target.index, ...(handle ? { handle } : {}) }) } catch (e) { this.fail(e) } return null } + /** Commit an outline drop of a searched (existing) node: link it at the position. */ + async dropExisting(target: DropTarget, nodeId: string) { + try { + const parent = target.parentId || (await this.pageRoot()) + return await this.linkExisting(parent, nodeId, { handle: target.handle, index: target.index }) + } catch (e) { + this.fail(e) + return null + } + } + // --- every property editable where it is rendered --------------------------------------- /** Remove the markers inference put on the page (the next selection re-infers for its own block). */ diff --git a/src/editors/builtin.tsx b/src/editors/builtin.tsx index 6a4291d..18848a1 100644 --- a/src/editors/builtin.tsx +++ b/src/editors/builtin.tsx @@ -3,7 +3,14 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { mountMarkdownEditor } from '@k6n/cms-editor' import { diffData } from '../diff' -import { allowedChildren, sortGroups, typeName } from '../schema' +import { allowedChildren, connectionsOf, sortGroups, typeName } from '../schema' + +/** Types that fit a named slot; empty `to` means any known type. */ +function slotTypes(schema: unknown, all: string[], handle: string): string[] { + const def = connectionsOf(schema as never)[handle] + if (!def) return [] + return def.to.length ? def.to.filter((t) => all.includes(t)) : all +} import { useEditorState } from '../ui/hooks' import { getIn, parsePath } from '../page/infer' import type { Blueprint } from '../api' @@ -81,7 +88,7 @@ function RawJson({ node, ctrl, close }: EditorProps) { // --- Insert picker: children allowed by x-graph + blueprints --------------------- -export function InsertPicker({ parentId, index, ctrl, close }: { parentId: string; index?: number; ctrl: Controller; close(): void }) { +export function InsertPicker({ parentId, handle, index, ctrl, close }: { parentId: string; handle?: string; index?: number; ctrl: Controller; close(): void }) { const [state, setState] = useState<{ types: string[]; blueprints: Blueprint[] } | { error: string } | null>(null) useEffect(() => { let live = true @@ -89,8 +96,8 @@ export function InsertPicker({ parentId, index, ctrl, close }: { parentId: strin try { const parent = await ctrl.node(parentId) const [schema, all, blueprints] = await Promise.all([ctrl.schema(parent.dataType), ctrl.api.types(), ctrl.api.blueprints()]) - const types = allowedChildren(schema, all.map(typeName)).filter((t) => !ctrl.isStaticRoot(t)) - if (live) setState({ types, blueprints }) + const types = (handle ? slotTypes(schema, all.map(typeName), handle) : allowedChildren(schema, all.map(typeName))).filter((t) => !ctrl.isStaticRoot(t)) + if (live) setState({ types, blueprints: handle ? [] : blueprints }) void ctrl.loadSchemas(types) // for grouping } catch (e) { if (live) setState({ error: (e as Error).message }) @@ -99,7 +106,7 @@ export function InsertPicker({ parentId, index, ctrl, close }: { parentId: strin return () => { live = false } - }, [parentId, ctrl]) + }, [parentId, handle, ctrl]) useEditorState((st) => st.schemasVersion) if (!state) return

Loading…

if ('error' in state) return

{state.error}

@@ -114,7 +121,7 @@ export function InsertPicker({ parentId, index, ctrl, close }: { parentId: strin title={t} onClick={async () => { close() - await ctrl.insert(parentId, t, index !== undefined ? { index } : {}) + await ctrl.insert(parentId, t, { ...(index !== undefined ? { index } : {}), ...(handle ? { handle } : {}) }) }} > diff --git a/src/page/dnd.ts b/src/page/dnd.ts index fa837b3..b0b721d 100644 --- a/src/page/dnd.ts +++ b/src/page/dnd.ts @@ -19,6 +19,8 @@ export interface DndBlock { export interface DropTarget { /** Container id, or '' for the page root. */ parentId: string + /** Named slot (edge handle) within the parent, if any. */ + handle?: string /** Index among the parent's children *excluding* the dragged block. */ index: number /** Where to draw the insertion line (viewport coordinates). */ diff --git a/src/schema.ts b/src/schema.ts index f7c752d..3bf7e95 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -42,6 +42,33 @@ export const groupOf = (schema?: JSONSchema): string | undefined => { return typeof g === 'string' && g ? g : undefined } +export interface ConnectionDef { + to: string[] + kind: 'single' | 'multi' + description?: string +} + +/** Named slots of a node type (`x-graph.connections`), e.g. pdp's `info-section` / `3d-accessories` / `model`. */ +export function connectionsOf(schema: JSONSchema | undefined): Record { + const c = xGraph(schema).connections as Record | undefined + if (!c || typeof c !== 'object') return {} + const out: Record = {} + for (const [name, v] of Object.entries(c)) { + if (!v || typeof v !== 'object') continue + const o = v as Record + const to = Array.isArray(o.to) ? (o.to as unknown[]).map(String) : [] + out[name] = { to, kind: o.kind === 'single' ? 'single' : 'multi', description: typeof o.description === 'string' ? o.description : undefined } + } + return out +} + +/** Can `type` be linked into `handle` of this schema? Empty `to` = any type. */ +export function acceptsHandle(schema: JSONSchema | undefined, handle: string, type: string): boolean { + const c = connectionsOf(schema)[handle] + if (!c) return false + return c.to.length === 0 || c.to.includes(type) +} + export const OTHER_GROUP = 'Other' /** Group names in display order: alphabetical, with the catch-all last. */ diff --git a/src/ui/Dock.tsx b/src/ui/Dock.tsx index 18bffbd..988e66e 100644 --- a/src/ui/Dock.tsx +++ b/src/ui/Dock.tsx @@ -37,7 +37,7 @@ export function Dock() { ctrl.toast('error', (e as Error).message) } } - const rail = (which: 'outline' | 'blocks') => ctrl.store.set({ rail: s.rail === which ? null : which }) + const panel = () => ctrl.store.set({ rail: !s.rail }) return (