mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 another’s 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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';
|
||||
@@ -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 isn’t 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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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 source’s 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user