@unoverse-platform/studio 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -8,7 +8,7 @@ import { ComponentStore } from "@unoverse/sdk";
8
8
  import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
9
9
  import { client, authedFetch, hasSourceAccess } from "studio-host";
10
10
  import { SourceView } from "./SourceView";
11
- import { stateActivation } from "./states";
11
+ import { stateActivation, faceOf, switchLayers } from "./states";
12
12
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
13
13
 
14
14
  const CHAT_ID = "demo";
@@ -198,28 +198,11 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
198
198
  // keys it declares in `state` — that is what the toolbar is allowed to write.
199
199
  const subStatesOf = (faceName: string): { on: string; name: string }[] => {
200
200
  if (!current?.root || !hasFaces || faceName === "inline") return [];
201
- let face: unknown;
202
- const findFace = (n: any): void => {
203
- if (face || !n || typeof n !== "object") return;
204
- if (Array.isArray(n)) { n.forEach(findFace); return; }
205
- if (n.type === "Switch" && n.on === "defaultState" && n.cases?.[faceName]) { face = n.cases[faceName]; return; }
206
- Object.values(n).forEach(findFace);
207
- };
208
- findFace(current.root);
209
201
  // The component's OWN state keys — everything else in the subtree belongs to an
210
- // inlined atom or shared component, or is plain data.
202
+ // inlined atom or shared component, or is plain data. The derivation itself is
203
+ // the SHARED layer resolver (states.ts, UNOVERSE_LAYERS.md §3c).
211
204
  const ownKeys = new Set(Object.keys((current.state ?? {}) as Record<string, unknown>));
212
- const out: { on: string; name: string }[] = [];
213
- const walk = (n: any): void => {
214
- if (!n || typeof n !== "object") return;
215
- if (Array.isArray(n)) { n.forEach(walk); return; }
216
- if (n.type === "Switch" && typeof n.on === "string" && n.on !== "defaultState" && ownKeys.has(n.on) && n.cases && typeof n.cases === "object") {
217
- for (const k of Object.keys(n.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: n.on, name: k });
218
- }
219
- Object.values(n).forEach(walk);
220
- };
221
- walk(face);
222
- return out;
205
+ return switchLayers(faceOf(current.root, faceName), ownKeys);
223
206
  };
224
207
  const subStates = subStatesOf(masterState);
225
208
 
