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