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:
mats committed 2026-11-06 20:38:31 +01:00
1 parent a4ed99f13e
commit 33bd5269fd
16 files changed
+852 -110

No files matched your search

+13 -3
View File
@@ -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
+14 -2
View File
@@ -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',
+13
View File
@@ -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>()
+12
View File
@@ -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]))
+10
View File
@@ -137,6 +137,16 @@ export class Api {
me = () => this.get<{ [k: string]: unknown }>('/api/auth/me')
node = (id: string) => this.get<CmsNode>(`/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<NodeEdges>(`/api/nodes/${encodeURIComponent(id)}/edges`)
edit = (ops: Op[]) => this.post<EditResult>('/api/edit', { ops })
publish = (ids: string[]) => this.post<{ publishedAt?: string; results?: { id: string; error?: string }[] }>('/api/publish-roots', { ids })
+129 -16
View File
@@ -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<string, Promise<CmsNode>>()
private edgeCache = new Map<string, Promise<CmsEdge[]>>()
private handleCache = new Map<string, string | undefined>()
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<void> {
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<JSONSchema> => this.api.schema(type)
private schemaCache = new Map<string, JSONSchema>()
@@ -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<CmsEdge[]> {
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). */
+13 -6
View File
@@ -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 <p className="cms-muted">Loading…</p>
if ('error' in state) return <p className="cms-error">{state.error}</p>
@@ -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 } : {}) })
}}
>
<TypeIcon type={t} size={20} />
+2
View File
@@ -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). */
+27
View File
@@ -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<string, ConnectionDef> {
const c = xGraph(schema).connections as Record<string, unknown> | undefined
if (!c || typeof c !== 'object') return {}
const out: Record<string, ConnectionDef> = {}
for (const [name, v] of Object.entries(c)) {
if (!v || typeof v !== 'object') continue
const o = v as Record<string, unknown>
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. */
+2 -5
View File
@@ -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 (
<nav className="cms-dock" aria-label="Page editor">
<button className={`cms-pill${s.mode === 'edit' ? ' is-on' : ''}`} onClick={() => ctrl.setMode(s.mode === 'edit' ? 'browse' : 'edit')} title="Toggle editing (links work in browse mode)">
@@ -51,10 +51,7 @@ export function Dock() {
<Icon name="redo" />
</button>
<span className="cms-dock__sep" />
<button className={`cms-ico${s.rail === 'blocks' ? ' is-on' : ''}`} onClick={() => rail('blocks')} title="Blocks: drag onto the page" aria-label="Blocks">
<Icon name="blocks" />
</button>
<button className={`cms-ico${s.rail === 'outline' ? ' is-on' : ''}`} onClick={() => rail('outline')} title="Outline" aria-label="Outline">
<button className={`cms-ico${s.rail ? ' is-on' : ''}`} onClick={() => panel()} title="Page panel: outline, blocks and nodes" aria-label="Panel">
<Icon name="outline" />
</button>
{aiSupported() && (
+1 -1
View File
@@ -196,7 +196,7 @@ function DropGhost() {
<div className="cms-dropline" style={{ left: t.line.x, top: t.line.y, width: t.line.w, height: t.line.h }} />
<div className="cms-ghost" style={vertical ? { left: t.line.x + 10, top: t.line.y + Math.min(t.line.h / 2, 120) - 17, width: 130 } : { left: t.line.x, top: t.line.y + 6, width: Math.max(t.line.w, 120) }}>
<TypeIcon type={drag.type ?? drag.label} size={16} />
<span>{drag.source === 'palette' ? `New ${drag.label}` : `Move ${drag.label}`}</span>
<span>{drag.source === 'palette' ? `New ${drag.label}` : drag.source === 'existing' ? `Link ${drag.label}` : `Move ${drag.label}`}</span>
</div>
</>
)}
+425 -65
View File
@@ -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>
)
}
+28 -3
View File
@@ -224,10 +224,35 @@ input.cms-map__key { padding-top: 6px; }
.cms-attrchip { position: fixed; pointer-events: auto; transform: translate(-100%, -100%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 999px; border: 1px solid #5ee0a0; background: #14161d; color: #5ee0a0; z-index: 1; }
.cms-attrchip:hover { background: #1f3a2e; }
/* left rail: outline + block palette */
.cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: 280px; display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
.cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px 0 6px; border-bottom: 1px solid var(--cms-line); }
/* left panel: outline + blocks + nodes, one scroll (no tabs) */
.cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: 320px; display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
.cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 6px 6px 12px; border-bottom: 1px solid var(--cms-line); font-weight: 600; }
.cms-rail__title { display: inline-flex; align-items: center; gap: 8px; }
.cms-rail__body { overflow-y: auto; flex: 1; min-height: 0; padding-bottom: 12px; }
.cms-rail__body > section + section { border-top: 1px solid var(--cms-line); margin-top: 8px; padding-top: 4px; }
.cms-rail__sec { margin: 10px 12px 4px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
.cms-rail .cms-tabs { border: 0; } .cms-rail .cms-tabs button { display: inline-flex; align-items: center; gap: 6px; }
.cms-orow { display: flex; align-items: center; gap: 2px; border-radius: 6px; padding-right: 4px; }
.cms-orow:hover { background: #242836; }
.cms-orow.is-sel { background: #242836; color: var(--cms-accent); }
.cms-orow__main { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; background: none; border: 0; border-radius: 6px; padding: 4px 6px 4px 2px; text-align: left; overflow: hidden; white-space: nowrap; }
.cms-orow__grip { cursor: grab; color: #5b6075; display: inline-flex; padding: 4px 2px; touch-action: none; user-select: none; }
.cms-orow__grip:active { cursor: grabbing; }
.cms-orow__chev { flex-shrink: 0; }
.cms-orow__spacer { width: 28px; flex-shrink: 0; }
.cms-outline--nested { padding: 0; overflow: visible; }
.cms-ogap { height: 6px; border-radius: 3px; margin: 1px 6px; transition: background .1s; }
.cms-ogap.is-over { background: rgba(94,224,160,.7); height: 8px; box-shadow: 0 0 0 2px rgba(94,224,160,.25); }
.cms-ogap.is-refused { background: rgba(163,58,74,.35); }
.cms-oslot { list-style: none; }
.cms-oslot__head { display: flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px; color: #8b8fa0; font-size: 12px; }
.cms-oslot__head .cms-muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.cms-oslot-tag { font-style: normal; font-size: 10px; padding: 0 5px; border-radius: 99px; background: #2b3350; color: #9fb2ff; }
.cms-nodefind { display: flex; flex-direction: column; gap: 6px; }
.cms-nodelist { list-style: none; margin: 0; padding: 0 8px; display: flex; flex-direction: column; gap: 2px; }
.cms-noderow { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border: 1px solid var(--cms-line); border-radius: 8px; background: #1a1d26; cursor: grab; touch-action: none; user-select: none; }
.cms-noderow:active { cursor: grabbing; }
.cms-noderow__main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; white-space: nowrap; }
.cms-pad { padding: 10px 12px 4px; }
.cms-hint { font-size: 11.5px; margin-top: 6px; }
.cms-outline { list-style: none; margin: 0; padding: 4px; overflow: auto; }
+3 -3
View File
@@ -117,7 +117,7 @@ describe('block palette and drag & drop', () => {
it('lists the types that fit the page, searchable', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.press('Panel')
await h.settle()
const names = qa('.cms-block[data-type]').map((b) => b.dataset.type)
expect(names).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'layout', 'footer']))
@@ -133,7 +133,7 @@ describe('block palette and drag & drop', () => {
it('click adds to the selection: inside a container, otherwise after the block', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.press('Panel')
await select('s1') // container
await act(async () => void q('.cms-block[data-type="button"]').click())
await h.settle()
@@ -147,7 +147,7 @@ describe('block palette and drag & drop', () => {
it('drag from the palette shows a ghost at the target and inserts at the drop index', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.press('Panel')
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
+154
View File
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
const kids = (id: string) => h.cms.graph.children(id).map((e) => e.to)
const setInput = async (el: HTMLInputElement, v: string) => {
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(el, v)
el.dispatchEvent(new Event('input', { bubbles: true }))
})
await h.settle()
}
describe('node search (existing nodes, type filter)', () => {
it('lists nodes and filters by type and text', async () => {
h = await setup()
await h.settle()
const all = await h.editor.ctrl.api.searchNodes({})
expect(all.total).toBeGreaterThan(5)
expect(all.items.map((n) => n.id)).toContain('orphan1')
const texts = await h.editor.ctrl.api.searchNodes({ type: 'text' })
expect(texts.items.length).toBeGreaterThan(0)
expect(texts.items.every((n) => n.dataType === 'text')).toBe(true)
const q1 = await h.editor.ctrl.api.searchNodes({ q: 'sparrow' })
expect(q1.items.map((n) => n.id).sort()).toEqual(['orphan1', 'orphan2'])
const both = await h.editor.ctrl.api.searchNodes({ type: 'button', q: 'sparrow' })
expect(both.items.map((n) => n.id)).toEqual(['orphan2'])
})
it('the panel shows a Nodes section with a type filter; linking adds a shared block', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
await h.settle()
expect(q('[aria-label="Search nodes"]')).not.toBeNull()
expect(q('[aria-label="Filter by type"]')).not.toBeNull()
const rows = () => qa('.cms-noderow')
expect(rows().length).toBeGreaterThan(0)
await setInput(q('[aria-label="Search nodes"]') as HTMLInputElement, 'sparrow')
await new Promise((r) => setTimeout(r, 400)) // search debounce (bare: act() holds component timers)
await h.settle()
expect(qa('.cms-noderow').map((r) => r.getAttribute('title'))).toEqual([expect.stringContaining('orphan1'), expect.stringContaining('orphan2')])
// filter to buttons: only orphan2 stays
const sel = q('[aria-label="Filter by type"]') as HTMLSelectElement
await act(async () => {
sel.value = 'button'
sel.dispatchEvent(new Event('change', { bubbles: true }))
})
await new Promise((r) => setTimeout(r, 400)) // debounce again (bare: act() holds component timers)
await h.settle()
expect(qa('.cms-noderow').map((r) => r.getAttribute('title'))).toEqual([expect.stringContaining('orphan2')])
// link it after the selected block: select t2 (child of s2), link lands in s2
await act(async () => void byId('t2').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true })))
await h.settle()
const before = kids('s2')
await act(async () => void qa('.cms-noderow button')[0].click())
await h.settle()
await h.settle()
expect(kids('s2')).toHaveLength(before.length + 1)
expect(h.cms.graph.node('orphan2')).toBeDefined() // shared: the node itself survives
})
})
describe('slots / dropzones (x-graph.connections)', () => {
it('section exposes its sidebar slot; plain children still win when they accept the type', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.fillHandles()
const slots = h.editor.ctrl.slotsFor('s1')
expect(slots.map((s) => s.name)).toEqual(['sidebar'])
expect(slots[0].def.to).toEqual(['text', 'button', 'footer'])
expect(h.editor.ctrl.acceptsInSlot('s1', 'sidebar', 'footer')).toBe(true)
expect(h.editor.ctrl.acceptsInSlot('s1', 'sidebar', 'section')).toBe(false)
expect(h.editor.ctrl.acceptsInSlot('s1', 'nope', 'text')).toBe(false)
// footer fits no plain-children rule, so drops resolve to the slot
expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar')
// heading fits plain children: no slot needed
expect(h.editor.ctrl.handleForDrop('s1', 'heading')).toBeUndefined()
})
it('inserting into a slot lands the block there and the outline shows it grouped', async () => {
h = await setup()
await h.settle()
const res = await h.editor.ctrl.insertIntoSlot('s1', 'sidebar', 'footer', 0)
expect(res).not.toBeNull()
await h.settle()
await h.editor.ctrl.fillHandles()
await h.settle()
expect(h.editor.ctrl.handleOf(res!.results[0].node!)).toBe('sidebar')
expect(h.editor.ctrl.slotChildren('s1', 'sidebar').map((b) => b.id)).toEqual([res!.results[0].node!])
await h.press('Panel')
await h.settle()
const heads = qa('.cms-oslot__head').map((x) => x.textContent)
expect(heads.some((t) => t!.includes('sidebar'))).toBe(true)
})
it('linking an existing node into a slot shares it (no copy)', async () => {
h = await setup()
await h.settle()
const res = await h.editor.ctrl.linkExisting('s1', 'orphan1', { handle: 'sidebar' })
expect(res).not.toBeNull()
expect(kids('s1')).toContain('orphan1')
await h.editor.ctrl.fillHandles()
expect(h.editor.ctrl.handleOf('orphan1')).toBe('sidebar')
// still reachable as its own node (shared, not copied)
expect(h.cms.graph.node('orphan1').data.body).toContain('sparrows')
})
it('outline drops commit through the same engine: reorder, insert, link', async () => {
h = await setup()
await h.settle()
const line = { x: 0, y: 0, w: 0, h: 0 }
// reorder b1 before h1 within s1
await h.editor.ctrl.dropBlock('b1', { parentId: 's1', index: 0, line })
expect(kids('s1')).toEqual(['b1', 'h1', 't1'])
// insert a new heading at index 1
await h.editor.ctrl.dropNew({ parentId: 's1', index: 1, line }, { type: 'heading' })
expect(kids('s1')).toHaveLength(4)
expect(h.cms.graph.node(kids('s1')[1]).dataType).toBe('heading')
// link the orphan into the sidebar slot
await h.editor.ctrl.dropExisting({ parentId: 's1', handle: 'sidebar', index: 0, line }, 'orphan2')
await h.editor.ctrl.fillHandles()
expect(h.editor.ctrl.handleOf('orphan2')).toBe('sidebar')
})
it('the slot add button opens an insert picker limited to the slot types', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
await h.settle()
const add = qa('[aria-label="Add to sidebar"]')
expect(add.length).toBeGreaterThan(0)
await act(async () => void add[0].click())
await h.settle()
await h.settle()
const tiles = qa('.cms-dialog .cms-tile').map((x) => x.getAttribute('data-type'))
expect(tiles.sort()).toEqual(['button', 'footer', 'text'])
await act(async () => void q('.cms-dialog .cms-tile[data-type="footer"]').click())
await h.settle()
await h.editor.ctrl.fillHandles()
const footers = h.editor.ctrl.slotChildren('s1', 'sidebar')
expect(footers).toHaveLength(1)
expect(h.cms.graph.node(footers[0].id).dataType).toBe('footer')
})
})
+6 -6
View File
@@ -43,8 +43,8 @@ describe('node titles (x-graph.label)', () => {
h = await setup()
await h.settle()
await h.settle()
await h.press('Outline')
const items = qa('.cms-outline li button')
await h.press('Panel')
const items = qa('.cms-orow__main')
const s1 = items.find((b) => b.textContent!.includes('Welcome'))!
expect(s1.querySelector('.cms-outline__type')!.textContent).toBe('Welcome')
expect(s1.querySelector('.cms-outline__text')!.textContent).toBe('section')
@@ -105,7 +105,7 @@ describe('groups in the UI', () => {
it('the palette is grouped by x-graph.group (collapsible), blueprints last', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.press('Panel')
await h.settle()
await h.settle()
const secs = qa('.cms-group-sec')
@@ -134,7 +134,7 @@ describe('the block list shows titles and groups straight from /api/types', () =
it('seeds every schema from the one types request - no per-type schema fetches for the palette', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.press('Panel')
await h.settle()
await h.settle()
// /api/types carries each type's schema: types that are not on the page are never fetched individually
@@ -147,7 +147,7 @@ describe('the block list shows titles and groups straight from /api/types', () =
it('palette items show the schema title with the type id underneath (disambiguates look-alikes)', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.press('Panel')
await h.settle()
const heading = q('.cms-block[data-type="heading"]')
expect(heading.querySelector('span')!.textContent).toBe('Heading')
@@ -162,7 +162,7 @@ describe('the block list shows titles and groups straight from /api/types', () =
await h.settle()
expect(h.editor.ctrl.isStaticRoot('page')).toBe(true)
expect(h.editor.ctrl.isStaticRoot('heading')).toBe(false)
await h.press('Blocks')
await h.press('Panel')
await h.settle()
expect(qa('.cms-block[data-type="page"]')).toHaveLength(0)
expect(qa('.cms-group-sec summary').map((s) => s.textContent)).not.toContain('Pages')