Editor: interactive composition wireframe — drag to reorder and restructure

Drag a view's box (or Alt+up/down) to reorder it within its container; drag onto another view's edge to pair the two in a new row/column, move across containers, or insert. Each leaf shows a glyph of its mark type.

Core: spec-restructure.wrapViews (wrap, with flatten-to-insert, collapse, and data-pin invariants) and spec-insert.moveViewTo; the editor applies every drag as one undoable edit via AppStore.composeRequest, keeping the editor the single text source.
This commit is contained in:
2026-06-29 14:25:31 +03:00
parent 57604a80a6
commit 216797ff9b
16 changed files with 1160 additions and 56 deletions
+68 -5
View File
@@ -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 isnt 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,