Add on-page editor for the go-graph-cms

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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
// 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)
}
}