mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: cursor-scoped Add view and reorder for composition views
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user