mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: composition wireframe — pull-out, stacking, and simplify
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
.pop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 264px;
|
||||
width: 300px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
@@ -36,8 +36,14 @@
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* The padding is the *pull-out margin*: a frame's gutter, between its border and its
|
||||
child boxes, is the drop zone that lifts a view out into a new full-span row/column
|
||||
(vs. a box's own edge, which pairs or reorders). Sized for a comfortable target. */
|
||||
.container {
|
||||
padding: var(--space-2);
|
||||
--pull-gutter: var(--space-5);
|
||||
--reveal-tint: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
position: relative;
|
||||
padding: var(--pull-gutter);
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaf {
|
||||
@@ -63,26 +69,93 @@
|
||||
.node[data-dragging] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Drop indicator (arch 10 §5): a 3px accent line on the edge the dragged box would
|
||||
land. `wrap` mode additionally rings + tints the target box, signalling the two
|
||||
pair into a new split rather than just reordering. */
|
||||
.node[data-drop-edge='left'] {
|
||||
/* Reorder (move) indicator (arch 10 §5): a 3px accent line on the target box's edge
|
||||
marks where the dragged view lands in the sequence. */
|
||||
.node[data-drop-mode='move'][data-drop-edge='left'] {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.node[data-drop-edge='right'] {
|
||||
.node[data-drop-mode='move'][data-drop-edge='right'] {
|
||||
box-shadow: inset -3px 0 0 var(--accent);
|
||||
}
|
||||
.node[data-drop-edge='top'] {
|
||||
.node[data-drop-mode='move'][data-drop-edge='top'] {
|
||||
box-shadow: inset 0 3px 0 var(--accent);
|
||||
}
|
||||
.node[data-drop-edge='bottom'] {
|
||||
.node[data-drop-mode='move'][data-drop-edge='bottom'] {
|
||||
box-shadow: inset 0 -3px 0 var(--accent);
|
||||
}
|
||||
/* Pair (wrap) indicator: the two views split into a new row/column. The target box is
|
||||
outlined, and the half the incoming view will take is shaded with a seam line at the
|
||||
split — so it previews the split, not just "drop here". */
|
||||
.node[data-drop-mode='wrap'] {
|
||||
background: var(--accent-soft);
|
||||
position: relative;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.node[data-drop-mode='wrap']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='top']::after {
|
||||
inset: 0 0 50% 0;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='bottom']::after {
|
||||
inset: 50% 0 0 0;
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='left']::after {
|
||||
inset: 0 50% 0 0;
|
||||
border-right: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='right']::after {
|
||||
inset: 0 0 0 50%;
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
/* Pull-out indicator: the dragged view would lift out into a new full-span row/column
|
||||
here. A shaded band fills the frame's margin on the resolved side, capped by a solid
|
||||
accent line — the "shaded drop-zone" feedback (vs. the box-edge line of a pair/move). */
|
||||
.node[data-drop-pull]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
}
|
||||
.node[data-drop-pull='top']::after {
|
||||
inset: 0 0 auto 0;
|
||||
height: var(--pull-gutter);
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-pull='bottom']::after {
|
||||
inset: auto 0 0 0;
|
||||
height: var(--pull-gutter);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-pull='left']::after {
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--pull-gutter);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-pull='right']::after {
|
||||
inset: 0 0 0 auto;
|
||||
width: var(--pull-gutter);
|
||||
border-right: 2px solid var(--accent);
|
||||
}
|
||||
/* Reveal-on-drag: while a drag is live, every frame's pull-out margins glow faintly so
|
||||
the targets are discoverable without hunting — across the container's own axis (a
|
||||
row's margins are top/bottom, a column's left/right). The active target reads
|
||||
stronger via the band above. */
|
||||
.tree[data-drag-active] .container[data-orientation='horizontal'] {
|
||||
box-shadow:
|
||||
inset 0 var(--pull-gutter) 0 var(--reveal-tint),
|
||||
inset 0 calc(-1 * var(--pull-gutter)) 0 var(--reveal-tint);
|
||||
}
|
||||
.tree[data-drag-active] .container[data-orientation='vertical'] {
|
||||
box-shadow:
|
||||
inset var(--pull-gutter) 0 0 var(--reveal-tint),
|
||||
inset calc(-1 * var(--pull-gutter)) 0 0 var(--reveal-tint);
|
||||
}
|
||||
/* Selection mirrors the library's active-row language (arch 09 §4). */
|
||||
.node.selected {
|
||||
border-color: var(--accent);
|
||||
@@ -179,3 +252,62 @@
|
||||
.muted {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* Drag chip: follows the cursor during a drag, naming the pending action ("New row
|
||||
above", "Pair into a column", "Reorder"). Inverted ink-on-bg for contrast on any
|
||||
theme; above the popover (z 1000) since it portals to the body. */
|
||||
.dragChip {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--bg);
|
||||
background: var(--text);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dragChip[data-empty] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Redundant single-child wrapper: a frame holding one view adds no structure. A soft
|
||||
warning hairline marks it as cleanable (not broken); the prompt below offers the fix. */
|
||||
.node[data-degenerate] {
|
||||
border-style: dashed;
|
||||
border-color: var(--support-warning-fg);
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 11px;
|
||||
color: var(--support-warning-fg);
|
||||
background: color-mix(in srgb, var(--support-warning) 12%, var(--bg));
|
||||
border: var(--border-width) solid color-mix(in srgb, var(--support-warning) 40%, var(--bg));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.warning span {
|
||||
flex: 1;
|
||||
}
|
||||
.simplify {
|
||||
flex-shrink: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.simplify:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.simplify:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -199,24 +199,26 @@ describe('CompositionWireframe — drag to restructure', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const drag = (fromKey: string, to: { x: number; y: number }) => {
|
||||
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
|
||||
act(() => {
|
||||
item(fromKey).dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: to.x, clientY: to.y }));
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('pointerup', {}));
|
||||
});
|
||||
};
|
||||
|
||||
test('dropping onto a perpendicular edge wraps the two views into a row', async () => {
|
||||
test('dropping onto a view’s far cross edge pairs the two into a row', async () => {
|
||||
setEditableSpec(COMPOSED);
|
||||
await renderOpen();
|
||||
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view
|
||||
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view (cross axis)
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'wrap',
|
||||
targetPath: ['vconcat', 0],
|
||||
@@ -239,6 +241,123 @@ describe('CompositionWireframe — drag to restructure', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompositionWireframe — pull a view out via the frame margin', () => {
|
||||
const HCON = JSON.stringify({ hconcat: [{ mark: 'point' }, { mark: 'bar' }, { mark: 'line' }] });
|
||||
// A row of three boxes inset inside the root frame, leaving a margin (the pull-out
|
||||
// zone) all around. happy-dom has no layout, so the hit-test rects are stubbed.
|
||||
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
root: { x: 0, y: 0, w: 300, h: 120 },
|
||||
'hconcat|0': { x: 24, y: 24, w: 80, h: 72 },
|
||||
'hconcat|1': { x: 110, y: 24, w: 80, h: 72 },
|
||||
'hconcat|2': { x: 196, y: 24, w: 80, h: 72 },
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
): DOMRect {
|
||||
const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const box = {
|
||||
left: r.x,
|
||||
top: r.y,
|
||||
right: r.x + r.w,
|
||||
bottom: r.y + r.h,
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
};
|
||||
return { ...box, toJSON: () => ({}) };
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
|
||||
act(() => {
|
||||
item(fromKey).dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true, clientX: 60, clientY: 60 }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('pointerup', {}));
|
||||
});
|
||||
};
|
||||
|
||||
test('dropping in the cross-axis margin pulls the view into a new full-span row', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|0', { x: 150, y: 8 }); // top margin of the row — across its axis
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'wrap-container',
|
||||
containerPath: [],
|
||||
sourcePath: ['hconcat', 0],
|
||||
axis: 'vertical',
|
||||
side: 'before',
|
||||
});
|
||||
});
|
||||
|
||||
test('a with-axis margin reorders to that end of the row, not a pull', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|1', { x: 8, y: 60 }); // left margin (along the axis), before every box
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'move',
|
||||
arrayPath: ['hconcat'],
|
||||
from: 1,
|
||||
to: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping over a sibling’s central band reorders past it', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|2', { x: 50, y: 70 }); // central band of the first box → before it
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'move',
|
||||
arrayPath: ['hconcat'],
|
||||
from: 2,
|
||||
to: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping on a sibling’s top edge stacks the two into a column', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|2', { x: 130, y: 30 }); // top edge of the middle box (cross axis)
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'wrap',
|
||||
targetPath: ['hconcat', 1],
|
||||
sourcePath: ['hconcat', 2],
|
||||
axis: 'vertical',
|
||||
side: 'before',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompositionWireframe — simplify redundant wrappers', () => {
|
||||
const simplifyButton = () =>
|
||||
Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent === 'Simplify');
|
||||
|
||||
test('offers Simplify for a single-child composition and dispatches it', async () => {
|
||||
setEditableSpec(JSON.stringify({ hconcat: [{ mark: 'point' }] }));
|
||||
await renderOpen();
|
||||
const btn = simplifyButton();
|
||||
expect(btn).toBeTruthy();
|
||||
act(() => btn!.click());
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'simplify' });
|
||||
});
|
||||
|
||||
test('no Simplify prompt when every composition has multiple views', async () => {
|
||||
setEditableSpec(COMPOSED); // a vconcat of two
|
||||
await renderOpen();
|
||||
expect(simplifyButton()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markIconName', () => {
|
||||
test('maps marks to glyphs, collapsing synonyms', () => {
|
||||
expect(markIconName('bar')).toBe('mark-bar');
|
||||
|
||||
@@ -10,17 +10,23 @@
|
||||
* is the WAI-ARIA APG **tree** widget — `tree`/`treeitem`/`group`, single-select,
|
||||
* roving tabindex, arrow-key nav in logical (document) order (arch 10 §5).
|
||||
*
|
||||
* On the editable draft a view can be **restructured by dragging its box**:
|
||||
* - onto a sibling's edge *along* its container → reorder within it;
|
||||
* - onto a view's edge *across* its container → pair the two in a new
|
||||
* `hconcat`/`vconcat`, or, dragging in from elsewhere, move/insert it there.
|
||||
* The nearest edge of the box under the pointer picks the axis (left/right → a row,
|
||||
* top/bottom → a column) and side. The keyboard equivalent for in-container reorder
|
||||
* is Alt+↑/↓ (the APG rearrangeable-listbox pattern); cross-container restructuring
|
||||
* stays the editor's wrap actions for keyboard users. Every move is applied by the
|
||||
* editor (which owns the undoable edit) via `AppStore.requestComposeMove` /
|
||||
* `requestComposeWrap`, focus follows the affected box, and a polite live region
|
||||
* announces the result (arch 10 §5).
|
||||
* On the editable draft a view can be **restructured by dragging its box**. Intent is
|
||||
* read from where the pointer falls against a row/column's children, not one nearest
|
||||
* edge (arch 10 §5):
|
||||
* - the interior **central band** reorders within the container;
|
||||
* - the **cross-axis frame margin** (a row's top/bottom, a column's left/right, or
|
||||
* past the block) **pulls the source out** into a new full-span row/column wrapping
|
||||
* the whole container — the root included;
|
||||
* - a view's **far cross edge** pairs the two in a perpendicular `hconcat`/`vconcat`
|
||||
* (`Shift` forces a pair from the centre); a with-axis drop moves/inserts there.
|
||||
* Opaque `layer`/`facet`/`repeat`/grid boxes keep the simpler nearest-edge model, and a
|
||||
* redundant single-child wrapper is flagged with a one-click **Simplify**. The keyboard
|
||||
* equivalent for in-container reorder is Alt+↑/↓ (the APG rearrangeable-listbox pattern);
|
||||
* cross-container restructuring stays the editor's wrap actions for keyboard users. Every
|
||||
* edit is applied by the editor (which owns the undoable edit) via
|
||||
* `AppStore.requestComposeMove`/`requestComposeWrap`/`requestComposeWrapContainer`/
|
||||
* `requestComposeSimplify`, focus follows the affected box, and a polite live region
|
||||
* announces the result.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -36,6 +42,7 @@ import {
|
||||
import { createPortal } from 'react-dom';
|
||||
import { isPrefixPath, type SpecPath } from '@core/spec-insert';
|
||||
import type { DropAxis } from '@core/spec-restructure';
|
||||
import { ARRAY_COMPOSITIONS } from '@core/spec-transforms';
|
||||
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
|
||||
import { usePopover } from '../hooks/usePopover';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
@@ -54,9 +61,31 @@ const DESCENDABLE: ReadonlySet<Orientation> = new Set<Orientation>(['horizontal'
|
||||
|
||||
type Edge = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
/**
|
||||
* Fraction of a view's cross-axis size, at each end, that reads as "pair here": drop a
|
||||
* view onto another's far edge (a row view's top/bottom, a column view's left/right) to
|
||||
* stack the two in a perpendicular split. The central band stays reorder, so an ordinary
|
||||
* along-the-axis drag rearranges. (Shift pairs from the central band too.)
|
||||
*/
|
||||
const PAIR_BAND = 0.25;
|
||||
|
||||
/** A DOM-safe, unique key for a node from its path. */
|
||||
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
|
||||
|
||||
// TODO: vocabulary diverges from the pair labels — pull-out names a vconcat slot "a row"
|
||||
// (spatial: a full-span band) and an hconcat slot "a column", while the pair-into labels and
|
||||
// the commit announcements use the container convention (hconcat = "row", vconcat = "column").
|
||||
// Both describe the same axis; unify or keep the gesture-specific framing (docs/ux-second-pass.md).
|
||||
/** The pull-out action label for the margin a drop landed in. */
|
||||
const pullLabel = (edge: Edge): string =>
|
||||
edge === 'top'
|
||||
? 'New row above'
|
||||
: edge === 'bottom'
|
||||
? 'New row below'
|
||||
: edge === 'left'
|
||||
? 'New column left'
|
||||
: 'New column right';
|
||||
|
||||
/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
|
||||
function pathLabel(path: SpecPath): string {
|
||||
if (path.length === 0) return 'root';
|
||||
@@ -115,7 +144,9 @@ function edgeOf(r: DOMRect, x: number, y: number): Edge {
|
||||
interface DropResolution {
|
||||
targetKey: string;
|
||||
edge: Edge;
|
||||
mode: 'move' | 'wrap';
|
||||
mode: 'move' | 'wrap' | 'pull';
|
||||
/** A short human label for what the drop will do — shown in the drag chip. */
|
||||
label: string;
|
||||
commit:
|
||||
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number }
|
||||
| {
|
||||
@@ -124,6 +155,13 @@ interface DropResolution {
|
||||
sourcePath: SpecPath;
|
||||
axis: DropAxis;
|
||||
side: 'before' | 'after';
|
||||
}
|
||||
| {
|
||||
kind: 'wrap-container';
|
||||
containerPath: SpecPath;
|
||||
sourcePath: SpecPath;
|
||||
axis: DropAxis;
|
||||
side: 'before' | 'after';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,18 +169,34 @@ interface DragState {
|
||||
sourceKey: string;
|
||||
sourceNode: ViewNode;
|
||||
resolution: DropResolution | null;
|
||||
/** Live pointer position, for the drag chip that follows the cursor. */
|
||||
pointer: { x: number; y: number };
|
||||
}
|
||||
|
||||
function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
const requestRevealView = useAppStore((s) => s.requestRevealView);
|
||||
const requestComposeMove = useAppStore((s) => s.requestComposeMove);
|
||||
const requestComposeWrap = useAppStore((s) => s.requestComposeWrap);
|
||||
const requestComposeWrapContainer = useAppStore((s) => s.requestComposeWrapContainer);
|
||||
const requestComposeSimplify = useAppStore((s) => s.requestComposeSimplify);
|
||||
// Restructure only on the editable draft — the published view is read-only.
|
||||
const editable = useSnippetStore((s) => s.editorView === 'draft' && s.activeSnippetId !== null);
|
||||
const flat = useMemo(() => flatten(tree), [tree]);
|
||||
const parentByKey = useMemo(() => new Map(flat.map((f) => [f.key, f.parent])), [flat]);
|
||||
const rootKey = keyOf(tree.path);
|
||||
|
||||
// Redundant single-child wrappers — a `{hconcat: [oneView]}` is just that view (a
|
||||
// layer/concat of one is the same). Flagged on the boxes and offered up for a one-click
|
||||
// Simplify. (facet/repeat hold one child by design, so they're never array-compositions.)
|
||||
const degenerateKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const { node, key } of flat) {
|
||||
if (node.op && ARRAY_COMPOSITIONS.includes(node.op) && node.children.length === 1)
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}, [flat]);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
||||
@@ -229,29 +283,151 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
);
|
||||
|
||||
const resolveDrop = useCallback(
|
||||
(x: number, y: number, source: ViewNode): DropResolution | null => {
|
||||
(x: number, y: number, source: ViewNode, pair: boolean): DropResolution | null => {
|
||||
const sourcePath = source.path;
|
||||
const target = dropTargetNode(x, y, keyOf(sourcePath));
|
||||
if (!target) return null;
|
||||
|
||||
// The row/column container we're acting within, and the child under the pointer
|
||||
// (null when the pointer fell in the container's margin or an inter-child gap).
|
||||
const targetIsRowCol =
|
||||
target.kind === 'composition' &&
|
||||
!!target.orientation &&
|
||||
DESCENDABLE.has(target.orientation);
|
||||
const container = targetIsRowCol ? target : (parentByKey.get(keyOf(target.path)) ?? null);
|
||||
const childUnder = targetIsRowCol ? null : target;
|
||||
|
||||
// Inside a row/column, intent is read against the children's bounding box: reorder
|
||||
// is the default for the interior central band (so a drag along the block just
|
||||
// rearranges it); a pull-out needs the pointer past the children on the *cross* axis
|
||||
// (the frame margin or beyond the block); dropping onto a view's *far cross edge*
|
||||
// pairs the two into a nested split. (Opaque layer/grid contexts keep the
|
||||
// nearest-edge model further down.)
|
||||
if (container?.orientation && DESCENDABLE.has(container.orientation) && container.op) {
|
||||
const horizontal = container.orientation === 'horizontal';
|
||||
const arrayPath: SpecPath = [...container.path, container.op];
|
||||
const kids = container.children
|
||||
.map((c) => ({ node: c, rect: rectOf(keyOf(c.path)) }))
|
||||
.filter((k): k is { node: ViewNode; rect: DOMRect } => k.rect != null);
|
||||
if (!kids.length) return null;
|
||||
const bbox = {
|
||||
left: Math.min(...kids.map((k) => k.rect.left)),
|
||||
top: Math.min(...kids.map((k) => k.rect.top)),
|
||||
right: Math.max(...kids.map((k) => k.rect.right)),
|
||||
bottom: Math.max(...kids.map((k) => k.rect.bottom)),
|
||||
};
|
||||
|
||||
// 1) Pull-out — pointer past the children on the cross axis. Pulling a node into
|
||||
// its own descendant is the one degenerate case.
|
||||
const before = horizontal ? y < bbox.top : x < bbox.left;
|
||||
const after = horizontal ? y > bbox.bottom : x > bbox.right;
|
||||
if (before || after) {
|
||||
if (isPrefixPath(sourcePath, container.path)) return null;
|
||||
const edge: Edge = horizontal ? (before ? 'top' : 'bottom') : before ? 'left' : 'right';
|
||||
return {
|
||||
targetKey: keyOf(container.path),
|
||||
edge,
|
||||
mode: 'pull',
|
||||
label: pullLabel(edge),
|
||||
commit: {
|
||||
kind: 'wrap-container',
|
||||
containerPath: container.path,
|
||||
sourcePath,
|
||||
axis: horizontal ? 'vertical' : 'horizontal',
|
||||
side: before ? 'before' : 'after',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Pair — drop onto a view's far cross edge (a row view's top/bottom, a column
|
||||
// view's left/right) to nest the two in a perpendicular split; Shift pairs from
|
||||
// the central band too. The cross half picks the order.
|
||||
if (childUnder) {
|
||||
const cr = rectOf(keyOf(childUnder.path));
|
||||
const related =
|
||||
isPrefixPath(sourcePath, childUnder.path) || isPrefixPath(childUnder.path, sourcePath);
|
||||
if (cr && !related) {
|
||||
const cf = horizontal ? (y - cr.top) / cr.height : (x - cr.left) / cr.width;
|
||||
if (pair || cf < PAIR_BAND || cf > 1 - PAIR_BAND) {
|
||||
const lead = cf < 0.5;
|
||||
const axis: DropAxis = horizontal ? 'vertical' : 'horizontal';
|
||||
const edge: Edge = horizontal ? (lead ? 'top' : 'bottom') : lead ? 'left' : 'right';
|
||||
return {
|
||||
targetKey: keyOf(childUnder.path),
|
||||
edge,
|
||||
mode: 'wrap',
|
||||
label: `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
|
||||
commit: {
|
||||
kind: 'wrap',
|
||||
targetPath: childUnder.path,
|
||||
sourcePath,
|
||||
axis,
|
||||
side: lead ? 'before' : 'after',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Reorder (default) — the insertion slot from the pointer's main-axis position,
|
||||
// anchored on the child whose edge marks the gap.
|
||||
const mainPos = horizontal ? x : y;
|
||||
let index = 0;
|
||||
for (const k of kids) {
|
||||
const center = horizontal
|
||||
? (k.rect.left + k.rect.right) / 2
|
||||
: (k.rect.top + k.rect.bottom) / 2;
|
||||
if (mainPos > center) index += 1;
|
||||
}
|
||||
const atEnd = index >= kids.length;
|
||||
const anchor = atEnd ? kids[kids.length - 1].node : kids[index].node;
|
||||
const edge: Edge = horizontal ? (atEnd ? 'right' : 'left') : atEnd ? 'bottom' : 'top';
|
||||
|
||||
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
|
||||
const from = sourcePath[sourcePath.length - 1] as number;
|
||||
let to = index > from ? index - 1 : index;
|
||||
to = Math.max(0, Math.min(to, container.children.length - 1));
|
||||
if (to === from) return null; // no-op
|
||||
return {
|
||||
targetKey: keyOf(anchor.path),
|
||||
edge,
|
||||
mode: 'move',
|
||||
label: 'Reorder',
|
||||
commit: { kind: 'move', arrayPath, from, to },
|
||||
};
|
||||
}
|
||||
// From another container → insert here, as a with-axis wrap that flattens in.
|
||||
if (isPrefixPath(sourcePath, anchor.path) || isPrefixPath(anchor.path, sourcePath))
|
||||
return null;
|
||||
return {
|
||||
targetKey: keyOf(anchor.path),
|
||||
edge,
|
||||
mode: 'move',
|
||||
label: 'Move here',
|
||||
commit: {
|
||||
kind: 'wrap',
|
||||
targetPath: anchor.path,
|
||||
sourcePath,
|
||||
axis: horizontal ? 'horizontal' : 'vertical',
|
||||
side: atEnd ? 'after' : 'before',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Opaque container (layer/grid) or root: the simpler nearest-edge model.
|
||||
const targetPath = target.path;
|
||||
// Degenerate: the root, or an ancestor/descendant relationship.
|
||||
if (targetPath.length === 0) return null;
|
||||
if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;
|
||||
|
||||
const r = rectOf(keyOf(targetPath));
|
||||
if (!r) return null;
|
||||
const edge = edgeOf(r, x, y);
|
||||
const axis: DropAxis = edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical';
|
||||
const side: 'before' | 'after' = edge === 'left' || edge === 'top' ? 'before' : 'after';
|
||||
|
||||
const parent = parentByKey.get(keyOf(targetPath));
|
||||
const tIdx = targetPath[targetPath.length - 1];
|
||||
const along =
|
||||
(axis === 'horizontal' && parent?.orientation === 'horizontal') ||
|
||||
(axis === 'vertical' && parent?.orientation === 'vertical');
|
||||
|
||||
// A drop *along* a sibling's own container is a reorder/insert into it; same
|
||||
// container → a plain move, a different one → a wrap that flattens to an insert.
|
||||
if (along && parent && typeof tIdx === 'number') {
|
||||
const arrayPath = targetPath.slice(0, -1);
|
||||
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
|
||||
@@ -264,15 +440,16 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
targetKey: keyOf(targetPath),
|
||||
edge,
|
||||
mode: 'move',
|
||||
label: 'Reorder',
|
||||
commit: { kind: 'move', arrayPath, from, to },
|
||||
};
|
||||
}
|
||||
}
|
||||
// Across the container (or into an opaque target) → wrap the two together.
|
||||
return {
|
||||
targetKey: keyOf(targetPath),
|
||||
edge,
|
||||
mode: 'wrap',
|
||||
label: along ? 'Move here' : `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
|
||||
commit: { kind: 'wrap', targetPath, sourcePath, axis, side },
|
||||
};
|
||||
},
|
||||
@@ -285,6 +462,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
const { arrayPath, from, to } = res.commit;
|
||||
const count = parentByKey.get(res.targetKey)?.children.length ?? 0;
|
||||
reorder(arrayPath, from, to, source, count);
|
||||
} else if (res.commit.kind === 'wrap-container') {
|
||||
const { containerPath, sourcePath, axis, side } = res.commit;
|
||||
requestComposeWrapContainer(containerPath, sourcePath, axis, side);
|
||||
// Focus/selection lands on the container's slot — now the new split holding it.
|
||||
settleOn(
|
||||
keyOf(containerPath),
|
||||
`Pulled ${descriptor(source)} into a new ${axis === 'vertical' ? 'row' : 'column'}`,
|
||||
);
|
||||
} else {
|
||||
const { targetPath, sourcePath, axis, side } = res.commit;
|
||||
requestComposeWrap(targetPath, sourcePath, axis, side);
|
||||
@@ -299,15 +484,31 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
);
|
||||
}
|
||||
},
|
||||
[parentByKey, reorder, requestComposeWrap, settleOn],
|
||||
[parentByKey, reorder, requestComposeWrap, requestComposeWrapContainer, settleOn],
|
||||
);
|
||||
|
||||
const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, source: ViewNode) => {
|
||||
if (e.button !== 0 || !editable) return;
|
||||
// Only the innermost view under the pointer starts the drag: every nested frame is
|
||||
// itself draggable, and without this the pointerdown bubbles to each ancestor, whose
|
||||
// beginDrag runs *after* (bubble order) and overwrites the source — so grabbing a
|
||||
// child would drag its outer block instead.
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let started = false;
|
||||
// The last pointer + Shift state, so a Shift press/release re-resolves in place
|
||||
// (Shift forces a pair when the pointer is over a view's central band).
|
||||
let last = { x: startX, y: startY, pair: e.shiftKey };
|
||||
|
||||
const resolveAt = () => {
|
||||
setDragState({
|
||||
sourceKey: keyOf(source.path),
|
||||
sourceNode: source,
|
||||
resolution: resolveDrop(last.x, last.y, source, last.pair),
|
||||
pointer: { x: last.x, y: last.y },
|
||||
});
|
||||
};
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!started) {
|
||||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
|
||||
@@ -315,16 +516,21 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
setDragState({
|
||||
sourceKey: keyOf(source.path),
|
||||
sourceNode: source,
|
||||
resolution: resolveDrop(ev.clientX, ev.clientY, source),
|
||||
});
|
||||
last = { x: ev.clientX, y: ev.clientY, pair: ev.shiftKey };
|
||||
resolveAt();
|
||||
};
|
||||
const onShift = (ev: WindowEventMap['keydown']) => {
|
||||
if (started && ev.key === 'Shift') {
|
||||
last = { ...last, pair: ev.type === 'keydown' };
|
||||
resolveAt();
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onCancel);
|
||||
window.removeEventListener('keydown', onShift);
|
||||
window.removeEventListener('keyup', onShift);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
@@ -345,6 +551,8 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onCancel);
|
||||
window.addEventListener('keydown', onShift);
|
||||
window.addEventListener('keyup', onShift);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -429,8 +637,11 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
className={cls}
|
||||
data-draggable={draggable || undefined}
|
||||
data-dragging={drag?.sourceKey === key || undefined}
|
||||
data-drop-edge={onTarget?.edge}
|
||||
data-drop-mode={onTarget?.mode}
|
||||
data-orientation={n.orientation}
|
||||
data-degenerate={degenerateKeys.has(key) || undefined}
|
||||
data-drop-edge={onTarget && onTarget.mode !== 'pull' ? onTarget.edge : undefined}
|
||||
data-drop-mode={onTarget && onTarget.mode !== 'pull' ? onTarget.mode : undefined}
|
||||
data-drop-pull={onTarget?.mode === 'pull' ? onTarget.edge : undefined}
|
||||
onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -478,10 +689,27 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
role="tree"
|
||||
aria-label="View composition"
|
||||
className={styles.tree}
|
||||
data-drag-active={drag ? '' : undefined}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{renderNode(tree, null)}
|
||||
</div>
|
||||
{editable && degenerateKeys.size > 0 && (
|
||||
<div className={styles.warning}>
|
||||
<span>
|
||||
{degenerateKeys.size === 1
|
||||
? 'A single-view wrapper adds no structure.'
|
||||
: `${degenerateKeys.size} single-view wrappers add no structure.`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.simplify}
|
||||
onClick={() => requestComposeSimplify()}
|
||||
>
|
||||
Simplify
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className={styles.caption} aria-hidden="true">
|
||||
{captionNode ? (
|
||||
<>
|
||||
@@ -490,7 +718,7 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
) : (
|
||||
<span className={styles.muted}>
|
||||
{editable
|
||||
? 'Drag a block onto another’s edge to rearrange · Alt+↑/↓ to reorder · click to reveal'
|
||||
? 'Drag to reorder · onto a view’s edge to stack · into a frame’s margin to pull out · click to reveal'
|
||||
: 'Hover a block to identify it · click to reveal it'}
|
||||
</span>
|
||||
)}
|
||||
@@ -498,6 +726,21 @@ function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
<div className="visually-hidden" role="status" aria-live="polite">
|
||||
{announcement}
|
||||
</div>
|
||||
{/* A chip following the cursor names what the drop will do — the live "what
|
||||
happens" hint (pointer-only affordance, so aria-hidden; SR users get the
|
||||
commit announcement above). Portaled out of the clipped popover. */}
|
||||
{drag &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.dragChip}
|
||||
data-empty={drag.resolution ? undefined : ''}
|
||||
style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 16 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{drag.resolution?.label ?? 'No change here'}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
installSpecTransformActions,
|
||||
installSpecTransformCodeLens,
|
||||
runMoveViewTo,
|
||||
runSimplifyStructure,
|
||||
runUnwrap,
|
||||
runWrap,
|
||||
runWrapContainer,
|
||||
runWrapViews,
|
||||
} from '../services/spec-transform-actions';
|
||||
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
|
||||
@@ -523,6 +525,15 @@ export function SpecEditor() {
|
||||
if (!editor || !composeRequest) return;
|
||||
if (composeRequest.kind === 'move')
|
||||
runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to);
|
||||
else if (composeRequest.kind === 'simplify') runSimplifyStructure(editor);
|
||||
else if (composeRequest.kind === 'wrap-container')
|
||||
runWrapContainer(
|
||||
editor,
|
||||
composeRequest.containerPath,
|
||||
composeRequest.sourcePath,
|
||||
composeRequest.axis,
|
||||
composeRequest.side,
|
||||
);
|
||||
else
|
||||
runWrapViews(
|
||||
editor,
|
||||
|
||||
Reference in New Issue
Block a user