Files
astrolabe/src/app/services/spec-transform-actions.ts
T
oleh 216797ff9b 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.
2026-06-29 14:25:31 +03:00

665 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Structural spec transforms as editor actions (docs/architecture/08 → editor
* augmentation) — the refactor counterpart to spec-config-actions. Wrap the
* focused view in a composition (layer / hconcat / vconcat / facet / repeat) or
* collapse a single-child composition back to a unit, over the portable core
* transforms (core/spec-transforms).
*
* **Scope** is the current selection when there is one (the user said exactly
* what to target); otherwise the view the cursor sits in — an element of a
* layer/concat or a facet/repeat child, resolved by core/spec-cursor — falling
* back to the whole document for a flat unit spec with no inner view.
*
* **Surfacing** mirrors spec-config-actions' three-tier model:
* - the editor toolbar's **Transform menu** (SpecEditor) is the discoverable
* home, calling `runWrap` / `runUnwrap`;
* - the **lightbulb** (`configureSpecTransformCodeActions`, registered once,
* global per-language) offers the same transforms contextually at the cursor;
* - the **F1 palette** (`installSpecTransformActions`, per editor) is the
* keyboard accelerator. These are *not* added to the right-click menu — the
* lightbulb already covers the in-place case, and config-actions hold the
* three context-menu slots; nine items there would be a thicket.
*
* Edits go through `executeEdits` (toolbar/palette) or a `WorkspaceEdit` (the
* lightbulb, which has no editor handle) — both build the replacement text via
* the one `buildNext` + `formatScoped` path, so there is a single transform
* path, only the application differs. ⌘Z restores the previous text; invalid
* JSON no-ops with a toast; `!editorReadonly` hides the actions on the published
* view, and the lightbulb is gated to the active draft.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { defaultFieldType } from '@core/chart-builder';
import { formatJson } from '@core/json-format';
import { isJsonObject, type JsonObject } from '@core/spec-config';
import { findViewRange } from '@core/spec-cursor';
import {
compositionTargetAt,
elementOffset,
insertView,
moveView,
moveViewTo,
type SpecPath,
} from '@core/spec-insert';
import { wrapViews, type DropAxis } from '@core/spec-restructure';
import {
unwrapSingleton,
wrapInConcat,
wrapInFacet,
wrapInLayer,
wrapInRepeat,
} from '@core/spec-transforms';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumnsAt } from './active-dataset';
import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */
type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
/** The slice of the document a transform reads and rewrites. */
interface Scope {
range: monaco.Range;
text: string;
/** Whole-document offset of the focused view, for resolving its data binding. */
offset: number;
/** Column the slice starts at (0-based), so re-indented output stays aligned. */
baseCol: number;
}
const isEmptyRange = (r: monaco.IRange): boolean =>
r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn;
/** The whole document, as a scope. */
function wholeDocument(model: monaco.editor.ITextModel): Scope {
const range = model.getFullModelRange();
return { range, text: model.getValue(), offset: 0, baseCol: 0 };
}
/**
* The slice a transform acts on: an explicit selection if present; else the view
* the cursor sits in (core/spec-cursor); else the whole document. `offset` is the
* cursor/selection-start position in the whole document, used to resolve the
* focused view's data binding (facet/repeat field defaults).
*/
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
if (!range) return wholeDocument(model);
const offset = model.getOffsetAt({
lineNumber: range.startLineNumber,
column: range.startColumn,
});
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
}
const node = findViewRange(model.getValue(), offset);
if (!node) return wholeDocument(model);
const start = model.getPositionAt(node.offset);
const end = model.getPositionAt(node.offset + node.length);
const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
return { range: r, text: model.getValueInRange(r), offset, baseCol: start.column - 1 };
}
/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
function reindent(text: string, baseCol: number): string {
if (baseCol <= 0) return text;
const pad = ' '.repeat(baseCol);
return text
.split('\n')
.map((line, i) => (i === 0 ? line : pad + line))
.join('\n');
}
/** Serialize the replacement in the app's compact JSON style, aligned to the scope. */
function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonObject): string {
const raw = JSON.stringify(next);
const formatted = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
return reindent(formatted, scope.baseCol);
}
/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
function defaultFacet(text: string, offset: number): { field: string; type: string } {
const cols = boundColumnsAt(text, offset);
const categorical = cols.find((c) => {
const t = defaultFieldType(c.type);
return t === 'nominal' || t === 'ordinal';
});
const col = categorical ?? cols[0];
return col
? { field: col.name, type: defaultFieldType(col.type) }
: { field: 'field', type: 'nominal' };
}
/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
function defaultRepeat(
spec: JsonObject,
text: string,
offset: number,
): { fields: string[]; channel: string | null } {
const cols = boundColumnsAt(text, offset);
const numeric = cols
.filter((c) => defaultFieldType(c.type) === 'quantitative')
.map((c) => c.name);
const fields = numeric.length > 0 ? numeric.slice(0, 3) : cols.slice(0, 2).map((c) => c.name);
const encoding = isJsonObject(spec.encoding) ? spec.encoding : null;
const channel = encoding ? ('y' in encoding ? 'y' : (Object.keys(encoding)[0] ?? null)) : null;
return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
}
/**
* Apply a wrap of the given kind to the parsed (scoped) spec. `text`/`offset` are
* the whole document and the focused view's position, used to resolve that view's
* data binding for the facet/repeat field defaults.
*/
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): JsonObject {
switch (kind) {
case 'layer':
return wrapInLayer(spec);
case 'hconcat':
return wrapInConcat(spec, 'h');
case 'vconcat':
return wrapInConcat(spec, 'v');
case 'facet': {
const { field, type } = defaultFacet(text, offset);
return wrapInFacet(spec, field, type);
}
case 'repeat': {
const { fields, channel } = defaultRepeat(spec, text, offset);
return wrapInRepeat(spec, fields, channel);
}
}
}
const WRAP_NOUN: Record<WrapKind, string> = {
layer: 'a layer',
hconcat: 'a horizontal concat',
vconcat: 'a vertical concat',
facet: 'a facet',
repeat: 'a repeat',
};
/**
* 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();
if (focus) editor.focus();
}
/**
* The model, focused scope, and the spec parsed off it — the shared prologue of
* the scoped actions, or null (after toasting on invalid JSON) when there is
* nothing to act on.
*/
function resolveTarget(
editor: monaco.editor.IStandaloneCodeEditor,
): { model: monaco.editor.ITextModel; scope: Scope; spec: JsonObject } | null {
const model = editor.getModel();
if (!model) return null;
const scope = resolveScope(model, editor.getSelection());
const spec = parseSpecObject(scope.text);
return spec ? { model, scope, spec } : null;
}
/** Wrap the focused view (selection, else whole document) in a composition. */
export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapKind): void {
const target = resolveTarget(editor);
if (!target) return;
const { model, scope, spec } = target;
const next = buildNext(spec, kind, model.getValue(), scope.offset);
writeBack(editor, scope.range, formatScoped(model, scope, next));
notify({
kind: 'success',
title: 'View wrapped',
message: `Wrapped in ${WRAP_NOUN[kind]}. Undo with ⌘/Ctrl+Z.`,
});
}
/** Collapse a single-child layer/concat in the focused scope back to a unit. */
export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
const target = resolveTarget(editor);
if (!target) return;
const { model, scope, spec } = target;
const next = unwrapSingleton(spec);
if (!next) {
notify({
kind: 'info',
title: 'Nothing to simplify',
message: 'Select a layer or concat with a single child to collapse it.',
});
return;
}
writeBack(editor, scope.range, formatScoped(model, scope, next));
notify({
kind: 'success',
title: 'Composition simplified',
message: 'Collapsed the single-child composition. Undo with ⌘/Ctrl+Z.',
});
}
/**
* Apply a structural array edit (insert / move) as one undoable whole-document
* rewrite, then put the cursor on the affected view so the lenses re-anchor to it
* 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 | null,
focusEditor = true,
): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model.getValue());
if (!spec) return;
const next = build(spec);
if (!next) {
notify({
kind: 'info',
title: 'Could not change the composition',
message: 'The composition changed — try the affordance again.',
});
return;
}
const formatted = formatScoped(model, wholeDocument(model), next);
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);
}
if (successTitle)
notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
}
/** Insert an empty view at `index` of the composition at `arrayPath`. */
function runAddView(
editor: monaco.editor.IStandaloneCodeEditor,
arrayPath: SpecPath,
index: number,
): void {
applyArrayEdit(editor, (s) => insertView(s, arrayPath, index), arrayPath, index, 'View added');
}
/** Swap the view at `index` with its sibling `delta` steps away (±1). */
function runMoveView(
editor: monaco.editor.IStandaloneCodeEditor,
arrayPath: SpecPath,
index: number,
delta: number,
): void {
applyArrayEdit(
editor,
(s) => moveView(s, arrayPath, index, delta),
arrayPath,
index + delta,
delta < 0 ? 'View moved up' : 'View moved down',
);
}
/**
* 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,
where: 'above' | 'below',
): void {
const model = editor.getModel();
const pos = editor.getPosition();
if (!model || !pos) return;
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
if (!target) {
notify({
kind: 'info',
title: 'No composition here',
message: 'Place the cursor in a view inside a layer or concat.',
});
return;
}
if (!target.element) {
runAddView(editor, target.arrayPath, 0); // empty composition → its first view
return;
}
runAddView(
editor,
target.arrayPath,
where === 'above' ? target.element.index : target.element.index + 1,
);
}
/** Move the view the cursor is in up/down among its siblings (the keyboard path). */
function runMoveRelative(editor: monaco.editor.IStandaloneCodeEditor, delta: number): void {
const model = editor.getModel();
const pos = editor.getPosition();
if (!model || !pos) return;
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
if (!target?.element) {
notify({
kind: 'info',
title: 'No view to move',
message: 'Place the cursor in a view inside a layer or concat.',
});
return;
}
const to = target.element.index + delta;
if (to < 0 || to >= target.count) {
notify({
kind: 'info',
title: delta < 0 ? 'Already first' : 'Already last',
message: 'This view is at the edge of its composition.',
});
return;
}
runMoveView(editor, target.arrayPath, target.element.index, delta);
}
/**
* Install the cursor-aware composition CodeLens (per editor — the lens commands
* need this editor's handle to apply the edit). Over the view the cursor sits in
* it offers ` Add view above/below` at the view's edges and `↑/↓ Move` to
* reorder it among its siblings; an empty composition gets a single ` Add view`.
* Returns a disposable; dispose on unmount.
*/
export function installSpecTransformCodeLens(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
runAddView(editor, path, index),
);
const moveCmd = editor.addCommand(0, (_a, path: SpecPath, index: number, delta: number) =>
runMoveView(editor, path, index, delta),
);
const lens = (
line: number,
title: string,
id: string | null,
args: unknown[],
): monaco.languages.CodeLens => ({
range: new monaco.Range(line, 1, line, 1),
command: { id: id ?? '', title, arguments: args },
});
// The lenses depend on the cursor, so signal a refresh when the enclosing view
// changes — keyed so typing within one view does not re-render them.
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
const codeLensProvider: monaco.languages.CodeLensProvider = {
onDidChange: onDidChange.event,
provideCodeLenses(model) {
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
const pos = editor.getPosition();
if (!pos) return { lenses: [], dispose() {} };
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
if (!target) return { lenses: [], dispose() {} };
const lenses: monaco.languages.CodeLens[] = [];
if (!target.element) {
// Off a view, only an empty composition gets an affordance — the array's
// own line stays clean otherwise.
if (target.count === 0) {
const line = model.getPositionAt(target.arrayOffset).lineNumber;
lenses.push(lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0]));
}
return { lenses, dispose() {} };
}
const { index, offset, length } = target.element;
// "above" actions sit on the view's first line, "below" on the line after
// its last — so up-actions group at the top, down-actions at the bottom.
const topLine = model.getPositionAt(offset).lineNumber;
const bottomLine = Math.min(
model.getLineCount(),
model.getPositionAt(offset + length).lineNumber + 1,
);
lenses.push(lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]));
if (index > 0)
lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1]));
lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
if (index < target.count - 1)
lenses.push(
lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]),
);
return { lenses, dispose() {} };
},
};
const registration = monaco.languages.registerCodeLensProvider('json', codeLensProvider);
let lastKey = '';
const cursorSub = editor.onDidChangeCursorPosition(() => {
const model = editor.getModel();
const pos = editor.getPosition();
const target =
model && pos && useSnippetStore.getState().editorView === 'draft'
? compositionTargetAt(model.getValue(), model.getOffsetAt(pos))
: null;
const key = target
? JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count])
: '';
if (key !== lastKey) {
lastKey = key;
onDidChange.fire(codeLensProvider);
}
});
return {
dispose() {
cursorSub.dispose();
onDidChange.dispose();
registration.dispose();
},
};
}
/**
* Register the F1-palette actions on the editor (the keyboard accelerator).
* Returns a disposable; dispose on editor unmount, like the other per-editor
* installs. Deliberately no `contextMenuGroupId` — see the module header.
*/
export function installSpecTransformActions(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const actions = [
editor.addAction({
id: 'astrolabe.wrap-layer',
label: 'Wrap View in a Layer',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'layer'),
}),
editor.addAction({
id: 'astrolabe.wrap-hconcat',
label: 'Wrap View in Horizontal Concat',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'hconcat'),
}),
editor.addAction({
id: 'astrolabe.wrap-vconcat',
label: 'Wrap View in Vertical Concat',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'vconcat'),
}),
editor.addAction({
id: 'astrolabe.wrap-facet',
label: 'Wrap View in a Facet',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'facet'),
}),
editor.addAction({
id: 'astrolabe.wrap-repeat',
label: 'Wrap View in a Repeat',
precondition: '!editorReadonly',
run: () => runWrap(editor, 'repeat'),
}),
editor.addAction({
id: 'astrolabe.unwrap',
label: 'Simplify Single-Child Composition',
precondition: '!editorReadonly',
run: () => runUnwrap(editor),
}),
editor.addAction({
id: 'astrolabe.insert-view-above',
label: 'Insert View Above',
precondition: '!editorReadonly',
run: () => runInsertRelative(editor, 'above'),
}),
editor.addAction({
id: 'astrolabe.insert-view-below',
label: 'Insert View Below',
precondition: '!editorReadonly',
run: () => runInsertRelative(editor, 'below'),
}),
editor.addAction({
id: 'astrolabe.move-view-up',
label: 'Move View Up',
precondition: '!editorReadonly',
run: () => runMoveRelative(editor, -1),
}),
editor.addAction({
id: 'astrolabe.move-view-down',
label: 'Move View Down',
precondition: '!editorReadonly',
run: () => runMoveRelative(editor, 1),
}),
];
return {
dispose() {
for (const action of actions) action.dispose();
},
};
}
/**
* The lightbulb has no editor handle, so it returns a `WorkspaceEdit` instead of
* calling `executeEdits`; both paths build the replacement through `buildNext` +
* `formatScoped`.
*/
function editAction(
model: monaco.editor.ITextModel,
scope: Scope,
title: string,
next: JsonObject,
): monaco.languages.CodeAction {
return {
title,
kind: 'refactor.rewrite',
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: scope.range, text: formatScoped(model, scope, next) },
},
],
},
};
}
let codeActionsRegistered = false;
/**
* Register the wrap/simplify refactors as code actions (the lightbulb) once,
* globally for JSON — like the schema and formatter, not per editor. Idempotent.
*/
export function configureSpecTransformCodeActions(): void {
if (codeActionsRegistered) return;
codeActionsRegistered = true;
monaco.languages.registerCodeActionProvider('json', {
provideCodeActions(model, range) {
const empty = { actions: [], dispose() {} };
// Global provider, no editor handle: gate on the store the way the run*
// path is gated by `!editorReadonly` — only on the active snippet's draft.
const snippet = useSnippetStore.getState();
if (snippet.activeSnippetId === null || snippet.editorView !== 'draft') return empty;
const scope = resolveScope(model, range);
let spec: unknown;
try {
spec = JSON.parse(scope.text);
} catch {
return empty;
}
if (!isJsonObject(spec)) return empty;
const viewSpec = spec;
const fullText = model.getValue();
const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
const actions: monaco.languages.CodeAction[] = [
editAction(model, scope, 'Wrap view in a layer', build('layer')),
editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
editAction(model, scope, 'Wrap view in a facet', build('facet')),
editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
];
const collapsed = unwrapSingleton(spec);
if (collapsed)
actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));
return { actions, dispose() {} };
},
});
}