Editor: composition wireframe — pull-out, stacking, and simplify

This commit is contained in:
2026-06-30 15:42:22 +03:00
parent 26b383ff33
commit c019660692
13 changed files with 966 additions and 88 deletions
@@ -7,7 +7,7 @@
.pop {
position: fixed;
z-index: 1000;
width: 264px;
width: 300px;
max-height: 60vh;
overflow: auto;
padding: var(--space-4);
@@ -36,8 +36,14 @@
border: 1px solid var(--border-strong);
background: var(--bg);
}
/* The padding is the *pull-out margin*: a frame's gutter, between its border and its
child boxes, is the drop zone that lifts a view out into a new full-span row/column
(vs. a box's own edge, which pairs or reorders). Sized for a comfortable target. */
.container {
padding: var(--space-2);
--pull-gutter: var(--space-5);
--reveal-tint: color-mix(in srgb, var(--accent) 7%, transparent);
position: relative;
padding: var(--pull-gutter);
cursor: pointer;
}
.leaf {
@@ -63,26 +69,93 @@
.node[data-dragging] {
opacity: 0.4;
}
/* Drop indicator (arch 10 §5): a 3px accent line on the edge the dragged box would
land. `wrap` mode additionally rings + tints the target box, signalling the two
pair into a new split rather than just reordering. */
.node[data-drop-edge='left'] {
/* Reorder (move) indicator (arch 10 §5): a 3px accent line on the target box's edge
marks where the dragged view lands in the sequence. */
.node[data-drop-mode='move'][data-drop-edge='left'] {
box-shadow: inset 3px 0 0 var(--accent);
}
.node[data-drop-edge='right'] {
.node[data-drop-mode='move'][data-drop-edge='right'] {
box-shadow: inset -3px 0 0 var(--accent);
}
.node[data-drop-edge='top'] {
.node[data-drop-mode='move'][data-drop-edge='top'] {
box-shadow: inset 0 3px 0 var(--accent);
}
.node[data-drop-edge='bottom'] {
.node[data-drop-mode='move'][data-drop-edge='bottom'] {
box-shadow: inset 0 -3px 0 var(--accent);
}
/* Pair (wrap) indicator: the two views split into a new row/column. The target box is
outlined, and the half the incoming view will take is shaded with a seam line at the
split — so it previews the split, not just "drop here". */
.node[data-drop-mode='wrap'] {
background: var(--accent-soft);
position: relative;
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.node[data-drop-mode='wrap']::after {
content: '';
position: absolute;
background: var(--accent-soft);
pointer-events: none;
}
.node[data-drop-mode='wrap'][data-drop-edge='top']::after {
inset: 0 0 50% 0;
border-bottom: 2px solid var(--accent);
}
.node[data-drop-mode='wrap'][data-drop-edge='bottom']::after {
inset: 50% 0 0 0;
border-top: 2px solid var(--accent);
}
.node[data-drop-mode='wrap'][data-drop-edge='left']::after {
inset: 0 50% 0 0;
border-right: 2px solid var(--accent);
}
.node[data-drop-mode='wrap'][data-drop-edge='right']::after {
inset: 0 0 0 50%;
border-left: 2px solid var(--accent);
}
/* Pull-out indicator: the dragged view would lift out into a new full-span row/column
here. A shaded band fills the frame's margin on the resolved side, capped by a solid
accent line — the "shaded drop-zone" feedback (vs. the box-edge line of a pair/move). */
.node[data-drop-pull]::after {
content: '';
position: absolute;
background: var(--accent-soft);
pointer-events: none;
}
.node[data-drop-pull='top']::after {
inset: 0 0 auto 0;
height: var(--pull-gutter);
border-top: 2px solid var(--accent);
}
.node[data-drop-pull='bottom']::after {
inset: auto 0 0 0;
height: var(--pull-gutter);
border-bottom: 2px solid var(--accent);
}
.node[data-drop-pull='left']::after {
inset: 0 auto 0 0;
width: var(--pull-gutter);
border-left: 2px solid var(--accent);
}
.node[data-drop-pull='right']::after {
inset: 0 0 0 auto;
width: var(--pull-gutter);
border-right: 2px solid var(--accent);
}
/* Reveal-on-drag: while a drag is live, every frame's pull-out margins glow faintly so
the targets are discoverable without hunting — across the container's own axis (a
row's margins are top/bottom, a column's left/right). The active target reads
stronger via the band above. */
.tree[data-drag-active] .container[data-orientation='horizontal'] {
box-shadow:
inset 0 var(--pull-gutter) 0 var(--reveal-tint),
inset 0 calc(-1 * var(--pull-gutter)) 0 var(--reveal-tint);
}
.tree[data-drag-active] .container[data-orientation='vertical'] {
box-shadow:
inset var(--pull-gutter) 0 0 var(--reveal-tint),
inset calc(-1 * var(--pull-gutter)) 0 0 var(--reveal-tint);
}
/* Selection mirrors the library's active-row language (arch 09 §4). */
.node.selected {
border-color: var(--accent);
@@ -179,3 +252,62 @@
.muted {
color: var(--text-placeholder);
}
/* Drag chip: follows the cursor during a drag, naming the pending action ("New row
above", "Pair into a column", "Reorder"). Inverted ink-on-bg for contrast on any
theme; above the popover (z 1000) since it portals to the body. */
.dragChip {
position: fixed;
z-index: 1100;
pointer-events: none;
padding: 3px 8px;
font-size: 11px;
font-weight: 600;
color: var(--bg);
background: var(--text);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.dragChip[data-empty] {
opacity: 0.6;
}
/* Redundant single-child wrapper: a frame holding one view adds no structure. A soft
warning hairline marks it as cleanable (not broken); the prompt below offers the fix. */
.node[data-degenerate] {
border-style: dashed;
border-color: var(--support-warning-fg);
}
.warning {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
padding: var(--space-2) var(--space-3);
font-size: 11px;
color: var(--support-warning-fg);
background: color-mix(in srgb, var(--support-warning) 12%, var(--bg));
border: var(--border-width) solid color-mix(in srgb, var(--support-warning) 40%, var(--bg));
border-radius: var(--radius);
}
.warning span {
flex: 1;
}
.simplify {
flex-shrink: 0;
font: inherit;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
}
.simplify:hover {
text-decoration: underline;
}
.simplify:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
@@ -199,24 +199,26 @@ describe('CompositionWireframe — drag to restructure', () => {
vi.restoreAllMocks();
});
const drag = (fromKey: string, to: { x: number; y: number }) => {
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
act(() => {
item(fromKey).dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }),
);
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: to.x, clientY: to.y }));
window.dispatchEvent(
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
);
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', {}));
});
};
test('dropping onto a perpendicular edge wraps the two views into a row', async () => {
test('dropping onto a views far cross edge pairs the two into a row', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view (cross axis)
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap',
targetPath: ['vconcat', 0],
@@ -239,6 +241,123 @@ describe('CompositionWireframe — drag to restructure', () => {
});
});
describe('CompositionWireframe — pull a view out via the frame margin', () => {
const HCON = JSON.stringify({ hconcat: [{ mark: 'point' }, { mark: 'bar' }, { mark: 'line' }] });
// A row of three boxes inset inside the root frame, leaving a margin (the pull-out
// zone) all around. happy-dom has no layout, so the hit-test rects are stubbed.
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
root: { x: 0, y: 0, w: 300, h: 120 },
'hconcat|0': { x: 24, y: 24, w: 80, h: 72 },
'hconcat|1': { x: 110, y: 24, w: 80, h: 72 },
'hconcat|2': { x: 196, y: 24, w: 80, h: 72 },
};
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
): DOMRect {
const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
const box = {
left: r.x,
top: r.y,
right: r.x + r.w,
bottom: r.y + r.h,
width: r.w,
height: r.h,
x: r.x,
y: r.y,
};
return { ...box, toJSON: () => ({}) };
});
});
afterEach(() => vi.restoreAllMocks());
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
act(() => {
item(fromKey).dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, clientX: 60, clientY: 60 }),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
);
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', {}));
});
};
test('dropping in the cross-axis margin pulls the view into a new full-span row', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|0', { x: 150, y: 8 }); // top margin of the row — across its axis
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap-container',
containerPath: [],
sourcePath: ['hconcat', 0],
axis: 'vertical',
side: 'before',
});
});
test('a with-axis margin reorders to that end of the row, not a pull', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|1', { x: 8, y: 60 }); // left margin (along the axis), before every box
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['hconcat'],
from: 1,
to: 0,
});
});
test('dropping over a siblings central band reorders past it', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|2', { x: 50, y: 70 }); // central band of the first box → before it
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['hconcat'],
from: 2,
to: 0,
});
});
test('dropping on a siblings top edge stacks the two into a column', async () => {
setEditableSpec(HCON);
await renderOpen();
drag('hconcat|2', { x: 130, y: 30 }); // top edge of the middle box (cross axis)
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap',
targetPath: ['hconcat', 1],
sourcePath: ['hconcat', 2],
axis: 'vertical',
side: 'before',
});
});
});
describe('CompositionWireframe — simplify redundant wrappers', () => {
const simplifyButton = () =>
Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent === 'Simplify');
test('offers Simplify for a single-child composition and dispatches it', async () => {
setEditableSpec(JSON.stringify({ hconcat: [{ mark: 'point' }] }));
await renderOpen();
const btn = simplifyButton();
expect(btn).toBeTruthy();
act(() => btn!.click());
expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'simplify' });
});
test('no Simplify prompt when every composition has multiple views', async () => {
setEditableSpec(COMPOSED); // a vconcat of two
await renderOpen();
expect(simplifyButton()).toBeUndefined();
});
});
describe('markIconName', () => {
test('maps marks to glyphs, collapsing synonyms', () => {
expect(markIconName('bar')).toBe('mark-bar');
+272 -29
View File
@@ -10,17 +10,23 @@
* is the WAI-ARIA APG **tree** widget — `tree`/`treeitem`/`group`, single-select,
* roving tabindex, arrow-key nav in logical (document) order (arch 10 §5).
*
* On the editable draft a view can be **restructured by dragging its box**:
* - onto a sibling's edge *along* its container → reorder within it;
* - onto a view's edge *across* its container → pair the two in a new
* `hconcat`/`vconcat`, or, dragging in from elsewhere, move/insert it there.
* The nearest edge of the box under the pointer picks the axis (left/right → a row,
* top/bottom → a column) and side. The keyboard equivalent for in-container reorder
* is Alt+↑/↓ (the APG rearrangeable-listbox pattern); cross-container restructuring
* stays the editor's wrap actions for keyboard users. Every move is applied by the
* editor (which owns the undoable edit) via `AppStore.requestComposeMove` /
* `requestComposeWrap`, focus follows the affected box, and a polite live region
* announces the result (arch 10 §5).
* On the editable draft a view can be **restructured by dragging its box**. Intent is
* read from where the pointer falls against a row/column's children, not one nearest
* edge (arch 10 §5):
* - the interior **central band** reorders within the container;
* - the **cross-axis frame margin** (a row's top/bottom, a column's left/right, or
* past the block) **pulls the source out** into a new full-span row/column wrapping
* the whole container — the root included;
* - a view's **far cross edge** pairs the two in a perpendicular `hconcat`/`vconcat`
* (`Shift` forces a pair from the centre); a with-axis drop moves/inserts there.
* Opaque `layer`/`facet`/`repeat`/grid boxes keep the simpler nearest-edge model, and a
* redundant single-child wrapper is flagged with a one-click **Simplify**. The keyboard
* equivalent for in-container reorder is Alt+↑/↓ (the APG rearrangeable-listbox pattern);
* cross-container restructuring stays the editor's wrap actions for keyboard users. Every
* edit is applied by the editor (which owns the undoable edit) via
* `AppStore.requestComposeMove`/`requestComposeWrap`/`requestComposeWrapContainer`/
* `requestComposeSimplify`, focus follows the affected box, and a polite live region
* announces the result.
*/
import {
@@ -36,6 +42,7 @@ import {
import { createPortal } from 'react-dom';
import { isPrefixPath, type SpecPath } from '@core/spec-insert';
import type { DropAxis } from '@core/spec-restructure';
import { ARRAY_COMPOSITIONS } from '@core/spec-transforms';
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
import { usePopover } from '../hooks/usePopover';
import { useAppStore } from '../stores/AppStore';
@@ -54,9 +61,31 @@ const DESCENDABLE: ReadonlySet<Orientation> = new Set<Orientation>(['horizontal'
type Edge = 'left' | 'right' | 'top' | 'bottom';
/**
* Fraction of a view's cross-axis size, at each end, that reads as "pair here": drop a
* view onto another's far edge (a row view's top/bottom, a column view's left/right) to
* stack the two in a perpendicular split. The central band stays reorder, so an ordinary
* along-the-axis drag rearranges. (Shift pairs from the central band too.)
*/
const PAIR_BAND = 0.25;
/** A DOM-safe, unique key for a node from its path. */
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
// TODO: vocabulary diverges from the pair labels — pull-out names a vconcat slot "a row"
// (spatial: a full-span band) and an hconcat slot "a column", while the pair-into labels and
// the commit announcements use the container convention (hconcat = "row", vconcat = "column").
// Both describe the same axis; unify or keep the gesture-specific framing (docs/ux-second-pass.md).
/** The pull-out action label for the margin a drop landed in. */
const pullLabel = (edge: Edge): string =>
edge === 'top'
? 'New row above'
: edge === 'bottom'
? 'New row below'
: edge === 'left'
? 'New column left'
: 'New column right';
/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
function pathLabel(path: SpecPath): string {
if (path.length === 0) return 'root';
@@ -115,7 +144,9 @@ function edgeOf(r: DOMRect, x: number, y: number): Edge {
interface DropResolution {
targetKey: string;
edge: Edge;
mode: 'move' | 'wrap';
mode: 'move' | 'wrap' | 'pull';
/** A short human label for what the drop will do — shown in the drag chip. */
label: string;
commit:
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number }
| {
@@ -124,6 +155,13 @@ interface DropResolution {
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
}
| {
kind: 'wrap-container';
containerPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
};
}
@@ -131,18 +169,34 @@ interface DragState {
sourceKey: string;
sourceNode: ViewNode;
resolution: DropResolution | null;
/** Live pointer position, for the drag chip that follows the cursor. */
pointer: { x: number; y: number };
}
function WireframeTree({ tree }: { tree: ViewNode }) {
const requestRevealView = useAppStore((s) => s.requestRevealView);
const requestComposeMove = useAppStore((s) => s.requestComposeMove);
const requestComposeWrap = useAppStore((s) => s.requestComposeWrap);
const requestComposeWrapContainer = useAppStore((s) => s.requestComposeWrapContainer);
const requestComposeSimplify = useAppStore((s) => s.requestComposeSimplify);
// Restructure only on the editable draft — the published view is read-only.
const editable = useSnippetStore((s) => s.editorView === 'draft' && s.activeSnippetId !== null);
const flat = useMemo(() => flatten(tree), [tree]);
const parentByKey = useMemo(() => new Map(flat.map((f) => [f.key, f.parent])), [flat]);
const rootKey = keyOf(tree.path);
// Redundant single-child wrappers — a `{hconcat: [oneView]}` is just that view (a
// layer/concat of one is the same). Flagged on the boxes and offered up for a one-click
// Simplify. (facet/repeat hold one child by design, so they're never array-compositions.)
const degenerateKeys = useMemo(() => {
const keys = new Set<string>();
for (const { node, key } of flat) {
if (node.op && ARRAY_COMPOSITIONS.includes(node.op) && node.children.length === 1)
keys.add(key);
}
return keys;
}, [flat]);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const [hoverKey, setHoverKey] = useState<string | null>(null);
@@ -229,29 +283,151 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
);
const resolveDrop = useCallback(
(x: number, y: number, source: ViewNode): DropResolution | null => {
(x: number, y: number, source: ViewNode, pair: boolean): DropResolution | null => {
const sourcePath = source.path;
const target = dropTargetNode(x, y, keyOf(sourcePath));
if (!target) return null;
// The row/column container we're acting within, and the child under the pointer
// (null when the pointer fell in the container's margin or an inter-child gap).
const targetIsRowCol =
target.kind === 'composition' &&
!!target.orientation &&
DESCENDABLE.has(target.orientation);
const container = targetIsRowCol ? target : (parentByKey.get(keyOf(target.path)) ?? null);
const childUnder = targetIsRowCol ? null : target;
// Inside a row/column, intent is read against the children's bounding box: reorder
// is the default for the interior central band (so a drag along the block just
// rearranges it); a pull-out needs the pointer past the children on the *cross* axis
// (the frame margin or beyond the block); dropping onto a view's *far cross edge*
// pairs the two into a nested split. (Opaque layer/grid contexts keep the
// nearest-edge model further down.)
if (container?.orientation && DESCENDABLE.has(container.orientation) && container.op) {
const horizontal = container.orientation === 'horizontal';
const arrayPath: SpecPath = [...container.path, container.op];
const kids = container.children
.map((c) => ({ node: c, rect: rectOf(keyOf(c.path)) }))
.filter((k): k is { node: ViewNode; rect: DOMRect } => k.rect != null);
if (!kids.length) return null;
const bbox = {
left: Math.min(...kids.map((k) => k.rect.left)),
top: Math.min(...kids.map((k) => k.rect.top)),
right: Math.max(...kids.map((k) => k.rect.right)),
bottom: Math.max(...kids.map((k) => k.rect.bottom)),
};
// 1) Pull-out — pointer past the children on the cross axis. Pulling a node into
// its own descendant is the one degenerate case.
const before = horizontal ? y < bbox.top : x < bbox.left;
const after = horizontal ? y > bbox.bottom : x > bbox.right;
if (before || after) {
if (isPrefixPath(sourcePath, container.path)) return null;
const edge: Edge = horizontal ? (before ? 'top' : 'bottom') : before ? 'left' : 'right';
return {
targetKey: keyOf(container.path),
edge,
mode: 'pull',
label: pullLabel(edge),
commit: {
kind: 'wrap-container',
containerPath: container.path,
sourcePath,
axis: horizontal ? 'vertical' : 'horizontal',
side: before ? 'before' : 'after',
},
};
}
// 2) Pair — drop onto a view's far cross edge (a row view's top/bottom, a column
// view's left/right) to nest the two in a perpendicular split; Shift pairs from
// the central band too. The cross half picks the order.
if (childUnder) {
const cr = rectOf(keyOf(childUnder.path));
const related =
isPrefixPath(sourcePath, childUnder.path) || isPrefixPath(childUnder.path, sourcePath);
if (cr && !related) {
const cf = horizontal ? (y - cr.top) / cr.height : (x - cr.left) / cr.width;
if (pair || cf < PAIR_BAND || cf > 1 - PAIR_BAND) {
const lead = cf < 0.5;
const axis: DropAxis = horizontal ? 'vertical' : 'horizontal';
const edge: Edge = horizontal ? (lead ? 'top' : 'bottom') : lead ? 'left' : 'right';
return {
targetKey: keyOf(childUnder.path),
edge,
mode: 'wrap',
label: `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
commit: {
kind: 'wrap',
targetPath: childUnder.path,
sourcePath,
axis,
side: lead ? 'before' : 'after',
},
};
}
}
}
// 3) Reorder (default) — the insertion slot from the pointer's main-axis position,
// anchored on the child whose edge marks the gap.
const mainPos = horizontal ? x : y;
let index = 0;
for (const k of kids) {
const center = horizontal
? (k.rect.left + k.rect.right) / 2
: (k.rect.top + k.rect.bottom) / 2;
if (mainPos > center) index += 1;
}
const atEnd = index >= kids.length;
const anchor = atEnd ? kids[kids.length - 1].node : kids[index].node;
const edge: Edge = horizontal ? (atEnd ? 'right' : 'left') : atEnd ? 'bottom' : 'top';
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
const from = sourcePath[sourcePath.length - 1] as number;
let to = index > from ? index - 1 : index;
to = Math.max(0, Math.min(to, container.children.length - 1));
if (to === from) return null; // no-op
return {
targetKey: keyOf(anchor.path),
edge,
mode: 'move',
label: 'Reorder',
commit: { kind: 'move', arrayPath, from, to },
};
}
// From another container → insert here, as a with-axis wrap that flattens in.
if (isPrefixPath(sourcePath, anchor.path) || isPrefixPath(anchor.path, sourcePath))
return null;
return {
targetKey: keyOf(anchor.path),
edge,
mode: 'move',
label: 'Move here',
commit: {
kind: 'wrap',
targetPath: anchor.path,
sourcePath,
axis: horizontal ? 'horizontal' : 'vertical',
side: atEnd ? 'after' : 'before',
},
};
}
// Opaque container (layer/grid) or root: the simpler nearest-edge model.
const targetPath = target.path;
// Degenerate: the root, or an ancestor/descendant relationship.
if (targetPath.length === 0) return null;
if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;
const r = rectOf(keyOf(targetPath));
if (!r) return null;
const edge = edgeOf(r, x, y);
const axis: DropAxis = edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical';
const side: 'before' | 'after' = edge === 'left' || edge === 'top' ? 'before' : 'after';
const parent = parentByKey.get(keyOf(targetPath));
const tIdx = targetPath[targetPath.length - 1];
const along =
(axis === 'horizontal' && parent?.orientation === 'horizontal') ||
(axis === 'vertical' && parent?.orientation === 'vertical');
// A drop *along* a sibling's own container is a reorder/insert into it; same
// container → a plain move, a different one → a wrap that flattens to an insert.
if (along && parent && typeof tIdx === 'number') {
const arrayPath = targetPath.slice(0, -1);
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
@@ -264,15 +440,16 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
targetKey: keyOf(targetPath),
edge,
mode: 'move',
label: 'Reorder',
commit: { kind: 'move', arrayPath, from, to },
};
}
}
// Across the container (or into an opaque target) → wrap the two together.
return {
targetKey: keyOf(targetPath),
edge,
mode: 'wrap',
label: along ? 'Move here' : `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
commit: { kind: 'wrap', targetPath, sourcePath, axis, side },
};
},
@@ -285,6 +462,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
const { arrayPath, from, to } = res.commit;
const count = parentByKey.get(res.targetKey)?.children.length ?? 0;
reorder(arrayPath, from, to, source, count);
} else if (res.commit.kind === 'wrap-container') {
const { containerPath, sourcePath, axis, side } = res.commit;
requestComposeWrapContainer(containerPath, sourcePath, axis, side);
// Focus/selection lands on the container's slot — now the new split holding it.
settleOn(
keyOf(containerPath),
`Pulled ${descriptor(source)} into a new ${axis === 'vertical' ? 'row' : 'column'}`,
);
} else {
const { targetPath, sourcePath, axis, side } = res.commit;
requestComposeWrap(targetPath, sourcePath, axis, side);
@@ -299,15 +484,31 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
);
}
},
[parentByKey, reorder, requestComposeWrap, settleOn],
[parentByKey, reorder, requestComposeWrap, requestComposeWrapContainer, settleOn],
);
const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, source: ViewNode) => {
if (e.button !== 0 || !editable) return;
// Only the innermost view under the pointer starts the drag: every nested frame is
// itself draggable, and without this the pointerdown bubbles to each ancestor, whose
// beginDrag runs *after* (bubble order) and overwrites the source — so grabbing a
// child would drag its outer block instead.
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
let started = false;
// The last pointer + Shift state, so a Shift press/release re-resolves in place
// (Shift forces a pair when the pointer is over a view's central band).
let last = { x: startX, y: startY, pair: e.shiftKey };
const resolveAt = () => {
setDragState({
sourceKey: keyOf(source.path),
sourceNode: source,
resolution: resolveDrop(last.x, last.y, source, last.pair),
pointer: { x: last.x, y: last.y },
});
};
const onMove = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
@@ -315,16 +516,21 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
setDragState({
sourceKey: keyOf(source.path),
sourceNode: source,
resolution: resolveDrop(ev.clientX, ev.clientY, source),
});
last = { x: ev.clientX, y: ev.clientY, pair: ev.shiftKey };
resolveAt();
};
const onShift = (ev: WindowEventMap['keydown']) => {
if (started && ev.key === 'Shift') {
last = { ...last, pair: ev.type === 'keydown' };
resolveAt();
}
};
const cleanup = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
window.removeEventListener('keydown', onShift);
window.removeEventListener('keyup', onShift);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
@@ -345,6 +551,8 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
window.addEventListener('keydown', onShift);
window.addEventListener('keyup', onShift);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
@@ -429,8 +637,11 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
className={cls}
data-draggable={draggable || undefined}
data-dragging={drag?.sourceKey === key || undefined}
data-drop-edge={onTarget?.edge}
data-drop-mode={onTarget?.mode}
data-orientation={n.orientation}
data-degenerate={degenerateKeys.has(key) || undefined}
data-drop-edge={onTarget && onTarget.mode !== 'pull' ? onTarget.edge : undefined}
data-drop-mode={onTarget && onTarget.mode !== 'pull' ? onTarget.mode : undefined}
data-drop-pull={onTarget?.mode === 'pull' ? onTarget.edge : undefined}
onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined}
onClick={(e) => {
e.stopPropagation();
@@ -478,10 +689,27 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
role="tree"
aria-label="View composition"
className={styles.tree}
data-drag-active={drag ? '' : undefined}
onKeyDown={onKeyDown}
>
{renderNode(tree, null)}
</div>
{editable && degenerateKeys.size > 0 && (
<div className={styles.warning}>
<span>
{degenerateKeys.size === 1
? 'A single-view wrapper adds no structure.'
: `${degenerateKeys.size} single-view wrappers add no structure.`}
</span>
<button
type="button"
className={styles.simplify}
onClick={() => requestComposeSimplify()}
>
Simplify
</button>
</div>
)}
<p className={styles.caption} aria-hidden="true">
{captionNode ? (
<>
@@ -490,7 +718,7 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
) : (
<span className={styles.muted}>
{editable
? 'Drag a block onto anothers edge to rearrange · Alt+↑/↓ to reorder · click to reveal'
? 'Drag to reorder · onto a views edge to stack · into a frames margin to pull out · click to reveal'
: 'Hover a block to identify it · click to reveal it'}
</span>
)}
@@ -498,6 +726,21 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
<div className="visually-hidden" role="status" aria-live="polite">
{announcement}
</div>
{/* A chip following the cursor names what the drop will do — the live "what
happens" hint (pointer-only affordance, so aria-hidden; SR users get the
commit announcement above). Portaled out of the clipped popover. */}
{drag &&
createPortal(
<div
className={styles.dragChip}
data-empty={drag.resolution ? undefined : ''}
style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 16 }}
aria-hidden="true"
>
{drag.resolution?.label ?? 'No change here'}
</div>,
document.body,
)}
</>
);
}
+11
View File
@@ -38,8 +38,10 @@ import {
installSpecTransformActions,
installSpecTransformCodeLens,
runMoveViewTo,
runSimplifyStructure,
runUnwrap,
runWrap,
runWrapContainer,
runWrapViews,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
@@ -523,6 +525,15 @@ export function SpecEditor() {
if (!editor || !composeRequest) return;
if (composeRequest.kind === 'move')
runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to);
else if (composeRequest.kind === 'simplify') runSimplifyStructure(editor);
else if (composeRequest.kind === 'wrap-container')
runWrapContainer(
editor,
composeRequest.containerPath,
composeRequest.sourcePath,
composeRequest.axis,
composeRequest.side,
);
else
runWrapViews(
editor,
+56 -17
View File
@@ -41,7 +41,7 @@ import {
moveViewTo,
type SpecPath,
} from '@core/spec-insert';
import { wrapViews, type DropAxis } from '@core/spec-restructure';
import { simplifyStructure, wrapContainer, wrapViews, type DropAxis } from '@core/spec-restructure';
import {
unwrapSingleton,
wrapInConcat,
@@ -331,30 +331,30 @@ export function runMoveViewTo(
}
/**
* Restructure the spec by pairing the dragged `sourcePath` view beside the drop
* `targetPath` view in a new concat of `axis` — the wireframe's cross-container
* drag (wrap, move-in, collapse, all in core/spec-restructure). One undoable edit;
* no toast and no editor focus-steal, as with the reorder path. A degenerate or
* stale drop surfaces an info toast rather than silently doing nothing.
* Apply a whole-spec structural edit: parse the draft, `build` the next spec, and
* write it back as one undoable edit — or surface the "could not restructure" info
* toast when `build` returns null (a degenerate or stale drop). The whole-spec sibling
* of `applyArrayEdit`, for the wireframe's drag/simplify family — no cursor-follow and
* no success toast, since the wireframe gives its own feedback. `notifyOnNull` of false
* stays silent: a no-op (e.g. Simplify with nothing redundant) is not a failure.
*/
export function runWrapViews(
function applyWholeSpecEdit(
editor: monaco.editor.IStandaloneCodeEditor,
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
build: (spec: JsonObject) => JsonObject | null,
notifyOnNull = true,
): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model.getValue());
if (!spec) return;
const next = wrapViews(spec, targetPath, sourcePath, axis, side);
const next = build(spec);
if (!next) {
notify({
kind: 'info',
title: 'Could not restructure',
message: 'That drop isnt possible here — the composition may have changed.',
});
if (notifyOnNull)
notify({
kind: 'info',
title: 'Could not restructure',
message: 'That drop isnt possible here — the composition may have changed.',
});
return;
}
writeBack(
@@ -365,6 +365,45 @@ export function runWrapViews(
);
}
/**
* Restructure the spec by pairing the dragged `sourcePath` view beside the drop
* `targetPath` view in a new concat of `axis` — the wireframe's cross-container drag
* (wrap, move-in, collapse, all in core/spec-restructure).
*/
export function runWrapViews(
editor: monaco.editor.IStandaloneCodeEditor,
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): void {
applyWholeSpecEdit(editor, (s) => wrapViews(s, targetPath, sourcePath, axis, side));
}
/**
* Pull the dragged `sourcePath` view out into a new full-span row/column around the
* whole `containerPath` container — the wireframe's frame-margin drag (core
* `wrapContainer`, the complement to the edge-drop `wrapViews`).
*/
export function runWrapContainer(
editor: monaco.editor.IStandaloneCodeEditor,
containerPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): void {
applyWholeSpecEdit(editor, (s) => wrapContainer(s, containerPath, sourcePath, axis, side));
}
/**
* Collapse every redundant single-child composition in the spec (core
* `simplifyStructure`) — the wireframe's Simplify action, offered when it detects a
* `{hconcat: [oneView]}`-style wrapper. A no-op (silent) when nothing is redundant.
*/
export function runSimplifyStructure(editor: monaco.editor.IStandaloneCodeEditor): void {
applyWholeSpecEdit(editor, simplifyStructure, false);
}
/** Insert a view above/below the one the cursor is in (the keyboard path). */
function runInsertRelative(
editor: monaco.editor.IStandaloneCodeEditor,
+38 -4
View File
@@ -75,10 +75,11 @@ export interface AppState {
revealTarget: { offset: number; length: number; nonce: number } | null;
/**
* A request from the composition wireframe to restructure — reorder a view
* within its array (`move`), or pair the dragged view beside a drop target in a
* new concat (`wrap`, the cross-container drag). Applied by the editor (which
* owns the undoable edit) as one ⌘Z step; the nonce makes a repeat re-fire. Null
* until the first.
* within its array (`move`), pair the dragged view beside a drop target in a new
* concat (`wrap`, the cross-container drag), or stack it against a whole container
* pulled out into a new full-span row/column (`wrap-container`, the frame-margin
* drag). Applied by the editor (which owns the undoable edit) as one ⌘Z step; the
* nonce makes a repeat re-fire. Null until the first.
*/
composeRequest:
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number; nonce: number }
@@ -90,6 +91,15 @@ export interface AppState {
side: 'before' | 'after';
nonce: number;
}
| {
kind: 'wrap-container';
containerPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
nonce: number;
}
| { kind: 'simplify'; nonce: number }
| null;
setTheme: (theme: UiTheme) => void;
@@ -121,6 +131,15 @@ export interface AppState {
axis: DropAxis,
side: 'before' | 'after',
) => void;
/** Ask the editor to pull a dragged view out into a new full-span row/column around a container (wireframe margin drag). */
requestComposeWrapContainer: (
containerPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
) => void;
/** Ask the editor to collapse redundant single-child compositions (wireframe Simplify). */
requestComposeSimplify: () => void;
}
export const useAppStore = create<AppState>((set) => ({
@@ -163,4 +182,19 @@ export const useAppStore = create<AppState>((set) => ({
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
requestComposeWrapContainer: (containerPath, sourcePath, axis, side) =>
set((s) => ({
composeRequest: {
kind: 'wrap-container',
containerPath,
sourcePath,
axis,
side,
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
requestComposeSimplify: () =>
set((s) => ({
composeRequest: { kind: 'simplify', nonce: (s.composeRequest?.nonce ?? 0) + 1 },
})),
}));
+126 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { wrapViews } from './spec-restructure';
import { simplifyStructure, wrapContainer, wrapViews } from './spec-restructure';
describe('wrapViews — wrap across the target axis', () => {
it('pairs a view beside a sibling in a new perpendicular concat', () => {
@@ -102,3 +102,128 @@ describe('wrapViews — degenerate drops', () => {
expect(wrapViews(spec, [], ['vconcat', 1], 'horizontal', 'after')).toBeNull();
});
});
describe('wrapContainer — pull a view out into a new full-span row/column', () => {
it('pulls a view out of the root row into a new row above the rest', () => {
// The motivating case: hconcat[A,B,C,D], drop A on the frame's top margin →
// vconcat[A, hconcat[B,C,D]] — impossible with wrapViews (it pairs two views).
const spec = {
data: { name: 'd' },
hconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }, { mark: 'd' }],
};
const next = wrapContainer(spec, [], ['hconcat', 0], 'vertical', 'before');
expect(next).toEqual({
data: { name: 'd' },
vconcat: [{ mark: 'a' }, { hconcat: [{ mark: 'b' }, { mark: 'c' }, { mark: 'd' }] }],
});
expect(spec.hconcat).toHaveLength(4); // input untouched
});
it('keeps spec-level metadata on the wrapper when wrapping the root', () => {
const spec = {
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
data: { name: 'd' },
config: { view: { stroke: null } },
hconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }],
};
const next = wrapContainer(spec, [], ['hconcat', 2], 'vertical', 'after');
expect(next).toEqual({
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
data: { name: 'd' },
config: { view: { stroke: null } },
vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }],
});
});
it('pulls a view out of a vconcat into a new column beside it', () => {
const spec = { vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] };
const next = wrapContainer(spec, [], ['vconcat', 0], 'horizontal', 'before');
expect(next).toEqual({
hconcat: [{ mark: 'a' }, { vconcat: [{ mark: 'b' }, { mark: 'c' }] }],
});
});
it('flattens into the parent when the new wrap matches the grandparent axis', () => {
// Drop A on the inner hconcat's top margin: the new vconcat around it is bare and
// same-orientation as the outer vconcat, so it flattens to a plain row above.
const spec = {
vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] }, { mark: 'x' }],
};
const next = wrapContainer(
spec,
['vconcat', 0],
['vconcat', 0, 'hconcat', 0],
'vertical',
'before',
);
expect(next).toEqual({
vconcat: [{ mark: 'a' }, { hconcat: [{ mark: 'b' }, { mark: 'c' }] }, { mark: 'x' }],
});
});
it('collapses a two-child container down to the lone survivor', () => {
// hconcat[A,B], pull A out above → the hconcat is left with one child, which
// collapses, so the result is the bare vconcat of the two.
const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }] };
const next = wrapContainer(spec, [], ['hconcat', 0], 'vertical', 'before');
expect(next).toEqual({ vconcat: [{ mark: 'a' }, { mark: 'b' }] });
});
it('pins the sources inherited data when pulled across data contexts', () => {
const spec = {
data: { name: 'root' },
vconcat: [{ data: { name: 'd2' }, hconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }],
};
// Pull A (which inherits d2) out to wrap the whole root vconcat in a column. A is
// pinned to d2 (it would otherwise rebind to root); B's old hconcat collapses to a
// bare unit that keeps d2.
const next = wrapContainer(spec, [], ['vconcat', 0, 'hconcat', 0], 'horizontal', 'before');
expect(next).toEqual({
data: { name: 'root' },
hconcat: [
{ data: { name: 'd2' }, mark: 'a' },
{ vconcat: [{ data: { name: 'd2' }, mark: 'b' }, { mark: 'c' }] },
],
});
});
it('returns null when the source is an ancestor of the container', () => {
const spec = {
vconcat: [
{ hconcat: [{ vconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }] },
{ mark: 'd' },
],
};
// Drag the outer hconcat onto the inner vconcat's margin — the container is nested
// inside the source, a degenerate drop.
expect(
wrapContainer(spec, ['vconcat', 0, 'hconcat', 0], ['vconcat', 0], 'vertical', 'after'),
).toBeNull();
});
});
describe('simplifyStructure — collapse redundant single-child wrappers', () => {
it('unwraps a single-child hconcat to the bare view', () => {
const spec = { data: { name: 'd' }, hconcat: [{ mark: 'bar' }] };
expect(simplifyStructure(spec)).toEqual({ data: { name: 'd' }, mark: 'bar' });
});
it('collapses nested single-child wrappers recursively', () => {
const spec = { vconcat: [{ hconcat: [{ mark: 'a' }] }, { mark: 'b' }] };
expect(simplifyStructure(spec)).toEqual({ vconcat: [{ mark: 'a' }, { mark: 'b' }] });
});
it('the lone child wins on key conflict, like unwrapSingleton', () => {
const spec = { width: 100, layer: [{ width: 200, mark: 'line' }] };
expect(simplifyStructure(spec)).toEqual({ width: 200, mark: 'line' });
});
it('returns null when there is nothing to simplify', () => {
expect(simplifyStructure({ hconcat: [{ mark: 'a' }, { mark: 'b' }] })).toBeNull();
});
it('leaves a facets single child alone (one child by design)', () => {
const spec = { facet: { field: 'c', type: 'nominal' }, spec: { mark: 'bar' } };
expect(simplifyStructure(spec)).toBeNull();
});
});
+122 -4
View File
@@ -4,10 +4,14 @@
* drag-to-restructure, the complement to spec-insert (single-array reorder within
* one container) and spec-transforms (whole-spec wrap/unwrap).
*
* One operation does the work: `wrapViews(target, source, axis, side)` pairs the
* dragged `source` view beside the drop `target` in a new `hconcat`/`vconcat`,
* placed where the target was, and removes the source from where it came. Three
* correctness rules ride along (the traps that make this the hard part):
* Two operations do the work. `wrapViews(target, source, axis, side)` pairs the
* dragged `source` view beside the drop `target` *view* in a new `hconcat`/`vconcat`,
* placed where the target was, and removes the source from where it came.
* `wrapContainer(container, source, axis, side)` is the complement for a drop on a
* frame's *margin*: it stacks the source against the whole `container` (the root
* included) — pulling a view out into a new full-span row/column — rather than
* beside one sibling. Three correctness rules ride along (the traps that make this
* the hard part):
*
* - **Flatten** — a bare same-orientation concat nested directly in a concat is
* redundant, so wrapping a view beside a sibling already in a matching-axis
@@ -26,6 +30,7 @@
import { dataBindingAtPath } from './spec-data';
import { isJsonObject, type JsonObject } from './spec-config';
import { arrayAtPath, isPrefixPath, valueAtPath, type SpecPath } from './spec-insert';
import { ARRAY_COMPOSITIONS, concatRootBeside } from './spec-transforms';
/** The orientation of the concat a drop creates. */
export type DropAxis = 'horizontal' | 'vertical';
@@ -184,3 +189,116 @@ export function wrapViews(
flattenBareConcats(next);
return next;
}
/**
* Collapse every single-child array-composition (`layer`/`concat`) to its lone
* child (the child wins on key conflict, as `unwrapSingleton`) and drop empty
* ones — recursively over the whole tree, bottom-up. The complement to
* `flattenBareConcats`: it both cleans up after `wrapContainer` pulls a view out
* (removing the source can leave a one- or zero-child composition along its old
* branch) and powers `simplifyStructure`'s global pass. Whole-tree rather than
* path-scoped (unlike `removeAndCollapse`) because re-slotting the container shifts
* the source's old path; the collapse is semantics-preserving, so the wider sweep
* is safe.
*/
function collapseSingletons(node: unknown): unknown {
if (Array.isArray(node)) {
return node
.map(collapseSingletons)
.filter((c) => !(isJsonObject(c) && Object.keys(c).length === 0));
}
if (!isJsonObject(node)) return node;
const obj: Record<string, unknown> = {};
for (const k of Object.keys(node)) obj[k] = collapseSingletons(node[k]);
for (const op of ARRAY_COMPOSITIONS) {
const arr = obj[op];
if (!Array.isArray(arr)) continue;
if (arr.length === 1 && isJsonObject(arr[0])) {
const child = arr[0];
delete obj[op];
return { ...obj, ...child }; // child wins, like unwrapSingleton
}
if (arr.length === 0) delete obj[op];
}
return obj;
}
/**
* Stack the `source` view against the *whole* container at `containerPath` in a new
* concat of `axis` — the frame-margin pull-out. The container (the root included)
* keeps its place; the source becomes a full-span sibling on `side`, removed from
* where it was. The complement to `wrapViews`: there the source pairs with a single
* sibling view, here with the container's entire contents.
*
* The source may sit *inside* the container (the motivating "pull a view out of its
* row into a new row above") or in a sibling subtree. Returns null for a degenerate
* drop (no source/container, or the source is the container or an ancestor of it).
* The root case lifts the spec-level metadata via core/spec-transforms; the input is
* not mutated.
*/
export function wrapContainer(
spec: JsonObject,
containerPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): JsonObject | null {
// The source can be inside the container or in a sibling subtree, but never the
// container itself or an ancestor of it.
if (isPrefixPath(sourcePath, containerPath)) return null;
const next = clone(spec);
const source = valueAtPath(next, sourcePath);
const container = valueAtPath(next, containerPath);
if (!isJsonObject(source) || !isJsonObject(container)) return null;
// The array holding the source today — captured before re-slotting shifts its path.
const srcArr = arrayAtPath(next, sourcePath.slice(0, -1));
if (!srcArr) return null;
// Preserve the source's data before its context changes (reads the unmutated tree).
pinData(next, source, sourcePath, containerPath);
const op = axis === 'horizontal' ? 'hconcat' : 'vconcat';
let root: JsonObject;
if (containerPath.length === 0) {
// Wrapping the whole spec: shared metadata stays on top, only the view is wrapped.
root = concatRootBeside(container, source, axis === 'horizontal' ? 'h' : 'v', side);
} else {
const wrapper: JsonObject = {
[op]: side === 'before' ? [source, container] : [container, source],
};
const cParent = valueAtPath(next, containerPath.slice(0, -1));
const cKey = containerPath[containerPath.length - 1];
if (Array.isArray(cParent)) cParent[cKey as number] = wrapper;
else if (isJsonObject(cParent)) cParent[cKey as string] = wrapper;
else return null;
root = next;
}
// Remove the source from its old home (by reference — its path moved under the new
// wrapper), then collapse any container left one-/zero-child and flatten redundant
// same-orientation nesting the wrap produced (e.g. a new row inside its own column).
const si = srcArr.indexOf(source);
if (si >= 0) srcArr.splice(si, 1);
// `collapseSingletons` runs whole-tree (the re-slot above shifts the source's path, so
// a path-scoped walk can't find it). A deliberate side effect: a pull-out also tidies
// any pre-existing redundant single-child wrapper elsewhere — the same cleanup the
// Simplify action offers. Semantics-preserving, so it's safe to fold into the one edit.
const collapsed = collapseSingletons(root) as JsonObject;
flattenBareConcats(collapsed);
return collapsed;
}
/**
* Collapse every redundant single-child composition in the spec to its lone child —
* a `{hconcat: [oneView]}` is just that view. Recursive and semantics-preserving: the
* "simplify" the wireframe offers once it spots such wrappers. Returns the cleaned
* spec, or null when there is nothing to simplify. (`facet`/`repeat` hold a single
* child by design and aren't array-compositions, so they're left alone.)
*/
export function simplifyStructure(spec: JsonObject): JsonObject | null {
const next = collapseSingletons(spec) as JsonObject;
return JSON.stringify(next) === JSON.stringify(spec) ? null : next;
}
+20
View File
@@ -85,6 +85,26 @@ export function wrapInConcat(spec: JsonObject, dir: ConcatDir): JsonObject {
return { ...top, [key]: [view, placeholderView()] };
}
/**
* Wrap the whole spec's view in a concat of `dir` placed beside `other`, the
* pulled-out view, on `side` — the root case of the wireframe's frame-margin
* pull-out (core/spec-restructure). Shared metadata ($schema, data, config, …)
* stays on the wrapper exactly as the other whole-spec wraps; unlike them there is
* no placeholder — `other` is a real view the caller then removes from its old
* home and cleans up. The returned tree shares the source view's nested arrays, so
* the caller's removal of `other` from inside it takes effect here too.
*/
export function concatRootBeside(
spec: JsonObject,
other: JsonObject,
dir: ConcatDir,
side: 'before' | 'after',
): JsonObject {
const key = dir === 'h' ? 'hconcat' : 'vconcat';
const { top, view } = partition(spec, SHARED_TOP);
return { ...top, [key]: side === 'before' ? [other, view] : [view, other] };
}
/**
* Wrap the view in a facet (small multiples across `field`). The caller resolves
* a sensible field + type from the bound dataset; the field is left editable.