Editor: cursor-scoped Add view and reorder for composition views

This commit is contained in:
2026-06-29 11:06:56 +03:00
parent 01df58acf1
commit 4d1825b5ff
5 changed files with 445 additions and 106 deletions
+2 -2
View File
@@ -440,8 +440,8 @@ export function SpecEditor() {
// disposed below like the config actions.
const transformActionsSub = installSpecTransformActions(editor);
// " Add view" CodeLens over composition arrays — per editor, because its
// command needs this editor's handle to apply the edit.
// Cursor-aware composition CodeLens (add view above/below, reorder) — per
// editor, because its commands need this editor's handle to apply the edit.
const codeLensSub = installSpecTransformCodeLens(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
+217 -31
View File
@@ -33,7 +33,13 @@ 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 { appendView, compositionArrays, type SpecPath } from '@core/spec-insert';
import {
compositionTargetAt,
elementOffset,
insertView,
moveView,
type SpecPath,
} from '@core/spec-insert';
import {
unwrapSingleton,
wrapInConcat,
@@ -233,60 +239,216 @@ export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
});
}
/** Append an empty view to the composition at `path` (the CodeLens affordance). */
function runAddView(editor: monaco.editor.IStandaloneCodeEditor, path: SpecPath): void {
/**
* 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.
*/
function applyArrayEdit(
editor: monaco.editor.IStandaloneCodeEditor,
build: (spec: JsonObject) => JsonObject | null,
arrayPath: SpecPath,
followIndex: number,
successTitle: string,
): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model.getValue());
if (!spec) return;
const next = appendView(spec, path);
const next = build(spec);
if (!next) {
notify({
kind: 'info',
title: 'Could not add a view',
title: 'Could not change the composition',
message: 'The composition changed — try the affordance again.',
});
return;
}
// Whole-document edit (the change is structural and deep); reformatted in the
// app's compact style, which is idempotent on an already-formatted draft.
writeBack(editor, model.getFullModelRange(), formatScoped(model, wholeDocument(model), next));
notify({
kind: 'success',
title: 'View added',
message: `Added an empty view to the ${path[path.length - 1]}. Undo with ⌘/Ctrl+Z.`,
});
const formatted = formatScoped(model, wholeDocument(model), next);
writeBack(editor, model.getFullModelRange(), formatted);
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.' });
}
/** 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',
);
}
/** 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);
}
/**
* Register the " Add view" CodeLens over each composition array (per editor —
* the lens command needs the editor handle to apply the edit). Returns a
* disposable; dispose on unmount.
* 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 addViewCommand = editor.addCommand(0, (_accessor, path: SpecPath) =>
runAddView(editor, path),
const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
runAddView(editor, path, index),
);
const provider = monaco.languages.registerCodeLensProvider('json', {
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 lenses = compositionArrays(model.getValue()).map((array) => {
const { lineNumber } = model.getPositionAt(array.offset);
return {
range: new monaco.Range(lineNumber, 1, lineNumber, 1),
command: {
id: addViewCommand ?? '',
title: '$(add) Add view',
arguments: [array.path],
},
};
});
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: () => provider.dispose() };
return {
dispose() {
cursorSub.dispose();
onDidChange.dispose();
registration.dispose();
},
};
}
/**
@@ -334,6 +496,30 @@ export function installSpecTransformActions(
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() {