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:
@@ -296,7 +296,9 @@ thin app-layer services.
|
|||||||
cursor's ancestor chain (`derivedFieldNamesAtPath`).
|
cursor's ancestor chain (`derivedFieldNamesAtPath`).
|
||||||
- `spec-inline-data` — the rows a specific `data` binding carries for profiling
|
- `spec-inline-data` — the rows a specific `data` binding carries for profiling
|
||||||
(`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry).
|
(`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry).
|
||||||
- `spec-insert` — composition arrays + appending a view to one.
|
- `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view
|
||||||
|
at an index (`insertView`) and reordering siblings (`moveView`), plus `elementOffset` to
|
||||||
|
re-find a view after the edit.
|
||||||
|
|
||||||
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
|
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
|
||||||
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
|
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
|
||||||
@@ -318,6 +320,17 @@ Decision rules:
|
|||||||
- **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar
|
- **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar
|
||||||
and palette use `executeEdits`. Both build the replacement through the same
|
and palette use `executeEdits`. Both build the replacement through the same
|
||||||
serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text.
|
serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text.
|
||||||
|
- **Composition CodeLens is cursor-scoped.** It follows the view the cursor sits in —
|
||||||
|
`+ Add view above/below` at the view's edges and `↑/↓ Move` to reorder among its siblings —
|
||||||
|
rather than one fixed button per composition array; an empty composition shows a single `+ Add view`,
|
||||||
|
and the F1 palette mirrors all four for the keyboard. The provider reads `editor.getPosition()`
|
||||||
|
and refreshes via an `onDidChange` emitter fired on cursor moves (keyed to the enclosing view,
|
||||||
|
so typing inside one view doesn't churn the lenses). After an edit the cursor follows the
|
||||||
|
affected view (`elementOffset`), so a repeated click keeps acting on it instead of the
|
||||||
|
neighbour that slid into place. Cursor-scoping does not strand a load-bearing action
|
||||||
|
([arch 10](10-interaction-and-feedback.md) — revealed actions): editing a composition puts
|
||||||
|
the cursor in a view exactly when add/reorder is wanted, and the palette is the ever-present
|
||||||
|
path for the keyboard.
|
||||||
- **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data
|
- **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data
|
||||||
binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a
|
binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a
|
||||||
parent's data unless it declares its own), then its columns: a named **library dataset**
|
parent's data unless it declares its own), then its columns: a named **library dataset**
|
||||||
|
|||||||
@@ -440,8 +440,8 @@ export function SpecEditor() {
|
|||||||
// disposed below like the config actions.
|
// disposed below like the config actions.
|
||||||
const transformActionsSub = installSpecTransformActions(editor);
|
const transformActionsSub = installSpecTransformActions(editor);
|
||||||
|
|
||||||
// "+ Add view" CodeLens over composition arrays — per editor, because its
|
// Cursor-aware composition CodeLens (add view above/below, reorder) — per
|
||||||
// command needs this editor's handle to apply the edit.
|
// editor, because its commands need this editor's handle to apply the edit.
|
||||||
const codeLensSub = installSpecTransformCodeLens(editor);
|
const codeLensSub = installSpecTransformCodeLens(editor);
|
||||||
|
|
||||||
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ import { defaultFieldType } from '@core/chart-builder';
|
|||||||
import { formatJson } from '@core/json-format';
|
import { formatJson } from '@core/json-format';
|
||||||
import { isJsonObject, type JsonObject } from '@core/spec-config';
|
import { isJsonObject, type JsonObject } from '@core/spec-config';
|
||||||
import { findViewRange } from '@core/spec-cursor';
|
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 {
|
import {
|
||||||
unwrapSingleton,
|
unwrapSingleton,
|
||||||
wrapInConcat,
|
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();
|
const model = editor.getModel();
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
const spec = parseSpecObject(model.getValue());
|
const spec = parseSpecObject(model.getValue());
|
||||||
if (!spec) return;
|
if (!spec) return;
|
||||||
const next = appendView(spec, path);
|
const next = build(spec);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
title: 'Could not add a view',
|
title: 'Could not change the composition',
|
||||||
message: 'The composition changed — try the affordance again.',
|
message: 'The composition changed — try the affordance again.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Whole-document edit (the change is structural and deep); reformatted in the
|
const formatted = formatScoped(model, wholeDocument(model), next);
|
||||||
// app's compact style, which is idempotent on an already-formatted draft.
|
writeBack(editor, model.getFullModelRange(), formatted);
|
||||||
writeBack(editor, model.getFullModelRange(), formatScoped(model, wholeDocument(model), next));
|
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({
|
notify({
|
||||||
kind: 'success',
|
kind: 'info',
|
||||||
title: 'View added',
|
title: 'No composition here',
|
||||||
message: `Added an empty view to the ${path[path.length - 1]}. Undo with ⌘/Ctrl+Z.`,
|
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 —
|
* Install the cursor-aware composition CodeLens (per editor — the lens commands
|
||||||
* the lens command needs the editor handle to apply the edit). Returns a
|
* need this editor's handle to apply the edit). Over the view the cursor sits in
|
||||||
* disposable; dispose on unmount.
|
* 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(
|
export function installSpecTransformCodeLens(
|
||||||
editor: monaco.editor.IStandaloneCodeEditor,
|
editor: monaco.editor.IStandaloneCodeEditor,
|
||||||
): monaco.IDisposable {
|
): monaco.IDisposable {
|
||||||
const addViewCommand = editor.addCommand(0, (_accessor, path: SpecPath) =>
|
const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
|
||||||
runAddView(editor, path),
|
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) {
|
provideCodeLenses(model) {
|
||||||
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
|
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
|
||||||
const lenses = compositionArrays(model.getValue()).map((array) => {
|
const pos = editor.getPosition();
|
||||||
const { lineNumber } = model.getPositionAt(array.offset);
|
if (!pos) return { lenses: [], dispose() {} };
|
||||||
return {
|
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||||||
range: new monaco.Range(lineNumber, 1, lineNumber, 1),
|
if (!target) return { lenses: [], dispose() {} };
|
||||||
command: {
|
const lenses: monaco.languages.CodeLens[] = [];
|
||||||
id: addViewCommand ?? '',
|
if (!target.element) {
|
||||||
title: '$(add) Add view',
|
// Off a view, only an empty composition gets an affordance — the array's
|
||||||
arguments: [array.path],
|
// 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() {} };
|
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',
|
precondition: '!editorReadonly',
|
||||||
run: () => runUnwrap(editor),
|
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 {
|
return {
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|||||||
+101
-23
@@ -1,49 +1,127 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { appendView, compositionArrays } from './spec-insert';
|
import {
|
||||||
|
compositionTargetAt,
|
||||||
|
elementOffset,
|
||||||
|
insertView,
|
||||||
|
moveView,
|
||||||
|
type SpecPath,
|
||||||
|
} from './spec-insert';
|
||||||
|
|
||||||
const layered = JSON.stringify(
|
const layered = JSON.stringify(
|
||||||
{
|
{
|
||||||
data: { name: 'd' },
|
data: { name: 'd' },
|
||||||
hconcat: [{ layer: [{ mark: 'bar' }] }, { mark: 'point' }],
|
hconcat: [{ layer: [{ mark: 'bar' }, { mark: 'line' }] }, { mark: 'point' }],
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('compositionArrays', () => {
|
/** Offset of the first occurrence of `needle` in `text` (a cursor inside it). */
|
||||||
it('finds every composition array with its path, including nested', () => {
|
const at = (text: string, needle: string): number => text.indexOf(needle) + 1;
|
||||||
const found = compositionArrays(layered);
|
|
||||||
const byKey = found.map((c) => ({ key: c.key, path: c.path }));
|
describe('compositionTargetAt', () => {
|
||||||
expect(byKey).toContainEqual({ key: 'hconcat', path: ['hconcat'] });
|
it('finds the innermost composition and the element the cursor is on', () => {
|
||||||
expect(byKey).toContainEqual({ key: 'layer', path: ['hconcat', 0, 'layer'] });
|
const target = compositionTargetAt(layered, at(layered, '"line"'))!;
|
||||||
|
expect(target.arrayPath).toEqual(['hconcat', 0, 'layer']);
|
||||||
|
expect(target.count).toBe(2);
|
||||||
|
expect(target.element?.index).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns offsets that point inside the source text', () => {
|
it('targets the outer composition when the cursor is on its direct element', () => {
|
||||||
const [first] = compositionArrays(layered);
|
const target = compositionTargetAt(layered, at(layered, '"point"'))!;
|
||||||
expect(layered[first.offset]).toBe('['); // the array node starts at its bracket
|
expect(target.arrayPath).toEqual(['hconcat']);
|
||||||
|
expect(target.element?.index).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty for a flat unit spec', () => {
|
it('reports no element when the cursor is not on one (non-empty array)', () => {
|
||||||
expect(compositionArrays(JSON.stringify({ mark: 'bar' }))).toEqual([]);
|
const spec = '{ "vconcat": [\n { "mark": "bar" }\n] }';
|
||||||
|
const target = compositionTargetAt(spec, spec.indexOf('['))!;
|
||||||
|
expect(target.count).toBe(1);
|
||||||
|
expect(target.element).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an empty composition (count 0, no element) for the append fallback', () => {
|
||||||
|
const spec = '{ "vconcat": [] }';
|
||||||
|
const target = compositionTargetAt(spec, spec.indexOf('[') + 1)!;
|
||||||
|
expect(target.arrayPath).toEqual(['vconcat']);
|
||||||
|
expect(target.count).toBe(0);
|
||||||
|
expect(target.element).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when the cursor is in a flat unit spec', () => {
|
||||||
|
const spec = JSON.stringify({ mark: 'bar' });
|
||||||
|
expect(compositionTargetAt(spec, at(spec, '"bar"'))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('appendView', () => {
|
describe('insertView', () => {
|
||||||
it('adds a placeholder view to the array at the given path', () => {
|
it('inserts an empty placeholder at the given index', () => {
|
||||||
const spec = { hconcat: [{ mark: 'bar' }] };
|
const spec = { hconcat: [{ mark: 'bar' }, { mark: 'point' }] };
|
||||||
const out = appendView(spec, ['hconcat'])!;
|
const out = insertView(spec, ['hconcat'], 1)!;
|
||||||
expect(out.hconcat).toEqual([{ mark: 'bar' }, { mark: 'point', encoding: {} }]);
|
expect(out.hconcat).toEqual([
|
||||||
expect(spec.hconcat).toHaveLength(1); // input untouched
|
{ mark: 'bar' },
|
||||||
|
{ mark: 'point', encoding: {} },
|
||||||
|
{ mark: 'point' },
|
||||||
|
]);
|
||||||
|
expect(spec.hconcat).toHaveLength(2); // input untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps an out-of-range index to the array bounds', () => {
|
||||||
|
const spec = { layer: [{ mark: 'bar' }] };
|
||||||
|
expect(insertView(spec, ['layer'], 99)!.layer as unknown[]).toHaveLength(2);
|
||||||
|
expect((insertView(spec, ['layer'], -5)!.layer as unknown[])[0]).toEqual({
|
||||||
|
mark: 'point',
|
||||||
|
encoding: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reaches a nested composition array', () => {
|
it('reaches a nested composition array', () => {
|
||||||
const spec = { hconcat: [{ layer: [{ mark: 'bar' }] }] };
|
const spec = { hconcat: [{ layer: [{ mark: 'bar' }] }] };
|
||||||
const out = appendView(spec, ['hconcat', 0, 'layer'])!;
|
const out = insertView(spec, ['hconcat', 0, 'layer'], 1)!;
|
||||||
const layer = (out.hconcat as { layer: unknown[] }[])[0].layer;
|
expect((out.hconcat as { layer: unknown[] }[])[0].layer).toHaveLength(2);
|
||||||
expect(layer).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when the path is not an array', () => {
|
it('returns null when the path is not an array', () => {
|
||||||
expect(appendView({ mark: 'bar' }, ['layer'])).toBeNull();
|
expect(insertView({ mark: 'bar' }, ['layer'], 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveView', () => {
|
||||||
|
it('swaps a view with its neighbor', () => {
|
||||||
|
const spec = { vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] };
|
||||||
|
expect(moveView(spec, ['vconcat'], 1, -1)!.vconcat).toEqual([
|
||||||
|
{ mark: 'b' },
|
||||||
|
{ mark: 'a' },
|
||||||
|
{ mark: 'c' },
|
||||||
|
]);
|
||||||
|
expect(moveView(spec, ['vconcat'], 1, 1)!.vconcat).toEqual([
|
||||||
|
{ mark: 'a' },
|
||||||
|
{ mark: 'c' },
|
||||||
|
{ mark: 'b' },
|
||||||
|
]);
|
||||||
|
expect(spec.vconcat[0]).toEqual({ mark: 'a' }); // input untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null at the edges', () => {
|
||||||
|
const spec = { vconcat: [{ mark: 'a' }, { mark: 'b' }] };
|
||||||
|
expect(moveView(spec, ['vconcat'], 0, -1)).toBeNull();
|
||||||
|
expect(moveView(spec, ['vconcat'], 1, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the path is not an array', () => {
|
||||||
|
expect(moveView({ mark: 'bar' }, ['layer'], 0, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('elementOffset', () => {
|
||||||
|
it('returns the start offset of the element at the path', () => {
|
||||||
|
const path: SpecPath = ['hconcat', 0, 'layer'];
|
||||||
|
const off = elementOffset(layered, path, 1)!;
|
||||||
|
expect(layered.slice(off, off + 1)).toBe('{'); // the element object's brace
|
||||||
|
expect(layered.slice(off)).toContain('"line"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null for an out-of-range index', () => {
|
||||||
|
expect(elementOffset(layered, ['hconcat'], 9)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+111
-49
@@ -1,72 +1,134 @@
|
|||||||
/**
|
/**
|
||||||
* Locating composition arrays and appending a view to one (docs/architecture/08 →
|
* Cursor-scoped composition editing (docs/architecture/08 → editor augmentation).
|
||||||
* editor augmentation). Powers the "+ Add view" CodeLens: an always-visible
|
* Powers the composition CodeLens and its palette twins: over the view the cursor
|
||||||
* affordance over each `layer`/`hconcat`/`vconcat`/`concat` for adding a sibling
|
* sits in, add a sibling before/after it or reorder it among its siblings — the
|
||||||
* view — the complement to the wrap refactors (which create a composition; this
|
* complement to the wrap refactors (which create a composition; this grows and
|
||||||
* grows an existing one).
|
* rearranges an existing one).
|
||||||
*
|
*
|
||||||
* Portable core. `compositionArrays` uses jsonc-parser to find each array and its
|
* Portable core. `compositionTargetAt` maps a cursor offset to the innermost
|
||||||
* path (so the CodeLens knows where to sit and what to grow); `appendView` is a
|
* `layer`/`hconcat`/`vconcat`/`concat` it is inside, the element it is on, and the
|
||||||
* plain immutable push at that path. JSON-text formatting is the editor's job.
|
* sibling count — everything a surface needs to place affordances and pick an
|
||||||
|
* index. `insertView` / `moveView` are plain immutable edits at that path;
|
||||||
|
* `elementOffset` locates an element afterwards so the editor can keep the cursor
|
||||||
|
* on it. JSON-text formatting is the editor's job.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseTree, type Node } from 'jsonc-parser';
|
import {
|
||||||
|
findNodeAtLocation,
|
||||||
|
findNodeAtOffset,
|
||||||
|
getNodePath,
|
||||||
|
parseTree,
|
||||||
|
type Node,
|
||||||
|
} from 'jsonc-parser';
|
||||||
import { isJsonObject, type JsonObject } from './spec-config';
|
import { isJsonObject, type JsonObject } from './spec-config';
|
||||||
import { ARRAY_COMPOSITIONS, placeholderView } from './spec-transforms';
|
import { ARRAY_COMPOSITIONS, placeholderView } from './spec-transforms';
|
||||||
|
|
||||||
/** A spec path: object keys and array indices, from the root. */
|
/** A spec path: object keys and array indices, from the root. */
|
||||||
export type SpecPath = (string | number)[];
|
export type SpecPath = (string | number)[];
|
||||||
|
|
||||||
/** A composition array found in the spec text. */
|
/** The composition the cursor is inside, and where in it the cursor sits. */
|
||||||
interface CompositionArray {
|
export interface CompositionTarget {
|
||||||
/** The operator key (`layer`, `hconcat`, …). */
|
/** Path to the composition array (`layer`/`hconcat`/…) for the edit. */
|
||||||
key: string;
|
arrayPath: SpecPath;
|
||||||
/** Start offset of the array node, for placing the affordance. */
|
/** Start offset of the array node, for the empty-composition fallback anchor. */
|
||||||
offset: number;
|
arrayOffset: number;
|
||||||
/** Path to the array, for `appendView`. */
|
/** Number of element views in the array. */
|
||||||
path: SpecPath;
|
count: number;
|
||||||
|
/** The element the cursor is on, or null between/around elements. */
|
||||||
|
element: { index: number; offset: number; length: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every composition array in the spec, with its path and start offset. */
|
/** Is this array node a composition array (its property key is `layer`/`concat`/…)? */
|
||||||
export function compositionArrays(text: string): CompositionArray[] {
|
function isCompositionArray(node: Node): boolean {
|
||||||
const tree = parseTree(text);
|
const parent = node.parent;
|
||||||
if (!tree) return [];
|
if (parent?.type !== 'property') return false;
|
||||||
const found: CompositionArray[] = [];
|
const key: unknown = parent.children?.[0]?.value;
|
||||||
|
return typeof key === 'string' && ARRAY_COMPOSITIONS.includes(key);
|
||||||
const walk = (node: Node, path: SpecPath): void => {
|
|
||||||
if (node.type === 'object') {
|
|
||||||
for (const prop of node.children ?? []) {
|
|
||||||
const key: unknown = prop.children?.[0]?.value;
|
|
||||||
const value = prop.children?.[1];
|
|
||||||
if (typeof key !== 'string' || !value) continue;
|
|
||||||
if (ARRAY_COMPOSITIONS.includes(key) && value.type === 'array') {
|
|
||||||
found.push({ key, offset: value.offset, path: [...path, key] });
|
|
||||||
}
|
|
||||||
walk(value, [...path, key]);
|
|
||||||
}
|
|
||||||
} else if (node.type === 'array') {
|
|
||||||
(node.children ?? []).forEach((child, i) => walk(child, [...path, i]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
walk(tree, []);
|
|
||||||
return found;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append an empty placeholder view to the array at `path`. Returns a new spec, or
|
* The composition the cursor at `offset` is inside (the innermost one, so a view
|
||||||
* null when the path does not lead to an array (the text changed since the path
|
* nested in a layer-in-concat targets the layer), or null when the cursor is not
|
||||||
* was computed). Input is not mutated.
|
* inside any `layer`/`hconcat`/`vconcat`/`concat`. Error-tolerant, so it works
|
||||||
|
* mid-edit. `element` is null when the cursor is on the array's brackets or
|
||||||
|
* between views — an empty array then offers a single append; a non-empty one
|
||||||
|
* shows nothing until the cursor enters a view.
|
||||||
*/
|
*/
|
||||||
export function appendView(spec: JsonObject, path: SpecPath): JsonObject | null {
|
export function compositionTargetAt(text: string, offset: number): CompositionTarget | null {
|
||||||
const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
|
const tree = parseTree(text);
|
||||||
let node: unknown = next;
|
if (!tree) return null;
|
||||||
|
let array: Node | undefined;
|
||||||
|
for (let n = findNodeAtOffset(tree, offset, true); n; n = n.parent) {
|
||||||
|
if (n.type === 'array' && isCompositionArray(n)) {
|
||||||
|
array = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!array) return null;
|
||||||
|
const children = array.children ?? [];
|
||||||
|
let element: CompositionTarget['element'] = null;
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
const c = children[i];
|
||||||
|
if (offset >= c.offset && offset <= c.offset + c.length) {
|
||||||
|
element = { index: i, offset: c.offset, length: c.length };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
arrayPath: getNodePath(array),
|
||||||
|
arrayOffset: array.offset,
|
||||||
|
count: children.length,
|
||||||
|
element,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The array at `path` in `spec`, or null when the path does not lead to one. */
|
||||||
|
function arrayAt(spec: JsonObject, path: SpecPath): unknown[] | null {
|
||||||
|
let node: unknown = spec;
|
||||||
for (const segment of path) {
|
for (const segment of path) {
|
||||||
if (Array.isArray(node)) node = node[segment as number];
|
if (Array.isArray(node)) node = node[segment as number];
|
||||||
else if (isJsonObject(node)) node = node[segment as string];
|
else if (isJsonObject(node)) node = node[segment as string];
|
||||||
else return null;
|
else return null;
|
||||||
}
|
}
|
||||||
if (!Array.isArray(node)) return null;
|
return Array.isArray(node) ? node : null;
|
||||||
node.push(placeholderView());
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert an empty placeholder view at `index` of the array at `path` (clamped to
|
||||||
|
* the array's bounds). Returns a new spec, or null when the path does not lead to
|
||||||
|
* an array (the text changed since the path was computed). Input is not mutated.
|
||||||
|
*/
|
||||||
|
export function insertView(spec: JsonObject, path: SpecPath, index: number): JsonObject | null {
|
||||||
|
const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
|
||||||
|
const array = arrayAt(next, path);
|
||||||
|
if (!array) return null;
|
||||||
|
array.splice(Math.max(0, Math.min(index, array.length)), 0, placeholderView());
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap the view at `index` of the array at `path` with its neighbor `delta` steps
|
||||||
|
* away (±1). Returns a new spec, or null when the path is not an array or either
|
||||||
|
* position is out of bounds. Input is not mutated.
|
||||||
|
*/
|
||||||
|
export function moveView(
|
||||||
|
spec: JsonObject,
|
||||||
|
path: SpecPath,
|
||||||
|
index: number,
|
||||||
|
delta: number,
|
||||||
|
): JsonObject | null {
|
||||||
|
const next = JSON.parse(JSON.stringify(spec)) as JsonObject;
|
||||||
|
const array = arrayAt(next, path);
|
||||||
|
if (!array) return null;
|
||||||
|
const to = index + delta;
|
||||||
|
if (index < 0 || index >= array.length || to < 0 || to >= array.length) return null;
|
||||||
|
[array[index], array[to]] = [array[to], array[index]];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start offset of the element at `[...path, index]` in the text, or null. */
|
||||||
|
export function elementOffset(text: string, path: SpecPath, index: number): number | null {
|
||||||
|
const tree = parseTree(text);
|
||||||
|
if (!tree) return null;
|
||||||
|
return findNodeAtLocation(tree, [...path, index])?.offset ?? null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user