// 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, sortGroups, typeName } from '../schema' 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>(node.data ?? {}) const [saving, setSaving] = useState(false) const patch = useMemo(() => diffData(node.data ?? {}, draft), [node, draft]) const form = useRef(null) useEffect(() => void form.current?.querySelector('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.setData(node.id, patch, { version: node.version }) setSaving(false) if (res) close() } return (
{ e.preventDefault() if (changed && !problems.length) void save() }} > {changed && (
{problems.length > 0 && ( `${p || 'form'}: ${m}`).join('\n')}> {problems.length} problem{problems.length === 1 ? '' : 's'} to fix )}
)} ) } // --- Raw JSON: escape hatch for anything the form can't express ----------------- function RawJson({ node, ctrl, close }: EditorProps) { const [draft, setDraft] = useState(node.data ?? {}) const patch = diffData(node.data ?? {}, draft && typeof draft === 'object' ? draft : {}) return (
{Object.keys(patch).length > 0 && (
)}
) } // --- Insert picker: children allowed by x-graph + blueprints --------------------- export function InsertPicker({ parentId, index, ctrl, close }: { parentId: 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 = allowedChildren(schema, all.map(typeName)).filter((t) => !ctrl.isStaticRoot(t)) if (live) setState({ types, blueprints }) void ctrl.loadSchemas(types) // for grouping } catch (e) { if (live) setState({ error: (e as Error).message }) } })() return () => { live = false } }, [parentId, ctrl]) useEditorState((st) => st.schemasVersion) if (!state) return

Loading…

if ('error' in state) return

{state.error}

if (!state.types.length && !state.blueprints.length) return

This block does not accept children.

const byGroup = new Map() for (const t of state.types) byGroup.set(ctrl.groupOfType(t), [...(byGroup.get(ctrl.groupOfType(t)) ?? []), t]) const tile = (t: string) => ( ) return (
{sortGroups(byGroup.keys()).map((g) => (

{g}

{byGroup.get(g)!.map(tile)}
))} {state.blueprints.length > 0 &&

Blueprints

}
{state.blueprints.map((b) => ( ))}
) } function AddChild({ info, ctrl, close }: EditorProps) { return } 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 ? :

Loading…

} 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 | 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() } }