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>
119 lines
4.6 KiB
TypeScript
119 lines
4.6 KiB
TypeScript
import { useState } from 'react'
|
|
import type { JSONSchema } from '../api'
|
|
import { Icon } from '../ui/Icon'
|
|
import { useForm } from './context'
|
|
import { Widget } from './Form'
|
|
import { defaultFor, resolveSchema } from './resolve'
|
|
|
|
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
|
|
/**
|
|
* Key → value editor: `additionalProperties` schemas, `x-editor: dictionary`, objects without a
|
|
* declared shape. With `x-dict-keys` the keys are a fixed vocabulary (one row each, blank removes);
|
|
* otherwise keys are free-form (rename, add, remove).
|
|
*/
|
|
export function MapField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
|
|
const { root } = useForm()
|
|
const obj = isObj(value) ? value : {}
|
|
const ap = s.additionalProperties
|
|
const valueSchema: JSONSchema = ap && typeof ap === 'object' ? ap : {}
|
|
const fixed = Array.isArray(s['x-dict-keys']) ? (s['x-dict-keys'] as string[]) : null
|
|
const [draftKey, setDraftKey] = useState('')
|
|
const [dupe, setDupe] = useState(false)
|
|
|
|
const setVal = (k: string, v: unknown) => {
|
|
const next = { ...obj }
|
|
if (fixed && (v === undefined || v === '')) delete next[k]
|
|
else next[k] = v === undefined ? defaultFor(root, valueSchema) : v
|
|
onChange(next)
|
|
}
|
|
|
|
if (fixed) {
|
|
const rest = Object.keys(obj).filter((k) => !fixed.includes(k))
|
|
return (
|
|
<div className="cms-map">
|
|
{[...fixed, ...rest].map((k) => (
|
|
<div key={k} className="cms-map__row">
|
|
<span className="cms-map__key">{k}</span>
|
|
<div className="cms-map__value">
|
|
<Widget schema={valueSchema} value={obj[k]} onChange={(v) => setVal(k, v)} path={`${path}.${k}`} />
|
|
</div>
|
|
{k in obj && (
|
|
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Clear ${k}`} onClick={() => setVal(k, undefined)}>
|
|
<Icon name="close" size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const rename = (from: string, to: string) => {
|
|
to = to.trim()
|
|
if (!to || to === from) return false
|
|
if (to in obj) return setDupe(true), false
|
|
// Rebuild to keep the row's position.
|
|
onChange(Object.fromEntries(Object.entries(obj).map(([k, v]) => [k === from ? to : k, v])))
|
|
return true
|
|
}
|
|
const add = () => {
|
|
const k = draftKey.trim()
|
|
if (!k) return
|
|
if (k in obj) return setDupe(true)
|
|
onChange({ ...obj, [k]: defaultFor(root, valueSchema) })
|
|
setDraftKey('')
|
|
setDupe(false)
|
|
}
|
|
|
|
return (
|
|
<div className="cms-map">
|
|
{Object.entries(obj).map(([k, v]) => (
|
|
<div key={k} className="cms-map__row">
|
|
<KeyInput name={k} onCommit={(to) => rename(k, to)} />
|
|
<div className="cms-map__value">
|
|
<Widget schema={valueSchema} value={v} onChange={(nv) => setVal(k, nv)} path={`${path}.${k}`} required />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="cms-ico cms-ico--danger"
|
|
aria-label={`Remove ${k}`}
|
|
onClick={() => {
|
|
const { [k]: _gone, ...rest } = obj
|
|
onChange(rest)
|
|
}}
|
|
>
|
|
<Icon name="close" size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="cms-map__add">
|
|
<input
|
|
className={`cms-input${dupe ? ' cms-input--bad' : ''}`}
|
|
placeholder="new key"
|
|
aria-label="New key"
|
|
value={draftKey}
|
|
onChange={(e) => (setDraftKey(e.target.value), setDupe(false))}
|
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
|
|
/>
|
|
<button type="button" className="cms-btn" aria-label="Add entry" onClick={add} disabled={!draftKey.trim()}>
|
|
<Icon name="add" size={16} /> Add
|
|
</button>
|
|
</div>
|
|
{dupe && <span className="cms-field__error">That key already exists</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Key text is local until blur/Enter: renaming rebuilds the object, which would otherwise steal focus per keystroke. */
|
|
function KeyInput({ name, onCommit }: { name: string; onCommit(to: string): boolean }) {
|
|
const [text, setText] = useState(name)
|
|
const [seen, setSeen] = useState(name)
|
|
if (seen !== name) {
|
|
setSeen(name)
|
|
setText(name)
|
|
}
|
|
const commit = () => (onCommit(text) ? undefined : setText(name))
|
|
return <input className="cms-input cms-map__key cms-mono" aria-label={`Key ${name}`} value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), commit())} />
|
|
}
|