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
@@ -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');
});
});