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>
305 lines
11 KiB
TypeScript
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() }
|
|
}
|