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';