@@ -1,4 +1,16 @@
1
1
  /**
2
+ * ============================================================================
3
+ * ⛔ THIS FILE IS NOT TO BE EDITED. ⛔
4
+ *
5
+ * Every single change in this file — and any change made on behalf of a
6
+ * component or template that seems to need one here — MUST be confirmed by
7
+ * Gavin first. No exceptions: not bug fixes, not one-liners, not reverts.
8
+ * State the intended diff and wait for an explicit yes.
9
+ *
10
+ * Components and templates are DATA (rx/). If a template seems to need code
11
+ * here, the answer is almost always to express it as data — or to ask.
12
+ * ============================================================================
13
+ *
2
14
  * Templates view — renders a TEMPLATE definition (layout) and fills its
3
15
  * ComponentSlots from a mock conversation seeded into the store.
4
16
  *
@@ -11,8 +23,9 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
11
23
  import { useAuth } from "react-oidc-context";
12
24
  import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
13
25
  import { ComponentStore } from "@unoverse/sdk";
14
- import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
26
+ import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
15
27
  import { NativeAppHost } from "./NativeAppHost";
28
+ import { inArrangement } from "./states";
16
29
  import { client, authedFetch, hasAuth, UNOVERSE_BASE } from "studio-host";
17
30
 
18
31
  // Per-APP test conversation: each app chats in its own conversation id, so testing
@@ -46,6 +59,10 @@ function loadConn(): typeof CONN_DEFAULTS {
46
59
 
47
60
  interface PropDef {
48
61
  default?: unknown;
62
+ // Briefed pages declare their Studio mock as `preview` (ComponentsView reads the
63
+ // same pair) — the mock is `preview ?? default`, per the authoring contract.
64
+ preview?: unknown;
65
+ type?: string;
49
66
  }
50
67
  interface Def {
51
68
  name: string;
@@ -96,7 +113,9 @@ interface Def {
96
113
  // Per-STATE mock seeding: `{ "<state>": ["coursecard", "coursecard"] }` — the
97
114
  // author decides what each state's preview shows (a repeated name = several
98
115
  // instances). Supersedes the legacy flat `previewComponents`.
99
- preview?: Record<string, string[]>;
116
+ // Per-state mock: an ARRAY seeds components; an OBJECT is authored template-state
117
+ // data (what the workflow would have echoed) merged verbatim on pick.
118
+ preview?: Record<string, string[] | Record<string, unknown>>;
100
119
  previewComponents?: string[];
101
120
  fluidHeight?: boolean;
102
121
  // The app's named default state on load (the single authored field, OPEN name):
@@ -147,6 +166,13 @@ export function TemplatesView({
147
166
  // null = the default layout. Independent of the local-state axis — any layout can
148
167
  // combine with any local state (a voice List layout while agent-speaking).
149
168
  const [activeLayout, setActiveLayout] = useState<string | null>(null);
169
+ // A STATE BELONGS TO THE LAYOUTS THAT DRAW IT (UNOVERSE_LAYERS.md §3c) — same shared
170
+ // layer resolver ComponentsView uses for a face's steps (states.ts): a state is in
171
+ // scope when the ACTIVE layout's tree carries its selector. States shared by all
172
+ // layouts (a chat core's welcome) match everywhere; a rail-only state matches only
173
+ // its own arrangement, like Detail under Course.
174
+ const stateInLayout = (s: { when?: unknown; where?: unknown }, t: { layouts?: Record<string, unknown>; defaultLayout?: string; root?: unknown }): boolean =>
175
+ !t.layouts || inArrangement(t.layouts[activeLayout ?? t.defaultLayout ?? "main"] ?? t.root, s);
150
176
  useEffect(() => {
151
177
  const t = templates.find((x) => x.name === selected);
152
178
  setActiveState(t?.states?.filter((s) => !(t.layouts && s.name in t.layouts!))[0] ?? null);
@@ -229,8 +255,13 @@ export function TemplatesView({
229
255
  const liveStateName = useMemo(() => {
230
256
  void storeVersion; // recompute on every store write
231
257
  const states = selectedDef?.states ?? [];
258
+ // This name lights the LOCAL-state rail, so layouts (which have their own pill row
259
+ // and their own `activeLayout` highlight) are not candidates: a claimed `review`
260
+ // view must not blank the rail while a local discriminant is active beside it —
261
+ // the two axes compose (a review arrangement in its gathering state).
232
262
  const claims = new Map<string, string>(); // view -> state name
233
263
  for (const s of states) {
264
+ if (selectedDef?.layouts && s.name in selectedDef.layouts) continue;
234
265
  const eq = (s.where as { eq?: unknown } | undefined)?.eq;
235
266
  if (typeof eq === "string") claims.set(eq, s.name);
236
267
  }
@@ -246,6 +277,13 @@ export function TemplatesView({
246
277
  }
247
278
  }
248
279
  if (view) return claims.get(view) ?? null;
280
+ // LOCAL states carry their own selector (`when`, a condition on template state —
281
+ // railState/callState-style discriminants). Evaluate it with the RENDERER'S OWN
282
+ // predicate (`visible`, one condition semantics), against the template-state bag —
283
+ // so a pill pick, a component's write, or a live workflow echo all light the same.
284
+ const ts = store.getTemplateState() as Record<string, unknown>;
285
+ const local = states.find((s) => s.when && typeof s.when === "object" && visible(s.when as Parameters<typeof visible>[0], ts));
286
+ if (local) return local.name;
249
287
  const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
250
288
  return states.find((s) => s.when === base)?.name ?? null;
251
289
  }, [storeVersion, store, selectedDef]);
@@ -413,7 +451,9 @@ export function TemplatesView({
413
451
  }, [store, live]);
414
452
 
415
453
  const defaultsOf = (d: Def) =>
416
- Object.fromEntries(Object.entries(d.props ?? {}).map(([k, v]) => [k, v.default]));
454
+ Object.fromEntries(
455
+ Object.entries(d.props ?? {}).map(([k, v]) => [k, v.preview ?? v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : "")]),
456
+ );
417
457
 
418
458
  // Seed the given components into the latest response, so any slot that matches fills;
419
459
  // unmatched slots skeleton. The SET is chosen by the caller — scoped to the selected
@@ -500,7 +540,13 @@ export function TemplatesView({
500
540
  // • { field, eq } (template-state selector, e.g. defaultState="focus") → set it after seeding
501
541
  // An explicit preview entry for the state OVERRIDES all of it — the author decided.
502
542
  const previewMap = selectedDef?.app?.preview;
503
- const entry = activeLayout ? previewMap?.[activeLayout] : activeState ? previewMap?.[activeState.name] : undefined;
543
+ // An OBJECT preview entry is authored TEMPLATE-STATE mock data — what the workflow
544
+ // would have echoed (comments, railState, an anchor) — merged as-is so a state's
545
+ // pill previews with real content. Arrays keep the existing meaning: component seeds.
546
+ const stateEntry = activeState ? previewMap?.[activeState.name] : undefined;
547
+ const stateData = stateEntry && !Array.isArray(stateEntry) ? (stateEntry as Record<string, unknown>) : undefined;
548
+ const layoutEntry = activeLayout ? previewMap?.[activeLayout] : stateEntry;
549
+ const entry = Array.isArray(layoutEntry) ? layoutEntry : undefined;
504
550
  const wantEmpty = !entry && !activeLayout && typeof when === "string" && when === "isEmpty";
505
551
  // A COMPONENT is only seeded for a reaction-contract state (select.where) — the
506
552
  // surface needs one to grab (Focus). Plain template states (welcome, conversation)
@@ -516,7 +562,11 @@ export function TemplatesView({
516
562
  : [];
517
563
  // Seed when there's preview content OR a picked state that needs a live conversation.
518
564
  if (set.length || activeState) {
519
- store.addUserMessage(chatIdFor(selected ?? "app"), "Show me this app");
565
+ // An autoTrigger app launches by RAW trigger — nothing prints to the
566
+ // conversation (protocol §launch-from-UI). The mock mirrors it: components
567
+ // seed without the synthetic user turn, so a template that renders user turns
568
+ // outside a chat (a review pile) never shows "Show me this app" as content.
569
+ if (!selectedDef?.app?.autoTrigger) store.addUserMessage(chatIdFor(selected ?? "app"), "Show me this app");
520
570
  // Component states need a body to grab; app declares no previewComponents →
521
571
  // fall back to the SAMPLE ANSWER (the Markdown component's own default content)
522
572
  // so the conversation still previews a real assistant turn.
@@ -534,6 +584,9 @@ export function TemplatesView({
534
584
  } else if (typeof when === "string" && when !== "isEmpty" && when !== "hasMessages") {
535
585
  store.mergeTemplateState({ [when]: true });
536
586
  }
587
+ // Authored template-state mock (object preview entry) — merged AFTER the selector so
588
+ // the author's data wins (it may refine the same keys, e.g. railState + comments).
589
+ if (stateData) store.mergeTemplateState(stateData);
537
590
  // Reaction-contract activation (STATE_MODEL §5b): the surface (or the layout pill —
538
591
  // same selector) reacts to COMPONENT state, so activate it the way the runtime
539
592
  // does — put a seeded component INTO that view. Same write the component's own
@@ -578,7 +631,7 @@ export function TemplatesView({
578
631
  the rail keeps only the template's LOCAL states. */}
