Files
cms-onpageeditor/src/editors/builtin.tsx
T
mats 33bd5269fd Single panel: outline tree with slots, node search with type filter
The outline and blocks are no longer tabs: one panel shows the
outline tree, the new-block palette and a search over existing
nodes. Outline rows drag to reorder (same snap engine, explicit
gap targets), palette blocks and searched nodes drop onto the
outline gaps as well as the page.

Named slots (x-graph.connections, e.g. a pdp's info-section /
3d-accessories / model) render as groups under their block with
allowed types, live counts and a slot-limited insert picker.
Drops that fit no plain-children rule resolve to the first
accepting slot; existing nodes link via graft tree.link.

Adds Api.searchNodes, schema connections helpers, handle-aware
controller ops (linkExisting, insertIntoSlot, dropExisting) and
mock support for /api/nodes listing and graft links.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-11-06 20:38:31 +01:00

305 lines
11 KiB
TypeScript

// 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, connectionsOf, sortGroups, typeName } from '../schema'
/** Types that fit a named slot; empty `to` means any known type. */
function slotTypes(schema: unknown, all: string[], handle: string): string[] {
const def = connectionsOf(schema as never)[handle]
if (!def) return []
return def.to.length ? def.to.filter((t) => all.includes(t)) : all
}
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.saveData(node.id, node.data ?? {}, patch)
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} />
{changed && (
<footer className="cms-actions">
<button type="button" className="cms-btn" onClick={() => setDraft(node.data ?? {})}>
<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={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} />
{Object.keys(patch).length > 0 && (
<footer className="cms-actions">
<button className="cms-btn cms-btn--primary" onClick={async () => (await ctrl.saveData(node.id, node.data ?? {}, patch)) && close()}>
<Icon name="save" size={16} /> Save
</button>
</footer>
)}
</div>
)
}
// --- Insert picker: children allowed by x-graph + blueprints ---------------------
export function InsertPicker({ parentId, handle, index, ctrl, close }: { parentId: string; handle?: 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 = (handle ? slotTypes(schema, all.map(typeName), handle) : allowedChildren(schema, all.map(typeName))).filter((t) => !ctrl.isStaticRoot(t))
if (live) setState({ types, blueprints: handle ? [] : blueprints })
void ctrl.loadSchemas(types) // for grouping
} catch (e) {
if (live) setState({ error: (e as Error).message })
}
})()
return () => {
live = false
}
}, [parentId, handle, 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 } : {}), ...(handle ? { handle } : {}) })
}}
>
<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() }
}