Files
cms-onpageeditor/mock/graph.ts
T
mats 33bd5269fd 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>
2026-11-06 20:38:31 +01:00

210 lines
8.6 KiB
TypeScript

// An in-memory stand-in for the CMS: just enough of /api/edit, /api/nodes and
// /api/schemas to develop and test the editor with no server and no login.
// Semantics follow docs/onpage-editor.md (atomic batches, inverse ops, 409 on a
// stale version, patches = re-rendered subtrees). It is deliberately small - when
// real behaviour differs, fix the mock, then the editor.
export interface MNode { id: string; dataType: string; data: Record<string, any>; version: number; tags: string[] }
export interface MEdge { id: string; from: string; to: string; type: string; handle?: string; sort: number }
export class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message)
}
}
type Op = Record<string, any>
export class Graph {
nodes = new Map<string, MNode>()
edges = new Map<string, MEdge>()
private seq = 100
constructor(seed?: { nodes: Omit<MNode, 'version' | 'tags'>[]; edges: Omit<MEdge, 'type'>[] }) {
if (seed) {
for (const n of seed.nodes) this.nodes.set(n.id, { version: 1, tags: [], ...n })
for (const e of seed.edges) this.edges.set(e.id, { type: 'child', ...e })
}
}
private id(p: string) {
return `${p}${++this.seq}`
}
node(id: string): MNode {
const n = this.nodes.get(id)
if (!n) throw new HttpError(404, `node ${id} not found`)
return n
}
edge(id: string): MEdge {
const e = this.edges.get(id)
if (!e) throw new HttpError(404, `edge ${id} not found`)
return e
}
children(id: string): MEdge[] {
return [...this.edges.values()].filter((e) => e.from === id && e.type === 'child').sort((a, b) => a.sort - b.sort)
}
parents(id: string): MEdge[] {
return [...this.edges.values()].filter((e) => e.to === id)
}
edgesOf(id: string) {
return { from: this.children(id), into: this.parents(id) }
}
private renumber(parent: string) {
this.children(parent).forEach((e, i) => (e.sort = i))
}
/** Place edge `e` at `index` among its parent's children. */
private place(e: MEdge, index?: number) {
const sibs = this.children(e.from).filter((x) => x.id !== e.id)
const at = index === undefined || index > sibs.length ? sibs.length : Math.max(0, index)
sibs.splice(at, 0, e)
sibs.forEach((x, i) => (x.sort = i))
}
private subtree(edgeId: string): { nodes: MNode[]; edges: MEdge[] } {
const root = this.edge(edgeId)
const nodes: MNode[] = []
const edges: MEdge[] = [root]
const walk = (id: string) => {
nodes.push(this.node(id))
for (const c of this.children(id)) {
edges.push(c)
walk(c.to)
}
}
walk(root.to)
return { nodes, edges }
}
/** Apply a batch atomically. Returns results, inverse (already reversed) and touched ids. */
edit(ops: Op[]) {
if (!Array.isArray(ops) || ops.length < 1 || ops.length > 200) throw new HttpError(400, 'ops must hold 1-200 operations')
const snapshot = structuredClone({ nodes: [...this.nodes], edges: [...this.edges], seq: this.seq })
const results: Array<{ node?: string; edge?: string }> = []
const inverse: Op[] = []
const touched = new Set<string>()
try {
ops.forEach((op, i) => {
try {
const { result, inv, touch } = this.applyOne(op)
results.push(result)
inverse.unshift(...inv)
touch.forEach((t) => touched.add(t))
} catch (e) {
if (e instanceof HttpError) throw new HttpError(e.status, `op ${i} (${op.op}): ${e.message}`)
throw e
}
})
} catch (e) {
this.nodes = new Map(snapshot.nodes)
this.edges = new Map(snapshot.edges)
this.seq = snapshot.seq
throw e
}
return { results, inverse, touched: [...touched] }
}
private applyOne(op: Op): { result: { node?: string; edge?: string }; inv: Op[]; touch: string[] } {
switch (op.op) {
case 'insert': {
this.node(op.parent)
if (!op.type) throw new HttpError(400, 'type required')
const node: MNode = { id: op.id ?? this.id('n'), dataType: op.type, data: { ...(op.data ?? {}) }, version: 1, tags: [] }
this.nodes.set(node.id, node)
const edge: MEdge = { id: this.id('e'), from: op.parent, to: node.id, type: 'child', handle: op.handle, sort: 0 }
this.edges.set(edge.id, edge)
this.place(edge, op.index)
return { result: { node: node.id, edge: edge.id }, inv: [{ op: 'delete', edge: edge.id }], touch: [op.parent] }
}
case 'set': {
const n = this.node(op.id)
if (op.version !== undefined && op.version !== n.version) throw new HttpError(409, `stale version (have ${n.version}, got ${op.version})`)
const undo: Record<string, unknown> = {}
for (const [k, v] of Object.entries<any>(op.data ?? {})) {
if (['lastModified', 'status'].includes(k)) throw new HttpError(400, `lifecycle key ${k} cannot be set`)
undo[k] = k in n.data ? n.data[k] : null
if (v === null) delete n.data[k]
else n.data[k] = v
}
n.version++
return { result: { node: n.id }, inv: [{ op: 'set', id: n.id, data: undo }], touch: [n.id] }
}
case 'move': {
const e = this.edge(op.edge)
const before = { parent: e.from, handle: e.handle, index: this.children(e.from).findIndex((x) => x.id === e.id) }
const to = op.parent ?? e.from
if (this.isDescendant(to, e.to)) throw new HttpError(400, 'move would create a cycle')
const oldParent = e.from
e.from = to
if ('handle' in op) e.handle = op.handle
this.renumber(oldParent)
this.place(e, op.index)
return {
result: { edge: e.id },
inv: [{ op: 'move', edge: e.id, parent: before.parent, handle: before.handle, index: before.index }],
touch: [...new Set([oldParent, to])],
}
}
case 'delete': {
const e = this.edge(op.edge)
const snap = this.subtree(e.id)
for (const x of snap.edges) this.edges.delete(x.id)
// Subtree nodes survive only if still composed elsewhere (shared blocks).
for (const n of snap.nodes) if (this.parents(n.id).length === 0) this.nodes.delete(n.id)
this.renumber(e.from)
return { result: {}, inv: [{ op: 'restore', edge: e.id, nodes: structuredClone(snap.nodes), edges: structuredClone(snap.edges) }], touch: [e.from] }
}
case 'restore': {
for (const n of op.nodes ?? []) this.nodes.set(n.id, structuredClone(n))
for (const e of op.edges ?? []) this.edges.set(e.id, structuredClone(e))
const root = this.edge(op.edge)
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>()
const clone = (id: string): string => {
const src = this.node(id)
const copy: MNode = { ...structuredClone(src), id: this.id('n'), version: 1 }
this.nodes.set(copy.id, copy)
map.set(id, copy.id)
for (const c of this.children(id)) {
const ce: MEdge = { ...c, id: this.id('e'), from: copy.id, to: clone(c.to) }
this.edges.set(ce.id, ce)
}
return copy.id
}
const nid = clone(e.to)
const ne: MEdge = { ...e, id: this.id('e'), to: nid }
this.edges.set(ne.id, ne)
this.place(ne, e.sort + 1)
return { result: { node: nid, edge: ne.id }, inv: [{ op: 'delete', edge: ne.id }], touch: [e.from] }
}
default:
throw new HttpError(400, `op "${op.op}" is not implemented by the mock`)
}
}
private isDescendant(id: string, ancestor: string): boolean {
if (id === ancestor) return true
return this.children(ancestor).some((c) => this.isDescendant(id, c.to))
}
/** Root = a node nobody composes. */
roots(): MNode[] {
return [...this.nodes.values()].filter((n) => this.parents(n.id).length === 0)
}
}