diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index 3fadaf8..a47d4b8 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -296,7 +296,9 @@ thin app-layer services. cursor's ancestor chain (`derivedFieldNamesAtPath`). - `spec-inline-data` — the rows a specific `data` binding carries for profiling (`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 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 and palette use `executeEdits`. Both build the replacement through the same 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 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** diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 4543198..4d84c53 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -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 → diff --git a/src/app/services/spec-transform-actions.ts b/src/app/services/spec-transform-actions.ts index 19d302e..e482d85 100644 --- a/src/app/services/spec-transform-actions.ts +++ b/src/app/services/spec-transform-actions.ts @@ -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(); + 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() { diff --git a/src/core/spec-insert.test.ts b/src/core/spec-insert.test.ts index 98ef76d..f9a3129 100644 --- a/src/core/spec-insert.test.ts +++ b/src/core/spec-insert.test.ts @@ -1,49 +1,127 @@ 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( { data: { name: 'd' }, - hconcat: [{ layer: [{ mark: 'bar' }] }, { mark: 'point' }], + hconcat: [{ layer: [{ mark: 'bar' }, { mark: 'line' }] }, { mark: 'point' }], }, null, 2, ); -describe('compositionArrays', () => { - it('finds every composition array with its path, including nested', () => { - const found = compositionArrays(layered); - const byKey = found.map((c) => ({ key: c.key, path: c.path })); - expect(byKey).toContainEqual({ key: 'hconcat', path: ['hconcat'] }); - expect(byKey).toContainEqual({ key: 'layer', path: ['hconcat', 0, 'layer'] }); +/** Offset of the first occurrence of `needle` in `text` (a cursor inside it). */ +const at = (text: string, needle: string): number => text.indexOf(needle) + 1; + +describe('compositionTargetAt', () => { + it('finds the innermost composition and the element the cursor is on', () => { + 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', () => { - const [first] = compositionArrays(layered); - expect(layered[first.offset]).toBe('['); // the array node starts at its bracket + it('targets the outer composition when the cursor is on its direct element', () => { + const target = compositionTargetAt(layered, at(layered, '"point"'))!; + expect(target.arrayPath).toEqual(['hconcat']); + expect(target.element?.index).toBe(1); }); - it('is empty for a flat unit spec', () => { - expect(compositionArrays(JSON.stringify({ mark: 'bar' }))).toEqual([]); + it('reports no element when the cursor is not on one (non-empty array)', () => { + 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', () => { - it('adds a placeholder view to the array at the given path', () => { - const spec = { hconcat: [{ mark: 'bar' }] }; - const out = appendView(spec, ['hconcat'])!; - expect(out.hconcat).toEqual([{ mark: 'bar' }, { mark: 'point', encoding: {} }]); - expect(spec.hconcat).toHaveLength(1); // input untouched +describe('insertView', () => { + it('inserts an empty placeholder at the given index', () => { + const spec = { hconcat: [{ mark: 'bar' }, { mark: 'point' }] }; + const out = insertView(spec, ['hconcat'], 1)!; + expect(out.hconcat).toEqual([ + { 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', () => { const spec = { hconcat: [{ layer: [{ mark: 'bar' }] }] }; - const out = appendView(spec, ['hconcat', 0, 'layer'])!; - const layer = (out.hconcat as { layer: unknown[] }[])[0].layer; - expect(layer).toHaveLength(2); + const out = insertView(spec, ['hconcat', 0, 'layer'], 1)!; + expect((out.hconcat as { layer: unknown[] }[])[0].layer).toHaveLength(2); }); 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(); }); }); diff --git a/src/core/spec-insert.ts b/src/core/spec-insert.ts index 4b54b67..5dda863 100644 --- a/src/core/spec-insert.ts +++ b/src/core/spec-insert.ts @@ -1,72 +1,134 @@ /** - * Locating composition arrays and appending a view to one (docs/architecture/08 → - * editor augmentation). Powers the "+ Add view" CodeLens: an always-visible - * affordance over each `layer`/`hconcat`/`vconcat`/`concat` for adding a sibling - * view — the complement to the wrap refactors (which create a composition; this - * grows an existing one). + * Cursor-scoped composition editing (docs/architecture/08 → editor augmentation). + * Powers the composition CodeLens and its palette twins: over the view the cursor + * sits in, add a sibling before/after it or reorder it among its siblings — the + * complement to the wrap refactors (which create a composition; this grows and + * rearranges an existing one). * - * Portable core. `compositionArrays` uses jsonc-parser to find each array and its - * path (so the CodeLens knows where to sit and what to grow); `appendView` is a - * plain immutable push at that path. JSON-text formatting is the editor's job. + * Portable core. `compositionTargetAt` maps a cursor offset to the innermost + * `layer`/`hconcat`/`vconcat`/`concat` it is inside, the element it is on, and the + * 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 { ARRAY_COMPOSITIONS, placeholderView } from './spec-transforms'; /** A spec path: object keys and array indices, from the root. */ export type SpecPath = (string | number)[]; -/** A composition array found in the spec text. */ -interface CompositionArray { - /** The operator key (`layer`, `hconcat`, …). */ - key: string; - /** Start offset of the array node, for placing the affordance. */ - offset: number; - /** Path to the array, for `appendView`. */ - path: SpecPath; +/** The composition the cursor is inside, and where in it the cursor sits. */ +export interface CompositionTarget { + /** Path to the composition array (`layer`/`hconcat`/…) for the edit. */ + arrayPath: SpecPath; + /** Start offset of the array node, for the empty-composition fallback anchor. */ + arrayOffset: number; + /** Number of element views in the array. */ + 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. */ -export function compositionArrays(text: string): CompositionArray[] { - const tree = parseTree(text); - if (!tree) return []; - const found: CompositionArray[] = []; - - 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; +/** Is this array node a composition array (its property key is `layer`/`concat`/…)? */ +function isCompositionArray(node: Node): boolean { + const parent = node.parent; + if (parent?.type !== 'property') return false; + const key: unknown = parent.children?.[0]?.value; + return typeof key === 'string' && ARRAY_COMPOSITIONS.includes(key); } /** - * Append an empty placeholder view to the array at `path`. 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. + * The composition the cursor at `offset` is inside (the innermost one, so a view + * nested in a layer-in-concat targets the layer), or null when the cursor is not + * 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 { - const next = JSON.parse(JSON.stringify(spec)) as JsonObject; - let node: unknown = next; +export function compositionTargetAt(text: string, offset: number): CompositionTarget | null { + const tree = parseTree(text); + 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) { if (Array.isArray(node)) node = node[segment as number]; else if (isJsonObject(node)) node = node[segment as string]; else return null; } - if (!Array.isArray(node)) return null; - node.push(placeholderView()); + return Array.isArray(node) ? node : null; +} + +/** + * 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; } + +/** + * 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; +}