Editor: interactive composition wireframe — drag to reorder and restructure

Drag a view's box (or Alt+up/down) to reorder it within its container; drag onto another view's edge to pair the two in a new row/column, move across containers, or insert. Each leaf shows a glyph of its mark type.

Core: spec-restructure.wrapViews (wrap, with flatten-to-insert, collapse, and data-pin invariants) and spec-insert.moveViewTo; the editor applies every drag as one undoable edit via AppStore.composeRequest, keeping the editor the single text source.
This commit is contained in:
2026-06-29 14:25:31 +03:00
parent 57604a80a6
commit 216797ff9b
16 changed files with 1160 additions and 56 deletions
@@ -43,10 +43,46 @@
.leaf {
min-height: 48px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* The mark glyph is a quiet identity hint, not chrome — muted, inherits theme. */
.markIcon {
color: var(--text-secondary);
}
.node:hover {
border-color: var(--text-secondary);
}
/* Draggable boxes (draft only) advertise the drag with a grab cursor; the body
cursor flips to grabbing for the duration of an active drag (set in JS). */
.node[data-draggable] {
cursor: grab;
}
.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'] {
box-shadow: inset 3px 0 0 var(--accent);
}
.node[data-drop-edge='right'] {
box-shadow: inset -3px 0 0 var(--accent);
}
.node[data-drop-edge='top'] {
box-shadow: inset 0 3px 0 var(--accent);
}
.node[data-drop-edge='bottom'] {
box-shadow: inset 0 -3px 0 var(--accent);
}
.node[data-drop-mode='wrap'] {
background: var(--accent-soft);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* Selection mirrors the library's active-row language (arch 09 §4). */
.node.selected {
border-color: var(--accent);
@@ -8,13 +8,14 @@
* itself is covered in core (spec-view-tree.test.ts).
*/
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useAppStore } from '../stores/AppStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { CompositionWireframe } from './CompositionWireframe';
import { markIconName } from './mark-icon';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -28,6 +29,14 @@ const setSpec = (text: string) => {
useSnippetStore.setState({ draftText: text });
};
/** Like `setSpec`, but with an active draft so reorder affordances are enabled. */
const setEditableSpec = (text: string) => {
useSnippetStore.getState().reset();
useSnippetStore.setState({ draftText: text, activeSnippetId: 'snip', editorView: 'draft' });
};
const key = (e: KeyboardEventInit) => new KeyboardEvent('keydown', { bubbles: true, ...e });
const treeItems = () =>
Array.from(document.body.querySelectorAll<HTMLElement>('[role="treeitem"]'));
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;
@@ -45,7 +54,7 @@ async function renderOpen() {
beforeEach(() => {
usePopoverStore.setState({ openId: null });
useAppStore.setState({ revealTarget: null });
useAppStore.setState({ revealTarget: null, composeRequest: null });
setSpec(COMPOSED);
container = document.createElement('div');
document.body.appendChild(container);
@@ -56,7 +65,7 @@ afterEach(() => {
act(() => root.unmount());
container.remove();
usePopoverStore.setState({ openId: null });
useAppStore.setState({ revealTarget: null });
useAppStore.setState({ revealTarget: null, composeRequest: null });
useSnippetStore.getState().reset();
});
@@ -99,4 +108,126 @@ describe('CompositionWireframe', () => {
const target = useAppStore.getState().revealTarget!;
expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
});
test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
// Move the roving focus onto the first leaf, then reorder it down.
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
});
const req = useAppStore.getState().composeRequest!;
expect(req).toMatchObject({ arrayPath: ['vconcat'], from: 0, to: 1 });
});
test('Alt+ArrowUp at the start does not reorder', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('vconcat|0').dispatchEvent(key({ key: 'ArrowUp', altKey: true }));
});
expect(useAppStore.getState().composeRequest).toBeNull();
});
test('reorder is inert on a read-only (non-draft) view', async () => {
// Default COMPOSED is set with no active snippet → not editable.
await renderOpen();
act(() => {
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
});
act(() => {
item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
});
expect(useAppStore.getState().composeRequest).toBeNull();
});
});
describe('CompositionWireframe — drag to restructure', () => {
// happy-dom has no layout, so the drop hit-test (which reads box rects) needs them
// stubbed. A vertical stack of two equal halves, keyed by the box's data-key.
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
root: { x: 0, y: 0, w: 200, h: 200 },
'vconcat|0': { x: 0, y: 0, w: 200, h: 100 },
'vconcat|1': { x: 0, y: 100, w: 200, h: 100 },
};
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 };
return {
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,
toJSON: () => ({}),
};
});
});
afterEach(() => {
vi.restoreAllMocks();
});
const drag = (fromKey: string, to: { x: number; y: number }) => {
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 }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', {}));
});
};
test('dropping onto a perpendicular edge wraps the two views into a row', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'wrap',
targetPath: ['vconcat', 0],
sourcePath: ['vconcat', 1],
axis: 'horizontal',
side: 'after',
});
});
test('dropping along the container reorders within it', async () => {
setEditableSpec(COMPOSED);
await renderOpen();
drag('vconcat|1', { x: 100, y: 8 }); // top edge of the top view
expect(useAppStore.getState().composeRequest).toMatchObject({
kind: 'move',
arrayPath: ['vconcat'],
from: 1,
to: 0,
});
});
});
describe('markIconName', () => {
test('maps marks to glyphs, collapsing synonyms', () => {
expect(markIconName('bar')).toBe('mark-bar');
expect(markIconName('circle')).toBe('mark-point');
expect(markIconName('square')).toBe('mark-point');
expect(markIconName('trail')).toBe('mark-line');
});
test('falls back to a generic glyph for an unknown or absent mark', () => {
expect(markIconName('boxplot')).toBe('mark-generic');
expect(markIconName(undefined)).toBe('mark-generic');
});
});
+315 -22
View File
@@ -1,37 +1,58 @@
/**
* Composition wireframe — a read-only schematic of the active spec's multi-view
* structure (docs/architecture/08 → editor augmentation). Bare nested boxes for
* `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views; no
* labels in the boxes (the caption names the hovered/selected one). Clicking a box
* selects it and reveals that view's source range in the editor
* Composition wireframe — an interactive schematic of the active spec's multi-view
* structure (docs/architecture/08 → editor augmentation). Nested boxes for
* `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views;
* each leaf carries a glyph of its mark type so which-is-which reads at a glance.
* Clicking a box selects it and reveals that view's source range in the editor
* (`AppStore.requestRevealView`) — the editor stays the source of truth.
*
* A disclosure popover (`usePopover`) off a glyph in the preview toolbar; the panel
* is the WAI-ARIA APG **tree** widget — `tree`/`treeitem`/`group`, single-select,
* roving tabindex, arrow-key nav in logical (document) order (arch 10 §5). Phase A
* is read-only navigation; drag-reorder and resize land on this same tree.
* 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).
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { SpecPath } from '@core/spec-insert';
import { isPrefixPath, type SpecPath } from '@core/spec-insert';
import type { DropAxis } from '@core/spec-restructure';
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
import { usePopover } from '../hooks/usePopover';
import { useAppStore } from '../stores/AppStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { markIconName } from './mark-icon';
import styles from './CompositionWireframe.module.css';
const POPOVER_ID = 'composition-wireframe';
const INITIAL_FOCUS = ['[role="treeitem"]'] as const;
/** Pointer travel (px) before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 4;
/** The concat orientations a drop can descend into (layers/grids are opaque targets). */
const DESCENDABLE: ReadonlySet<Orientation> = new Set<Orientation>(['horizontal', 'vertical']);
type Edge = 'left' | 'right' | 'top' | 'bottom';
/** A DOM-safe, unique key for a node from its path. */
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
@@ -55,9 +76,8 @@ const ariaLabelOf = (n: ViewNode): string =>
: `${n.op}, ${n.orientation}, ${n.children.length} views`;
// TODO (deferred polish — docs/exploration/visual-composition-editing-exploration.md §6a):
// (1) render a simplified mark-type glyph inside each leaf box for at-a-glance ID;
// (2) replace the offset-rectangle `layered` look with a "stacked planes" primitive
// (overlapping sheets/disks, like the database glyph) to read as one shared space.
// replace the offset-rectangle `layered` look with a "stacked planes" primitive
// (overlapping sheets/disks, like the database glyph) to read as one shared space.
/** Children layout class per orientation (layered overlaps in one grid cell). */
const LAYOUT: Record<Orientation, string> = {
@@ -71,28 +91,79 @@ interface Flat {
node: ViewNode;
key: string;
parentKey: string | null;
/** The composition that holds this node, or null for the root. */
parent: ViewNode | null;
}
/** Pre-order flatten — drives roving keyboard nav (next/prev/parent/first-child). */
function flatten(root: ViewNode): Flat[] {
const out: Flat[] = [];
const walk = (n: ViewNode, parentKey: string | null) => {
const walk = (n: ViewNode, parentKey: string | null, parent: ViewNode | null) => {
const key = keyOf(n.path);
out.push({ node: n, key, parentKey });
for (const c of n.children) walk(c, key);
out.push({ node: n, key, parentKey, parent });
for (const c of n.children) walk(c, key, n);
};
walk(root, null);
walk(root, null, null);
return out;
}
/** The nearest edge of rect `r` to the point — drives the drop axis and side. */
function edgeOf(r: DOMRect, x: number, y: number): Edge {
const fx = (x - r.left) / r.width;
const fy = (y - r.top) / r.height;
const dist: Record<Edge, number> = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
return (Object.keys(dist) as Edge[]).reduce((a, b) => (dist[a] <= dist[b] ? a : b));
}
/** The resolved drop the wireframe will commit, and how to draw it on the target. */
interface DropResolution {
targetKey: string;
edge: Edge;
mode: 'move' | 'wrap';
commit:
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number }
| {
kind: 'wrap';
targetPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
};
}
interface DragState {
sourceKey: string;
sourceNode: ViewNode;
resolution: DropResolution | null;
}
function WireframeTree({ tree }: { tree: ViewNode }) {
const requestRevealView = useAppStore((s) => s.requestRevealView);
const requestComposeMove = useAppStore((s) => s.requestComposeMove);
const requestComposeWrap = useAppStore((s) => s.requestComposeWrap);
// 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);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const [hoverKey, setHoverKey] = useState<string | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [announcement, setAnnouncement] = useState('');
// The box to focus once the tree rebuilds after a restructure (a drag leaves focus
// on the body; keyboard needs focus to follow the view to its new place — APG).
const [pendingFocusKey, setPendingFocusKey] = useState<string | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const justDraggedRef = useRef(false);
const setDragState = (d: DragState | null) => {
dragRef.current = d;
setDrag(d);
};
const effectiveFocus =
(focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey;
@@ -105,15 +176,207 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
[requestRevealView],
);
// Select, focus-after-rebuild, and announce the box at `key` once a restructure
// has been requested (the tree rebuilds from the edited draft text).
const settleOn = useCallback((key: string, message: string) => {
setSelectedKey(key);
setFocusedKey(key);
setPendingFocusKey(key);
setAnnouncement(message);
}, []);
const reorder = useCallback(
(arrayPath: SpecPath, from: number, to: number, node: ViewNode, count: number) => {
requestComposeMove(arrayPath, from, to);
settleOn(
keyOf([...arrayPath, to]),
`Moved ${descriptor(node)} to position ${to + 1} of ${count}`,
);
},
[requestComposeMove, settleOn],
);
// Restore focus to the affected box once it re-renders at its new position.
useEffect(() => {
if (!pendingFocusKey) return;
const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${pendingFocusKey}"]`);
if (el) {
el.focus();
setPendingFocusKey(null);
}
}, [flat, pendingFocusKey]);
// Rect of the box for `key`, read live from the DOM during a drag.
const rectOf = useCallback((key: string): DOMRect | null => {
const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${key}"]`);
return el ? el.getBoundingClientRect() : null;
}, []);
// The drop target: descend through concats (row/column) into the child under the
// pointer; stop at a leaf or an opaque container (layer/facet/repeat/grid). The
// dragged box is skipped so the pointer never targets it or its subtree.
const dropTargetNode = useCallback(
(x: number, y: number, sourceKey: string): ViewNode | null => {
let node = tree;
while (node.kind === 'composition' && node.orientation && DESCENDABLE.has(node.orientation)) {
const child = node.children.find((c) => {
if (keyOf(c.path) === sourceKey) return false;
const r = rectOf(keyOf(c.path));
return r ? x >= r.left && x <= r.right && y >= r.top && y <= r.bottom : false;
});
if (!child) break;
node = child;
}
return keyOf(node.path) === sourceKey ? null : node;
},
[tree, rectOf],
);
const resolveDrop = useCallback(
(x: number, y: number, source: ViewNode): DropResolution | null => {
const sourcePath = source.path;
const target = dropTargetNode(x, y, keyOf(sourcePath));
if (!target) return null;
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)) {
const from = sourcePath[sourcePath.length - 1] as number;
const gap = side === 'before' ? tIdx : tIdx + 1;
let to = gap > from ? gap - 1 : gap;
to = Math.max(0, Math.min(to, parent.children.length - 1));
if (to === from) return null; // no-op
return {
targetKey: keyOf(targetPath),
edge,
mode: 'move',
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',
commit: { kind: 'wrap', targetPath, sourcePath, axis, side },
};
},
[dropTargetNode, parentByKey, rectOf],
);
const commitDrop = useCallback(
(res: DropResolution, source: ViewNode) => {
if (res.commit.kind === 'move') {
const { arrayPath, from, to } = res.commit;
const count = parentByKey.get(res.targetKey)?.children.length ?? 0;
reorder(arrayPath, from, to, source, count);
} else {
const { targetPath, sourcePath, axis, side } = res.commit;
requestComposeWrap(targetPath, sourcePath, axis, side);
// Focus/selection lands on the new split at the target's slot. When the drop
// flattens to a plain insert (a with-axis cross-container drop), the target's
// path shifts, so this key no longer resolves and the focus-restore no-ops — a
// tolerated gap while cross-container wrap is pointer-only (focus matters for the
// keyboard path, which is in-container `Alt+↑/↓` reorder, where the key is exact).
settleOn(
keyOf(targetPath),
`Paired ${descriptor(source)} into a ${axis === 'horizontal' ? 'row' : 'column'}`,
);
}
},
[parentByKey, reorder, requestComposeWrap, settleOn],
);
const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, source: ViewNode) => {
if (e.button !== 0 || !editable) return;
const startX = e.clientX;
const startY = e.clientY;
let started = false;
const onMove = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
started = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
setDragState({
sourceKey: keyOf(source.path),
sourceNode: source,
resolution: resolveDrop(ev.clientX, ev.clientY, source),
});
};
const cleanup = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
const onUp = () => {
const d = dragRef.current;
cleanup();
setDragState(null);
if (started) {
justDraggedRef.current = true; // swallow the click that follows a real drag
if (d?.resolution) commitDrop(d.resolution, d.sourceNode);
}
};
// A touch the browser reclaims for scrolling fires pointercancel — abort cleanly.
const onCancel = () => {
cleanup();
setDragState(null);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const i = flat.findIndex((f) => f.key === effectiveFocus);
if (i < 0) return;
// Alt+↑/↓ reorders the focused view among its siblings (APG rearrangeable list).
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
const f = flat[i];
const idx = f.node.path.at(-1);
if (!editable || !f.parent || typeof idx !== 'number') return;
const count = f.parent.children.length;
const to = e.key === 'ArrowUp' ? idx - 1 : idx + 1;
if (to < 0 || to >= count) {
setAnnouncement(e.key === 'ArrowUp' ? 'Already at the start' : 'Already at the end');
return;
}
reorder(f.node.path.slice(0, -1), idx, to, f.node, count);
return;
}
const moveTo = (j: number) => {
const target = flat[j];
if (!target) return;
e.preventDefault();
setFocusedKey(target.key);
e.currentTarget.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
treeRef.current?.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
};
switch (e.key) {
case 'ArrowDown':
@@ -137,10 +400,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
}
};
const renderNode = (n: ViewNode): ReactNode => {
const renderNode = (n: ViewNode, parent: ViewNode | null): ReactNode => {
const key = keyOf(n.path);
const container = n.kind === 'composition';
const generated = n.op === 'facet' || n.op === 'repeat';
const idx = n.path.at(-1);
const draggable = editable && parent != null && typeof idx === 'number';
const onTarget = drag?.resolution?.targetKey === key ? drag.resolution : null;
const cls = [
styles.node,
container ? styles.container : styles.leaf,
@@ -157,10 +424,20 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
aria-label={ariaLabelOf(n)}
aria-selected={key === selectedKey}
aria-expanded={container ? true : undefined}
aria-keyshortcuts={draggable ? 'Alt+ArrowUp Alt+ArrowDown' : undefined}
tabIndex={key === effectiveFocus ? 0 : -1}
className={cls}
data-draggable={draggable || undefined}
data-dragging={drag?.sourceKey === key || undefined}
data-drop-edge={onTarget?.edge}
data-drop-mode={onTarget?.mode}
onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined}
onClick={(e) => {
e.stopPropagation();
if (justDraggedRef.current) {
justDraggedRef.current = false;
return;
}
select(n);
}}
onMouseEnter={(e) => {
@@ -173,13 +450,16 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
setFocusedKey(key);
}}
>
{container && (
{container ? (
<div
role="group"
data-orientation={n.orientation}
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
>
{n.children.map(renderNode)}
{n.children.map((c) => renderNode(c, n))}
</div>
) : (
<Icon name={markIconName(n.mark)} size="md" className={styles.markIcon} />
)}
</div>
);
@@ -190,8 +470,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
return (
<>
<div role="tree" aria-label="View composition" className={styles.tree} onKeyDown={onKeyDown}>
{renderNode(tree)}
<div
ref={treeRef}
role="tree"
aria-label="View composition"
className={styles.tree}
onKeyDown={onKeyDown}
>
{renderNode(tree, null)}
</div>
<p className={styles.caption} aria-hidden="true">
{captionNode ? (
@@ -199,9 +485,16 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
<code>{pathLabel(captionNode.path)}</code> {descriptor(captionNode)}
</>
) : (
<span className={styles.muted}>Hover a block to identify it · click to reveal it</span>
<span className={styles.muted}>
{editable
? 'Drag a block onto anothers edge to rearrange · Alt+↑/↓ to reorder · click to reveal'
: 'Hover a block to identify it · click to reveal it'}
</span>
)}
</p>
<div className="visually-hidden" role="status" aria-live="polite">
{announcement}
</div>
</>
);
}
+83
View File
@@ -35,6 +35,19 @@ export type IconName =
| 'info' // about / information — Carbon Information (outline)
| 'revert' // revert draft to last published — Carbon Reset
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
// Mark sub-family (composition wireframe leaves) — a simplified glyph of a unit
// view's mark type, so which-is-which reads at a glance. Vega-Lite mark synonyms
// collapse onto these via `markIconName` (CompositionWireframe); unknown → generic.
| 'mark-bar'
| 'mark-line'
| 'mark-area'
| 'mark-point'
| 'mark-arc'
| 'mark-rect'
| 'mark-tick'
| 'mark-rule'
| 'mark-text'
| 'mark-generic'
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
// glyph shows *which* pane it controls by position (left / centre / right).
| 'pane-library' // toggle the library pane (left)
@@ -164,6 +177,76 @@ const GLYPHS: Record<IconName, ReactNode> = {
<rect x="9" y="17" width="14" height="5" />
</>
),
// Mark glyphs: simplified renderings of each Vega-Lite mark, drawn on the same
// 32-grid. Stroke-based where a line reads truer than a fill (line/rule/generic).
'mark-bar': (
<>
<rect x="5" y="14" width="5" height="14" />
<rect x="13" y="8" width="5" height="20" />
<rect x="21" y="18" width="5" height="10" />
</>
),
'mark-line': (
<polyline
points="4,22 11,13 18,17 28,6"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
),
'mark-area': <path d="M4,28 L4,17 L12,11 L20,16 L28,7 L28,28 Z" />,
'mark-point': (
<>
<circle cx="9" cy="20" r="2.6" />
<circle cx="16" cy="12" r="2.6" />
<circle cx="22" cy="22" r="2.6" />
<circle cx="25" cy="9" r="2.6" />
</>
),
// A three-quarter pie wedge (one quadrant empty) reads as arc/pie at a glance.
'mark-arc': <path d="M16,16 L16,4 A12,12 0 1,1 4,16 Z" />,
'mark-rect': (
<>
<rect x="5" y="9" width="6" height="6" />
<rect x="13" y="9" width="6" height="6" />
<rect x="21" y="9" width="6" height="6" />
<rect x="5" y="17" width="6" height="6" />
<rect x="13" y="17" width="6" height="6" />
<rect x="21" y="17" width="6" height="6" />
</>
),
'mark-tick': (
<>
<rect x="6" y="10" width="2" height="12" />
<rect x="12" y="10" width="2" height="12" />
<rect x="18" y="10" width="2" height="12" />
<rect x="24" y="10" width="2" height="12" />
</>
),
'mark-rule': (
<line
x1="5"
y1="24"
x2="27"
y2="8"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
/>
),
'mark-text': (
<>
<rect x="6" y="8" width="20" height="3" />
<rect x="6" y="15" width="14" height="3" />
<rect x="6" y="22" width="18" height="3" />
</>
),
'mark-generic': (
<rect x="5" y="6" width="22" height="20" fill="none" stroke="currentColor" strokeWidth={2} />
),
moon: (
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
),
+20
View File
@@ -37,8 +37,10 @@ import {
configureSpecTransformCodeActions,
installSpecTransformActions,
installSpecTransformCodeLens,
runMoveViewTo,
runUnwrap,
runWrap,
runWrapViews,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import { runExtract } from '../services/extract-action';
@@ -385,6 +387,7 @@ export function SpecEditor() {
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const uiTheme = useAppStore((s) => s.uiTheme);
const revealTarget = useAppStore((s) => s.revealTarget);
const composeRequest = useAppStore((s) => s.composeRequest);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
@@ -513,6 +516,23 @@ export function SpecEditor() {
// can keep browsing blocks while the editor scrolls/selects to follow.
}, [revealTarget]);
// Apply a composition restructure when the wireframe asks (wireframe → editor;
// the editor owns the undoable edit). The nonce makes a repeat request re-fire.
useEffect(() => {
const editor = editorRef.current;
if (!editor || !composeRequest) return;
if (composeRequest.kind === 'move')
runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to);
else
runWrapViews(
editor,
composeRequest.targetPath,
composeRequest.sourcePath,
composeRequest.axis,
composeRequest.side,
);
}, [composeRequest]);
return (
<div className={styles.editorPane}>
<EditorToolbar editorRef={editorRef} />
+27
View File
@@ -0,0 +1,27 @@
/**
* Mark → wireframe glyph ledger (composition wireframe leaves, arch 08). Maps a
* unit view's Vega-Lite mark type to its glyph in the Icon vocabulary's `mark-*`
* sub-family, collapsing synonyms (circle/square → point, trail → line, image →
* rect) so the set stays small; anything unmapped or absent falls to `mark-generic`.
*/
import type { IconName } from './Icon';
const MARK_ICON: Record<string, IconName> = {
bar: 'mark-bar',
line: 'mark-line',
trail: 'mark-line',
area: 'mark-area',
point: 'mark-point',
circle: 'mark-point',
square: 'mark-point',
tick: 'mark-tick',
rect: 'mark-rect',
image: 'mark-rect',
arc: 'mark-arc',
rule: 'mark-rule',
text: 'mark-text',
};
export const markIconName = (mark?: string): IconName =>
(mark && MARK_ICON[mark]) || 'mark-generic';
+68 -5
View File
@@ -38,8 +38,10 @@ import {
elementOffset,
insertView,
moveView,
moveViewTo,
type SpecPath,
} from '@core/spec-insert';
import { wrapViews, type DropAxis } from '@core/spec-restructure';
import {
unwrapSingleton,
wrapInConcat,
@@ -176,16 +178,22 @@ const WRAP_NOUN: Record<WrapKind, string> = {
repeat: 'a repeat',
};
/** Replace the scope as one undoable edit (the toolbar / palette path). */
/**
* Replace the scope as one undoable edit (the toolbar / palette path). `focus`
* returns focus to the editor afterwards — true for editor-originated actions,
* false when the wireframe drove the edit and must keep its own focus (APG: a
* keyboard reorder stays on the moved item for consecutive moves).
*/
function writeBack(
editor: monaco.editor.IStandaloneCodeEditor,
range: monaco.Range,
text: string,
focus = true,
): void {
editor.pushUndoStop();
editor.executeEdits('spec-transform', [{ range, text }]);
editor.pushUndoStop();
editor.focus();
if (focus) editor.focus();
}
/**
@@ -245,13 +253,18 @@ export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
* and a repeated click keeps acting on the same view. `followIndex` is the view's
* index in `arrayPath` *after* the edit. Reformatted in the app's compact style,
* which is idempotent on an already-formatted draft.
*
* `successTitle` of null suppresses the success toast (the wireframe gives its own
* feedback — visible move + live-region announcement — so a per-move toast would
* double up); `focusEditor` of false keeps focus off the editor for the same path.
*/
function applyArrayEdit(
editor: monaco.editor.IStandaloneCodeEditor,
build: (spec: JsonObject) => JsonObject | null,
arrayPath: SpecPath,
followIndex: number,
successTitle: string,
successTitle: string | null,
focusEditor = true,
): void {
const model = editor.getModel();
if (!model) return;
@@ -267,14 +280,15 @@ function applyArrayEdit(
return;
}
const formatted = formatScoped(model, wholeDocument(model), next);
writeBack(editor, model.getFullModelRange(), formatted);
writeBack(editor, model.getFullModelRange(), formatted, focusEditor);
const offset = elementOffset(formatted, arrayPath, followIndex);
if (offset !== null) {
const pos = model.getPositionAt(offset);
editor.setPosition(pos);
editor.revealPositionInCenterIfOutsideViewport(pos);
}
notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
if (successTitle)
notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
}
/** Insert an empty view at `index` of the composition at `arrayPath`. */
@@ -302,6 +316,55 @@ function runMoveView(
);
}
/**
* Reorder the view at `from` to `to` within the composition at `arrayPath` — the
* composition wireframe's drag/keyboard reorder. No success toast and no editor
* focus-steal: the wireframe owns the feedback and keeps focus on the moved box.
*/
export function runMoveViewTo(
editor: monaco.editor.IStandaloneCodeEditor,
arrayPath: SpecPath,
from: number,
to: number,
): void {
applyArrayEdit(editor, (s) => moveViewTo(s, arrayPath, from, to), arrayPath, to, null, false);
}
/**
* 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.
*/
export function runWrapViews(
editor: monaco.editor.IStandaloneCodeEditor,
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model.getValue());
if (!spec) return;
const next = wrapViews(spec, targetPath, sourcePath, axis, side);
if (!next) {
notify({
kind: 'info',
title: 'Could not restructure',
message: 'That drop isnt possible here — the composition may have changed.',
});
return;
}
writeBack(
editor,
model.getFullModelRange(),
formatScoped(model, wholeDocument(model), next),
false,
);
}
/** Insert a view above/below the one the cursor is in (the keyboard path). */
function runInsertRelative(
editor: monaco.editor.IStandaloneCodeEditor,
+51
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand';
import type { FitMode } from '@core/rendering';
import type { SpecPath } from '@core/spec-insert';
import type { DropAxis } from '@core/spec-restructure';
import type { UiTheme } from '@core/theme';
import type { ChartThemeSelection } from '@core/vega-themes';
import type { ModalName } from '../modals/types';
@@ -71,6 +73,24 @@ export interface AppState {
* nonce makes a repeat request for the same range re-fire. Null until the first.
*/
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.
*/
composeRequest:
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number; nonce: number }
| {
kind: 'wrap';
targetPath: SpecPath;
sourcePath: SpecPath;
axis: DropAxis;
side: 'before' | 'after';
nonce: number;
}
| null;
setTheme: (theme: UiTheme) => void;
/** Flip between light and dark — the header ThemeToggle's action. */
@@ -92,6 +112,15 @@ export interface AppState {
setActiveModal: (modal: ModalName | null) => void;
/** Ask the editor to select + reveal a view's source range (composition wireframe). */
requestRevealView: (offset: number, length: number) => void;
/** Ask the editor to reorder a view within its composition array (wireframe drag/keyboard). */
requestComposeMove: (arrayPath: SpecPath, from: number, to: number) => void;
/** Ask the editor to pair a dragged view beside a drop target in a new concat (wireframe drag). */
requestComposeWrap: (
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
) => void;
}
export const useAppStore = create<AppState>((set) => ({
@@ -102,6 +131,7 @@ export const useAppStore = create<AppState>((set) => ({
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
activeModal: null,
revealTarget: null,
composeRequest: null,
setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
@@ -112,4 +142,25 @@ export const useAppStore = create<AppState>((set) => ({
setActiveModal: (activeModal) => set({ activeModal }),
requestRevealView: (offset, length) =>
set((s) => ({ revealTarget: { offset, length, nonce: (s.revealTarget?.nonce ?? 0) + 1 } })),
requestComposeMove: (arrayPath, from, to) =>
set((s) => ({
composeRequest: {
kind: 'move',
arrayPath,
from,
to,
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
requestComposeWrap: (targetPath, sourcePath, axis, side) =>
set((s) => ({
composeRequest: {
kind: 'wrap',
targetPath,
sourcePath,
axis,
side,
nonce: (s.composeRequest?.nonce ?? 0) + 1,
},
})),
}));
+31
View File
@@ -4,6 +4,7 @@ import {
elementOffset,
insertView,
moveView,
moveViewTo,
type SpecPath,
} from './spec-insert';
@@ -113,6 +114,36 @@ describe('moveView', () => {
});
});
describe('moveViewTo', () => {
it('slides a view to an arbitrary index, shifting the rest', () => {
const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] };
// first → last
expect(moveViewTo(spec, ['hconcat'], 0, 2)!.hconcat).toEqual([
{ mark: 'b' },
{ mark: 'c' },
{ mark: 'a' },
]);
// last → first
expect(moveViewTo(spec, ['hconcat'], 2, 0)!.hconcat).toEqual([
{ mark: 'c' },
{ mark: 'a' },
{ mark: 'b' },
]);
expect(spec.hconcat[0]).toEqual({ mark: 'a' }); // input untouched
});
it('returns null for a no-op move or an out-of-bounds index', () => {
const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }] };
expect(moveViewTo(spec, ['hconcat'], 1, 1)).toBeNull();
expect(moveViewTo(spec, ['hconcat'], 0, 2)).toBeNull();
expect(moveViewTo(spec, ['hconcat'], -1, 0)).toBeNull();
});
it('returns null when the path is not an array', () => {
expect(moveViewTo({ mark: 'bar' }, ['hconcat'], 0, 1)).toBeNull();
});
});
describe('elementOffset', () => {
it('returns the start offset of the element at the path', () => {
const path: SpecPath = ['hconcat', 0, 'layer'];
+41 -14
View File
@@ -82,17 +82,28 @@ export function compositionTargetAt(text: string, offset: number): CompositionTa
};
}
/** The array at `path` in `spec`, or null when the path does not lead to one. */
function arrayAt(spec: JsonObject, path: SpecPath): unknown[] | null {
/** Value at `path` in `spec`, or undefined when the path runs off a non-object/array. */
export function valueAtPath(spec: unknown, path: SpecPath): unknown {
let node: unknown = spec;
for (const segment of path) {
if (Array.isArray(node)) node = node[segment as number];
else if (isJsonObject(node)) node = node[segment as string];
else return null;
else return undefined;
}
return node;
}
/** The array at `path` in `spec`, or null when the path does not lead to one. */
export function arrayAtPath(spec: unknown, path: SpecPath): unknown[] | null {
const node = valueAtPath(spec, path);
return Array.isArray(node) ? node : null;
}
/** Is `a` a prefix of (or equal to) `b` — an ancestor of, or the same as, it? */
export function isPrefixPath(a: SpecPath, b: SpecPath): boolean {
return a.length <= b.length && a.every((seg, i) => seg === b[i]);
}
/**
* Insert an empty placeholder view at `index` of the array at `path` (clamped to
* the array's bounds). Returns a new spec, or null when the path does not lead to
@@ -100,16 +111,38 @@ function arrayAt(spec: JsonObject, path: SpecPath): unknown[] | null {
*/
export function insertView(spec: JsonObject, path: SpecPath, index: number): JsonObject | null {
const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
const array = arrayAt(next, path);
const array = arrayAtPath(next, path);
if (!array) return null;
array.splice(Math.max(0, Math.min(index, array.length)), 0, placeholderView());
return next;
}
/**
* Swap the view at `index` of the array at `path` with its neighbor `delta` steps
* away (±1). Returns a new spec, or null when the path is not an array or either
* position is out of bounds. Input is not mutated.
* Move the view at `from` of the array at `path` to `to`, sliding the views
* between them over by one (a drag-reorder, not a swap). Returns a new spec, or
* null when the path is not an array, an index is out of bounds, or `from === to`
* (a no-op). Input is not mutated.
*/
export function moveViewTo(
spec: JsonObject,
path: SpecPath,
from: number,
to: number,
): JsonObject | null {
const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
const array = arrayAtPath(next, path);
if (!array) return null;
if (from === to) return null;
if (from < 0 || from >= array.length || to < 0 || to >= array.length) return null;
const [moved] = array.splice(from, 1);
array.splice(to, 0, moved);
return next;
}
/**
* Move the view at `index` of the array at `path` by `delta` steps (±1) — the
* neighbor-step reorder behind the CodeLens Move up/down. A one-step move is a
* swap, so this is `moveViewTo(index, index + delta)`.
*/
export function moveView(
spec: JsonObject,
@@ -117,13 +150,7 @@ export function moveView(
index: number,
delta: number,
): JsonObject | null {
const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
const array = arrayAt(next, path);
if (!array) return null;
const to = index + delta;
if (index < 0 || index >= array.length || to < 0 || to >= array.length) return null;
[array[index], array[to]] = [array[to], array[index]];
return next;
return moveViewTo(spec, path, index, index + delta);
}
/** Start offset of the element at `[...path, index]` in the text, or null. */
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { wrapViews } from './spec-restructure';
describe('wrapViews — wrap across the target axis', () => {
it('pairs a view beside a sibling in a new perpendicular concat', () => {
// The motivating case: a vconcat of three, drop the last beside the middle to
// make a row — vconcat:[A, hconcat:[B, C]].
const spec = { data: { name: 'd' }, vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] };
const next = wrapViews(spec, ['vconcat', 1], ['vconcat', 2], 'horizontal', 'after');
expect(next).toEqual({
data: { name: 'd' },
vconcat: [{ mark: 'a' }, { hconcat: [{ mark: 'b' }, { mark: 'c' }] }],
});
expect(spec.vconcat).toHaveLength(3); // input untouched
});
it('honors the side — source before the target', () => {
// Wrapping A and removing B empties the hconcat to one child, which collapses,
// so the result is the bare vconcat with the source ahead of the target.
const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }] };
const next = wrapViews(spec, ['hconcat', 0], ['hconcat', 1], 'vertical', 'before');
expect(next).toEqual({ vconcat: [{ mark: 'b' }, { mark: 'a' }] });
});
});
describe('wrapViews — flatten makes a with-axis drop an insert', () => {
it('inserts into the target concat instead of nesting a redundant one', () => {
// Drop S (from a different container) onto the bottom edge of B, which already
// sits in a vconcat → S joins that vconcat after B, the source container collapses.
const spec = {
hconcat: [{ vconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 's' }],
};
const next = wrapViews(spec, ['hconcat', 0, 'vconcat', 1], ['hconcat', 1], 'vertical', 'after');
expect(next).toEqual({ vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 's' }] });
});
});
describe('wrapViews — collapse the source container', () => {
it('unwraps a single-child container left behind by the move', () => {
const spec = { vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }] };
// Drag B out to sit below C (a with-axis drop → reorder via flatten), leaving the
// hconcat with one child, which collapses to a bare unit.
const next = wrapViews(spec, ['vconcat', 1], ['vconcat', 0, 'hconcat', 1], 'vertical', 'after');
expect(next).toEqual({ vconcat: [{ mark: 'a' }, { mark: 'c' }, { mark: 'b' }] });
});
});
describe('wrapViews — data pinning', () => {
it('pins the sources inherited data when it would otherwise rebind', () => {
const spec = {
data: { name: 'root' },
vconcat: [{ mark: 'a' }, { data: { name: 'd2' }, hconcat: [{ mark: 'b' }, { mark: 'c' }] }],
};
const next = wrapViews(
spec,
['vconcat', 0],
['vconcat', 1, 'hconcat', 0],
'horizontal',
'after',
);
expect(next).toEqual({
data: { name: 'root' },
vconcat: [
{ hconcat: [{ mark: 'a' }, { data: { name: 'd2' }, mark: 'b' }] },
{ data: { name: 'd2' }, mark: 'c' },
],
});
});
it('adds no data key when the source already inherits the destination data', () => {
const spec = { data: { name: 'd' }, vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] };
const next = wrapViews(spec, ['vconcat', 0], ['vconcat', 2], 'horizontal', 'after');
// C had no data and the whole spec shares one source, so nothing is pinned.
expect(JSON.stringify(next)).not.toContain('"data":{"name":"d"},{"mark":"c"');
expect(next).toEqual({
data: { name: 'd' },
vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'c' }] }, { mark: 'b' }],
});
});
});
describe('wrapViews — degenerate drops', () => {
const spec = { vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }] };
it('returns null for a drop onto itself', () => {
expect(wrapViews(spec, ['vconcat', 1], ['vconcat', 1], 'horizontal', 'after')).toBeNull();
});
it('returns null when the source is an ancestor of the target', () => {
expect(
wrapViews(spec, ['vconcat', 0, 'hconcat', 1], ['vconcat', 0], 'horizontal', 'after'),
).toBeNull();
});
it('returns null when the target is a descendant of the source', () => {
expect(
wrapViews(spec, ['vconcat', 0], ['vconcat', 0, 'hconcat', 0], 'vertical', 'after'),
).toBeNull();
});
it('returns null when wrapping the root', () => {
expect(wrapViews(spec, [], ['vconcat', 1], 'horizontal', 'after')).toBeNull();
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* Path-targeted structural restructuring (docs/architecture/08 → editor
* augmentation) — the cross-container moves behind the composition wireframe's
* 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):
*
* - **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
* concat reads as a plain *insert*, not a nested box.
* - **Collapse** — removing the source can leave its old container with one child
* (unwrap it) or none (drop it, recursing upward) — `unwrapSingleton`'s job, here
* along an arbitrary path.
* - **Data-pin** — a source with no own `data` inherits its ancestor's; moved under
* a different one it would silently rebind, so its effective data is pinned on.
*
* Degenerate drops (onto itself, into its own descendant or its own ancestor, or
* wrapping the root) return null. Portable core; JSON text/format/undo is the
* editor's job, as for every transform here.
*/
import { dataBindingAtPath } from './spec-data';
import { isJsonObject, type JsonObject } from './spec-config';
import { arrayAtPath, isPrefixPath, valueAtPath, type SpecPath } from './spec-insert';
/** The orientation of the concat a drop creates. */
export type DropAxis = 'horizontal' | 'vertical';
/** Deep clone via JSON — specs are plain JSON, as everywhere in the transforms. */
const clone = (spec: JsonObject): JsonObject => JSON.parse(JSON.stringify(spec)) as JsonObject;
/** The concat operators a drop flattens through (layers are z-order, never flattened). */
const CONCAT_OPS = ['hconcat', 'vconcat', 'concat'] as const;
/**
* The data block a sibling of the view at `targetPath` would inherit — the context
* the new concat sits in, which is the binding in scope at the target's *parent*
* (so the target's own `data`, if any, doesn't count: a sibling doesn't inherit it).
*/
function contextDataFor(spec: JsonObject, targetPath: SpecPath): unknown {
return dataBindingAtPath(spec, targetPath.slice(0, -1))?.data;
}
/**
* Pin the source's effective data onto it before the move, so it keeps the same
* data in its new home. No-op when the source declares its own `data` (unaffected),
* has no effective data to preserve, or already inherits the destination's data.
*/
function pinData(
spec: JsonObject,
source: JsonObject,
sourcePath: SpecPath,
targetPath: SpecPath,
): void {
if ('data' in source) return;
const current = dataBindingAtPath(spec, sourcePath);
if (!current) return;
const destData = contextDataFor(spec, targetPath);
if (JSON.stringify(destData) !== JSON.stringify(current.data)) source.data = current.data;
}
/**
* Remove the array element at `path` (its last segment an index), then collapse the
* container it left behind: one child remaining → unwrap it onto the wrapper (the
* child wins on conflict); none remaining → drop the empty composition key and, if
* that empties its wrapper too, remove the wrapper from its parent array and repeat
* upward. Mutates `spec`.
*/
function removeAndCollapse(spec: JsonObject, path: SpecPath): void {
let arrPath = path.slice(0, -1);
const idx = path[path.length - 1];
const arr = arrayAtPath(spec, arrPath);
if (!arr || typeof idx !== 'number') return;
arr.splice(idx, 1);
// Walk up collapsing redundant containers; only the splice above reduces a count,
// and an unwrap preserves the parent's count, so each step is independent.
while (arrPath.length >= 1) {
const current = arrayAtPath(spec, arrPath);
if (!current) break;
const opKey = arrPath[arrPath.length - 1] as string;
const wrapper = valueAtPath(spec, arrPath.slice(0, -1));
if (!isJsonObject(wrapper)) break;
if (current.length >= 2) break;
if (current.length === 1) {
const child = current[0];
delete wrapper[opKey];
if (isJsonObject(child)) Object.assign(wrapper, child); // child wins, like unwrapSingleton
break; // count preserved upward — nothing more to collapse
}
// Empty composition: drop the key; if the wrapper is now an empty array element,
// remove it from its parent array and continue collapsing that array.
delete wrapper[opKey];
const wrapperPath = arrPath.slice(0, -1);
const wIdx = wrapperPath[wrapperPath.length - 1];
if (Object.keys(wrapper).length === 0 && typeof wIdx === 'number') {
const parentArr = arrayAtPath(spec, wrapperPath.slice(0, -1));
if (!parentArr) break;
parentArr.splice(wIdx, 1);
arrPath = wrapperPath.slice(0, -1);
continue;
}
break;
}
}
/** Flatten any bare same-orientation concat nested directly in a concat, in place. */
function flattenBareConcats(node: unknown): void {
if (Array.isArray(node)) {
for (const item of node) flattenBareConcats(item);
return;
}
if (!isJsonObject(node)) return;
for (const op of CONCAT_OPS) {
const arr = node[op];
if (!Array.isArray(arr)) continue;
const out: unknown[] = [];
for (const el of arr) {
flattenBareConcats(el);
if (
isJsonObject(el) &&
Object.keys(el).length === 1 &&
Array.isArray(el[op]) // a bare concat of the same orientation
) {
out.push(...(el[op] as unknown[]));
} else {
out.push(el);
}
}
node[op] = out;
}
for (const key of Object.keys(node)) {
if (!CONCAT_OPS.includes(key as (typeof CONCAT_OPS)[number])) flattenBareConcats(node[key]);
}
}
/**
* Pair the `source` view beside the `target` view in a new concat of `axis`, in
* the slot the target held, removing the source from where it was. `side` places
* the source `before` or `after` the target. Returns a new spec, or null for a
* degenerate drop (source is the target, an ancestor or descendant of it, or the
* target is the root). The input is not mutated.
*
* Because a bare same-orientation concat is flattened afterward, dropping a view
* onto an edge that runs *with* the target's existing concat reads as inserting it
* into that concat; an edge that runs *across* it nests a new concat — the one
* operation behind both the wireframe's "move into" and "wrap" drops.
*/
export function wrapViews(
spec: JsonObject,
targetPath: SpecPath,
sourcePath: SpecPath,
axis: DropAxis,
side: 'before' | 'after',
): JsonObject | null {
if (targetPath.length === 0) return null; // can't wrap the root
// Self, ancestor, or descendant drops are degenerate.
if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;
const next = clone(spec);
const target = valueAtPath(next, targetPath);
const source = valueAtPath(next, sourcePath);
if (!isJsonObject(target) || !isJsonObject(source)) return null;
// Preserve the source's data before it changes context (reads the unmutated tree).
pinData(next, source, sourcePath, targetPath);
// Replace the target's slot with the new concat (by reference, so no index shifts).
const op = axis === 'horizontal' ? 'hconcat' : 'vconcat';
const wrapper: JsonObject = { [op]: side === 'before' ? [source, target] : [target, source] };
const tParent = valueAtPath(next, targetPath.slice(0, -1));
const tKey = targetPath[targetPath.length - 1];
if (Array.isArray(tParent)) tParent[tKey as number] = wrapper;
else if (isJsonObject(tParent)) tParent[tKey as string] = wrapper;
else return null;
// Remove the source from its old location (+ collapse), then flatten redundant nesting.
removeAndCollapse(next, sourcePath);
flattenBareConcats(next);
return next;
}