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