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

+253
View File
@@ -0,0 +1,253 @@
// Built-in editors. They go through the same registries as third-party ones, so
// replacing any of them is `registerEditor({ id: 'properties', ... })`.
import { useEffect, useMemo, useRef, useState } from 'react'
import { mountMarkdownEditor } from '@k6n/cms-editor'
import { diffData } from '../diff'
import { allowedChildren, sortGroups, typeName } from '../schema'
import { useEditorState } from '../ui/hooks'
import { getIn, parsePath } from '../page/infer'
import type { Blueprint } from '../api'
import type { Controller } from '../controller'
import { registerEditor, registerInlineEditor } from '../registry'
import { describeStyle } from '../style/model'
import { StylePanel } from '../style/StylePanel'
import type { EditorProps } from '../registry'
import { SchemaForm, JsonArea, validate } from '../form/Form'
import { Icon, TypeIcon } from '../ui/Icon'
// --- Properties: schema-driven form with explicit Save ------------------------
function Properties({ node, schema, ctrl, close }: EditorProps) {
const [draft, setDraft] = useState<Record<string, any>>(node.data ?? {})
const [saving, setSaving] = useState(false)
const patch = useMemo(() => diffData(node.data ?? {}, draft), [node, draft])
const form = useRef<HTMLFormElement>(null)
useEffect(() => void form.current?.querySelector<HTMLElement>('input:not([type=checkbox]), textarea, select')?.focus(), [])
const problems = useMemo(() => Object.entries(validate(schema, schema, draft)), [schema, draft])
const changed = Object.keys(patch).length > 0
const save = async () => {
setSaving(true)
const res = await ctrl.setData(node.id, patch, { version: node.version })
setSaving(false)
if (res) close()
}
return (
<form
ref={form}
className="cms-form"
onSubmit={(e) => {
e.preventDefault()
if (changed && !problems.length) void save()
}}
>
<SchemaForm schema={schema} value={draft} onChange={setDraft} node={node} ctrl={ctrl} />
<footer className="cms-actions">
<button type="button" className="cms-btn" onClick={() => setDraft(node.data ?? {})} disabled={!changed}>
<Icon name="reset" size={16} /> Reset
</button>
{problems.length > 0 && (
<span className="cms-error cms-actions__note" role="alert" title={problems.map(([p, m]) => `${p || 'form'}: ${m}`).join('\n')}>
{problems.length} problem{problems.length === 1 ? '' : 's'} to fix
</span>
)}
<button type="submit" className="cms-btn cms-btn--primary" disabled={!changed || saving || problems.length > 0}>
<Icon name="save" size={16} /> {saving ? 'Saving…' : 'Save'}
</button>
</footer>
</form>
)
}
// --- Raw JSON: escape hatch for anything the form can't express -----------------
function RawJson({ node, ctrl, close }: EditorProps) {
const [draft, setDraft] = useState<any>(node.data ?? {})
const patch = diffData(node.data ?? {}, draft && typeof draft === 'object' ? draft : {})
return (
<div className="cms-form">
<JsonArea value={node.data ?? {}} onChange={setDraft} />
<footer className="cms-actions">
<button
className="cms-btn cms-btn--primary"
disabled={!Object.keys(patch).length}
onClick={async () => (await ctrl.setData(node.id, patch, { version: node.version })) && close()}
>
<Icon name="save" size={16} /> Save
</button>
</footer>
</div>
)
}
// --- Insert picker: children allowed by x-graph + blueprints ---------------------
export function InsertPicker({ parentId, index, ctrl, close }: { parentId: string; index?: number; ctrl: Controller; close(): void }) {
const [state, setState] = useState<{ types: string[]; blueprints: Blueprint[] } | { error: string } | null>(null)
useEffect(() => {
let live = true
;(async () => {
try {
const parent = await ctrl.node(parentId)
const [schema, all, blueprints] = await Promise.all([ctrl.schema(parent.dataType), ctrl.api.types(), ctrl.api.blueprints()])
const types = allowedChildren(schema, all.map(typeName)).filter((t) => !ctrl.isStaticRoot(t))
if (live) setState({ types, blueprints })
void ctrl.loadSchemas(types) // for grouping
} catch (e) {
if (live) setState({ error: (e as Error).message })
}
})()
return () => {
live = false
}
}, [parentId, ctrl])
useEditorState((st) => st.schemasVersion)
if (!state) return <p className="cms-muted">Loading…</p>
if ('error' in state) return <p className="cms-error">{state.error}</p>
if (!state.types.length && !state.blueprints.length) return <p className="cms-muted">This block does not accept children.</p>
const byGroup = new Map<string, string[]>()
for (const t of state.types) byGroup.set(ctrl.groupOfType(t), [...(byGroup.get(ctrl.groupOfType(t)) ?? []), t])
const tile = (t: string) => (
<button
key={t}
className="cms-tile"
data-type={t}
title={t}
onClick={async () => {
close()
await ctrl.insert(parentId, t, index !== undefined ? { index } : {})
}}
>
<TypeIcon type={t} size={20} />
<span>{ctrl.typeTitle(t)}</span>
{ctrl.typeTitle(t) !== t && <small>{t}</small>}
</button>
)
return (
<div className="cms-picker">
{sortGroups(byGroup.keys()).map((g) => (
<section key={g}>
<h4>{g}</h4>
<div className="cms-grid">{byGroup.get(g)!.map(tile)}</div>
</section>
))}
{state.blueprints.length > 0 && <h4>Blueprints</h4>}
<div className="cms-grid">
{state.blueprints.map((b) => (
<button
key={b.id}
className="cms-tile"
title={b.description}
onClick={async () => {
close()
await ctrl.insertBlueprint(parentId, b.id, index)
}}
>
{b.name ?? b.id}
</button>
))}
</div>
</div>
)
}
function AddChild({ info, ctrl, close }: EditorProps) {
return <InsertPicker parentId={info.id} ctrl={ctrl} close={close} />
}
function AddAfter({ info, ctrl, close }: EditorProps) {
const [target, setTarget] = useState<{ parent: string; index: number } | null>(null)
useEffect(() => {
void (async () => {
const parent = await ctrl.parentIdOf(info.id)
const { index } = await ctrl.position(info.id)
setTarget({ parent, index: index + 1 })
})().catch((e) => ctrl.toast('error', (e as Error).message))
}, [info.id, ctrl])
return target ? <InsertPicker parentId={target.parent} index={target.index} ctrl={ctrl} close={close} /> : <p className="cms-muted">Loading…</p>
}
export function registerBuiltins(): () => void {
const off = [
registerEditor({ id: 'properties', label: 'Properties', icon: 'edit', match: '*', priority: 100, onDoubleClick: true, component: Properties }),
// Matches on the schema, not the node type: any type with x-style fields gets spacing/layout controls.
registerEditor({ id: 'style', label: 'Style', icon: 'style', match: (i) => describeStyle(i.schema).length > 0, priority: 90, size: 'md', component: StylePanel }),
registerEditor({ id: 'add-child', label: 'Add inside', icon: 'add-inside', match: (i) => i.container, priority: 50, size: 'sm', component: AddChild }),
registerEditor({ id: 'add-after', label: 'Add after', icon: 'add', match: '*', priority: 40, size: 'sm', component: AddAfter }),
registerEditor({ id: 'raw-json', label: 'JSON', icon: 'json', placement: 'menu', match: '*', priority: -100, size: 'lg', component: RawJson }),
registerInlineEditor({ id: 'text', start: startPlainText }),
registerInlineEditor({ id: 'markdown', start: startMarkdown }),
]
return () => off.forEach((f) => f())
}
// --- inline editors ------------------------------------------------------------
import type { InlineEditorDef } from '../registry'
const DEBOUNCE_MS = 350
// `field` is a dotted path ("title", "links.0.text"): explicit template markers use plain names, inferred ones may be nested.
const startPlainText: InlineEditorDef['start'] = ({ el, node, field, ctrl }) => {
const key = `inline:${node.id}:${field}`
const before = el.getAttribute('contenteditable')
el.setAttribute('contenteditable', 'plaintext-only')
if (el.contentEditable !== 'plaintext-only') el.setAttribute('contenteditable', 'true') // jsdom / old browsers
el.setAttribute('data-cms-editing', '')
el.focus()
try {
const sel = el.ownerDocument.defaultView!.getSelection()
const range = el.ownerDocument.createRange()
range.selectNodeContents(el)
sel?.removeAllRanges()
sel?.addRange(range) // select all: typing replaces, arrow keys place the caret
} catch {
/* no selection API */
}
let sent = el.textContent ?? ''
let timer: ReturnType<typeof setTimeout> | undefined
const flush = () => {
clearTimeout(timer)
const text = el.textContent ?? ''
if (text === sent) return
sent = text
void ctrl.setPath(node.id, field, text, { coalesce: key })
}
const onInput = () => {
clearTimeout(timer)
timer = setTimeout(flush, DEBOUNCE_MS)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !el.hasAttribute('data-cms-multiline')) {
e.preventDefault()
ctrl.stopInline()
}
}
const onBlur = () => ctrl.stopInline()
el.addEventListener('input', onInput)
el.addEventListener('keydown', onKey)
el.addEventListener('blur', onBlur)
return {
stop() {
el.removeEventListener('input', onInput)
el.removeEventListener('keydown', onKey)
el.removeEventListener('blur', onBlur)
flush()
if (before === null) el.removeAttribute('contenteditable')
else el.setAttribute('contenteditable', before)
el.removeAttribute('data-cms-editing')
},
}
}
const startMarkdown: InlineEditorDef['start'] = ({ el, node, field, ctrl }) => {
const key = `inline:${node.id}:${field}`
const handle = mountMarkdownEditor({
element: el,
markdown: String(getIn(node.data, parsePath(field)) ?? ''),
editorClass: el.className,
onChange: (md) => void ctrl.setPath(node.id, field, md, { coalesce: key }),
onExit: () => ctrl.stopInline(),
})
handle.focus()
return { stop: () => handle.destroy() }
}