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>
210 lines
8.6 KiB
TypeScript
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)
|
|
}
|
|
}
|