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
+14 -1
View File
@@ -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**
+2 -2
View File
@@ -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 →
+217 -31
View File
@@ -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);
notify({ if (offset !== null) {
kind: 'success', const pos = model.getPositionAt(offset);
title: 'View added', editor.setPosition(pos);
message: `Added an empty view to the ${path[path.length - 1]}. Undo with ⌘/Ctrl+Z.`, 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 — * 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
View File
@@ -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
View File
@@ -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;
}