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:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
+196
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user