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';
|
||||
Reference in New Issue
Block a user