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