Add on-page editor for the go-graph-cms
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>
This commit is contained in:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { EditorProps } from '../registry'
|
||||
import { useEditorState } from '../ui/hooks'
|
||||
import { BREAKPOINTS, effective, patchFor, readField } from './model'
|
||||
import type { Breakpoint, StyleField } from './model'
|
||||
import { pxOf } from './tokens'
|
||||
import type { Scale } from './tokens'
|
||||
import { Icon } from '../ui/Icon'
|
||||
import { describeStyle, fieldForSide } from './model'
|
||||
|
||||
type Data = Record<string, any>
|
||||
|
||||
const LEGACY_FLOW = ['vertical', 'horizontal', 'grid']
|
||||
const ICON: Record<string, Record<string, string>> = {
|
||||
flow: { row: 'flow-row', stack: 'flow-stack', actions: 'flow-actions', vertical: 'flow-stack', horizontal: 'flow-row', grid: 'flow-grid' },
|
||||
align: { start: 'align-start', center: 'align-center', end: 'align-end', left: 'text-left', right: 'text-right' },
|
||||
justify: { start: 'align-start', center: 'align-center', end: 'align-end' },
|
||||
}
|
||||
const cap = (o: string) => o[0].toUpperCase() + o.slice(1)
|
||||
|
||||
/** Apply a `set` payload to local data the way the server will (null deletes). */
|
||||
const applyLocal = (data: Data, patch: Record<string, unknown>): Data => {
|
||||
const next = { ...data }
|
||||
for (const [k, v] of Object.entries(patch)) v === null ? delete next[k] : (next[k] = v)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Style" dialog: controls for every schema field tagged `x-style` (or inferred from naming),
|
||||
* grouped by what they do. Nothing here knows a node type - add an `x-style` field to any schema
|
||||
* and it shows up. Spacing is a box-model diagram; breakpoints edit `responsive.<bp>.*`.
|
||||
*/
|
||||
export function StylePanel({ node, schema, ctrl }: EditorProps) {
|
||||
const fields = useMemo(() => describeStyle(schema), [schema])
|
||||
const [data, setData] = useState<Data>(node.data ?? {})
|
||||
const [bp, setBp] = useState<Breakpoint | undefined>()
|
||||
const rev = useEditorState((s) => s.editRev)
|
||||
useEffect(() => {
|
||||
if (rev) void ctrl.node(node.id, true).then((n) => setData(n.data ?? {})).catch(() => {})
|
||||
}, [rev, ctrl, node.id])
|
||||
|
||||
const hasResponsive = fields.some((f) => f.responsive.length)
|
||||
const shown = bp ? fields.filter((f) => f.responsive.includes(bp)) : fields
|
||||
const set = (f: StyleField, v: unknown) => {
|
||||
setData((d) => applyLocal(d, patchFor(d, f, v, bp)))
|
||||
void ctrl.setStyle(node.id, f, v, { bp })
|
||||
}
|
||||
const get = (f: StyleField) => readField(data, f, bp)
|
||||
|
||||
const of = (...kinds: StyleField['kind'][]) => shown.filter((f) => kinds.includes(f.kind))
|
||||
const flow = fields.find((f) => f.kind === 'flow')
|
||||
const flowValue = flow ? String(readField(data, flow, bp) ?? data[flow.name] ?? '') : ''
|
||||
const showColumns = !flow || !flowValue || ['row', 'grid', 'horizontal'].includes(flowValue)
|
||||
|
||||
return (
|
||||
<div className="cms-style">
|
||||
{hasResponsive && (
|
||||
<div className="cms-tabs" role="tablist" aria-label="Breakpoint">
|
||||
{[undefined, ...BREAKPOINTS].map((b) => (
|
||||
<button key={b ?? 'base'} role="tab" aria-selected={bp === b} className={bp === b ? 'is-on' : ''} onClick={() => setBp(b)}>
|
||||
{b ? b[0].toUpperCase() + b.slice(1) : 'All sizes'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bp && <p className="cms-muted">Overrides for {bp === 'mobile' ? 'small screens' : bp === 'tablet' ? 'tablets' : 'desktops'}; unset values inherit from “All sizes”.</p>}
|
||||
|
||||
{of('flow', 'columns', 'align', 'justify').length > 0 && (
|
||||
<Section title="Layout">
|
||||
{of('flow', 'columns', 'align', 'justify')
|
||||
.filter((f) => f.kind !== 'columns' || showColumns)
|
||||
.map((f) => (
|
||||
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!bp && fields.some((f) => f.kind === 'spacing' || f.kind === 'gap') && (
|
||||
<Section title="Spacing">
|
||||
<BoxModel fields={fields} data={data} onChange={set} ctrl={ctrl} />
|
||||
{fields.filter((f) => f.kind === 'gap').map((f) => (
|
||||
<Control key={f.name} f={f} value={data[f.name]} onChange={(v) => set(f, v)} scale={f.options.length ? ctrl.scaleFor(f) : undefined} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{of('tone', 'border').length > 0 && (
|
||||
<Section title="Surface">
|
||||
{of('tone', 'border').map((f) => (
|
||||
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{of('reveal', 'choice').length > 0 && (
|
||||
<Section title="More">
|
||||
{of('reveal', 'choice').map((f) => (
|
||||
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{fields.some((f) => f.inferred) && <p className="cms-muted cms-style__note">Controls detected from field names. Add <code>x-style</code> to the schema to make this explicit.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="cms-style__section">
|
||||
<h4>{title}</h4>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- controls ------------------------------------------------------------------------
|
||||
|
||||
function Control({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) {
|
||||
const set = value !== undefined && value !== null
|
||||
return (
|
||||
<div className="cms-ctl" data-style-field={f.name}>
|
||||
<div className="cms-ctl__head">
|
||||
<span className="cms-ctl__label" title={f.description}>
|
||||
{f.label}
|
||||
</span>
|
||||
{set && (
|
||||
<button type="button" className="cms-ctl__clear" aria-label={`Reset ${f.label}`} title="Reset (inherit)" onClick={() => onChange(undefined)}>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Widget f={f} value={value} onChange={onChange} scale={scale} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Widget({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) {
|
||||
if (f.kind === 'columns') return <Columns f={f} value={value} onChange={onChange} />
|
||||
const icons = ICON[f.kind] ?? {}
|
||||
let options = f.options.map(String)
|
||||
if (f.kind === 'flow' && !LEGACY_FLOW.includes(String(value))) options = options.filter((o) => !LEGACY_FLOW.includes(o))
|
||||
if (options.length > 6) {
|
||||
return (
|
||||
<select className="cms-input" value={value === undefined ? '' : String(value)} onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{options.map((o) => (
|
||||
<option key={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
return <Chips options={options} value={value as string | undefined} onChange={onChange} icons={icons} scale={scale} />
|
||||
}
|
||||
|
||||
function Chips({ options, value, onChange, icons = {}, scale }: { options: string[]; value?: string; onChange(v: string): void; icons?: Record<string, string>; scale?: Scale }) {
|
||||
return (
|
||||
<div className="cms-chips" role="radiogroup">
|
||||
{options.map((o) => {
|
||||
const px = scale ? pxOf(scale, o) : null
|
||||
return (
|
||||
<button key={o} type="button" role="radio" aria-checked={value === o} className={`cms-chip${value === o ? ' is-on' : ''}`} title={px !== null ? `${Math.round(px)}px` : undefined} onClick={() => onChange(o)}>
|
||||
{icons[o] && <Icon name={icons[o]} size={15} />}
|
||||
{icons[o] ? cap(o) : o}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Columns({ f, value, onChange }: { f: StyleField; value: unknown; onChange(v: unknown): void }) {
|
||||
if (f.options.length) return <Chips options={f.options.map(String)} value={value === undefined ? undefined : String(value)} onChange={(v) => onChange(Number(v))} />
|
||||
const min = f.min ?? 1
|
||||
const max = f.max ?? 12
|
||||
const n = typeof value === 'number' ? value : undefined
|
||||
return (
|
||||
<div className="cms-stepper">
|
||||
<button type="button" className="cms-ico" aria-label="Fewer columns" disabled={(n ?? min) <= min} onClick={() => onChange(Math.max(min, (n ?? min + 1) - 1))}>
|
||||
−
|
||||
</button>
|
||||
<input className="cms-input" type="number" aria-label="Columns" min={min} max={max} value={n ?? ''} placeholder="auto" onChange={(e) => onChange(e.target.value === '' ? undefined : Math.min(max, Math.max(min, Number(e.target.value))))} />
|
||||
<button type="button" className="cms-ico" aria-label="More columns" disabled={(n ?? 0) >= max} onClick={() => onChange(Math.min(max, (n ?? min - 1) + 1))}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- box model ---------------------------------------------------------------------------
|
||||
|
||||
type Edge = 'top' | 'right' | 'bottom' | 'left'
|
||||
|
||||
/** margin ⟶ padding diagram: one token select per side the schema can express. */
|
||||
function BoxModel({ fields, data, onChange, ctrl }: { fields: StyleField[]; data: Data; onChange(f: StyleField, v: unknown): void; ctrl: EditorProps['ctrl'] }) {
|
||||
const boxes = (['margin', 'padding'] as const).filter((b) => fields.some((f) => f.kind === 'spacing' && f.box === b && f.options.length))
|
||||
if (!boxes.length) return null
|
||||
const cell = (box: 'margin' | 'padding', edge: Edge, seenAll: Set<string>) => {
|
||||
const f = fieldForSide(fields, box, edge)
|
||||
if (!f) return <span className="cms-box__none">–</span>
|
||||
if (f.side === 'all' || f.side === 'x' || f.side === 'y') {
|
||||
// One field covers several edges: put its select on the first, mark the rest as linked.
|
||||
if (seenAll.has(f.name)) return <span className="cms-box__link" title={`${f.label} (shared)`}>
|
||||
<Icon name="link" size={12} /> {f.side === 'all' ? 'all' : f.side}
|
||||
</span>
|
||||
seenAll.add(f.name)
|
||||
}
|
||||
const { value, from } = effective(data, fields, f)
|
||||
const own = data[f.name]
|
||||
const scale = ctrl.scaleFor(f)
|
||||
return (
|
||||
<select
|
||||
className={`cms-box__sel${own === undefined || own === null ? ' is-inherited' : ''}`}
|
||||
aria-label={`${box} ${f.side === 'all' ? 'all sides' : edge}`}
|
||||
value={own === undefined || own === null ? '' : String(own)}
|
||||
onChange={(e) => onChange(f, e.target.value === '' ? undefined : e.target.value)}
|
||||
title={f.description}
|
||||
>
|
||||
<option value="">{value !== undefined && from !== f ? `${String(value)} (${from.name})` : '—'}</option>
|
||||
{f.options.map((o) => {
|
||||
const px = pxOf(scale, String(o))
|
||||
return (
|
||||
<option key={String(o)} value={String(o)}>
|
||||
{String(o)}
|
||||
{px !== null ? ` · ${Math.round(px)}` : ''}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
const render = (i: number): ReactNode => {
|
||||
const box = boxes[i]
|
||||
const seen = new Set<string>()
|
||||
return (
|
||||
<div className={`cms-box cms-box--${box}`} key={box}>
|
||||
<span className="cms-box__title">{box}</span>
|
||||
<div className="cms-box__top">{cell(box, 'top', seen)}</div>
|
||||
<div className="cms-box__left">{cell(box, 'left', seen)}</div>
|
||||
<div className="cms-box__mid">{i + 1 < boxes.length ? render(i + 1) : <span className="cms-box__content">content</span>}</div>
|
||||
<div className="cms-box__right">{cell(box, 'right', seen)}</div>
|
||||
<div className="cms-box__bottom">{cell(box, 'bottom', seen)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="cms-boxmodel">{render(0)}</div>
|
||||
}
|
||||
Reference in new issue
Block a user