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,