From 216797ff9bab607a48ddd00abf14f9053056ef80 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Mon, 29 Jun 2026 14:25:31 +0300 Subject: [PATCH] =?UTF-8?q?Editor:=20interactive=20composition=20wireframe?= =?UTF-8?q?=20=E2=80=94=20drag=20to=20reorder=20and=20restructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag a view's box (or Alt+up/down) to reorder it within its container; drag onto another view's edge to pair the two in a new row/column, move across containers, or insert. Each leaf shows a glyph of its mark type. Core: spec-restructure.wrapViews (wrap, with flatten-to-insert, collapse, and data-pin invariants) and spec-insert.moveViewTo; the editor applies every drag as one undoable edit via AppStore.composeRequest, keeping the editor the single text source. --- .../architecture/08-vega-editor-techniques.md | 28 +- docs/architecture/09-visual-design.md | 8 + .../10-interaction-and-feedback.md | 35 +- .../visual-composition-editing-exploration.md | 5 + .../CompositionWireframe.module.css | 36 ++ .../components/CompositionWireframe.test.tsx | 137 ++++++- src/app/components/CompositionWireframe.tsx | 337 ++++++++++++++++-- src/app/components/Icon.tsx | 83 +++++ src/app/components/SpecEditor.tsx | 20 ++ src/app/components/mark-icon.ts | 27 ++ src/app/services/spec-transform-actions.ts | 73 +++- src/app/stores/AppStore.ts | 51 +++ src/core/spec-insert.test.ts | 31 ++ src/core/spec-insert.ts | 55 ++- src/core/spec-restructure.test.ts | 104 ++++++ src/core/spec-restructure.ts | 186 ++++++++++ 16 files changed, 1160 insertions(+), 56 deletions(-) create mode 100644 src/app/components/mark-icon.ts create mode 100644 src/core/spec-restructure.test.ts create mode 100644 src/core/spec-restructure.ts diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index e4013c2..b1ce3c1 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -297,13 +297,24 @@ thin app-layer services. - `spec-inline-data` — the rows a specific `data` binding carries for profiling (`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry). - `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. + at an index (`insertView`) and reordering siblings (`moveView` swaps a neighbour, `moveViewTo` + slides to any index), plus `elementOffset` to re-find a view after the edit. It also owns the + shared `SpecPath` walkers (`valueAtPath`/`arrayAtPath`/`isPrefixPath`) — a module navigating a + path reuses these rather than re-inlining the array/object descent. - `spec-view-tree` — the whole composition as a recursive tree (`viewTree`), each node carrying its operator, orientation and byte range. Read at once (vs. `spec-insert`'s one-array edits) to - drive the composition wireframe — a read-only schematic of the multi-view structure in a preview- - toolbar disclosure (`CompositionWireframe`); clicking a box reveals that view's range in the - editor via `AppStore.requestRevealView`. Interaction contract in [arch 10](10-interaction-and-feedback.md). + drive the composition wireframe — a schematic of the multi-view structure in a preview-toolbar + disclosure (`CompositionWireframe`); clicking a box reveals that view's range in the editor via + `AppStore.requestRevealView`, and on the draft it is **drag-editable** (reorder + restructure). + Interaction contract in [arch 10](10-interaction-and-feedback.md). +- `spec-restructure` — the path-targeted cross-container moves behind the wireframe's drag. + `wrapViews(target, source, axis, side)` pairs the dragged source beside the drop target in a new + concat (placed where the target was, the source removed), enforcing three invariants: + **flatten** a bare same-orientation concat nested directly in a concat (so a with-axis drop reads + as a plain _insert_, not redundant nesting), **collapse** the source's emptied container (unwrap a + one-child, drop a zero-child, recursing up the chain), and **data-pin** a source's inherited + `data` before it changes ancestor (`dataBindingAtPath`, so it never silently rebinds). Degenerate + drops — onto itself, its own ancestor/descendant, or the root — return null. **Services (app, store-aware via `getState`):** `spec-transform-actions` (the wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion, @@ -325,6 +336,13 @@ 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. +- **Wireframe restructuring is one core op + the editor's undo.** Every drag resolves to either + `moveViewTo` (reorder within one container) or `wrapViews` (everything else — wrap, cross-container + move, insert); the wireframe only _requests_ it (`AppStore.requestComposeMove`/`requestComposeWrap`) + and `SpecEditor` applies it through the same `executeEdits` + `pushUndoStop` path as the other + transforms, so a drag is one ⌘Z and the editor stays the single text source. `wrapViews` covers + wrap and insert with one operation because it flattens bare same-orientation nesting afterward; the + drag's edge-zone interaction model lives in [arch 10](10-interaction-and-feedback.md). - **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`, diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index 4a3f4d0..306cdad 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -371,6 +371,14 @@ notifications/validation as they arrive): | Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) | | Info | `InformationFilled` | `--support-info` | `Toaster` (info) | +**Mark set** — a custom `mark-*` sub-family, one simplified glyph per Vega-Lite mark +(bar, line, area, point, arc, rect, tick, rule, text, plus a `mark-generic` fallback), +drawn on the same 32-grid (stroked where a line reads truer than a fill). It labels the +leaves of the composition wireframe so views read apart at a glance; mark synonyms +(circle/square → point, trail → line, image → rect) collapse onto it via `markIconName` +(`mark-icon.ts`), and an unknown or absent mark falls to `mark-generic`. Decorative there +(the box's `aria-label` names the view), so these are not in the ⭐ icon-only set. + **Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in the `Icon` registry**: diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 1c78325..808b829 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -222,17 +222,38 @@ _(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the cite it, not the APG file.)_ **Resolved — composition structure wireframe.** The preview toolbar's structure disclosure (a -read-only schematic of the spec's multi-view composition — `CompositionWireframe`, arch 08) is a +schematic of the spec's multi-view composition — `CompositionWireframe`, arch 08) is a **WAI-ARIA APG `tree`** inside a disclosure popover (`usePopover`): bare nested boxes are `tree` → `treeitem` → `group`, single-select via `aria-selected`, **one tab stop with a roving tabindex**, arrow keys in **logical (document) order** — Up/Down between nodes, Left → parent, Right → first child, Home/End, Enter/Space activate — not spatial, since a mixed horizontal/ -vertical layout makes spatial arrows ambiguous. Selecting a box reveals + selects that view's -source range in the editor (`AppStore.requestRevealView`) but **does not steal focus**, so the -wireframe stays the active surface while the editor scrolls to follow; the editor selection is the -single source of truth. The toolbar glyph appears **only for a composed spec** — a single-view -spec hides the affordance rather than disclosing an empty tree. _(Council: APG treeview; the -cursor-scoping reachability rationale is in [arch 08](08-vega-editor-techniques.md).)_ +vertical layout makes spatial arrows ambiguous. Each leaf carries a glyph of its mark type (the +`mark-*` icon sub-family, arch 09 §5) so views read apart at a glance. Selecting a box reveals + +selects that view's source range in the editor (`AppStore.requestRevealView`) but **does not steal +focus**, so the wireframe stays the active surface while the editor scrolls to follow; the editor +selection is the single source of truth. The toolbar glyph appears **only for a composed spec** — +a single-view spec hides the affordance rather than disclosing an empty tree. _(Council: APG +treeview; the cursor-scoping reachability rationale is in [arch 08](08-vega-editor-techniques.md).)_ + +On the **editable draft** the tree restructures the composition. Every restructure is **applied by +the editor** (which owns the one-⌘Z edit) via `AppStore.requestComposeMove`/`requestComposeWrap`, +never by writing the draft text directly — so the wireframe and editor share one undo history. + +- **Reorder within a container — APG rearrangeable-listbox.** `Alt+↑`/`Alt+↓` moves the focused + view among its siblings: a direct modifier+arrow move, **not** a grab/drop mode. Focus follows + the moved box for consecutive moves (so a screen reader re-announces its new position), a + **polite** live region states the result, and `aria-keyshortcuts` advertises the keys. _(Council: + APG listbox-rearrangeable.)_ +- **Restructure by drag — edge-zone (dock) model.** The nearest edge of the box under the pointer + picks the **axis** (left/right → a row `hconcat`, top/bottom → a column `vconcat`) and **side**. + A drop **along** a sibling's own container reorders within it; a drop **across** it — or onto an + opaque `layer`/`facet`/`repeat` box — pairs the two in a new concat. The hit-test descends only + through `hconcat`/`vconcat` and treats `layer`/`facet`/`repeat`/grid as **opaque** targets (their + children overlap or are data-generated, so the unit is the target, never inside it). A 3px accent + line marks the landing edge; a `wrap` drop also rings + tints the partner box. The drag is a + pointer accelerator over capabilities that stay keyboard-reachable (in-container reorder via + `Alt+↑/↓`; cross-container wrap via the editor's wrap actions), so it adds **no keyboard-only + gap**. Transform invariants in [arch 08](08-vega-editor-techniques.md). **Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as diff --git a/docs/exploration/visual-composition-editing-exploration.md b/docs/exploration/visual-composition-editing-exploration.md index 8ba9e0b..2d389b3 100644 --- a/docs/exploration/visual-composition-editing-exploration.md +++ b/docs/exploration/visual-composition-editing-exploration.md @@ -11,6 +11,11 @@ > because its risk (compiled-name↔source-path correlation, and an edit-vs-interact pointer > conflict) is real and isolated. First build step: a **read-only Phase A spike** — > `viewTree(spec)` + a static nested-box renderer with click-to-cursor sync. +> +> **Shipped (2026-06-29):** Phases A–C — the wireframe, mark-type leaf glyphs, in-container +> reorder (drag + `Alt+↑/↓`), and cross-container drag-to-restructure (`core/spec-restructure` +> `wrapViews`). The live contract is now arch 08 (transforms) + arch 10 (interaction). Phase D +> (on-chart overlay) and Phase E (size editing) remain deferred — size deferred by choice. --- diff --git a/src/app/components/CompositionWireframe.module.css b/src/app/components/CompositionWireframe.module.css index 6d0cbc3..b7d3d7a 100644 --- a/src/app/components/CompositionWireframe.module.css +++ b/src/app/components/CompositionWireframe.module.css @@ -43,10 +43,46 @@ .leaf { min-height: 48px; cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +/* The mark glyph is a quiet identity hint, not chrome — muted, inherits theme. */ +.markIcon { + color: var(--text-secondary); } .node:hover { border-color: var(--text-secondary); } + +/* Draggable boxes (draft only) advertise the drag with a grab cursor; the body + cursor flips to grabbing for the duration of an active drag (set in JS). */ +.node[data-draggable] { + cursor: grab; +} +.node[data-dragging] { + opacity: 0.4; +} +/* Drop indicator (arch 10 §5): a 3px accent line on the edge the dragged box would + land. `wrap` mode additionally rings + tints the target box, signalling the two + pair into a new split rather than just reordering. */ +.node[data-drop-edge='left'] { + box-shadow: inset 3px 0 0 var(--accent); +} +.node[data-drop-edge='right'] { + box-shadow: inset -3px 0 0 var(--accent); +} +.node[data-drop-edge='top'] { + box-shadow: inset 0 3px 0 var(--accent); +} +.node[data-drop-edge='bottom'] { + box-shadow: inset 0 -3px 0 var(--accent); +} +.node[data-drop-mode='wrap'] { + background: var(--accent-soft); + outline: 2px solid var(--accent); + outline-offset: -2px; +} /* Selection mirrors the library's active-row language (arch 09 §4). */ .node.selected { border-color: var(--accent); diff --git a/src/app/components/CompositionWireframe.test.tsx b/src/app/components/CompositionWireframe.test.tsx index c8665c2..507f5f0 100644 --- a/src/app/components/CompositionWireframe.test.tsx +++ b/src/app/components/CompositionWireframe.test.tsx @@ -8,13 +8,14 @@ * itself is covered in core (spec-view-tree.test.ts). */ -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useAppStore } from '../stores/AppStore'; import { usePopoverStore } from '../stores/PopoverStore'; import { useSnippetStore } from '../stores/SnippetStore'; import { CompositionWireframe } from './CompositionWireframe'; +import { markIconName } from './mark-icon'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -28,6 +29,14 @@ const setSpec = (text: string) => { useSnippetStore.setState({ draftText: text }); }; +/** Like `setSpec`, but with an active draft so reorder affordances are enabled. */ +const setEditableSpec = (text: string) => { + useSnippetStore.getState().reset(); + useSnippetStore.setState({ draftText: text, activeSnippetId: 'snip', editorView: 'draft' }); +}; + +const key = (e: KeyboardEventInit) => new KeyboardEvent('keydown', { bubbles: true, ...e }); + const treeItems = () => Array.from(document.body.querySelectorAll('[role="treeitem"]')); const item = (key: string) => document.body.querySelector(`[data-key="${key}"]`)!; @@ -45,7 +54,7 @@ async function renderOpen() { beforeEach(() => { usePopoverStore.setState({ openId: null }); - useAppStore.setState({ revealTarget: null }); + useAppStore.setState({ revealTarget: null, composeRequest: null }); setSpec(COMPOSED); container = document.createElement('div'); document.body.appendChild(container); @@ -56,7 +65,7 @@ afterEach(() => { act(() => root.unmount()); container.remove(); usePopoverStore.setState({ openId: null }); - useAppStore.setState({ revealTarget: null }); + useAppStore.setState({ revealTarget: null, composeRequest: null }); useSnippetStore.getState().reset(); }); @@ -99,4 +108,126 @@ describe('CompositionWireframe', () => { const target = useAppStore.getState().revealTarget!; expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"'); }); + + test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => { + setEditableSpec(COMPOSED); + await renderOpen(); + // Move the roving focus onto the first leaf, then reorder it down. + act(() => { + item('root').dispatchEvent(key({ key: 'ArrowDown' })); + }); + act(() => { + item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true })); + }); + const req = useAppStore.getState().composeRequest!; + expect(req).toMatchObject({ arrayPath: ['vconcat'], from: 0, to: 1 }); + }); + + test('Alt+ArrowUp at the start does not reorder', async () => { + setEditableSpec(COMPOSED); + await renderOpen(); + act(() => { + item('root').dispatchEvent(key({ key: 'ArrowDown' })); + }); + act(() => { + item('vconcat|0').dispatchEvent(key({ key: 'ArrowUp', altKey: true })); + }); + expect(useAppStore.getState().composeRequest).toBeNull(); + }); + + test('reorder is inert on a read-only (non-draft) view', async () => { + // Default COMPOSED is set with no active snippet → not editable. + await renderOpen(); + act(() => { + item('root').dispatchEvent(key({ key: 'ArrowDown' })); + }); + act(() => { + item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true })); + }); + expect(useAppStore.getState().composeRequest).toBeNull(); + }); +}); + +describe('CompositionWireframe — drag to restructure', () => { + // happy-dom has no layout, so the drop hit-test (which reads box rects) needs them + // stubbed. A vertical stack of two equal halves, keyed by the box's data-key. + const RECTS: Record = { + root: { x: 0, y: 0, w: 200, h: 200 }, + 'vconcat|0': { x: 0, y: 0, w: 200, h: 100 }, + 'vconcat|1': { x: 0, y: 100, w: 200, h: 100 }, + }; + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement, + ): DOMRect { + const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 }; + return { + left: r.x, + top: r.y, + right: r.x + r.w, + bottom: r.y + r.h, + width: r.w, + height: r.h, + x: r.x, + y: r.y, + toJSON: () => ({}), + }; + }); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + const drag = (fromKey: string, to: { x: number; y: number }) => { + act(() => { + item(fromKey).dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }), + ); + }); + act(() => { + window.dispatchEvent(new MouseEvent('pointermove', { clientX: to.x, clientY: to.y })); + }); + act(() => { + window.dispatchEvent(new MouseEvent('pointerup', {})); + }); + }; + + test('dropping onto a perpendicular edge wraps the two views into a row', async () => { + setEditableSpec(COMPOSED); + await renderOpen(); + drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view + expect(useAppStore.getState().composeRequest).toMatchObject({ + kind: 'wrap', + targetPath: ['vconcat', 0], + sourcePath: ['vconcat', 1], + axis: 'horizontal', + side: 'after', + }); + }); + + test('dropping along the container reorders within it', async () => { + setEditableSpec(COMPOSED); + await renderOpen(); + drag('vconcat|1', { x: 100, y: 8 }); // top edge of the top view + expect(useAppStore.getState().composeRequest).toMatchObject({ + kind: 'move', + arrayPath: ['vconcat'], + from: 1, + to: 0, + }); + }); +}); + +describe('markIconName', () => { + test('maps marks to glyphs, collapsing synonyms', () => { + expect(markIconName('bar')).toBe('mark-bar'); + expect(markIconName('circle')).toBe('mark-point'); + expect(markIconName('square')).toBe('mark-point'); + expect(markIconName('trail')).toBe('mark-line'); + }); + + test('falls back to a generic glyph for an unknown or absent mark', () => { + expect(markIconName('boxplot')).toBe('mark-generic'); + expect(markIconName(undefined)).toBe('mark-generic'); + }); }); diff --git a/src/app/components/CompositionWireframe.tsx b/src/app/components/CompositionWireframe.tsx index 583dba6..fcb613f 100644 --- a/src/app/components/CompositionWireframe.tsx +++ b/src/app/components/CompositionWireframe.tsx @@ -1,37 +1,58 @@ /** - * Composition wireframe — a read-only schematic of the active spec's multi-view - * structure (docs/architecture/08 → editor augmentation). Bare nested boxes for - * `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views; no - * labels in the boxes (the caption names the hovered/selected one). Clicking a box - * selects it and reveals that view's source range in the editor + * Composition wireframe — an interactive schematic of the active spec's multi-view + * structure (docs/architecture/08 → editor augmentation). Nested boxes for + * `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views; + * each leaf carries a glyph of its mark type so which-is-which reads at a glance. + * Clicking a box selects it and reveals that view's source range in the editor * (`AppStore.requestRevealView`) — the editor stays the source of truth. * * A disclosure popover (`usePopover`) off a glyph in the preview toolbar; the panel * is the WAI-ARIA APG **tree** widget — `tree`/`treeitem`/`group`, single-select, - * roving tabindex, arrow-key nav in logical (document) order (arch 10 §5). Phase A - * is read-only navigation; drag-reorder and resize land on this same tree. + * roving tabindex, arrow-key nav in logical (document) order (arch 10 §5). + * + * On the editable draft a view can be **restructured by dragging its box**: + * - onto a sibling's edge *along* its container → reorder within it; + * - onto a view's edge *across* its container → pair the two in a new + * `hconcat`/`vconcat`, or, dragging in from elsewhere, move/insert it there. + * The nearest edge of the box under the pointer picks the axis (left/right → a row, + * top/bottom → a column) and side. The keyboard equivalent for in-container reorder + * is Alt+↑/↓ (the APG rearrangeable-listbox pattern); cross-container restructuring + * stays the editor's wrap actions for keyboard users. Every move is applied by the + * editor (which owns the undoable edit) via `AppStore.requestComposeMove` / + * `requestComposeWrap`, focus follows the affected box, and a polite live region + * announces the result (arch 10 §5). */ import { useCallback, useEffect, useMemo, + useRef, useState, type KeyboardEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; -import type { SpecPath } from '@core/spec-insert'; +import { isPrefixPath, type SpecPath } from '@core/spec-insert'; +import type { DropAxis } from '@core/spec-restructure'; import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree'; import { usePopover } from '../hooks/usePopover'; import { useAppStore } from '../stores/AppStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; +import { markIconName } from './mark-icon'; import styles from './CompositionWireframe.module.css'; const POPOVER_ID = 'composition-wireframe'; const INITIAL_FOCUS = ['[role="treeitem"]'] as const; +/** Pointer travel (px) before a press becomes a drag rather than a click. */ +const DRAG_THRESHOLD = 4; +/** The concat orientations a drop can descend into (layers/grids are opaque targets). */ +const DESCENDABLE: ReadonlySet = new Set(['horizontal', 'vertical']); + +type Edge = 'left' | 'right' | 'top' | 'bottom'; /** A DOM-safe, unique key for a node from its path. */ const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root'); @@ -55,9 +76,8 @@ const ariaLabelOf = (n: ViewNode): string => : `${n.op}, ${n.orientation}, ${n.children.length} views`; // TODO (deferred polish — docs/exploration/visual-composition-editing-exploration.md §6a): -// (1) render a simplified mark-type glyph inside each leaf box for at-a-glance ID; -// (2) replace the offset-rectangle `layered` look with a "stacked planes" primitive -// (overlapping sheets/disks, like the database glyph) to read as one shared space. +// replace the offset-rectangle `layered` look with a "stacked planes" primitive +// (overlapping sheets/disks, like the database glyph) to read as one shared space. /** Children layout class per orientation (layered overlaps in one grid cell). */ const LAYOUT: Record = { @@ -71,28 +91,79 @@ interface Flat { node: ViewNode; key: string; parentKey: string | null; + /** The composition that holds this node, or null for the root. */ + parent: ViewNode | null; } /** Pre-order flatten — drives roving keyboard nav (next/prev/parent/first-child). */ function flatten(root: ViewNode): Flat[] { const out: Flat[] = []; - const walk = (n: ViewNode, parentKey: string | null) => { + const walk = (n: ViewNode, parentKey: string | null, parent: ViewNode | null) => { const key = keyOf(n.path); - out.push({ node: n, key, parentKey }); - for (const c of n.children) walk(c, key); + out.push({ node: n, key, parentKey, parent }); + for (const c of n.children) walk(c, key, n); }; - walk(root, null); + walk(root, null, null); return out; } +/** The nearest edge of rect `r` to the point — drives the drop axis and side. */ +function edgeOf(r: DOMRect, x: number, y: number): Edge { + const fx = (x - r.left) / r.width; + const fy = (y - r.top) / r.height; + const dist: Record = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy }; + return (Object.keys(dist) as Edge[]).reduce((a, b) => (dist[a] <= dist[b] ? a : b)); +} + +/** The resolved drop the wireframe will commit, and how to draw it on the target. */ +interface DropResolution { + targetKey: string; + edge: Edge; + mode: 'move' | 'wrap'; + commit: + | { kind: 'move'; arrayPath: SpecPath; from: number; to: number } + | { + kind: 'wrap'; + targetPath: SpecPath; + sourcePath: SpecPath; + axis: DropAxis; + side: 'before' | 'after'; + }; +} + +interface DragState { + sourceKey: string; + sourceNode: ViewNode; + resolution: DropResolution | null; +} + function WireframeTree({ tree }: { tree: ViewNode }) { const requestRevealView = useAppStore((s) => s.requestRevealView); + const requestComposeMove = useAppStore((s) => s.requestComposeMove); + const requestComposeWrap = useAppStore((s) => s.requestComposeWrap); + // Restructure only on the editable draft — the published view is read-only. + const editable = useSnippetStore((s) => s.editorView === 'draft' && s.activeSnippetId !== null); const flat = useMemo(() => flatten(tree), [tree]); + const parentByKey = useMemo(() => new Map(flat.map((f) => [f.key, f.parent])), [flat]); const rootKey = keyOf(tree.path); const [selectedKey, setSelectedKey] = useState(null); const [focusedKey, setFocusedKey] = useState(null); const [hoverKey, setHoverKey] = useState(null); + const [drag, setDrag] = useState(null); + const [announcement, setAnnouncement] = useState(''); + // The box to focus once the tree rebuilds after a restructure (a drag leaves focus + // on the body; keyboard needs focus to follow the view to its new place — APG). + const [pendingFocusKey, setPendingFocusKey] = useState(null); + + const treeRef = useRef(null); + const dragRef = useRef(null); + const justDraggedRef = useRef(false); + const setDragState = (d: DragState | null) => { + dragRef.current = d; + setDrag(d); + }; + const effectiveFocus = (focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey; @@ -105,15 +176,207 @@ function WireframeTree({ tree }: { tree: ViewNode }) { [requestRevealView], ); + // Select, focus-after-rebuild, and announce the box at `key` once a restructure + // has been requested (the tree rebuilds from the edited draft text). + const settleOn = useCallback((key: string, message: string) => { + setSelectedKey(key); + setFocusedKey(key); + setPendingFocusKey(key); + setAnnouncement(message); + }, []); + + const reorder = useCallback( + (arrayPath: SpecPath, from: number, to: number, node: ViewNode, count: number) => { + requestComposeMove(arrayPath, from, to); + settleOn( + keyOf([...arrayPath, to]), + `Moved ${descriptor(node)} to position ${to + 1} of ${count}`, + ); + }, + [requestComposeMove, settleOn], + ); + + // Restore focus to the affected box once it re-renders at its new position. + useEffect(() => { + if (!pendingFocusKey) return; + const el = treeRef.current?.querySelector(`[data-key="${pendingFocusKey}"]`); + if (el) { + el.focus(); + setPendingFocusKey(null); + } + }, [flat, pendingFocusKey]); + + // Rect of the box for `key`, read live from the DOM during a drag. + const rectOf = useCallback((key: string): DOMRect | null => { + const el = treeRef.current?.querySelector(`[data-key="${key}"]`); + return el ? el.getBoundingClientRect() : null; + }, []); + + // The drop target: descend through concats (row/column) into the child under the + // pointer; stop at a leaf or an opaque container (layer/facet/repeat/grid). The + // dragged box is skipped so the pointer never targets it or its subtree. + const dropTargetNode = useCallback( + (x: number, y: number, sourceKey: string): ViewNode | null => { + let node = tree; + while (node.kind === 'composition' && node.orientation && DESCENDABLE.has(node.orientation)) { + const child = node.children.find((c) => { + if (keyOf(c.path) === sourceKey) return false; + const r = rectOf(keyOf(c.path)); + return r ? x >= r.left && x <= r.right && y >= r.top && y <= r.bottom : false; + }); + if (!child) break; + node = child; + } + return keyOf(node.path) === sourceKey ? null : node; + }, + [tree, rectOf], + ); + + const resolveDrop = useCallback( + (x: number, y: number, source: ViewNode): DropResolution | null => { + const sourcePath = source.path; + const target = dropTargetNode(x, y, keyOf(sourcePath)); + if (!target) return null; + const targetPath = target.path; + // Degenerate: the root, or an ancestor/descendant relationship. + if (targetPath.length === 0) return null; + if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null; + + const r = rectOf(keyOf(targetPath)); + if (!r) return null; + const edge = edgeOf(r, x, y); + const axis: DropAxis = edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical'; + const side: 'before' | 'after' = edge === 'left' || edge === 'top' ? 'before' : 'after'; + + const parent = parentByKey.get(keyOf(targetPath)); + const tIdx = targetPath[targetPath.length - 1]; + const along = + (axis === 'horizontal' && parent?.orientation === 'horizontal') || + (axis === 'vertical' && parent?.orientation === 'vertical'); + + // A drop *along* a sibling's own container is a reorder/insert into it; same + // container → a plain move, a different one → a wrap that flattens to an insert. + if (along && parent && typeof tIdx === 'number') { + const arrayPath = targetPath.slice(0, -1); + if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) { + const from = sourcePath[sourcePath.length - 1] as number; + const gap = side === 'before' ? tIdx : tIdx + 1; + let to = gap > from ? gap - 1 : gap; + to = Math.max(0, Math.min(to, parent.children.length - 1)); + if (to === from) return null; // no-op + return { + targetKey: keyOf(targetPath), + edge, + mode: 'move', + commit: { kind: 'move', arrayPath, from, to }, + }; + } + } + // Across the container (or into an opaque target) → wrap the two together. + return { + targetKey: keyOf(targetPath), + edge, + mode: 'wrap', + commit: { kind: 'wrap', targetPath, sourcePath, axis, side }, + }; + }, + [dropTargetNode, parentByKey, rectOf], + ); + + const commitDrop = useCallback( + (res: DropResolution, source: ViewNode) => { + if (res.commit.kind === 'move') { + const { arrayPath, from, to } = res.commit; + const count = parentByKey.get(res.targetKey)?.children.length ?? 0; + reorder(arrayPath, from, to, source, count); + } else { + const { targetPath, sourcePath, axis, side } = res.commit; + requestComposeWrap(targetPath, sourcePath, axis, side); + // Focus/selection lands on the new split at the target's slot. When the drop + // flattens to a plain insert (a with-axis cross-container drop), the target's + // path shifts, so this key no longer resolves and the focus-restore no-ops — a + // tolerated gap while cross-container wrap is pointer-only (focus matters for the + // keyboard path, which is in-container `Alt+↑/↓` reorder, where the key is exact). + settleOn( + keyOf(targetPath), + `Paired ${descriptor(source)} into a ${axis === 'horizontal' ? 'row' : 'column'}`, + ); + } + }, + [parentByKey, reorder, requestComposeWrap, settleOn], + ); + + const beginDrag = (e: ReactPointerEvent, source: ViewNode) => { + if (e.button !== 0 || !editable) return; + const startX = e.clientX; + const startY = e.clientY; + let started = false; + + const onMove = (ev: PointerEvent) => { + if (!started) { + if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return; + started = true; + document.body.style.cursor = 'grabbing'; + document.body.style.userSelect = 'none'; + } + setDragState({ + sourceKey: keyOf(source.path), + sourceNode: source, + resolution: resolveDrop(ev.clientX, ev.clientY, source), + }); + }; + const cleanup = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + const onUp = () => { + const d = dragRef.current; + cleanup(); + setDragState(null); + if (started) { + justDraggedRef.current = true; // swallow the click that follows a real drag + if (d?.resolution) commitDrop(d.resolution, d.sourceNode); + } + }; + // A touch the browser reclaims for scrolling fires pointercancel — abort cleanly. + const onCancel = () => { + cleanup(); + setDragState(null); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); + }; + const onKeyDown = (e: KeyboardEvent) => { const i = flat.findIndex((f) => f.key === effectiveFocus); if (i < 0) return; + + // Alt+↑/↓ reorders the focused view among its siblings (APG rearrangeable list). + if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + e.preventDefault(); + const f = flat[i]; + const idx = f.node.path.at(-1); + if (!editable || !f.parent || typeof idx !== 'number') return; + const count = f.parent.children.length; + const to = e.key === 'ArrowUp' ? idx - 1 : idx + 1; + if (to < 0 || to >= count) { + setAnnouncement(e.key === 'ArrowUp' ? 'Already at the start' : 'Already at the end'); + return; + } + reorder(f.node.path.slice(0, -1), idx, to, f.node, count); + return; + } + const moveTo = (j: number) => { const target = flat[j]; if (!target) return; e.preventDefault(); setFocusedKey(target.key); - e.currentTarget.querySelector(`[data-key="${target.key}"]`)?.focus(); + treeRef.current?.querySelector(`[data-key="${target.key}"]`)?.focus(); }; switch (e.key) { case 'ArrowDown': @@ -137,10 +400,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) { } }; - const renderNode = (n: ViewNode): ReactNode => { + const renderNode = (n: ViewNode, parent: ViewNode | null): ReactNode => { const key = keyOf(n.path); const container = n.kind === 'composition'; const generated = n.op === 'facet' || n.op === 'repeat'; + const idx = n.path.at(-1); + const draggable = editable && parent != null && typeof idx === 'number'; + const onTarget = drag?.resolution?.targetKey === key ? drag.resolution : null; + const cls = [ styles.node, container ? styles.container : styles.leaf, @@ -157,10 +424,20 @@ function WireframeTree({ tree }: { tree: ViewNode }) { aria-label={ariaLabelOf(n)} aria-selected={key === selectedKey} aria-expanded={container ? true : undefined} + aria-keyshortcuts={draggable ? 'Alt+ArrowUp Alt+ArrowDown' : undefined} tabIndex={key === effectiveFocus ? 0 : -1} className={cls} + data-draggable={draggable || undefined} + data-dragging={drag?.sourceKey === key || undefined} + data-drop-edge={onTarget?.edge} + data-drop-mode={onTarget?.mode} + onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined} onClick={(e) => { e.stopPropagation(); + if (justDraggedRef.current) { + justDraggedRef.current = false; + return; + } select(n); }} onMouseEnter={(e) => { @@ -173,13 +450,16 @@ function WireframeTree({ tree }: { tree: ViewNode }) { setFocusedKey(key); }} > - {container && ( + {container ? (
- {n.children.map(renderNode)} + {n.children.map((c) => renderNode(c, n))}
+ ) : ( + )} ); @@ -190,8 +470,14 @@ function WireframeTree({ tree }: { tree: ViewNode }) { return ( <> -
- {renderNode(tree)} +
+ {renderNode(tree, null)}
+
+ {announcement} +
); } diff --git a/src/app/components/Icon.tsx b/src/app/components/Icon.tsx index 268a7c5..1eda6af 100644 --- a/src/app/components/Icon.tsx +++ b/src/app/components/Icon.tsx @@ -35,6 +35,19 @@ export type IconName = | 'info' // about / information — Carbon Information (outline) | 'revert' // revert draft to last published — Carbon Reset | 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks + // Mark sub-family (composition wireframe leaves) — a simplified glyph of a unit + // view's mark type, so which-is-which reads at a glance. Vega-Lite mark synonyms + // collapse onto these via `markIconName` (CompositionWireframe); unknown → generic. + | 'mark-bar' + | 'mark-line' + | 'mark-area' + | 'mark-point' + | 'mark-arc' + | 'mark-rect' + | 'mark-tick' + | 'mark-rule' + | 'mark-text' + | 'mark-generic' // Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the // glyph shows *which* pane it controls by position (left / centre / right). | 'pane-library' // toggle the library pane (left) @@ -164,6 +177,76 @@ const GLYPHS: Record = { ), + // Mark glyphs: simplified renderings of each Vega-Lite mark, drawn on the same + // 32-grid. Stroke-based where a line reads truer than a fill (line/rule/generic). + 'mark-bar': ( + <> + + + + + ), + 'mark-line': ( + + ), + 'mark-area': , + 'mark-point': ( + <> + + + + + + ), + // A three-quarter pie wedge (one quadrant empty) reads as arc/pie at a glance. + 'mark-arc': , + 'mark-rect': ( + <> + + + + + + + + ), + 'mark-tick': ( + <> + + + + + + ), + 'mark-rule': ( + + ), + 'mark-text': ( + <> + + + + + ), + 'mark-generic': ( + + ), moon: ( ), diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 1fda358..e391f95 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -37,8 +37,10 @@ import { configureSpecTransformCodeActions, installSpecTransformActions, installSpecTransformCodeLens, + runMoveViewTo, runUnwrap, runWrap, + runWrapViews, } from '../services/spec-transform-actions'; import { configureSpecDatasetHints } from '../services/spec-dataset-hints'; import { runExtract } from '../services/extract-action'; @@ -385,6 +387,7 @@ export function SpecEditor() { const bufferEpoch = useSnippetStore((s) => s.bufferEpoch); const uiTheme = useAppStore((s) => s.uiTheme); const revealTarget = useAppStore((s) => s.revealTarget); + const composeRequest = useAppStore((s) => s.composeRequest); const error = usePreviewStore((s) => s.error); // Editor preferences (spec §07 → Editor); applied live below as they change. const editorPrefs = useUserSettingsStore((s) => s.saved.editor); @@ -513,6 +516,23 @@ export function SpecEditor() { // can keep browsing blocks while the editor scrolls/selects to follow. }, [revealTarget]); + // Apply a composition restructure when the wireframe asks (wireframe → editor; + // the editor owns the undoable edit). The nonce makes a repeat request re-fire. + useEffect(() => { + const editor = editorRef.current; + if (!editor || !composeRequest) return; + if (composeRequest.kind === 'move') + runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to); + else + runWrapViews( + editor, + composeRequest.targetPath, + composeRequest.sourcePath, + composeRequest.axis, + composeRequest.side, + ); + }, [composeRequest]); + return (
diff --git a/src/app/components/mark-icon.ts b/src/app/components/mark-icon.ts new file mode 100644 index 0000000..d250e1a --- /dev/null +++ b/src/app/components/mark-icon.ts @@ -0,0 +1,27 @@ +/** + * Mark → wireframe glyph ledger (composition wireframe leaves, arch 08). Maps a + * unit view's Vega-Lite mark type to its glyph in the Icon vocabulary's `mark-*` + * sub-family, collapsing synonyms (circle/square → point, trail → line, image → + * rect) so the set stays small; anything unmapped or absent falls to `mark-generic`. + */ + +import type { IconName } from './Icon'; + +const MARK_ICON: Record = { + bar: 'mark-bar', + line: 'mark-line', + trail: 'mark-line', + area: 'mark-area', + point: 'mark-point', + circle: 'mark-point', + square: 'mark-point', + tick: 'mark-tick', + rect: 'mark-rect', + image: 'mark-rect', + arc: 'mark-arc', + rule: 'mark-rule', + text: 'mark-text', +}; + +export const markIconName = (mark?: string): IconName => + (mark && MARK_ICON[mark]) || 'mark-generic'; diff --git a/src/app/services/spec-transform-actions.ts b/src/app/services/spec-transform-actions.ts index e482d85..018d3d8 100644 --- a/src/app/services/spec-transform-actions.ts +++ b/src/app/services/spec-transform-actions.ts @@ -38,8 +38,10 @@ import { elementOffset, insertView, moveView, + moveViewTo, type SpecPath, } from '@core/spec-insert'; +import { wrapViews, type DropAxis } from '@core/spec-restructure'; import { unwrapSingleton, wrapInConcat, @@ -176,16 +178,22 @@ const WRAP_NOUN: Record = { repeat: 'a repeat', }; -/** Replace the scope as one undoable edit (the toolbar / palette path). */ +/** + * Replace the scope as one undoable edit (the toolbar / palette path). `focus` + * returns focus to the editor afterwards — true for editor-originated actions, + * false when the wireframe drove the edit and must keep its own focus (APG: a + * keyboard reorder stays on the moved item for consecutive moves). + */ function writeBack( editor: monaco.editor.IStandaloneCodeEditor, range: monaco.Range, text: string, + focus = true, ): void { editor.pushUndoStop(); editor.executeEdits('spec-transform', [{ range, text }]); editor.pushUndoStop(); - editor.focus(); + if (focus) editor.focus(); } /** @@ -245,13 +253,18 @@ export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void { * 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. + * + * `successTitle` of null suppresses the success toast (the wireframe gives its own + * feedback — visible move + live-region announcement — so a per-move toast would + * double up); `focusEditor` of false keeps focus off the editor for the same path. */ function applyArrayEdit( editor: monaco.editor.IStandaloneCodeEditor, build: (spec: JsonObject) => JsonObject | null, arrayPath: SpecPath, followIndex: number, - successTitle: string, + successTitle: string | null, + focusEditor = true, ): void { const model = editor.getModel(); if (!model) return; @@ -267,14 +280,15 @@ function applyArrayEdit( return; } const formatted = formatScoped(model, wholeDocument(model), next); - writeBack(editor, model.getFullModelRange(), formatted); + writeBack(editor, model.getFullModelRange(), formatted, focusEditor); 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.' }); + if (successTitle) + notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' }); } /** Insert an empty view at `index` of the composition at `arrayPath`. */ @@ -302,6 +316,55 @@ function runMoveView( ); } +/** + * Reorder the view at `from` to `to` within the composition at `arrayPath` — the + * composition wireframe's drag/keyboard reorder. No success toast and no editor + * focus-steal: the wireframe owns the feedback and keeps focus on the moved box. + */ +export function runMoveViewTo( + editor: monaco.editor.IStandaloneCodeEditor, + arrayPath: SpecPath, + from: number, + to: number, +): void { + applyArrayEdit(editor, (s) => moveViewTo(s, arrayPath, from, to), arrayPath, to, null, false); +} + +/** + * Restructure the spec by pairing the dragged `sourcePath` view beside the drop + * `targetPath` view in a new concat of `axis` — the wireframe's cross-container + * drag (wrap, move-in, collapse, all in core/spec-restructure). One undoable edit; + * no toast and no editor focus-steal, as with the reorder path. A degenerate or + * stale drop surfaces an info toast rather than silently doing nothing. + */ +export function runWrapViews( + editor: monaco.editor.IStandaloneCodeEditor, + targetPath: SpecPath, + sourcePath: SpecPath, + axis: DropAxis, + side: 'before' | 'after', +): void { + const model = editor.getModel(); + if (!model) return; + const spec = parseSpecObject(model.getValue()); + if (!spec) return; + const next = wrapViews(spec, targetPath, sourcePath, axis, side); + if (!next) { + notify({ + kind: 'info', + title: 'Could not restructure', + message: 'That drop isn’t possible here — the composition may have changed.', + }); + return; + } + writeBack( + editor, + model.getFullModelRange(), + formatScoped(model, wholeDocument(model), next), + false, + ); +} + /** Insert a view above/below the one the cursor is in (the keyboard path). */ function runInsertRelative( editor: monaco.editor.IStandaloneCodeEditor, diff --git a/src/app/stores/AppStore.ts b/src/app/stores/AppStore.ts index dac868c..fbeaa0b 100644 --- a/src/app/stores/AppStore.ts +++ b/src/app/stores/AppStore.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; import type { FitMode } from '@core/rendering'; +import type { SpecPath } from '@core/spec-insert'; +import type { DropAxis } from '@core/spec-restructure'; import type { UiTheme } from '@core/theme'; import type { ChartThemeSelection } from '@core/vega-themes'; import type { ModalName } from '../modals/types'; @@ -71,6 +73,24 @@ export interface AppState { * nonce makes a repeat request for the same range re-fire. Null until the first. */ revealTarget: { offset: number; length: number; nonce: number } | null; + /** + * A request from the composition wireframe to restructure — reorder a view + * within its array (`move`), or pair the dragged view beside a drop target in a + * new concat (`wrap`, the cross-container drag). Applied by the editor (which + * owns the undoable edit) as one ⌘Z step; the nonce makes a repeat re-fire. Null + * until the first. + */ + composeRequest: + | { kind: 'move'; arrayPath: SpecPath; from: number; to: number; nonce: number } + | { + kind: 'wrap'; + targetPath: SpecPath; + sourcePath: SpecPath; + axis: DropAxis; + side: 'before' | 'after'; + nonce: number; + } + | null; setTheme: (theme: UiTheme) => void; /** Flip between light and dark — the header ThemeToggle's action. */ @@ -92,6 +112,15 @@ export interface AppState { setActiveModal: (modal: ModalName | null) => void; /** Ask the editor to select + reveal a view's source range (composition wireframe). */ requestRevealView: (offset: number, length: number) => void; + /** Ask the editor to reorder a view within its composition array (wireframe drag/keyboard). */ + requestComposeMove: (arrayPath: SpecPath, from: number, to: number) => void; + /** Ask the editor to pair a dragged view beside a drop target in a new concat (wireframe drag). */ + requestComposeWrap: ( + targetPath: SpecPath, + sourcePath: SpecPath, + axis: DropAxis, + side: 'before' | 'after', + ) => void; } export const useAppStore = create((set) => ({ @@ -102,6 +131,7 @@ export const useAppStore = create((set) => ({ dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT, activeModal: null, revealTarget: null, + composeRequest: null, setTheme: (uiTheme) => set({ uiTheme }), toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })), @@ -112,4 +142,25 @@ export const useAppStore = create((set) => ({ setActiveModal: (activeModal) => set({ activeModal }), requestRevealView: (offset, length) => set((s) => ({ revealTarget: { offset, length, nonce: (s.revealTarget?.nonce ?? 0) + 1 } })), + requestComposeMove: (arrayPath, from, to) => + set((s) => ({ + composeRequest: { + kind: 'move', + arrayPath, + from, + to, + nonce: (s.composeRequest?.nonce ?? 0) + 1, + }, + })), + requestComposeWrap: (targetPath, sourcePath, axis, side) => + set((s) => ({ + composeRequest: { + kind: 'wrap', + targetPath, + sourcePath, + axis, + side, + nonce: (s.composeRequest?.nonce ?? 0) + 1, + }, + })), })); diff --git a/src/core/spec-insert.test.ts b/src/core/spec-insert.test.ts index f9a3129..a3c41fb 100644 --- a/src/core/spec-insert.test.ts +++ b/src/core/spec-insert.test.ts @@ -4,6 +4,7 @@ import { elementOffset, insertView, moveView, + moveViewTo, type SpecPath, } from './spec-insert'; @@ -113,6 +114,36 @@ describe('moveView', () => { }); }); +describe('moveViewTo', () => { + it('slides a view to an arbitrary index, shifting the rest', () => { + const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] }; + // first → last + expect(moveViewTo(spec, ['hconcat'], 0, 2)!.hconcat).toEqual([ + { mark: 'b' }, + { mark: 'c' }, + { mark: 'a' }, + ]); + // last → first + expect(moveViewTo(spec, ['hconcat'], 2, 0)!.hconcat).toEqual([ + { mark: 'c' }, + { mark: 'a' }, + { mark: 'b' }, + ]); + expect(spec.hconcat[0]).toEqual({ mark: 'a' }); // input untouched + }); + + it('returns null for a no-op move or an out-of-bounds index', () => { + const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }] }; + expect(moveViewTo(spec, ['hconcat'], 1, 1)).toBeNull(); + expect(moveViewTo(spec, ['hconcat'], 0, 2)).toBeNull(); + expect(moveViewTo(spec, ['hconcat'], -1, 0)).toBeNull(); + }); + + it('returns null when the path is not an array', () => { + expect(moveViewTo({ mark: 'bar' }, ['hconcat'], 0, 1)).toBeNull(); + }); +}); + describe('elementOffset', () => { it('returns the start offset of the element at the path', () => { const path: SpecPath = ['hconcat', 0, 'layer']; diff --git a/src/core/spec-insert.ts b/src/core/spec-insert.ts index 5dda863..83d60d0 100644 --- a/src/core/spec-insert.ts +++ b/src/core/spec-insert.ts @@ -82,17 +82,28 @@ export function compositionTargetAt(text: string, offset: number): CompositionTa }; } -/** The array at `path` in `spec`, or null when the path does not lead to one. */ -function arrayAt(spec: JsonObject, path: SpecPath): unknown[] | null { +/** Value at `path` in `spec`, or undefined when the path runs off a non-object/array. */ +export function valueAtPath(spec: unknown, path: SpecPath): unknown { 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; + else return undefined; } + return node; +} + +/** The array at `path` in `spec`, or null when the path does not lead to one. */ +export function arrayAtPath(spec: unknown, path: SpecPath): unknown[] | null { + const node = valueAtPath(spec, path); return Array.isArray(node) ? node : null; } +/** Is `a` a prefix of (or equal to) `b` — an ancestor of, or the same as, it? */ +export function isPrefixPath(a: SpecPath, b: SpecPath): boolean { + return a.length <= b.length && a.every((seg, i) => seg === b[i]); +} + /** * 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 @@ -100,16 +111,38 @@ function arrayAt(spec: JsonObject, path: SpecPath): unknown[] | null { */ 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); + const array = arrayAtPath(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. + * Move the view at `from` of the array at `path` to `to`, sliding the views + * between them over by one (a drag-reorder, not a swap). Returns a new spec, or + * null when the path is not an array, an index is out of bounds, or `from === to` + * (a no-op). Input is not mutated. + */ +export function moveViewTo( + spec: JsonObject, + path: SpecPath, + from: number, + to: number, +): JsonObject | null { + const next = JSON.parse(JSON.stringify(spec)) as JsonObject; + const array = arrayAtPath(next, path); + if (!array) return null; + if (from === to) return null; + if (from < 0 || from >= array.length || to < 0 || to >= array.length) return null; + const [moved] = array.splice(from, 1); + array.splice(to, 0, moved); + return next; +} + +/** + * Move the view at `index` of the array at `path` by `delta` steps (±1) — the + * neighbor-step reorder behind the CodeLens Move up/down. A one-step move is a + * swap, so this is `moveViewTo(index, index + delta)`. */ export function moveView( spec: JsonObject, @@ -117,13 +150,7 @@ export function moveView( 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; + return moveViewTo(spec, path, index, index + delta); } /** Start offset of the element at `[...path, index]` in the text, or null. */ diff --git a/src/core/spec-restructure.test.ts b/src/core/spec-restructure.test.ts new file mode 100644 index 0000000..94d9e9c --- /dev/null +++ b/src/core/spec-restructure.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { wrapViews } from './spec-restructure'; + +describe('wrapViews — wrap across the target axis', () => { + it('pairs a view beside a sibling in a new perpendicular concat', () => { + // The motivating case: a vconcat of three, drop the last beside the middle to + // make a row — vconcat:[A, hconcat:[B, C]]. + const spec = { data: { name: 'd' }, vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] }; + const next = wrapViews(spec, ['vconcat', 1], ['vconcat', 2], 'horizontal', 'after'); + expect(next).toEqual({ + data: { name: 'd' }, + vconcat: [{ mark: 'a' }, { hconcat: [{ mark: 'b' }, { mark: 'c' }] }], + }); + expect(spec.vconcat).toHaveLength(3); // input untouched + }); + + it('honors the side — source before the target', () => { + // Wrapping A and removing B empties the hconcat to one child, which collapses, + // so the result is the bare vconcat with the source ahead of the target. + const spec = { hconcat: [{ mark: 'a' }, { mark: 'b' }] }; + const next = wrapViews(spec, ['hconcat', 0], ['hconcat', 1], 'vertical', 'before'); + expect(next).toEqual({ vconcat: [{ mark: 'b' }, { mark: 'a' }] }); + }); +}); + +describe('wrapViews — flatten makes a with-axis drop an insert', () => { + it('inserts into the target concat instead of nesting a redundant one', () => { + // Drop S (from a different container) onto the bottom edge of B, which already + // sits in a vconcat → S joins that vconcat after B, the source container collapses. + const spec = { + hconcat: [{ vconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 's' }], + }; + const next = wrapViews(spec, ['hconcat', 0, 'vconcat', 1], ['hconcat', 1], 'vertical', 'after'); + expect(next).toEqual({ vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 's' }] }); + }); +}); + +describe('wrapViews — collapse the source container', () => { + it('unwraps a single-child container left behind by the move', () => { + const spec = { vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }] }; + // Drag B out to sit below C (a with-axis drop → reorder via flatten), leaving the + // hconcat with one child, which collapses to a bare unit. + const next = wrapViews(spec, ['vconcat', 1], ['vconcat', 0, 'hconcat', 1], 'vertical', 'after'); + expect(next).toEqual({ vconcat: [{ mark: 'a' }, { mark: 'c' }, { mark: 'b' }] }); + }); +}); + +describe('wrapViews — data pinning', () => { + it('pins the source’s inherited data when it would otherwise rebind', () => { + const spec = { + data: { name: 'root' }, + vconcat: [{ mark: 'a' }, { data: { name: 'd2' }, hconcat: [{ mark: 'b' }, { mark: 'c' }] }], + }; + const next = wrapViews( + spec, + ['vconcat', 0], + ['vconcat', 1, 'hconcat', 0], + 'horizontal', + 'after', + ); + expect(next).toEqual({ + data: { name: 'root' }, + vconcat: [ + { hconcat: [{ mark: 'a' }, { data: { name: 'd2' }, mark: 'b' }] }, + { data: { name: 'd2' }, mark: 'c' }, + ], + }); + }); + + it('adds no data key when the source already inherits the destination data', () => { + const spec = { data: { name: 'd' }, vconcat: [{ mark: 'a' }, { mark: 'b' }, { mark: 'c' }] }; + const next = wrapViews(spec, ['vconcat', 0], ['vconcat', 2], 'horizontal', 'after'); + // C had no data and the whole spec shares one source, so nothing is pinned. + expect(JSON.stringify(next)).not.toContain('"data":{"name":"d"},{"mark":"c"'); + expect(next).toEqual({ + data: { name: 'd' }, + vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'c' }] }, { mark: 'b' }], + }); + }); +}); + +describe('wrapViews — degenerate drops', () => { + const spec = { vconcat: [{ hconcat: [{ mark: 'a' }, { mark: 'b' }] }, { mark: 'c' }] }; + + it('returns null for a drop onto itself', () => { + expect(wrapViews(spec, ['vconcat', 1], ['vconcat', 1], 'horizontal', 'after')).toBeNull(); + }); + + it('returns null when the source is an ancestor of the target', () => { + expect( + wrapViews(spec, ['vconcat', 0, 'hconcat', 1], ['vconcat', 0], 'horizontal', 'after'), + ).toBeNull(); + }); + + it('returns null when the target is a descendant of the source', () => { + expect( + wrapViews(spec, ['vconcat', 0], ['vconcat', 0, 'hconcat', 0], 'vertical', 'after'), + ).toBeNull(); + }); + + it('returns null when wrapping the root', () => { + expect(wrapViews(spec, [], ['vconcat', 1], 'horizontal', 'after')).toBeNull(); + }); +}); diff --git a/src/core/spec-restructure.ts b/src/core/spec-restructure.ts new file mode 100644 index 0000000..0deedcd --- /dev/null +++ b/src/core/spec-restructure.ts @@ -0,0 +1,186 @@ +/** + * Path-targeted structural restructuring (docs/architecture/08 → editor + * augmentation) — the cross-container moves behind the composition wireframe's + * drag-to-restructure, the complement to spec-insert (single-array reorder within + * one container) and spec-transforms (whole-spec wrap/unwrap). + * + * One operation does the work: `wrapViews(target, source, axis, side)` pairs the + * dragged `source` view beside the drop `target` in a new `hconcat`/`vconcat`, + * placed where the target was, and removes the source from where it came. Three + * correctness rules ride along (the traps that make this the hard part): + * + * - **Flatten** — a bare same-orientation concat nested directly in a concat is + * redundant, so wrapping a view beside a sibling already in a matching-axis + * concat reads as a plain *insert*, not a nested box. + * - **Collapse** — removing the source can leave its old container with one child + * (unwrap it) or none (drop it, recursing upward) — `unwrapSingleton`'s job, here + * along an arbitrary path. + * - **Data-pin** — a source with no own `data` inherits its ancestor's; moved under + * a different one it would silently rebind, so its effective data is pinned on. + * + * Degenerate drops (onto itself, into its own descendant or its own ancestor, or + * wrapping the root) return null. Portable core; JSON text/format/undo is the + * editor's job, as for every transform here. + */ + +import { dataBindingAtPath } from './spec-data'; +import { isJsonObject, type JsonObject } from './spec-config'; +import { arrayAtPath, isPrefixPath, valueAtPath, type SpecPath } from './spec-insert'; + +/** The orientation of the concat a drop creates. */ +export type DropAxis = 'horizontal' | 'vertical'; + +/** Deep clone via JSON — specs are plain JSON, as everywhere in the transforms. */ +const clone = (spec: JsonObject): JsonObject => JSON.parse(JSON.stringify(spec)) as JsonObject; + +/** The concat operators a drop flattens through (layers are z-order, never flattened). */ +const CONCAT_OPS = ['hconcat', 'vconcat', 'concat'] as const; + +/** + * The data block a sibling of the view at `targetPath` would inherit — the context + * the new concat sits in, which is the binding in scope at the target's *parent* + * (so the target's own `data`, if any, doesn't count: a sibling doesn't inherit it). + */ +function contextDataFor(spec: JsonObject, targetPath: SpecPath): unknown { + return dataBindingAtPath(spec, targetPath.slice(0, -1))?.data; +} + +/** + * Pin the source's effective data onto it before the move, so it keeps the same + * data in its new home. No-op when the source declares its own `data` (unaffected), + * has no effective data to preserve, or already inherits the destination's data. + */ +function pinData( + spec: JsonObject, + source: JsonObject, + sourcePath: SpecPath, + targetPath: SpecPath, +): void { + if ('data' in source) return; + const current = dataBindingAtPath(spec, sourcePath); + if (!current) return; + const destData = contextDataFor(spec, targetPath); + if (JSON.stringify(destData) !== JSON.stringify(current.data)) source.data = current.data; +} + +/** + * Remove the array element at `path` (its last segment an index), then collapse the + * container it left behind: one child remaining → unwrap it onto the wrapper (the + * child wins on conflict); none remaining → drop the empty composition key and, if + * that empties its wrapper too, remove the wrapper from its parent array and repeat + * upward. Mutates `spec`. + */ +function removeAndCollapse(spec: JsonObject, path: SpecPath): void { + let arrPath = path.slice(0, -1); + const idx = path[path.length - 1]; + const arr = arrayAtPath(spec, arrPath); + if (!arr || typeof idx !== 'number') return; + arr.splice(idx, 1); + + // Walk up collapsing redundant containers; only the splice above reduces a count, + // and an unwrap preserves the parent's count, so each step is independent. + while (arrPath.length >= 1) { + const current = arrayAtPath(spec, arrPath); + if (!current) break; + const opKey = arrPath[arrPath.length - 1] as string; + const wrapper = valueAtPath(spec, arrPath.slice(0, -1)); + if (!isJsonObject(wrapper)) break; + + if (current.length >= 2) break; + if (current.length === 1) { + const child = current[0]; + delete wrapper[opKey]; + if (isJsonObject(child)) Object.assign(wrapper, child); // child wins, like unwrapSingleton + break; // count preserved upward — nothing more to collapse + } + // Empty composition: drop the key; if the wrapper is now an empty array element, + // remove it from its parent array and continue collapsing that array. + delete wrapper[opKey]; + const wrapperPath = arrPath.slice(0, -1); + const wIdx = wrapperPath[wrapperPath.length - 1]; + if (Object.keys(wrapper).length === 0 && typeof wIdx === 'number') { + const parentArr = arrayAtPath(spec, wrapperPath.slice(0, -1)); + if (!parentArr) break; + parentArr.splice(wIdx, 1); + arrPath = wrapperPath.slice(0, -1); + continue; + } + break; + } +} + +/** Flatten any bare same-orientation concat nested directly in a concat, in place. */ +function flattenBareConcats(node: unknown): void { + if (Array.isArray(node)) { + for (const item of node) flattenBareConcats(item); + return; + } + if (!isJsonObject(node)) return; + for (const op of CONCAT_OPS) { + const arr = node[op]; + if (!Array.isArray(arr)) continue; + const out: unknown[] = []; + for (const el of arr) { + flattenBareConcats(el); + if ( + isJsonObject(el) && + Object.keys(el).length === 1 && + Array.isArray(el[op]) // a bare concat of the same orientation + ) { + out.push(...(el[op] as unknown[])); + } else { + out.push(el); + } + } + node[op] = out; + } + for (const key of Object.keys(node)) { + if (!CONCAT_OPS.includes(key as (typeof CONCAT_OPS)[number])) flattenBareConcats(node[key]); + } +} + +/** + * Pair the `source` view beside the `target` view in a new concat of `axis`, in + * the slot the target held, removing the source from where it was. `side` places + * the source `before` or `after` the target. Returns a new spec, or null for a + * degenerate drop (source is the target, an ancestor or descendant of it, or the + * target is the root). The input is not mutated. + * + * Because a bare same-orientation concat is flattened afterward, dropping a view + * onto an edge that runs *with* the target's existing concat reads as inserting it + * into that concat; an edge that runs *across* it nests a new concat — the one + * operation behind both the wireframe's "move into" and "wrap" drops. + */ +export function wrapViews( + spec: JsonObject, + targetPath: SpecPath, + sourcePath: SpecPath, + axis: DropAxis, + side: 'before' | 'after', +): JsonObject | null { + if (targetPath.length === 0) return null; // can't wrap the root + // Self, ancestor, or descendant drops are degenerate. + if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null; + + const next = clone(spec); + const target = valueAtPath(next, targetPath); + const source = valueAtPath(next, sourcePath); + if (!isJsonObject(target) || !isJsonObject(source)) return null; + + // Preserve the source's data before it changes context (reads the unmutated tree). + pinData(next, source, sourcePath, targetPath); + + // Replace the target's slot with the new concat (by reference, so no index shifts). + const op = axis === 'horizontal' ? 'hconcat' : 'vconcat'; + const wrapper: JsonObject = { [op]: side === 'before' ? [source, target] : [target, source] }; + const tParent = valueAtPath(next, targetPath.slice(0, -1)); + const tKey = targetPath[targetPath.length - 1]; + if (Array.isArray(tParent)) tParent[tKey as number] = wrapper; + else if (isJsonObject(tParent)) tParent[tKey as string] = wrapper; + else return null; + + // Remove the source from its old location (+ collapse), then flatten redundant nesting. + removeAndCollapse(next, sourcePath); + flattenBareConcats(next); + return next; +}