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 (