579
632
  {!live && selected === t.name && (t.states?.length ?? 0) > 0 && (
580
633
  <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
581
- {t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).map((s) => (
634
+ {t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).filter((s) => stateInLayout(s, t)).map((s) => (
582
635
  <button
583
636
  key={s.name}
584
637
  onClick={() => {
package/screens/states.ts CHANGED
@@ -1,32 +1,75 @@
1
1
  /**
2
- * Component-state activation — shared by the Templates and Components views.
2
+ * Layer resolution + state activation — shared by the Templates and Components views.
3
3
  *
4
4
  * A component's `states/` files are its Switch-case layers: the state NAME is the
5
5
  * case key, and the Switch's `on` field is the discriminant that selects it
6
6
  * (UNOVERSE_LAYERS.md §7). To show a state, write `{ [discriminant]: name }` into
7
7
  * the component's own slice — exactly what the component's own buttons do.
8
+ *
9
+ * SCOPE lives here too (UNOVERSE_LAYERS.md §3c): a layer belongs to the arrangement
10
+ * that DRAWS it — a face's steps are the Switches on the component's own keys inside
11
+ * that face; a template state belongs to the layouts whose trees carry its selector.
12
+ * The structure is the declaration; both views derive, neither lists.
13
+ */
14
+
15
+ /** Walk every object node of a definition tree until `visit` says stop (returns true). */
16
+ function walkTree(n: unknown, visit: (node: Record<string, unknown>) => boolean | void): boolean {
17
+ if (!n || typeof n !== "object") return false;
18
+ if (Array.isArray(n)) return n.some((c) => walkTree(c, visit));
19
+ const o = n as Record<string, unknown>;
20
+ if (visit(o)) return true;
21
+ return Object.values(o).some((v) => walkTree(v, visit));
22
+ }
23
+
24
+ /** The subtree a face draws: the root's `Switch on defaultState` case of that name. */
25
+ export function faceOf(root: unknown, face: string): unknown {
26
+ let found: unknown;
27
+ walkTree(root, (o) => {
28
+ if (o.type === "Switch" && o.on === "defaultState" && o.cases && typeof o.cases === "object" && (o.cases as Record<string, unknown>)[face]) {
29
+ found = (o.cases as Record<string, unknown>)[face];
30
+ return true;
31
+ }
32
+ });
33
+ return found;
34
+ }
35
+
36
+ /**
37
+ * The STEPS a subtree owns: every case of a `Switch` that turns on one of the thing's
38
+ * OWN state keys (`defaultState` excluded — that Switch picks faces, not steps).
39
+ * Switches on data fields (`kind`, a nested component's key) are content selection,
40
+ * not layers — which is why the caller passes the declared keys.
41
+ */
42
+ export function switchLayers(tree: unknown, ownKeys: ReadonlySet<string>): { on: string; name: string }[] {
43
+ const out: { on: string; name: string }[] = [];
44
+ walkTree(tree, (o) => {
45
+ if (o.type === "Switch" && typeof o.on === "string" && o.on !== "defaultState" && ownKeys.has(o.on) && o.cases && typeof o.cases === "object") {
46
+ for (const k of Object.keys(o.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: o.on, name: k });
47
+ }
48
+ });
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Does an arrangement DRAW this state? Includes are inlined at serve time, so the
54
+ * selector authored on a state file (`visibleWhen` for a local layer, `select.where`
55
+ * for a reaction surface) appears verbatim inside every layout that `$include`s it.
56
+ * A state with no selector has nothing to scope by — always in.
8
57
  */
58
+ export function inArrangement(tree: unknown, state: { when?: unknown; where?: unknown }): boolean {
59
+ const sel = JSON.stringify(state.when ?? state.where ?? null);
60
+ if (sel === "null") return true;
61
+ return walkTree(tree, (o) => JSON.stringify(o.visibleWhen ?? null) === sel || JSON.stringify((o.select as { where?: unknown } | undefined)?.where ?? null) === sel);
62
+ }
9
63
 
10
64
  /** Find the discriminant field whose Switch has a case named `stateName`. */
11
65
  export function discriminantFor(root: unknown, stateName: string): string | null {
12
66
  let found: string | null = null;
13
- const walk = (n: unknown): void => {
14
- if (found || !n || typeof n !== "object") return;
15
- if (Array.isArray(n)) return n.forEach(walk);
16
- const o = n as Record<string, unknown>;
17
- if (
18
- o.type === "Switch" &&
19
- typeof o.on === "string" &&
20
- o.cases &&
21
- typeof o.cases === "object" &&
22
- Object.prototype.hasOwnProperty.call(o.cases, stateName)
23
- ) {
67
+ walkTree(root, (o) => {
68
+ if (o.type === "Switch" && typeof o.on === "string" && o.cases && typeof o.cases === "object" && Object.prototype.hasOwnProperty.call(o.cases, stateName)) {
24
69
  found = o.on;
25
- return;
70
+ return true;
26
71
  }
27
- Object.values(o).forEach(walk);
28
- };
29
- walk(root);
72
+ });
30
73
  return found;
31
74
  }
32
75
 
@@ -130,18 +130,96 @@ const inspectId = (node: UnoverseNode): string | undefined => {
130
130
  return typeof id === "string" ? id : undefined;
131
131
  };
132
132
 
133
- export function BoxView({ node, data, theme, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
133
+ export function BoxView({ node, data, theme, children, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode; onAction?: ActionHandler }): ReactNode {
134
134
  const { className, styleEls, style } = nodeChrome(node, data, theme);
135
135
  const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
136
136
  const revealRef = useRevealWhenStuck(node.revealOnStick === true);
137
+ const sel = (node as { selection?: { label?: string; action?: unknown } }).selection;
138
+ const { selRef, pick, clearPick } = useTextSelection(!!sel);
139
+ // A selection host must anchor its floating affordance; default to relative only when
140
+ // the definition declared no position of its own (sticky/absolute roots keep theirs).
141
+ const hostStyle = sel && !(style as CSSProperties).position ? ({ ...style, position: "relative" } as CSSProperties) : style;
137
142
  return (
138
- <div ref={scrollRef ?? revealRef} className={className} style={style} data-unoverse-id={inspectId(node)}>
143
+ <div ref={scrollRef ?? revealRef ?? selRef} className={className} style={hostStyle} data-unoverse-id={inspectId(node)}>
139
144
  {styleEls}
140
145
  {children}
146
+ {sel && pick && (
147
+ <button
148
+ style={{
149
+ position: "absolute",
150
+ top: pick.top,
151
+ left: pick.left,
152
+ transform: "translate(-50%, -100%)",
153
+ zIndex: 40,
154
+ whiteSpace: "nowrap",
155
+ ...styleToCss(((theme as { selection?: { chip?: Record<string, unknown> } }).selection?.chip ?? SELECTION_CHIP_BUILTIN) as Record<string, unknown>, theme),
156
+ }}
157
+ onMouseDown={(e) => e.preventDefault() /* keep the selection alive through the click */}
158
+ onClick={() => {
159
+ onAction?.((sel.action as Parameters<ActionHandler>[0]) ?? "select", { ...data, selection: pick.text });
160
+ clearPick();
161
+ }}
162
+ >
163
+ {sel.label ?? "Comment"}
164
+ </button>
165
+ )}
141
166
  </div>
142
167
  );
143
168
  }
144
169
 
170
+ // The selection chip's look: a SERVED recipe (`theme.selection.chip`) when the org styles
171
+ // one; else this neutral fallback of token NAMES resolved against the same theme — the
172
+ // skeleton-shimmer pattern. The SDK still authors no values, only names.
173
+ const SELECTION_CHIP_BUILTIN: Record<string, unknown> = {
174
+ background: "action.primary",
175
+ color: "text.inverse",
176
+ font: "body.sm",
177
+ radius: "full",
178
+ padding: ["1", "3"],
179
+ shadow: "lg",
180
+ border: "none",
181
+ cursor: "pointer",
182
+ };
183
+
184
+ /**
185
+ * Generic "text was selected here" behavior (opt-in via `node.selection`). The renderer is
186
+ * the ONLY layer that can know a reader selected text — definitions cannot express it —
187
+ * so, like `skeleton` and `autoScroll`, capture is SDK behavior while every consequence
188
+ * stays authored: the node declares the ACTION (fired with the selected text in scope as
189
+ * `{{selection}}`) and the chip's look is served (`theme.selection.chip`). Selection is
190
+ * read from the node's own root (shadow-DOM aware); collapsing it dismisses the chip.
191
+ */
192
+ function useTextSelection(enabled: boolean) {
193
+ const selRef = useRef<HTMLDivElement | null>(null);
194
+ const [pick, setPick] = useState<{ text: string; top: number; left: number } | null>(null);
195
+ useEffect(() => {
196
+ if (!enabled) return;
197
+ const el = selRef.current;
198
+ if (!el) return;
199
+ const read = () => {
200
+ const root = el.getRootNode() as Document | ShadowRoot;
201
+ const s = (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection();
202
+ if (!s || s.isCollapsed || s.rangeCount === 0) return setPick(null);
203
+ const range = s.getRangeAt(0);
204
+ if (!el.contains(range.commonAncestorContainer)) return setPick(null);
205
+ const text = s.toString().trim();
206
+ if (!text) return setPick(null);
207
+ const r = range.getBoundingClientRect();
208
+ const host = el.getBoundingClientRect();
209
+ setPick({ text, top: r.top - host.top, left: Math.min(Math.max(0, r.left - host.left + r.width / 2), host.width) });
210
+ };
211
+ const up = () => window.setTimeout(read, 0); // after the browser settles the selection
212
+ // Listen on the DOCUMENT, not the host: a drag-select routinely ends with the mouse
213
+ // released outside the host (over a rail, past the edge), and mouse events are
214
+ // composed, so they cross the shadow boundary to reach it. `read` still guards with
215
+ // `el.contains(...)`, so only selections inside this host raise the chip.
216
+ const doc = el.ownerDocument;
217
+ doc.addEventListener("mouseup", up);
218
+ return () => doc.removeEventListener("mouseup", up);
219
+ }, [enabled]);
220
+ return { selRef: enabled ? selRef : undefined, pick, clearPick: () => setPick(null) };
221
+ }
222
+
145
223
  /**
146
224
  * Generic "stick to the newest content" behavior for a scroll container (opt-in via
147
225
  * `node.autoScroll`). Smooth-scrolls to the bottom whenever content grows — a new turn
@@ -364,6 +442,27 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
364
442
  if (field == null) setLocal("");
365
443
  };
366
444
  const { style, disabled } = nodeChrome(node, data, theme);
445
+ // `multiline: true` — the same irreducible input as a `<textarea>` (critique, notes —
446
+ // anything longer than one line). Enter inserts a newline; Cmd/Ctrl+Enter submits.
447
+ // Same element class, no chrome, no new primitive.
448
+ if ((node as { multiline?: boolean }).multiline === true) {
449
+ return (
450
+ <textarea
451
+ style={style}
452
+ disabled={disabled}
453
+ value={value}
454
+ placeholder={node.placeholder}
455
+ rows={(node as { rows?: number }).rows ?? 3}
456
+ onChange={(e) => setValue(e.target.value)}
457
+ onKeyDown={(e) => {
458
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
459
+ e.preventDefault();
460
+ submit();
461
+ }
462
+ }}
463
+ />
464
+ );
465
+ }
367
466
  return (
368
467
  <input
369
468
  style={style}
@@ -173,7 +173,7 @@ export function renderNode(
173
173
  case "Row":
174
174
  case "Column":
175
175
  return (
176
- <BoxView key={key} node={node} data={data} theme={theme}>
176
+ <BoxView key={key} node={node} data={data} theme={theme} onAction={onAction}>
177
177
  {kids}
178
178
  </BoxView>
179
179
  );