This commit is contained in:
233
src/components/ui-editor/NodeInspector.jsx
Normal file
233
src/components/ui-editor/NodeInspector.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { ArrowDown, ArrowUp, Eye, EyeOff, Replace, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
|
||||
} from '@/lib/ui/node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
|
||||
|
||||
/**
|
||||
* Everything a person may change about the selected node.
|
||||
*
|
||||
* Every control below is *derived*: the properties come from the type's own
|
||||
* `propSchema`, the buttons from its declared `capabilities`, the replacement
|
||||
* options from `registry.replacements`, and the layout values from the closed
|
||||
* vocabulary in `lib/ui/node.js`. Nothing is listed by hand, which is what makes
|
||||
* a type registered tomorrow editable tomorrow — and what makes it impossible
|
||||
* for this panel to offer something the validator would refuse.
|
||||
*
|
||||
* There is no free-text style field anywhere, by construction. A person can set
|
||||
* a title, pick from an enum, choose a column count — and there is no control
|
||||
* that accepts a class name, a style, or markup, because no `propSchema`
|
||||
* declares one and the layout vocabulary is four fixed steps.
|
||||
*/
|
||||
|
||||
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
|
||||
const labelled = 'block text-[10px] font-semibold uppercase tracking-wide text-ink-4';
|
||||
|
||||
export function NodeInspector({ node, siblings, page = null, onOperate, registry = nodeRegistry }) {
|
||||
const entry = registry.get(node.type);
|
||||
if (!entry) {
|
||||
return <p className="text-caption text-ink-3">This node’s type is no longer available.</p>;
|
||||
}
|
||||
|
||||
const can = (capability) => entry.capabilities.includes(capability) && !node.locked;
|
||||
const index = siblings.findIndex((s) => s.id === node.id);
|
||||
|
||||
/** Reorder this node's own container by moving it one step. */
|
||||
const nudge = (delta) => {
|
||||
const op = nudgeOp(node, siblings, delta);
|
||||
if (op) onOperate(op);
|
||||
};
|
||||
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">{node.title || node.label}</p>
|
||||
<p className="font-mono text-[10px] text-ink-4">{node.id} · {node.type} · {node.origin}</p>
|
||||
</header>
|
||||
|
||||
{/* ── What may be done at all ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{can('hide') && (
|
||||
<Button
|
||||
size="xs" variant="outline" shape="rounded"
|
||||
onClick={() => onOperate(hideOp(node))}
|
||||
>
|
||||
{node.hidden
|
||||
? <><Eye className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Show</>
|
||||
: <><EyeOff className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Hide</>}
|
||||
</Button>
|
||||
)}
|
||||
{can('move') && (
|
||||
<>
|
||||
<Button size="xs" variant="outline" shape="rounded" disabled={index <= 0}
|
||||
onClick={() => nudge(-1)} aria-label="Move up">
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button size="xs" variant="outline" shape="rounded" disabled={index < 0 || index >= siblings.length - 1}
|
||||
onClick={() => nudge(1)} aria-label="Move down">
|
||||
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{can('remove') && (
|
||||
<Button size="xs" variant="outline" shape="rounded"
|
||||
onClick={() => onOperate(removeOp(node))}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Turn it into something else ─────────────────────────────────── */}
|
||||
{replacements.length > 0 && (
|
||||
<label className="block space-y-1">
|
||||
<span className={labelled}>
|
||||
<Replace className="mr-1 inline h-3 w-3" aria-hidden="true" />Show as
|
||||
</span>
|
||||
<select
|
||||
className={field}
|
||||
value=""
|
||||
onChange={(e) => e.target.value && onOperate(replaceOp(node, e.target.value))}
|
||||
>
|
||||
<option value="">Keep {entry.label}</option>
|
||||
{replacements.map((type) => (
|
||||
<option key={type} value={type}>{registry.get(type)?.label || type}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* ── Properties, exactly as the type declared them ───────────────── */}
|
||||
{can('update') && node.editable.length > 0 && (
|
||||
<div className="space-y-2.5">
|
||||
{node.editable.map((prop) => (
|
||||
<label key={prop.key} className="block space-y-1">
|
||||
<span className={labelled}>{prop.label}{prop.required ? ' *' : ''}</span>
|
||||
|
||||
{prop.kind === 'enum' && (
|
||||
<select
|
||||
className={field}
|
||||
value={prop.value ?? ''}
|
||||
onChange={(e) => onOperate(propOp(node, prop.key, e.target.value))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{prop.options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{prop.kind === 'boolean' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(prop.value)}
|
||||
onChange={(e) => onOperate(propOp(node, prop.key, e.target.checked || null))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.kind === 'number' && (
|
||||
<input
|
||||
type="number" className={field} defaultValue={prop.value ?? ''}
|
||||
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value === '' ? null : Number(e.target.value)))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.kind === 'string' && (
|
||||
<input
|
||||
type="text" className={field} defaultValue={prop.value ?? ''}
|
||||
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What it is reading ──────────────────────────────────────────── */}
|
||||
{node.data && (
|
||||
<div className="space-y-1">
|
||||
<span className={labelled}>Reading</span>
|
||||
<p className="rounded-lg border border-border bg-surface-subtle px-2 py-1.5 text-body-sm text-ink-2">
|
||||
{dataSourceLabel(node.data.source)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── How it looks ────────────────────────────────────────────────
|
||||
Offered only where the type says it can draw them, so a picker never
|
||||
shows a setting the component would ignore. */}
|
||||
{can('update') && (node.variants.length > 0 || node.densities.length > 0) && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{node.variants.length > 0 && (
|
||||
<Choice
|
||||
label="Style" value={node.presentation.variant} options={node.variants}
|
||||
onPick={(v) => onOperate(presentationOp(node, 'variant', v))}
|
||||
/>
|
||||
)}
|
||||
{node.densities.length > 0 && (
|
||||
<Choice
|
||||
label="Density" value={node.presentation.density} options={node.densities}
|
||||
onPick={(v) => onOperate(presentationOp(node, 'density', v))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Layout: four closed vocabularies, nothing typed ─────────────── */}
|
||||
{can('update') && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Choice
|
||||
label="Columns" value={node.layout.columns}
|
||||
options={range(entry.constraints.minColumns ?? MIN_COLUMNS, entry.constraints.maxColumns ?? MAX_COLUMNS)}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'columns', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Span" value={node.layout.span} options={range(MIN_COLUMNS, MAX_COLUMNS)}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'span', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Gap" value={node.layout.gap} options={GAP_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'gap', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Align" value={node.layout.align} options={ALIGN_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'align', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Space above" value={node.layout.spacingBefore} options={SPACING_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'spacingBefore', v))}
|
||||
/>
|
||||
<Choice
|
||||
label="Space below" value={node.layout.spacingAfter} options={SPACING_VALUES}
|
||||
onPick={(v) => onOperate(layoutOp(node, 'spacingAfter', v))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One closed-vocabulary picker. Empty means "leave it to the page". */
|
||||
function Choice({ label, value, options, onPick }) {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className={labelled}>{label}</span>
|
||||
<select
|
||||
className={cn(field, 'text-caption')}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onPick(e.target.value === '' ? null : coerce(e.target.value))}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
||||
const coerce = (value) => (/^\d+$/.test(value) ? Number(value) : value);
|
||||
90
src/components/ui-editor/NodePicker.jsx
Normal file
90
src/components/ui-editor/NodePicker.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { addableTypes } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { dataSourceLabel, placementProvides, sourcesForShape } from '@/lib/skills/surfaces';
|
||||
import { addOp } from './ops';
|
||||
|
||||
/**
|
||||
* What can be added here, and what it would show.
|
||||
*
|
||||
* Both lists are answers to questions the product already knows how to answer:
|
||||
* `addableTypes` asks the registry what this container accepts and what this
|
||||
* role may use, and `sourcesForShape` asks the closed data vocabulary which
|
||||
* readings can fill that shape. Nothing is offered that validation would then
|
||||
* refuse, and nothing can be typed — a source is chosen from what exists, which
|
||||
* is what makes an invented metric impossible to compose here.
|
||||
*/
|
||||
/** A property's value on a described node, read the way the inspector reads it. */
|
||||
const propOf = (node, key) => node?.editable?.find((prop) => prop.key === key)?.value || null;
|
||||
|
||||
export function NodePicker({ tree, parent, page = null, role = null, onAdd, registry = nodeRegistry }) {
|
||||
const [type, setType] = React.useState('');
|
||||
const [source, setSource] = React.useState('');
|
||||
|
||||
const options = addableTypes(parent?.type ?? null, { registry, role, page });
|
||||
const chosen = options.find((option) => option.type === type) || null;
|
||||
|
||||
/**
|
||||
* What this location can actually answer.
|
||||
*
|
||||
* Every reading in the vocabulary was offered here, including the ones that
|
||||
* need a record — so a Card bound to "Position activity" could be added to a
|
||||
* page that has no position, and rendered "This section needs a position to
|
||||
* read." forever. Offered, accepted, saved, and dead.
|
||||
*
|
||||
* `provides` on the surface already records which context each placement
|
||||
* supplies, and `sourcesForShape` already filters on it. Only the question
|
||||
* was missing. A node at the page root is inside no record, so it is offered
|
||||
* the readings that need none.
|
||||
*/
|
||||
const slot = propOf(parent, 'placement');
|
||||
const provided = slot ? placementProvides(propOf(parent, 'page') || page, slot) : [];
|
||||
const sources = chosen?.dataShapes.length
|
||||
? sourcesForShape(chosen.dataShapes[0], { context: provided }).map((s) => s.id)
|
||||
: [];
|
||||
|
||||
/* A type that reads data cannot be added until a reading is picked. The
|
||||
button says so by staying disabled rather than by failing on submit. */
|
||||
const ready = Boolean(chosen) && (!chosen.dataRequired || Boolean(source));
|
||||
|
||||
const add = () => {
|
||||
if (!ready) return;
|
||||
onAdd(addOp(tree, parent, chosen.type, source || null));
|
||||
setType('');
|
||||
setSource('');
|
||||
};
|
||||
|
||||
if (!options.length) {
|
||||
return <p className="text-caption text-ink-4">Nothing can be added here.</p>;
|
||||
}
|
||||
|
||||
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
Add {parent ? `inside ${parent.title || parent.label}` : 'to the page'}
|
||||
</p>
|
||||
|
||||
<select className={field} value={type} onChange={(e) => { setType(e.target.value); setSource(''); }}>
|
||||
<option value="">Choose a panel…</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{chosen?.dataShapes.length > 0 && (
|
||||
<select className={field} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">What should it show?</option>
|
||||
{sources.map((id) => <option key={id} value={id}>{dataSourceLabel(id)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<Button size="xs" shape="rounded" disabled={!ready} onClick={add}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/ui-editor/TreePanel.jsx
Normal file
87
src/components/ui-editor/TreePanel.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Eye, EyeOff, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
|
||||
/**
|
||||
* The page, as an outline.
|
||||
*
|
||||
* Everything shown here comes from `inspectTree` — the same inventory Owliver
|
||||
* reads before it resolves "the recent hiring timeline". There is no second
|
||||
* description of a page anywhere: if the editor can see a node, so can the
|
||||
* conversation, and vice versa.
|
||||
*
|
||||
* **Hidden nodes are listed.** A hidden node is still in the tree and still
|
||||
* addressable; the renderer skips it and nothing else does. Leaving it out here
|
||||
* would make the editor the one place a person could not undo a hide.
|
||||
*/
|
||||
export function TreePanel({ tree, selectedId, onSelect, registry = nodeRegistry }) {
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
|
||||
if (!nodes.length) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-caption text-ink-3">
|
||||
This page has no addressable sections yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* Depth from parentage rather than from a nested walk, so the flat inventory
|
||||
stays the one source and this only decides indentation. */
|
||||
const depthOf = (node) => {
|
||||
let depth = 0;
|
||||
let cursor = node;
|
||||
while (cursor?.parent) {
|
||||
cursor = nodes.find((n) => n.id === cursor.parent);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
||||
{nodes.map((node) => {
|
||||
const selected = node.id === selectedId;
|
||||
return (
|
||||
<li key={node.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
|
||||
selected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span style={{ paddingLeft: `${depthOf(node) * 14}px` }} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={cn('truncate text-body-sm', node.hidden ? 'text-ink-4' : 'text-ink-1')}>
|
||||
{node.title || node.label}
|
||||
</span>
|
||||
|
||||
{/* Provenance. A person needs to know which panels came from a
|
||||
definition they could switch off, and which are the page. */}
|
||||
{node.origin === 'skill' && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-1.5 py-0.5 text-[10px] font-semibold text-krow-blue">
|
||||
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Skill
|
||||
</span>
|
||||
)}
|
||||
{node.origin === 'user' && (
|
||||
<span className="shrink-0 rounded-full bg-surface-sunken px-1.5 py-0.5 text-[10px] font-semibold text-ink-3">
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 font-mono text-[10px] text-ink-4">{node.type}</span>
|
||||
{node.hidden
|
||||
? <EyeOff className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-label="Hidden" />
|
||||
: <Eye className="h-3.5 w-3.5 shrink-0 text-ink-4/40" aria-hidden="true" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
146
src/components/ui-editor/UiEditor.jsx
Normal file
146
src/components/ui-editor/UiEditor.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ds';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { inspectTree } from '@/lib/ui/inspect';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { TreePanel } from './TreePanel';
|
||||
import { NodeInspector } from './NodeInspector';
|
||||
import { NodePicker } from './NodePicker';
|
||||
|
||||
/**
|
||||
* The visual editor.
|
||||
*
|
||||
* It is a *client* of the UI system, not a second implementation of it. Every
|
||||
* control it draws ends in one call — `propose(op)` on the editing session —
|
||||
* with an operation object of exactly the shape Owliver produces for the same
|
||||
* change. From there the two are indistinguishable: same validation, same
|
||||
* preview merge, same Apply, same `preferences.uiLayouts`.
|
||||
*
|
||||
* That is the whole architecture:
|
||||
*
|
||||
* editor / Owliver → operation → propose → validate → preview
|
||||
* ↓ Apply
|
||||
* preferences.uiLayouts
|
||||
*
|
||||
* There is no page in this file, no component name, and no branch on what a
|
||||
* node is. What can be done to the selected node comes from its registration;
|
||||
* what can be added comes from the registry and the closed data vocabulary;
|
||||
* what it is showing comes from the tree. A page that migrates tomorrow is
|
||||
* editable tomorrow with nothing here changed.
|
||||
*/
|
||||
export function UiEditor({ registry = nodeRegistry }) {
|
||||
const editing = useUiEditing();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedId, setSelectedId] = React.useState(null);
|
||||
|
||||
/* Rendered only where a page has opted into composition. A page that has not
|
||||
is not broken; it simply has nothing to arrange. */
|
||||
if (!editing) return null;
|
||||
|
||||
const {
|
||||
tree, propose, discard, apply, undo, reset,
|
||||
previewing, customised, saving, problems, skipped,
|
||||
/* The page this session belongs to. Handed to the picker so what can be
|
||||
added here is decided by the registry rather than by the picker being
|
||||
shown everything that exists. */
|
||||
page,
|
||||
} = editing;
|
||||
|
||||
const nodes = inspectTree(tree, { registry });
|
||||
const selected = nodes.find((node) => node.id === selectedId) || null;
|
||||
/* A node's own container, for the reorder buttons and for the picker. */
|
||||
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
|
||||
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
|
||||
|
||||
/**
|
||||
* The one door out of this component.
|
||||
*
|
||||
* Everything the panels do arrives here as an operation and goes straight to
|
||||
* the session. Nothing is applied, nothing is stored, and nothing is
|
||||
* validated locally — `propose` refuses what cannot be kept and the refusal
|
||||
* is shown below.
|
||||
*/
|
||||
const operate = (op) => {
|
||||
const result = propose(op);
|
||||
/* A removed node cannot stay selected; a replaced one keeps its id. */
|
||||
if (result.ok && op.op === 'remove') setSelectedId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-ui-controls="editor" className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={open ? 'default' : 'outline'}
|
||||
shape="rounded"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||
Customise layout
|
||||
</Button>
|
||||
|
||||
{/* The preview bar. Unsaved and saved have to be told apart at a
|
||||
glance, because the whole promise is that nothing is kept until
|
||||
somebody says so. */}
|
||||
{(previewing || customised) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{previewing && (
|
||||
<span className="rounded-full bg-krow-blue-tint px-2 py-0.5 text-caption font-semibold text-krow-blue">
|
||||
Previewing — not saved yet
|
||||
</span>
|
||||
)}
|
||||
{!previewing && customised && (
|
||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold text-ink-3">
|
||||
Saved layout
|
||||
</span>
|
||||
)}
|
||||
{previewing && (
|
||||
<>
|
||||
<Button size="xs" shape="rounded" loading={saving} onClick={apply}>Apply</Button>
|
||||
<Button size="xs" variant="outline" shape="rounded" onClick={discard}>Discard</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="xs" variant="ghost" shape="rounded" onClick={undo}>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Undo
|
||||
</Button>
|
||||
{customised && (
|
||||
<Button size="xs" variant="ghost" shape="rounded" onClick={reset}>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{problems.length > 0 && (
|
||||
<p className="text-caption text-destructive">{problems[0].message}</p>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
|
||||
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-3">
|
||||
{selected
|
||||
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
|
||||
: (
|
||||
<p className="text-caption text-ink-3">
|
||||
Choose a section on the left to see what can be changed about it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/ui-editor/ops.js
Normal file
99
src/components/ui-editor/ops.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { freeNodeId } from '@/lib/ui/node';
|
||||
import { dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
/**
|
||||
* The operations the editor's controls stand for.
|
||||
*
|
||||
* Pure, and separate from the components, for one reason: it is the claim that
|
||||
* the editor and Owliver do the same thing, and a claim buried inside a click
|
||||
* handler cannot be checked. Here it can — `hideOp(node)` and the operation
|
||||
* `matchUiEdit('hide the recent hiring timeline')` produces are the same object,
|
||||
* and a test says so.
|
||||
*
|
||||
* Nothing here validates or applies. Every one of these is handed to
|
||||
* `propose()` on the editing session, which is the single gate both surfaces
|
||||
* pass through.
|
||||
*/
|
||||
|
||||
/** Hide a visible node, or show a hidden one. */
|
||||
export const hideOp = (node) => ({ op: 'hide', target: node.id, hidden: !node.hidden });
|
||||
|
||||
/** Set a node's hidden state explicitly, which is what a conversation says. */
|
||||
export const visibilityOp = (node, hidden) => ({ op: 'hide', target: node.id, hidden });
|
||||
|
||||
/**
|
||||
* Move a node one place within its own container.
|
||||
*
|
||||
* A reorder of the whole sibling list rather than a `move`, because that is
|
||||
* what the button means: nothing changes parent, the order around it changes.
|
||||
* The same shape Owliver produces for "move the timeline above the notice".
|
||||
*/
|
||||
export function nudgeOp(node, siblings, delta) {
|
||||
const order = siblings.map((s) => s.id);
|
||||
const from = order.indexOf(node.id);
|
||||
const to = from + delta;
|
||||
if (from < 0 || to < 0 || to >= order.length) return null;
|
||||
order.splice(to, 0, ...order.splice(from, 1));
|
||||
return { op: 'reorder', parent: node.parent ?? null, order };
|
||||
}
|
||||
|
||||
/** Put a node at an explicit index among its siblings. */
|
||||
export function reorderOp(node, siblings, index) {
|
||||
const order = siblings.map((s) => s.id);
|
||||
const from = order.indexOf(node.id);
|
||||
if (from < 0 || index < 0 || index >= order.length) return null;
|
||||
order.splice(index, 0, ...order.splice(from, 1));
|
||||
return { op: 'reorder', parent: node.parent ?? null, order };
|
||||
}
|
||||
|
||||
/** Turn a node into another registered type. */
|
||||
export const replaceOp = (node, type) => ({ op: 'replace', target: node.id, type });
|
||||
|
||||
/** Remove a node the person added. */
|
||||
export const removeOp = (node) => ({ op: 'remove', target: node.id });
|
||||
|
||||
/**
|
||||
* Change one declared property.
|
||||
*
|
||||
* An empty value becomes `null`, which the engine reads as "unset" — the only
|
||||
* way to clear a property, and the reason a blank field is not the same as a
|
||||
* field nobody touched.
|
||||
*/
|
||||
export const propOp = (node, key, value) => ({
|
||||
op: 'update', target: node.id, props: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/** Change one layout value, from the closed vocabulary. */
|
||||
export const layoutOp = (node, key, value) => ({
|
||||
op: 'update', target: node.id, layout: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/**
|
||||
* Change how a node presents itself, from the closed vocabulary.
|
||||
*
|
||||
* The same `update` operation everything else here produces — the editor has no
|
||||
* mutation of its own, and this is the shape Owliver emits for the same words.
|
||||
*/
|
||||
export const presentationOp = (node, key, value) => ({
|
||||
op: 'update',
|
||||
target: node.id,
|
||||
presentation: { [key]: value === '' || value === undefined ? null : value },
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a node of a registered type, optionally bound to a reading.
|
||||
*
|
||||
* The id is generated by the shared helper, so a node added here and a node
|
||||
* added by Owliver get their addresses from one rule.
|
||||
*/
|
||||
export function addOp(tree, parent, type, source = null) {
|
||||
return {
|
||||
op: 'add',
|
||||
parent: parent?.id ?? null,
|
||||
node: {
|
||||
id: freeNodeId(tree, type),
|
||||
type,
|
||||
...(source ? { data: { source }, props: { title: dataSourceLabel(source) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user