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

+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Blueprint, TypeInfo } from '../api'
import { OTHER_GROUP, allowedChildren, sortGroups, typeName } from '../schema'
import { Icon, TypeIcon } from './Icon'
import { Summary } from './Summary'
import { useCtrl, useEditorState } from './hooks'
/** Left rail: the page outline, and the palette of blocks you can drag onto the page. */
export function Rail() {
const ctrl = useCtrl()
const rail = useEditorState((s) => s.rail)
if (!rail) return null
return (
<aside className="cms-rail" aria-label={rail === 'outline' ? 'Outline' : 'Blocks'}>
<header>
<div className="cms-tabs" role="tablist">
{(['outline', 'blocks'] as const).map((t) => (
<button key={t} role="tab" aria-selected={rail === t} className={rail === t ? 'is-on' : ''} onClick={() => ctrl.store.set({ rail: t })}>
<Icon name={t} size={16} /> {t === 'outline' ? 'Outline' : 'Blocks'}
</button>
))}
</div>
<button className="cms-ico" onClick={() => ctrl.store.set({ rail: null })} aria-label="Close panel">
<Icon name="close" />
</button>
</header>
{rail === 'outline' ? <Outline /> : <Palette />}
</aside>
)
}
/** Page structure from the DOM scan: no API calls, always in sync with what is rendered. */
function Outline() {
const ctrl = useCtrl()
const { blocks, selected } = useEditorState((s) => ({ blocks: s.blocks, selected: s.selected }))
useEditorState((s) => s.labelsVersion)
if (!blocks.length) return <p className="cms-muted cms-pad">No editable blocks on this page.</p>
return (
<ul className="cms-outline">
{blocks.map((b, i) => (
<li key={`${b.id}-${i}`}>
<button
className={b.id === selected ? 'is-sel' : ''}
style={{ paddingLeft: 8 + b.depth * 14 }}
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
onMouseLeave={() => ctrl.store.set({ hovered: null })}
onClick={() => {
ctrl.setMode('edit')
ctrl.select(b.id, { scroll: true })
}}
>
<TypeIcon type={b.type} size={15} />
{/* title (x-graph.label) when the type has one, otherwise the type name and a text snippet */}
{(() => {
const title = ctrl.labelFor(b.id)
return title ? (
<>
<span className="cms-outline__type">{title}</span>
<span className="cms-outline__text">{b.type}</span>
</>
) : (
<>
<span className="cms-outline__type">{b.type || '?'}</span>
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
</>
)
})()}
</button>
</li>
))}
</ul>
)
}
const DRAG_THRESHOLD = 5
type Item = { key: string; label: string; /** the type id, shown under the title when it differs */ sub?: string; type?: string; blueprint?: string; group: string; description?: string }
/**
* Every block type (and blueprint) that fits somewhere on this page. Drag one onto the page - a ghost
* shows where it will land, and containers that do not accept it refuse the drop - or click to add it
* to the selection (inside a container, otherwise after the selected block, otherwise at the page end).
*/
function Palette() {
const ctrl = useCtrl()
const [types, setTypes] = useState<TypeInfo[] | null>(null)
const [blueprints, setBlueprints] = useState<Blueprint[]>([])
const [q, setQ] = useState('')
const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks }))
const schemasVersion = useEditorState((s) => s.schemasVersion)
const suppressClick = useRef(false)
useEffect(() => {
void ctrl.api
.types()
.then((ts) => {
ctrl.ingestTypes(ts) // /api/types includes every schema: titles + groups with no further requests
setTypes(ts)
void ctrl.loadSchemas(ts.map(typeName)) // only for entries that arrived without a schema
})
.catch(() => setTypes([]))
void ctrl.api.blueprints().then(setBlueprints).catch(() => {})
}, [ctrl])
// Types some container on the page (or the page itself) can hold - the rest would only ever be refused.
const items = useMemo<Item[]>(() => {
if (!types) return []
const all = types.map(typeName).filter(Boolean)
const allowed = new Set<string>()
for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t))
const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing
return [
...types
.map((t) => ({ t, name: typeName(t) }))
.filter(({ name }) => name && !ctrl.isStaticRoot(name) && (rootAllowed || allowed.has(name)))
.map(({ name }) => {
const title = ctrl.typeTitle(name)
return { key: `t:${name}`, label: title, sub: title !== name ? name : undefined, type: name, group: ctrl.groupOfType(name), description: ctrl.schemaOf(name)?.description as string | undefined }
}),
...blueprints.map((b) => ({ key: `b:${b.id}`, label: b.name ?? b.id, blueprint: b.id, group: 'Blueprints', description: b.description })),
]
}, [types, blueprints, blocks, ctrl, schemasVersion])
const needle = q.toLowerCase()
const shown = items.filter((i) => i.label.toLowerCase().includes(needle) || (i.type ?? '').toLowerCase().includes(needle))
const groups = sortGroups(shown.map((i) => i.group).filter((g) => g !== 'Blueprints'))
if (shown.some((i) => i.group === 'Blueprints')) groups.push('Blueprints')
const addToSelection = async (item: Item) => {
const sel = ctrl.block(selected)
try {
let parent: string
let index: number | undefined
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type))) parent = sel.id
else if (sel) {
parent = await ctrl.parentIdOf(sel.id)
index = (await ctrl.position(sel.id)).index + 1
} else parent = await ctrl.pageRoot()
if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index)
else if (item.type) await ctrl.insert(parent, item.type, index !== undefined ? { index } : {})
} catch (e) {
ctrl.toast('error', (e as Error).message)
}
}
const onPointerDown = (e: React.PointerEvent, item: Item) => {
if (e.button !== 0) return
const x0 = e.clientX
const y0 = e.clientY
const doc = e.currentTarget.ownerDocument
const move = (m: PointerEvent) => {
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
stop()
suppressClick.current = true
ctrl.setMode('edit')
ctrl.beginPaletteDrag({ type: item.type, blueprint: item.blueprint, label: item.label })
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}
return (
<div className="cms-palette">
<div className="cms-pad">
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
<p className="cms-muted cms-hint">Drag onto the page, or click to add to the selection.</p>
</div>
{!types && <p className="cms-muted cms-pad">Loading…</p>}
{groups.map((g) => (
<details key={g} className="cms-group-sec" open>
<Summary>
{g} <span className="cms-muted">{shown.filter((i) => i.group === g).length}</span>
</Summary>
<div className="cms-palette__grid">
{shown
.filter((i) => i.group === g)
.map((i) => (
<button
key={i.key}
className="cms-block"
data-type={i.type}
title={i.description ?? `Add ${i.label}`}
onPointerDown={(e) => onPointerDown(e, i)}
onClick={() => {
if (suppressClick.current) return void (suppressClick.current = false)
void addToSelection(i)
}}
>
<TypeIcon type={i.type ?? i.label} size={22} />
<span>{i.label}</span>
{i.sub && <small>{i.sub}</small>}
</button>
))}
</div>
</details>
))}
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
</div>
)
}