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
@@ -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() }
|
||||
}
|
||||
Reference in new issue
Block a user