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

+270
View File
@@ -0,0 +1,270 @@
import { createElement, useEffect, useRef, useState } from 'react'
import type { Block } from '../controller'
import { AttrEditor } from '../editors/AttrEditor'
import { InsertPicker } from '../editors/builtin'
import { getIn, parsePath } from '../page/infer'
import { editorsFor } from '../registry'
import { BoxHandles } from '../style/BoxHandles'
import { describeStyle } from '../style/model'
import { Icon, TypeIcon } from './Icon'
import { useCtrl, useEditorState, useRegistryVersion } from './hooks'
const rectOf = (el: HTMLElement) => el.getBoundingClientRect()
/** Hover + selection frames drawn over the page, the selected block's toolbar, drop ghost, slots and chips. */
export function Overlay() {
const { hovered, selected, mode, blocks } = useEditorState((s) => ({ hovered: s.hovered, selected: s.selected, mode: s.mode, blocks: s.blocks }))
useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation
useRegistryVersion()
useEditorState((s) => s.schemasVersion)
useEditorState((s) => s.labelsVersion)
const ctrl = useCtrl()
const inline = useEditorState((s) => s.inline)
const signedIn = useEditorState((s) => s.auth.status === 'signed-in')
const drag = useEditorState((s) => s.drag)
if (mode !== 'edit' || !signedIn) return null
const sel = blocks.filter((b) => b.id === selected)
const hov = hovered && hovered !== selected ? blocks.filter((b) => b.id === hovered) : []
const idle = !inline && !drag
return (
<div className="cms-overlay">
{hov.map((b, i) => (
<Frame key={`h${b.id}-${i}`} el={b.el} kind="hover" type={b.type} title={ctrl.labelFor(b.id)} group={ctrl.groupOfType(b.type)} />
))}
{sel.map((b, i) => (
<Frame key={`s${b.id}-${i}`} el={b.el} kind={inline ? 'editing' : 'selected'} type={b.type} />
))}
{idle && sel[0] && <BoxHandles block={sel[0]} />}
{idle && <InsertSlots blocks={blocks} selected={selected} hovered={hovered} />}
{idle && sel[0] && <AttrChips />}
{idle && sel[0] && <ActionBar block={sel[0]} />}
{drag && <DropGhost />}
</div>
)
}
function Frame({ el, kind, type, title, group }: { el: HTMLElement; kind: string; type: string; title?: string; group?: string }) {
const r = rectOf(el)
return (
<div className={`cms-frame cms-frame--${kind}`} style={{ left: r.left, top: r.top, width: r.width, height: r.height }}>
{kind === 'hover' && (
<span className="cms-frame__tag">
<TypeIcon type={type} size={12} /> {title ?? type}
{title && <i> {type}</i>}
{group && group !== 'Other' && <em>{group}</em>}
</span>
)}
</div>
)
}
// --- toolbar ----------------------------------------------------------------------------------
function ActionBar({ block }: { block: Block }) {
const ctrl = useCtrl()
const r = rectOf(block.el)
const defs = editorsFor(block)
const toolbar = defs.filter((d) => d.placement !== 'menu')
const menu = defs.filter((d) => d.placement === 'menu')
const [pos, setPos] = useState<{ index: number; count: number } | null>(null)
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let live = true
setPos(null)
setOpen(false)
ctrl
.position(block.id)
.then((p) => live && setPos(p))
.catch(() => {})
return () => {
live = false
}
}, [block.id, ctrl, block.el])
useEffect(() => {
if (!open) return
const away = (e: Event) => !e.composedPath().includes(menuRef.current!) && setOpen(false)
document.addEventListener('pointerdown', away, true)
return () => document.removeEventListener('pointerdown', away, true)
}, [open])
const wrapper = ctrl.wrapperType()
const canWrap = !!wrapper && !!block.schema && block.type !== wrapper && describeStyle(block.schema).length === 0
const below = r.top < 48
const style = { left: Math.max(4, Math.min(r.left, window.innerWidth - 420)), top: below ? Math.min(r.bottom + 6, window.innerHeight - 44) : r.top - 42 }
return (
<div className="cms-bar" style={style} role="toolbar" aria-label={`${block.type} actions`}>
<span
className="cms-bar__type cms-bar__grip"
title="Drag to move"
aria-label="Drag to move"
onPointerDown={(e) => {
e.preventDefault()
ctrl.beginDrag(block.id)
}}
>
<Icon name="grip" size={16} />
<TypeIcon type={block.type} size={15} />
<span className="cms-bar__name" title={`${block.type} · ${ctrl.groupOfType(block.type)}`}>
{ctrl.labelFor(block.id) ?? block.type}
</span>
</span>
{toolbar.map((d) => (
<button key={d.id} className="cms-ico" title={d.label} aria-label={d.label} onClick={() => void ctrl.openEditor(d, block.id)}>
<Icon name={d.icon ?? 'edit'} />
</button>
))}
{canWrap && (
<button className="cms-ico" title={`Add spacing & layout (wrap in ${wrapper})`} aria-label="Wrap in layout" onClick={() => void ctrl.wrapInLayout(block.id)}>
<Icon name="wrap" />
</button>
)}
<span className="cms-bar__sep" />
<button className="cms-ico" title="Move up" aria-label="Move up" disabled={!pos || pos.index <= 0} onClick={() => void ctrl.nudge(block.id, -1)}>
<Icon name="up" />
</button>
<button className="cms-ico" title="Move down" aria-label="Move down" disabled={!pos || pos.index >= pos.count - 1} onClick={() => void ctrl.nudge(block.id, 1)}>
<Icon name="down" />
</button>
<button className="cms-ico" title="Duplicate" aria-label="Duplicate" onClick={() => void ctrl.duplicate(block.id)}>
<Icon name="duplicate" />
</button>
<button className="cms-ico cms-ico--danger" title="Delete" aria-label="Delete" onClick={() => void ctrl.remove(block.id)}>
<Icon name="delete" />
</button>
<div className="cms-menuwrap" ref={menuRef}>
<button className={`cms-ico${open ? ' is-on' : ''}`} title="More" aria-label="More actions" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
<Icon name="more" />
</button>
{open && (
<div className="cms-menu" role="menu">
{menu.map((d) => (
<button
key={d.id}
role="menuitem"
onClick={() => {
setOpen(false)
void ctrl.openEditor(d, block.id)
}}
>
<Icon name={d.icon ?? 'edit'} size={16} /> {d.label}
</button>
))}
<button
role="menuitem"
onClick={() => {
setOpen(false)
void navigator.clipboard?.writeText(block.id).then(() => ctrl.toast('info', 'Node id copied'))
}}
>
<Icon name="duplicate" size={16} /> Copy node id
</button>
</div>
)}
</div>
</div>
)
}
// --- drop ghost: the "virtual element" that shows where a dragged / new block will land ---------
function DropGhost() {
const drag = useEditorState((s) => s.drag)!
const t = drag.target
const vertical = !!t && t.line.h > t.line.w // a vertical line = siblings side by side
return (
<>
{t?.snaps?.map((r, i) => (
<div key={i} className={`cms-snap${r === t.line ? ' is-active' : ''}`} style={{ left: r.x, top: r.y, width: r.w, height: r.h }} />
))}
{t && (
<>
<div className="cms-dropline" style={{ left: t.line.x, top: t.line.y, width: t.line.w, height: t.line.h }} />
<div className="cms-ghost" style={vertical ? { left: t.line.x + 10, top: t.line.y + Math.min(t.line.h / 2, 120) - 17, width: 130 } : { left: t.line.x, top: t.line.y + 6, width: Math.max(t.line.w, 120) }}>
<TypeIcon type={drag.type ?? drag.label} size={16} />
<span>{drag.source === 'palette' ? `New ${drag.label}` : `Move ${drag.label}`}</span>
</div>
</>
)}
{drag.pointer && (
<div className={`cms-dragchip${t ? '' : ' is-refused'}`} style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 14 }}>
<TypeIcon type={drag.type ?? drag.label} size={14} /> {drag.label}
{!t ? ' · not here' : drag.source === 'block' ? (drag.nesting ? ' · nesting (release Alt to sort)' : t.snaps ? ' · sorting · Alt to nest' : '') : ''}
</div>
)}
</>
)
}
// --- insertion slots: "+" between a container's children (and inside an empty one) ----------------
function InsertSlots({ blocks, selected, hovered }: { blocks: Block[]; selected: string | null; hovered: string | null }) {
const ctrl = useCtrl()
const containers = [selected, hovered].filter((id, i, a): id is string => !!id && a.indexOf(id) === i).map((id) => blocks.find((b) => b.id === id)).filter((b): b is Block => !!b && b.container)
const open = (parentId: string, index: number | undefined, x: number, y: number) => {
let key = 0
key = ctrl.openDialog({ id: `insert:${parentId}:${index ?? 'end'}`, title: 'Add block', size: 'sm', anchor: { x, y }, content: createElement(InsertPicker, { parentId, index, ctrl, close: () => ctrl.closeDialog(key) }) })
}
return (
<>
{containers.map((c) => {
const r = rectOf(c.el)
const kids = blocks.filter((b) => b.parentId === c.id).map((b) => ({ b, r: rectOf(b.el) }))
// Only vertical flows get between-slots; grids/rows would put the "+" in the wrong place.
const vertical = kids.every((k, i) => i === 0 || k.r.top >= kids[i - 1].r.bottom - 1)
if (!kids.length) {
return (
<button key={c.id} className="cms-slot cms-slot--empty" style={{ left: r.left + r.width / 2, top: r.top + Math.min(r.height / 2, 40) }} aria-label="Add block inside" onClick={() => open(c.id, undefined, r.left + r.width / 2, r.top)}>
<Icon name="add" size={16} /> Add block
</button>
)
}
const slots = vertical
? [...kids.map((k, i) => ({ index: i, y: i === 0 ? k.r.top : (kids[i - 1].r.bottom + k.r.top) / 2 })), { index: kids.length, y: kids[kids.length - 1].r.bottom }]
: [{ index: kids.length, y: r.bottom }]
return slots.map((s) => (
<button key={`${c.id}-${s.index}`} className="cms-slot" style={{ left: r.left + r.width / 2, top: s.y }} aria-label={`Add block at position ${s.index + 1}`} title="Add block here" onClick={() => open(c.id, s.index, r.left + r.width / 2, s.y)}>
<Icon name="add" size={14} />
</button>
))
})}
</>
)
}
// --- attribute chips: href / src / alt … of the selected block, editable where they are used ------------
const ATTR_ICON: Record<string, string> = { href: 'link', src: 'image', alt: 'info', title: 'info' }
function AttrChips() {
const ctrl = useCtrl()
const bindings = useEditorState((s) => s.bindings)
const selected = useEditorState((s) => s.selected)
return (
<>
{bindings.map((b) => {
if (!b.el.isConnected) return null
const r = rectOf(b.el)
return (
<button
key={`${b.path}:${b.attr}`}
className="cms-attrchip"
style={{ left: Math.max(4, r.right - 6), top: Math.max(4, r.top - 6) }}
title={`${b.attr}: ${b.value}`}
aria-label={`Edit ${b.attr}`}
onClick={async () => {
if (!selected) return
const node = await ctrl.node(selected, true)
const initial = String(getIn(node.data, parsePath(b.path)) ?? b.value)
let key = 0
const rr = b.el.getBoundingClientRect()
key = ctrl.openDialog({ id: `attr:${selected}:${b.path}`, title: `${b.attr} · ${b.path}`, size: 'sm', anchor: { x: rr.right, y: rr.top }, content: createElement(AttrEditor, { id: selected, path: b.path, attr: b.attr, initial, ctrl, close: () => ctrl.closeDialog(key) }) })
}}
>
<Icon name={ATTR_ICON[b.attr] ?? 'edit'} size={13} />
</button>
)
})}
</>
)
}