Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
197 lines
7.9 KiB
TypeScript
197 lines
7.9 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 '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)
|
|
}
|
|
}
|