298 lines
10 KiB
TypeScript
298 lines
10 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, 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<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.setData(node.id, patch, { version: node.version })
|
|
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.setData(node.id, patch, { version: node.version })) && close()}>
|
|
<Icon name="save" size={16} /> Save
|
|
</button>
|
|
</footer>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- 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 <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 } : {})
|
|
}}
|
|
>
|
|
<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() }
|
|
}
|