mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: composition structure wireframe (read-only)
This commit is contained in:
@@ -299,6 +299,11 @@ thin app-layer services.
|
|||||||
- `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view
|
- `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view
|
||||||
at an index (`insertView`) and reordering siblings (`moveView`), plus `elementOffset` to
|
at an index (`insertView`) and reordering siblings (`moveView`), plus `elementOffset` to
|
||||||
re-find a view after the edit.
|
re-find a view after the edit.
|
||||||
|
- `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).
|
||||||
|
|
||||||
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
|
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
|
||||||
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
|
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ Carbon and drawn `fill: currentColor`.
|
|||||||
**Core set** — recurring, cross-surface:
|
**Core set** — recurring, cross-surface:
|
||||||
|
|
||||||
| Meaning | Carbon glyph | Form | Surfaces |
|
| Meaning | Carbon glyph | Form | Surfaces |
|
||||||
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
| --------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
||||||
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
||||||
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
||||||
@@ -343,6 +343,7 @@ Carbon and drawn `fill: currentColor`.
|
|||||||
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
|
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
|
||||||
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
|
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
|
||||||
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
|
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
|
||||||
|
| Composition structure | (custom frame) | icon-only ⭐ | Preview toolbar — composition-wireframe disclosure (a frame holding nested view blocks) |
|
||||||
|
|
||||||
**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
|
**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
|
||||||
panel frame with one of three regions filled, where the filled bar's _position_
|
panel frame with one of three regions filled, where the filled bar's _position_
|
||||||
@@ -379,7 +380,8 @@ the `Icon` registry**:
|
|||||||
|
|
||||||
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
|
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
|
||||||
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance
|
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance
|
||||||
glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning);
|
glyphs `settings` (gear) and `structure` (the composition-wireframe frame) and the
|
||||||
|
**pane-toggle** trio (position is the meaning);
|
||||||
and **delete** as a deliberate destructive-row exception — a dense, repeated list
|
and **delete** as a deliberate destructive-row exception — a dense, repeated list
|
||||||
action where a label would cost more than it gives. `search` is _not_ ⭐: its
|
action where a label would cost more than it gives. `search` is _not_ ⭐: its
|
||||||
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
|
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
|
||||||
|
|||||||
@@ -221,6 +221,19 @@ below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row t
|
|||||||
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
||||||
cite it, not the APG file.)_
|
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
|
||||||
|
**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).)_
|
||||||
|
|
||||||
**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA
|
**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
|
APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as
|
||||||
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
|
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
.wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disclosure panel — portaled to body, positioned `fixed` by usePopover. The shared
|
||||||
|
disclosure-popover surface (arch 10): elevated --layer-01, hairline border, --radius. */
|
||||||
|
.pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 264px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--layer-01);
|
||||||
|
border: var(--border-width) solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bare wireframe: a crisp box, fill only the canvas. Passive structure, so the
|
||||||
|
boxes carry the function (drop targets, later) — `--border-strong`, no chrome. */
|
||||||
|
.node {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
padding: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaf {
|
||||||
|
min-height: 48px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.node:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
/* Selection mirrors the library's active-row language (arch 09 §4). */
|
||||||
|
.node.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
.leaf.selected {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.node:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.row > * {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
/* General concat wraps into a grid (honors `columns`). */
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
}
|
||||||
|
.grid > * {
|
||||||
|
flex: 1 1 84px;
|
||||||
|
}
|
||||||
|
/* Layer: children share one plotting area — overlap them in a single grid cell so
|
||||||
|
the stack reads as depth, not as siblings. */
|
||||||
|
.layered {
|
||||||
|
display: grid;
|
||||||
|
padding: 0 14px 14px 0;
|
||||||
|
}
|
||||||
|
.layered > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
.layered > *:nth-child(2) {
|
||||||
|
transform: translate(7px, 7px);
|
||||||
|
}
|
||||||
|
.layered > *:nth-child(3) {
|
||||||
|
transform: translate(14px, 14px);
|
||||||
|
}
|
||||||
|
.layered > *:nth-child(n + 4) {
|
||||||
|
transform: translate(21px, 21px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Facet / repeat: one authored child stands for many generated cells — a card
|
||||||
|
peeking out behind hints at the multiples. */
|
||||||
|
.generated {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.generated::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 5px -5px -5px 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
min-height: 1.4em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.caption code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* CompositionWireframe — the read-only structure tree (arch 08 / arch 10 §5).
|
||||||
|
*
|
||||||
|
* Guards the load-bearing behavior: the affordance is hidden for a single-view
|
||||||
|
* spec (nothing to schematize), the disclosed panel is an APG tree with one
|
||||||
|
* roving tab stop, arrow keys walk it in document order, and activating a box
|
||||||
|
* asks the editor to reveal that view's exact source range. The viewTree model
|
||||||
|
* itself is covered in core (spec-view-tree.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } 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';
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2);
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const setSpec = (text: string) => {
|
||||||
|
useSnippetStore.getState().reset();
|
||||||
|
useSnippetStore.setState({ draftText: text });
|
||||||
|
};
|
||||||
|
|
||||||
|
const treeItems = () =>
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
||||||
|
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;
|
||||||
|
|
||||||
|
async function renderOpen() {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<CompositionWireframe />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
usePopoverStore.getState().show('composition-wireframe');
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usePopoverStore.setState({ openId: null });
|
||||||
|
useAppStore.setState({ revealTarget: null });
|
||||||
|
setSpec(COMPOSED);
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
usePopoverStore.setState({ openId: null });
|
||||||
|
useAppStore.setState({ revealTarget: null });
|
||||||
|
useSnippetStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CompositionWireframe', () => {
|
||||||
|
test('renders nothing for a single-view spec', () => {
|
||||||
|
setSpec('{"mark":"point"}');
|
||||||
|
act(() => root.render(<CompositionWireframe />));
|
||||||
|
expect(container.querySelector('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows a structure trigger for a composed spec', () => {
|
||||||
|
act(() => root.render(<CompositionWireframe />));
|
||||||
|
expect(container.querySelector('button[aria-controls="composition-wireframe"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discloses an APG tree: the vconcat root and its two leaf views', async () => {
|
||||||
|
await renderOpen();
|
||||||
|
expect(document.body.querySelector('[role="tree"]')).not.toBeNull();
|
||||||
|
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'vconcat|0', 'vconcat|1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roving tabindex: exactly one treeitem is in the tab order', async () => {
|
||||||
|
await renderOpen();
|
||||||
|
expect(treeItems().filter((el) => el.tabIndex === 0)).toHaveLength(1);
|
||||||
|
expect(item('root').tabIndex).toBe(0); // the root holds it on open
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowDown moves the roving tab stop in document order', async () => {
|
||||||
|
await renderOpen();
|
||||||
|
act(() => {
|
||||||
|
item('root').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(item('root').tabIndex).toBe(-1);
|
||||||
|
expect(item('vconcat|0').tabIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activating a box asks the editor to reveal that view’s source range', async () => {
|
||||||
|
await renderOpen();
|
||||||
|
act(() => item('vconcat|1').click());
|
||||||
|
const target = useAppStore.getState().revealTarget!;
|
||||||
|
expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* (`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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { SpecPath } from '@core/spec-insert';
|
||||||
|
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 styles from './CompositionWireframe.module.css';
|
||||||
|
|
||||||
|
const POPOVER_ID = 'composition-wireframe';
|
||||||
|
const INITIAL_FOCUS = ['[role="treeitem"]'] as const;
|
||||||
|
|
||||||
|
/** A DOM-safe, unique key for a node from its path. */
|
||||||
|
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
|
||||||
|
|
||||||
|
/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
|
||||||
|
function pathLabel(path: SpecPath): string {
|
||||||
|
if (path.length === 0) return 'root';
|
||||||
|
let out = '';
|
||||||
|
for (const seg of path) out += typeof seg === 'number' ? `[${seg}]` : out ? `.${seg}` : seg;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = (n: ViewNode): string =>
|
||||||
|
n.kind === 'unit' ? (n.mark ? `${n.mark} view` : 'view') : `${n.op} · ${n.children.length} views`;
|
||||||
|
|
||||||
|
const ariaLabelOf = (n: ViewNode): string =>
|
||||||
|
n.kind === 'unit'
|
||||||
|
? n.mark
|
||||||
|
? `${n.mark} view`
|
||||||
|
: 'view'
|
||||||
|
: `${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.
|
||||||
|
|
||||||
|
/** Children layout class per orientation (layered overlaps in one grid cell). */
|
||||||
|
const LAYOUT: Record<Orientation, string> = {
|
||||||
|
horizontal: styles.row,
|
||||||
|
vertical: styles.col,
|
||||||
|
grid: styles.grid,
|
||||||
|
layered: styles.layered,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Flat {
|
||||||
|
node: ViewNode;
|
||||||
|
key: string;
|
||||||
|
parentKey: string | 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 key = keyOf(n.path);
|
||||||
|
out.push({ node: n, key, parentKey });
|
||||||
|
for (const c of n.children) walk(c, key);
|
||||||
|
};
|
||||||
|
walk(root, null);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||||
|
const requestRevealView = useAppStore((s) => s.requestRevealView);
|
||||||
|
const flat = useMemo(() => flatten(tree), [tree]);
|
||||||
|
const rootKey = keyOf(tree.path);
|
||||||
|
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||||
|
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
||||||
|
const effectiveFocus =
|
||||||
|
(focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey;
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
(n: ViewNode) => {
|
||||||
|
setSelectedKey(keyOf(n.path));
|
||||||
|
setFocusedKey(keyOf(n.path));
|
||||||
|
requestRevealView(n.offset, n.length);
|
||||||
|
},
|
||||||
|
[requestRevealView],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const i = flat.findIndex((f) => f.key === effectiveFocus);
|
||||||
|
if (i < 0) return;
|
||||||
|
const moveTo = (j: number) => {
|
||||||
|
const target = flat[j];
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedKey(target.key);
|
||||||
|
e.currentTarget.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
|
||||||
|
};
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
return moveTo(i + 1);
|
||||||
|
case 'ArrowUp':
|
||||||
|
return moveTo(i - 1);
|
||||||
|
case 'Home':
|
||||||
|
return moveTo(0);
|
||||||
|
case 'End':
|
||||||
|
return moveTo(flat.length - 1);
|
||||||
|
case 'ArrowRight': // first child is the next node in pre-order
|
||||||
|
return flat[i].node.children.length ? moveTo(i + 1) : undefined;
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
const pk = flat[i].parentKey;
|
||||||
|
return pk ? moveTo(flat.findIndex((f) => f.key === pk)) : undefined;
|
||||||
|
}
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
return select(flat[i].node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNode = (n: ViewNode): ReactNode => {
|
||||||
|
const key = keyOf(n.path);
|
||||||
|
const container = n.kind === 'composition';
|
||||||
|
const generated = n.op === 'facet' || n.op === 'repeat';
|
||||||
|
const cls = [
|
||||||
|
styles.node,
|
||||||
|
container ? styles.container : styles.leaf,
|
||||||
|
key === selectedKey ? styles.selected : '',
|
||||||
|
generated ? styles.generated : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
data-key={key}
|
||||||
|
role="treeitem"
|
||||||
|
aria-label={ariaLabelOf(n)}
|
||||||
|
aria-selected={key === selectedKey}
|
||||||
|
aria-expanded={container ? true : undefined}
|
||||||
|
tabIndex={key === effectiveFocus ? 0 : -1}
|
||||||
|
className={cls}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
select(n);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setHoverKey(key);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoverKey(null)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFocusedKey(key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{container && (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
|
||||||
|
>
|
||||||
|
{n.children.map(renderNode)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const captionNode =
|
||||||
|
(hoverKey ?? selectedKey) ? flat.find((f) => f.key === (hoverKey ?? selectedKey))?.node : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div role="tree" aria-label="View composition" className={styles.tree} onKeyDown={onKeyDown}>
|
||||||
|
{renderNode(tree)}
|
||||||
|
</div>
|
||||||
|
<p className={styles.caption} aria-hidden="true">
|
||||||
|
{captionNode ? (
|
||||||
|
<>
|
||||||
|
<code>{pathLabel(captionNode.path)}</code> — {descriptor(captionNode)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={styles.muted}>Hover a block to identify it · click to reveal it</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompositionWireframe() {
|
||||||
|
const { open, toggle, close, triggerRef, setPopNode } = usePopover({
|
||||||
|
id: POPOVER_ID,
|
||||||
|
align: 'right',
|
||||||
|
flip: true,
|
||||||
|
initialFocus: INITIAL_FOCUS,
|
||||||
|
});
|
||||||
|
const shownText = useSnippetStore(selectShownText);
|
||||||
|
const tree = useMemo(() => viewTree(shownText), [shownText]);
|
||||||
|
const hasComposition = !!tree && tree.kind === 'composition';
|
||||||
|
|
||||||
|
// The wireframe is meaningless for a single-view spec — hide the affordance, and
|
||||||
|
// close it if it was open when the composition is unwrapped away.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasComposition && open) close();
|
||||||
|
}, [hasComposition, open, close]);
|
||||||
|
|
||||||
|
if (!tree || tree.kind !== 'composition') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
label="View composition structure"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={POPOVER_ID}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<Icon name="structure" />
|
||||||
|
</IconButton>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={setPopNode}
|
||||||
|
id={POPOVER_ID}
|
||||||
|
className={styles.pop}
|
||||||
|
role="group"
|
||||||
|
aria-label="Composition structure"
|
||||||
|
>
|
||||||
|
<h4 className={styles.title}>Structure</h4>
|
||||||
|
<WireframeTree tree={tree} />
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export type IconName =
|
|||||||
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
|
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
|
||||||
| 'info' // about / information — Carbon Information (outline)
|
| 'info' // about / information — Carbon Information (outline)
|
||||||
| 'revert' // revert draft to last published — Carbon Reset
|
| 'revert' // revert draft to last published — Carbon Reset
|
||||||
|
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
|
||||||
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
|
// 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).
|
// glyph shows *which* pane it controls by position (left / centre / right).
|
||||||
| 'pane-library' // toggle the library pane (left)
|
| 'pane-library' // toggle the library pane (left)
|
||||||
@@ -150,6 +151,19 @@ const GLYPHS: Record<IconName, ReactNode> = {
|
|||||||
<rect x="21" y="10" width="4" height="12" />
|
<rect x="21" y="10" width="4" height="12" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
// Composition structure: a panel frame (matching the pane family) holding nested
|
||||||
|
// view blocks — two side by side over one wide — the wireframe in miniature.
|
||||||
|
structure: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="6" width="24" height="2" />
|
||||||
|
<rect x="4" y="24" width="24" height="2" />
|
||||||
|
<rect x="4" y="6" width="2" height="20" />
|
||||||
|
<rect x="26" y="6" width="2" height="20" />
|
||||||
|
<rect x="9" y="10" width="6" height="5" />
|
||||||
|
<rect x="17" y="10" width="6" height="5" />
|
||||||
|
<rect x="9" y="17" width="14" height="5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
moon: (
|
moon: (
|
||||||
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
|
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { usePreviewStore } from '../stores/PreviewStore';
|
|||||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { ChartExport } from './ChartExport';
|
import { ChartExport } from './ChartExport';
|
||||||
|
import { CompositionWireframe } from './CompositionWireframe';
|
||||||
import { DataInspector } from './DataInspector';
|
import { DataInspector } from './DataInspector';
|
||||||
import { InspectorSplitHandle } from './InspectorSplitHandle';
|
import { InspectorSplitHandle } from './InspectorSplitHandle';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
@@ -451,10 +452,12 @@ export function LivePreview() {
|
|||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<FitControl />
|
<FitControl />
|
||||||
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
|
{/* Right cluster: chart theme, export this chart, the structure wireframe,
|
||||||
|
then the settings gear. */}
|
||||||
<div className={styles.headerEnd}>
|
<div className={styles.headerEnd}>
|
||||||
<ChartThemeControl />
|
<ChartThemeControl />
|
||||||
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||||
|
<CompositionWireframe />
|
||||||
<PreviewSettings />
|
<PreviewSettings />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ export function SpecEditor() {
|
|||||||
const editorView = useSnippetStore((s) => s.editorView);
|
const editorView = useSnippetStore((s) => s.editorView);
|
||||||
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
|
const revealTarget = useAppStore((s) => s.revealTarget);
|
||||||
const error = usePreviewStore((s) => s.error);
|
const error = usePreviewStore((s) => s.error);
|
||||||
// Editor preferences (spec §07 → Editor); applied live below as they change.
|
// Editor preferences (spec §07 → Editor); applied live below as they change.
|
||||||
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
|
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
|
||||||
@@ -495,6 +496,23 @@ export function SpecEditor() {
|
|||||||
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
|
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
|
||||||
}, [uiTheme, editorPrefs.theme]);
|
}, [uiTheme, editorPrefs.theme]);
|
||||||
|
|
||||||
|
// Select + reveal a view's source range when the composition wireframe asks
|
||||||
|
// (arch 08 → composition wireframe). The nonce makes a repeat request re-fire.
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor || !revealTarget) return;
|
||||||
|
const model = editor.getModel();
|
||||||
|
if (!model) return;
|
||||||
|
const range = monaco.Range.fromPositions(
|
||||||
|
model.getPositionAt(revealTarget.offset),
|
||||||
|
model.getPositionAt(revealTarget.offset + revealTarget.length),
|
||||||
|
);
|
||||||
|
editor.setSelection(range);
|
||||||
|
editor.revealRangeInCenterIfOutsideViewport(range);
|
||||||
|
// Deliberately no focus(): the wireframe stays the active surface so the user
|
||||||
|
// can keep browsing blocks while the editor scrolls/selects to follow.
|
||||||
|
}, [revealTarget]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.editorPane}>
|
<div className={styles.editorPane}>
|
||||||
<EditorToolbar editorRef={editorRef} />
|
<EditorToolbar editorRef={editorRef} />
|
||||||
|
|||||||
@@ -42,3 +42,19 @@ describe('data inspector height (divider math)', () => {
|
|||||||
expect(inspectorHeightValue(96, 150)).toBeNull();
|
expect(inspectorHeightValue(96, 150)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('requestRevealView (wireframe → editor)', () => {
|
||||||
|
beforeEach(() => useAppStore.setState({ revealTarget: null }));
|
||||||
|
|
||||||
|
test('carries the requested range', () => {
|
||||||
|
store().requestRevealView(40, 12);
|
||||||
|
expect(store().revealTarget).toMatchObject({ offset: 40, length: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bumps the nonce on a repeat request for the same range, so it re-fires', () => {
|
||||||
|
store().requestRevealView(40, 12);
|
||||||
|
const first = store().revealTarget!.nonce;
|
||||||
|
store().requestRevealView(40, 12);
|
||||||
|
expect(store().revealTarget!.nonce).toBe(first + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export interface AppState {
|
|||||||
dataInspectorHeight: number;
|
dataInspectorHeight: number;
|
||||||
/** The currently open modal, or null. */
|
/** The currently open modal, or null. */
|
||||||
activeModal: ModalName | null;
|
activeModal: ModalName | null;
|
||||||
|
/**
|
||||||
|
* A request from the composition wireframe to select and reveal a view's source
|
||||||
|
* range in the editor (wireframe → editor; arch 08 → editor augmentation). The
|
||||||
|
* nonce makes a repeat request for the same range re-fire. Null until the first.
|
||||||
|
*/
|
||||||
|
revealTarget: { offset: number; length: number; nonce: number } | null;
|
||||||
|
|
||||||
setTheme: (theme: UiTheme) => void;
|
setTheme: (theme: UiTheme) => void;
|
||||||
/** Flip between light and dark — the header ThemeToggle's action. */
|
/** Flip between light and dark — the header ThemeToggle's action. */
|
||||||
@@ -84,6 +90,8 @@ export interface AppState {
|
|||||||
* which calls this; arrives with the modal system in M3.
|
* which calls this; arrives with the modal system in M3.
|
||||||
*/
|
*/
|
||||||
setActiveModal: (modal: ModalName | null) => void;
|
setActiveModal: (modal: ModalName | null) => void;
|
||||||
|
/** Ask the editor to select + reveal a view's source range (composition wireframe). */
|
||||||
|
requestRevealView: (offset: number, length: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
@@ -93,6 +101,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
dataInspectorOpen: false,
|
dataInspectorOpen: false,
|
||||||
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
|
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
|
revealTarget: null,
|
||||||
|
|
||||||
setTheme: (uiTheme) => set({ uiTheme }),
|
setTheme: (uiTheme) => set({ uiTheme }),
|
||||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||||
@@ -101,4 +110,6 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
|
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
|
||||||
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
|
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
|
||||||
setActiveModal: (activeModal) => set({ activeModal }),
|
setActiveModal: (activeModal) => set({ activeModal }),
|
||||||
|
requestRevealView: (offset, length) =>
|
||||||
|
set((s) => ({ revealTarget: { offset, length, nonce: (s.revealTarget?.nonce ?? 0) + 1 } })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { viewTree, type ViewNode } from './spec-view-tree';
|
||||||
|
|
||||||
|
const json = (o: unknown) => JSON.stringify(o, null, 2);
|
||||||
|
|
||||||
|
describe('viewTree', () => {
|
||||||
|
it('returns a single unit node for a flat spec, with its mark', () => {
|
||||||
|
const t = viewTree(json({ mark: 'bar', encoding: {} }))!;
|
||||||
|
expect(t.kind).toBe('unit');
|
||||||
|
expect(t.mark).toBe('bar');
|
||||||
|
expect(t.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads mark from an object mark (mark.type)', () => {
|
||||||
|
const t = viewTree(json({ mark: { type: 'line', point: true } }))!;
|
||||||
|
expect(t.mark).toBe('line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds an array composition with per-element paths and orientation', () => {
|
||||||
|
const t = viewTree(json({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }))!;
|
||||||
|
expect(t.kind).toBe('composition');
|
||||||
|
expect(t.op).toBe('vconcat');
|
||||||
|
expect(t.orientation).toBe('vertical');
|
||||||
|
expect(t.children.map((c) => c.path)).toEqual([
|
||||||
|
['vconcat', 0],
|
||||||
|
['vconcat', 1],
|
||||||
|
]);
|
||||||
|
expect(t.children.map((c) => c.mark)).toEqual(['point', 'bar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recurses into nested compositions', () => {
|
||||||
|
const t = viewTree(
|
||||||
|
json({ vconcat: [{ mark: 'point' }, { hconcat: [{ mark: 'bar' }, { mark: 'line' }] }] }),
|
||||||
|
)!;
|
||||||
|
const nested = t.children[1];
|
||||||
|
expect(nested.kind).toBe('composition');
|
||||||
|
expect(nested.op).toBe('hconcat');
|
||||||
|
expect(nested.orientation).toBe('horizontal');
|
||||||
|
expect(nested.children.map((c) => c.path)).toEqual([
|
||||||
|
['vconcat', 1, 'hconcat', 0],
|
||||||
|
['vconcat', 1, 'hconcat', 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a layer as layered', () => {
|
||||||
|
const t = viewTree(json({ layer: [{ mark: 'area' }, { mark: 'line' }] }))!;
|
||||||
|
expect(t.op).toBe('layer');
|
||||||
|
expect(t.orientation).toBe('layered');
|
||||||
|
expect(t.children).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats facet/repeat as a composition with one `spec` child', () => {
|
||||||
|
const facet = viewTree(
|
||||||
|
json({ facet: { field: 'c', type: 'nominal' }, spec: { mark: 'bar' } }),
|
||||||
|
)!;
|
||||||
|
expect(facet.op).toBe('facet');
|
||||||
|
expect(facet.children).toHaveLength(1);
|
||||||
|
expect(facet.children[0].path).toEqual(['spec']);
|
||||||
|
expect(facet.children[0].mark).toBe('bar');
|
||||||
|
|
||||||
|
const repeat = viewTree(json({ repeat: ['a', 'b'], spec: { mark: 'point' } }))!;
|
||||||
|
expect(repeat.op).toBe('repeat');
|
||||||
|
expect(repeat.children[0].path).toEqual(['spec']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes byte ranges that point at each node object', () => {
|
||||||
|
const text = json({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] });
|
||||||
|
const t = viewTree(text)!;
|
||||||
|
const child = t.children[1];
|
||||||
|
expect(text[child.offset]).toBe('{'); // the element object's opening brace
|
||||||
|
expect(text.slice(child.offset, child.offset + child.length)).toContain('"bar"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty or non-object input', () => {
|
||||||
|
expect(viewTree('')).toBeNull();
|
||||||
|
expect(viewTree('[1, 2]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is tolerant of a mid-edit trailing comma', () => {
|
||||||
|
const t = viewTree('{ "vconcat": [ { "mark": "point" }, ] }');
|
||||||
|
expect(t?.op).toBe('vconcat');
|
||||||
|
expect((t as ViewNode).children.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Spec → view tree (docs/architecture/08 → editor augmentation). Builds the
|
||||||
|
* recursive structure of a Vega-Lite spec's composition — the model behind the
|
||||||
|
* composition wireframe: nested boxes for `layer`/`hconcat`/`vconcat`/`concat`
|
||||||
|
* (array children) and `facet`/`repeat` (a single generated `spec` child), down to
|
||||||
|
* the unit views at the leaves.
|
||||||
|
*
|
||||||
|
* Portable core. Built over `jsonc-parser`'s error-tolerant tree (so it keeps
|
||||||
|
* working while the draft is briefly unparseable mid-edit), and each node carries
|
||||||
|
* its byte range so a surface can map a box to the editor selection and back. The
|
||||||
|
* complement to `spec-insert` (which mutates one composition array): this reads the
|
||||||
|
* whole tree at once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { findNodeAtLocation, parseTree, type Node } from 'jsonc-parser';
|
||||||
|
import type { SpecPath } from './spec-insert';
|
||||||
|
import { ARRAY_COMPOSITIONS } from './spec-transforms';
|
||||||
|
|
||||||
|
/** A composition operator. Array ops hold a child array; facet/repeat a `spec`. */
|
||||||
|
type CompositionOp = 'layer' | 'hconcat' | 'vconcat' | 'concat' | 'facet' | 'repeat';
|
||||||
|
|
||||||
|
/** How a composition lays its children out (a hint for the wireframe renderer). */
|
||||||
|
export type Orientation = 'horizontal' | 'vertical' | 'layered' | 'grid';
|
||||||
|
|
||||||
|
/** A node in the view tree: a composition (with children) or a leaf unit view. */
|
||||||
|
export interface ViewNode {
|
||||||
|
kind: 'composition' | 'unit';
|
||||||
|
/** The operator, for a composition node. */
|
||||||
|
op?: CompositionOp;
|
||||||
|
/** Layout hint, for a composition node. */
|
||||||
|
orientation?: Orientation;
|
||||||
|
/** Path from the root to this node (object keys + array indices). */
|
||||||
|
path: SpecPath;
|
||||||
|
/** Byte range of the node's object in the source, for editor selection sync. */
|
||||||
|
offset: number;
|
||||||
|
length: number;
|
||||||
|
/** The mark type, for a unit node (string mark or `mark.type`); else undefined. */
|
||||||
|
mark?: string;
|
||||||
|
children: ViewNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORIENTATION: Record<CompositionOp, Orientation> = {
|
||||||
|
hconcat: 'horizontal',
|
||||||
|
vconcat: 'vertical',
|
||||||
|
concat: 'grid', // general concat wraps into a grid (honors `columns`)
|
||||||
|
layer: 'layered',
|
||||||
|
facet: 'grid',
|
||||||
|
repeat: 'grid',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Facet and repeat hold their single child view under `spec` (not an array). */
|
||||||
|
const SPEC_CHILD_OPS: readonly CompositionOp[] = ['facet', 'repeat'];
|
||||||
|
|
||||||
|
/** A unit's mark type: a string `mark`, or `mark.type`; undefined when absent. */
|
||||||
|
function markOf(node: Node): string | undefined {
|
||||||
|
const mark = findNodeAtLocation(node, ['mark']);
|
||||||
|
if (!mark) return undefined;
|
||||||
|
if (mark.type === 'string') return mark.value as string;
|
||||||
|
const type = findNodeAtLocation(mark, ['type']);
|
||||||
|
return typeof type?.value === 'string' ? type.value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNode(node: Node, path: SpecPath): ViewNode {
|
||||||
|
const base = { path, offset: node.offset, length: node.length };
|
||||||
|
// Array compositions: children are the array elements.
|
||||||
|
for (const op of ARRAY_COMPOSITIONS as CompositionOp[]) {
|
||||||
|
const arr = findNodeAtLocation(node, [op]);
|
||||||
|
if (arr?.type === 'array') {
|
||||||
|
return {
|
||||||
|
kind: 'composition',
|
||||||
|
op,
|
||||||
|
orientation: ORIENTATION[op],
|
||||||
|
...base,
|
||||||
|
children: (arr.children ?? []).map((el, i) => buildNode(el, [...path, op, i])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Facet / repeat: the single child view lives under `spec`.
|
||||||
|
for (const op of SPEC_CHILD_OPS) {
|
||||||
|
if (findNodeAtLocation(node, [op])) {
|
||||||
|
const spec = findNodeAtLocation(node, ['spec']);
|
||||||
|
return {
|
||||||
|
kind: 'composition',
|
||||||
|
op,
|
||||||
|
orientation: ORIENTATION[op],
|
||||||
|
...base,
|
||||||
|
children: spec ? [buildNode(spec, [...path, 'spec'])] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise a leaf unit view.
|
||||||
|
return { kind: 'unit', ...base, mark: markOf(node), children: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view tree for the spec `text`, or null when it has no object root (empty or
|
||||||
|
* unparseable beyond recovery). A flat unit spec yields a single `unit` node with
|
||||||
|
* no children — the caller treats that as "no composition to show".
|
||||||
|
*/
|
||||||
|
export function viewTree(text: string): ViewNode | null {
|
||||||
|
const root = parseTree(text);
|
||||||
|
if (!root || root.type !== 'object') return null;
|
||||||
|
return buildNode(root, []);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user