@jxsuite/studio 0.28.4 → 0.29.0

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.
Files changed (81) hide show
  1. package/dist/studio.js +6769 -5399
  2. package/dist/studio.js.map +86 -79
  3. package/package.json +5 -4
  4. package/src/browse/browse-modal.ts +2 -2
  5. package/src/browse/browse.ts +20 -19
  6. package/src/canvas/canvas-diff.ts +3 -3
  7. package/src/canvas/canvas-helpers.ts +15 -2
  8. package/src/canvas/canvas-live-render.ts +168 -86
  9. package/src/canvas/canvas-patcher.ts +656 -0
  10. package/src/canvas/canvas-perf.ts +68 -0
  11. package/src/canvas/canvas-render.ts +135 -23
  12. package/src/canvas/canvas-subtree-render.ts +113 -0
  13. package/src/canvas/canvas-utils.ts +4 -0
  14. package/src/editor/component-inline-edit.ts +4 -35
  15. package/src/editor/context-menu.ts +92 -74
  16. package/src/editor/convert-to-component.ts +11 -2
  17. package/src/editor/convert-to-repeater.ts +4 -5
  18. package/src/editor/inline-edit.ts +9 -9
  19. package/src/editor/inline-format.ts +11 -11
  20. package/src/editor/merge-tags.ts +120 -0
  21. package/src/editor/shortcuts.ts +4 -4
  22. package/src/files/components.ts +37 -0
  23. package/src/files/file-ops.ts +28 -7
  24. package/src/files/files.ts +25 -21
  25. package/src/github/github-auth.ts +14 -2
  26. package/src/github/github-publish.ts +12 -2
  27. package/src/panels/activity-bar.ts +1 -1
  28. package/src/panels/ai-panel.ts +67 -39
  29. package/src/panels/block-action-bar.ts +72 -1
  30. package/src/panels/canvas-dnd.ts +55 -26
  31. package/src/panels/data-explorer.ts +5 -3
  32. package/src/panels/dnd.ts +12 -15
  33. package/src/panels/editors.ts +5 -23
  34. package/src/panels/elements-panel.ts +2 -12
  35. package/src/panels/events-panel.ts +1 -1
  36. package/src/panels/git-panel.ts +6 -6
  37. package/src/panels/head-panel.ts +1 -1
  38. package/src/panels/layers-panel.ts +177 -147
  39. package/src/panels/preview-render.ts +15 -0
  40. package/src/panels/properties-panel.ts +16 -27
  41. package/src/panels/quick-search.ts +2 -2
  42. package/src/panels/right-panel.ts +2 -6
  43. package/src/panels/shared.ts +3 -0
  44. package/src/panels/signals-panel.ts +24 -22
  45. package/src/panels/statusbar.ts +15 -6
  46. package/src/panels/style-panel.ts +3 -3
  47. package/src/panels/style-utils.ts +3 -3
  48. package/src/panels/stylebook-panel.ts +4 -4
  49. package/src/panels/tab-bar.ts +198 -0
  50. package/src/panels/tab-strip.ts +2 -2
  51. package/src/panels/toolbar.ts +6 -75
  52. package/src/platforms/devserver.ts +60 -34
  53. package/src/reactivity.ts +2 -0
  54. package/src/services/cem-export.ts +23 -2
  55. package/src/services/code-services.ts +16 -2
  56. package/src/services/monaco-setup.ts +2 -1
  57. package/src/settings/content-types-editor.ts +16 -16
  58. package/src/settings/css-vars-editor.ts +2 -2
  59. package/src/settings/defs-editor.ts +14 -14
  60. package/src/settings/general-settings.ts +6 -6
  61. package/src/settings/head-editor.ts +2 -2
  62. package/src/site-context.ts +1 -1
  63. package/src/state.ts +66 -12
  64. package/src/store.ts +15 -3
  65. package/src/studio.ts +73 -33
  66. package/src/tabs/patch-ops.ts +143 -0
  67. package/src/tabs/tab.ts +15 -1
  68. package/src/tabs/transact.ts +454 -27
  69. package/src/types.ts +41 -0
  70. package/src/ui/color-selector.ts +7 -7
  71. package/src/ui/icons.ts +0 -30
  72. package/src/ui/media-picker.ts +2 -2
  73. package/src/ui/panel-resize.ts +6 -1
  74. package/src/ui/unit-selector.ts +2 -2
  75. package/src/ui/value-selector.ts +3 -3
  76. package/src/utils/canvas-media.ts +6 -6
  77. package/src/utils/edit-display.ts +125 -83
  78. package/src/utils/google-fonts.ts +1 -1
  79. package/src/utils/inherited-style.ts +3 -2
  80. package/src/utils/studio-utils.ts +1 -1
  81. package/src/view.ts +4 -1
@@ -97,7 +97,7 @@ async function readFromClipboard() {
97
97
  for (const item of items) {
98
98
  if (item.types.includes(JX_MIME)) {
99
99
  const blob = await item.getType(JX_MIME);
100
- const json = JSON.parse(await blob.text());
100
+ const json = JSON.parse(await blob.text()) as JxNode;
101
101
  return [json];
102
102
  }
103
103
  if (item.types.includes("text/html")) {
@@ -116,7 +116,7 @@ async function readFromClipboard() {
116
116
  const text = await blob.text();
117
117
  // Try parsing as Jx JSON
118
118
  try {
119
- const parsed = JSON.parse(text);
119
+ const parsed = JSON.parse(text) as JxNode;
120
120
  if (parsed && parsed.tagName) {
121
121
  return [parsed];
122
122
  }
@@ -191,14 +191,14 @@ export async function pasteNode() {
191
191
  const idx = childIndex(tab.session.selection) as number;
192
192
  transactDoc(tab, (t) => {
193
193
  for (let i = 0; i < nodes.length; i++) {
194
- mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
194
+ mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
195
195
  }
196
196
  });
197
197
  } else {
198
198
  const idx = Array.isArray(parent.children) ? parent.children.length : 0;
199
199
  transactDoc(tab, (t) => {
200
200
  for (let i = 0; i < nodes.length; i++) {
201
- mutateInsertNode(t, pPath, idx + i, nodes[i]);
201
+ mutateInsertNode(t, pPath, idx + i, nodes[i]!);
202
202
  }
203
203
  });
204
204
  }
@@ -272,16 +272,23 @@ export function showContextMenu(
272
272
  // Select the node
273
273
  tab.session.selection = path;
274
274
 
275
+ // Index-based structural actions (cut/duplicate/insert/wrap/delete) require a numeric child
276
+ // Index. Repeater templates (path tail "map") and the document root don't have one — they get
277
+ // Copy only — so we never splice with a non-numeric index.
278
+ const idxIsNumber = typeof childIndex(path) === "number";
279
+
275
280
  const items: { label: string; action?: () => void | Promise<void>; danger?: boolean }[] = [
276
281
  { action: () => copyNode(), label: "Copy" },
277
282
  ];
278
283
 
279
- if (path.length >= 2) {
280
- items.push({ action: () => cutNode(), label: "Cut" });
281
- items.push({
282
- action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
283
- label: "Duplicate",
284
- });
284
+ if (path.length >= 2 && idxIsNumber) {
285
+ items.push(
286
+ { action: () => cutNode(), label: "Cut" },
287
+ {
288
+ action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
289
+ label: "Duplicate",
290
+ },
291
+ );
285
292
  if (node.style) {
286
293
  const nodeStyle = node.style;
287
294
  items.push({
@@ -305,33 +312,36 @@ export function showContextMenu(
305
312
  label: "Paste styles",
306
313
  });
307
314
  }
308
- items.push({ label: "—" }); // Separator
309
- items.push({
310
- action: () => {
311
- const pp = parentElementPath(path) as JxPath;
312
- const idx = childIndex(path) as number;
313
- transactDoc(activeTab.value, (t) =>
314
- mutateInsertNode(t, pp, idx, { children: [], tagName: "p" }),
315
- );
315
+ // Separator
316
+ items.push(
317
+ { label: "—" },
318
+ {
319
+ action: () => {
320
+ const pp = parentElementPath(path) as JxPath;
321
+ const idx = childIndex(path) as number;
322
+ transactDoc(activeTab.value, (t) =>
323
+ mutateInsertNode(t, pp, idx, { children: [], tagName: "p" }),
324
+ );
325
+ },
326
+ label: "Insert before",
316
327
  },
317
- label: "Insert before",
318
- });
319
- items.push({
320
- action: () => {
321
- const pp = parentElementPath(path) as JxPath;
322
- const idx = childIndex(path) as number;
323
- transactDoc(activeTab.value, (t) =>
324
- mutateInsertNode(t, pp, idx + 1, { children: [], tagName: "p" }),
325
- );
328
+ {
329
+ action: () => {
330
+ const pp = parentElementPath(path) as JxPath;
331
+ const idx = childIndex(path) as number;
332
+ transactDoc(activeTab.value, (t) =>
333
+ mutateInsertNode(t, pp, idx + 1, { children: [], tagName: "p" }),
334
+ );
335
+ },
336
+ label: "Insert after",
326
337
  },
327
- label: "Insert after",
328
- });
329
- items.push({
330
- action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
331
- label: "Wrap in Div",
332
- });
333
- // Don't show Repeat if already inside a repeater (path ends with "children", "map")
334
- if (!(path.length >= 2 && path.at(-2) === "children" && path.at(-1) === "map")) {
338
+ {
339
+ action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
340
+ label: "Wrap in Div",
341
+ },
342
+ );
343
+ // Don't offer Repeat on a repeater template (path tail "map") or on an array node itself.
344
+ if (path.at(-1) !== "map" && (node as JxMutableNode).$prototype !== "Array") {
335
345
  items.push({
336
346
  action: () => convertToRepeater(),
337
347
  label: "Repeat...",
@@ -368,48 +378,56 @@ export function showContextMenu(
368
378
  });
369
379
  }
370
380
  }
371
- items.push({ label: "—" }); // Separator
372
- items.push({
373
- action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
374
- danger: true,
375
- label: "Delete",
376
- });
381
+ // Separator
382
+ items.push(
383
+ { label: "—" },
384
+ {
385
+ action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
386
+ danger: true,
387
+ label: "Delete",
388
+ },
389
+ );
377
390
  }
378
- if (path.length >= 2) {
379
- items.push({ label: "" });
380
- items.push({
381
- action: async () => {
382
- const nodes = await readFromClipboard();
383
- if (!nodes || nodes.length === 0) {
384
- return;
385
- }
386
- const idx = Array.isArray(node.children) ? node.children.length : 0;
387
- transactDoc(activeTab.value, (t) => {
388
- for (let i = 0; i < nodes.length; i++) {
389
- mutateInsertNode(t, path, idx + i, nodes[i]);
391
+ // Paste targets — never into/after an array node (its content is the single map template).
392
+ if (path.length >= 2 && (node as JxMutableNode).$prototype !== "Array") {
393
+ items.push(
394
+ { label: "—" },
395
+ {
396
+ action: async () => {
397
+ const nodes = await readFromClipboard();
398
+ if (!nodes || nodes.length === 0) {
399
+ return;
390
400
  }
391
- });
392
- statusMessage("Pasted");
401
+ const idx = Array.isArray(node.children) ? node.children.length : 0;
402
+ transactDoc(activeTab.value, (t) => {
403
+ for (let i = 0; i < nodes.length; i++) {
404
+ mutateInsertNode(t, path, idx + i, nodes[i]!);
405
+ }
406
+ });
407
+ statusMessage("Pasted");
408
+ },
409
+ label: "Paste inside",
393
410
  },
394
- label: "Paste inside",
395
- });
396
- items.push({
397
- action: async () => {
398
- const nodes = await readFromClipboard();
399
- if (!nodes || nodes.length === 0) {
400
- return;
401
- }
402
- const pp = parentElementPath(path) as JxPath;
403
- const idx = childIndex(path) as number;
404
- transactDoc(activeTab.value, (t) => {
405
- for (let i = 0; i < nodes.length; i++) {
406
- mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
411
+ );
412
+ if (idxIsNumber) {
413
+ items.push({
414
+ action: async () => {
415
+ const nodes = await readFromClipboard();
416
+ if (!nodes || nodes.length === 0) {
417
+ return;
407
418
  }
408
- });
409
- statusMessage("Pasted");
410
- },
411
- label: "Paste after",
412
- });
419
+ const pp = parentElementPath(path) as JxPath;
420
+ const idx = childIndex(path) as number;
421
+ transactDoc(activeTab.value, (t) => {
422
+ for (let i = 0; i < nodes.length; i++) {
423
+ mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
424
+ }
425
+ });
426
+ statusMessage("Pasted");
427
+ },
428
+ label: "Paste after",
429
+ });
430
+ }
413
431
  }
414
432
 
415
433
  let x = e.clientX,
@@ -445,7 +463,7 @@ export function showContextMenu(
445
463
  style=${item.danger ? "color: var(--danger)" : ""}
446
464
  @click=${() => {
447
465
  dismissContextMenu();
448
- item.action?.();
466
+ void item.action?.();
449
467
  }}
450
468
  >${item.label}</sp-menu-item
451
469
  >`,
@@ -8,8 +8,10 @@ import { activeTab } from "../workspace/workspace";
8
8
  import { transact } from "../tabs/transact";
9
9
  import { componentRegistry, computeRelativePath, loadComponentRegistry } from "../files/components";
10
10
  import { getPlatform } from "../platform";
11
+ import { jsonClone } from "../utils/studio-utils";
11
12
  import { statusMessage } from "../panels/statusbar";
12
13
  import { showDialog } from "../ui/layers";
14
+ import { validateComponentSlots } from "../services/cem-export";
13
15
 
14
16
  import type { JxMutableNode } from "@jxsuite/schema/types";
15
17
 
@@ -75,7 +77,12 @@ export async function convertToComponent() {
75
77
  const platform = getPlatform();
76
78
  await platform.writeFile(componentFile, JSON.stringify(componentDef, null, 2));
77
79
  await loadComponentRegistry();
78
- statusMessage(`Converted to <${name}>`);
80
+ const warning = validateComponentSlots(componentDef);
81
+ if (warning) {
82
+ statusMessage(`Converted to <${name}> — Warning: ${warning}`, 6000);
83
+ } else {
84
+ statusMessage(`Converted to <${name}>`);
85
+ }
79
86
  } catch (error) {
80
87
  statusMessage(`Error saving component: ${errorMessage(error)}`);
81
88
  }
@@ -102,7 +109,9 @@ function deriveDefaultName(node: JxMutableNode) {
102
109
  * @returns {JxMutableNode}
103
110
  */
104
111
  function extractComponentDef(node: JxMutableNode) {
105
- const clone = structuredClone(node);
112
+ // JsonClone, not structuredClone: the selected node is a @vue/reactivity proxy,
113
+ // Which structuredClone rejects with DataCloneError.
114
+ const clone = jsonClone(node);
106
115
  delete clone.$id;
107
116
  delete clone.$layout;
108
117
  delete clone.$paths;
@@ -68,10 +68,9 @@ export async function convertToRepeater() {
68
68
  repeater.sort = config.sort;
69
69
  }
70
70
 
71
- (parent.children as (string | JxMutableNode)[])[idx] = {
72
- children: repeater as unknown as (string | JxMutableNode)[],
73
- tagName: "div",
74
- };
71
+ // Replace the selected element in place with the array pseudo-element — no wrapper div. The
72
+ // Repeated items render directly among the original parent's children.
73
+ (parent.children as (string | JxMutableNode)[])[idx] = repeater as unknown as JxMutableNode;
75
74
  });
76
75
  }
77
76
 
@@ -111,7 +110,7 @@ async function promptRepeaterConfig(defs: Record<string, unknown>) {
111
110
 
112
111
  const fnDefs = Object.entries(defs).filter(([, d]) => defCategory(d) === "function");
113
112
 
114
- let source = arrayDefs.length > 0 ? arrayDefs[0][0] : "__new__";
113
+ let source = arrayDefs.length > 0 ? arrayDefs[0]![0] : "__new__";
115
114
  let newDefName = "";
116
115
  let filterDef = "";
117
116
  let sortDef = "";
@@ -522,8 +522,8 @@ function elementToJx(el: HTMLElement): JxContentResult {
522
522
  if (nodes.length === 0) {
523
523
  return { textContent: "" };
524
524
  }
525
- if (nodes.length === 1 && nodes[0].nodeType === Node.TEXT_NODE) {
526
- return { textContent: nodes[0].textContent };
525
+ if (nodes.length === 1 && nodes[0]!.nodeType === Node.TEXT_NODE) {
526
+ return { textContent: nodes[0]!.textContent };
527
527
  }
528
528
 
529
529
  // Mixed content → children array
@@ -589,8 +589,8 @@ function domNodeToJx(node: Node) {
589
589
  const { childNodes } = el;
590
590
  if (childNodes.length === 0) {
591
591
  result.textContent = "";
592
- } else if (childNodes.length === 1 && childNodes[0].nodeType === Node.TEXT_NODE) {
593
- result.textContent = childNodes[0].textContent;
592
+ } else if (childNodes.length === 1 && childNodes[0]!.nodeType === Node.TEXT_NODE) {
593
+ result.textContent = childNodes[0]!.textContent;
594
594
  } else {
595
595
  result.children = [];
596
596
  for (const child of childNodes) {
@@ -615,8 +615,8 @@ function fragmentToJx(frag: DocumentFragment) {
615
615
  if (nodes.length === 0) {
616
616
  return { textContent: "" };
617
617
  }
618
- if (nodes.length === 1 && nodes[0].nodeType === Node.TEXT_NODE) {
619
- return { textContent: nodes[0].textContent };
618
+ if (nodes.length === 1 && nodes[0]!.nodeType === Node.TEXT_NODE) {
619
+ return { textContent: nodes[0]!.textContent };
620
620
  }
621
621
 
622
622
  const children: (JxMutableNode | string)[] = [];
@@ -630,10 +630,10 @@ function fragmentToJx(frag: DocumentFragment) {
630
630
  if (
631
631
  children.length === 1 &&
632
632
  typeof children[0] !== "string" &&
633
- children[0].tagName === "span" &&
634
- typeof children[0].textContent === "string"
633
+ children[0]!.tagName === "span" &&
634
+ typeof children[0]!.textContent === "string"
635
635
  ) {
636
- return { textContent: children[0].textContent };
636
+ return { textContent: children[0]!.textContent };
637
637
  }
638
638
 
639
639
  return children.length > 0 ? { children } : { textContent: "" };
@@ -149,14 +149,14 @@ function findIntersectingElements(tag: string, range: Range, root: HTMLElement)
149
149
  * @param {Element[]} matches
150
150
  */
151
151
  function unwrapTagInRange(
152
- tag: string,
153
- range: Range,
154
- editableRoot: HTMLElement,
152
+ _tag: string,
153
+ _range: Range,
154
+ _editableRoot: HTMLElement,
155
155
  matches: Element[],
156
156
  ) {
157
157
  // Process in reverse so DOM mutations don't shift later nodes
158
158
  for (let i = matches.length - 1; i >= 0; i--) {
159
- const el = matches[i];
159
+ const el = matches[i]!;
160
160
  unwrapElement(el);
161
161
  }
162
162
  }
@@ -249,7 +249,7 @@ function trimLeadingWhitespace(frag: DocumentFragment) {
249
249
  if (!match) {
250
250
  return null;
251
251
  }
252
- const [, ws] = match;
252
+ const ws = match[1]!;
253
253
  if (text.length === ws.length) {
254
254
  // Entire node is whitespace — remove it
255
255
  first.remove();
@@ -276,7 +276,7 @@ function trimTrailingWhitespace(frag: DocumentFragment) {
276
276
  if (!match) {
277
277
  return null;
278
278
  }
279
- const [, ws] = match;
279
+ const ws = match[1]!;
280
280
  if (text.length === ws.length) {
281
281
  last.remove();
282
282
  } else {
@@ -363,7 +363,7 @@ function mergeAdjacentSiblings(root: HTMLElement) {
363
363
 
364
364
  // Process in reverse to preserve earlier offsets
365
365
  for (let i = toMerge.length - 1; i >= 0; i--) {
366
- const [el, next] = toMerge[i];
366
+ const [el, next] = toMerge[i]!;
367
367
  // Move all children from next into el
368
368
  while (next.firstChild) {
369
369
  el.append(next.firstChild);
@@ -468,8 +468,8 @@ function liftEdgeWhitespace(root: HTMLElement) {
468
468
  if (first && first.nodeType === Node.TEXT_NODE) {
469
469
  const text = first.textContent ?? "";
470
470
  const m = text.match(/^(\s+)/);
471
- if (m && text.length > m[1].length) {
472
- ops.push({ el, type: "lift-leading", ws: m[1] });
471
+ if (m && text.length > m[1]!.length) {
472
+ ops.push({ el, type: "lift-leading", ws: m[1]! });
473
473
  }
474
474
  }
475
475
 
@@ -477,8 +477,8 @@ function liftEdgeWhitespace(root: HTMLElement) {
477
477
  if (last && last.nodeType === Node.TEXT_NODE && last !== first) {
478
478
  const text = last.textContent ?? "";
479
479
  const m = text.match(/(\s+)$/);
480
- if (m && text.length > m[1].length) {
481
- ops.push({ el, type: "lift-trailing", ws: m[1] });
480
+ if (m && text.length > m[1]!.length) {
481
+ ops.push({ el, type: "lift-trailing", ws: m[1]! });
482
482
  }
483
483
  }
484
484
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Merge tags — enumerate insertable `${…}` template tokens from the document's state.
3
+ *
4
+ * Powers the inline-edit "Insert data" menu (Mailchimp-style merge tags). Given the document's
5
+ * state definitions plus the live resolved scope, produces a flat list of accessor paths the author
6
+ * can drop into editable text: top-level `state.*` names, nested object paths, array `.length`, and
7
+ * — when editing inside a repeater — `item`, `item.*`, and `index`.
8
+ *
9
+ * Pure and DOM-free: callers pass the data in, this returns plain descriptors.
10
+ */
11
+
12
+ import { defCategory } from "../panels/signals-panel";
13
+ import { dataTypeLabel, unwrapSignal } from "../panels/data-explorer";
14
+
15
+ export interface MergeTag {
16
+ /** Insertion token placed between `${` and `}`, e.g. `state.user.name`, `item.title`, `index`. */
17
+ token: string;
18
+ /** Display path shown in the menu (currently identical to `token`). */
19
+ label: string;
20
+ /** Type + short value preview, e.g. `string · "Alice"`, `Array(4)`, `{3}`. */
21
+ hint: string;
22
+ /** Source category — `defCategory` of the root def, or `"repeater"` for item/index entries. */
23
+ category: string;
24
+ }
25
+
26
+ /** Levels of nesting walked beneath each root name. */
27
+ const DEPTH_CAP = 3;
28
+ /** Maximum object keys enumerated per level (mirrors the data-explorer tree cap). */
29
+ const BREADTH_CAP = 30;
30
+
31
+ /** Type label plus a short value preview for primitives. */
32
+ function previewHint(value: unknown): string {
33
+ const v = unwrapSignal(value);
34
+ const typeLabel = dataTypeLabel(v);
35
+ if (v === null || v === undefined || typeof v === "object") {
36
+ return typeLabel; // Null / pending / Array(n) / {n}
37
+ }
38
+ const text =
39
+ typeof v === "string" ? (v.length > 24 ? `"${v.slice(0, 24)}…"` : `"${v}"`) : String(v);
40
+ return `${typeLabel} · ${text}`;
41
+ }
42
+
43
+ /**
44
+ * Walk a resolved value, appending nested-path tags to `out`. Arrays contribute a `.length` tag and
45
+ * stop (index access is context-dependent); plain objects recurse up to the depth/breadth caps.
46
+ */
47
+ function walk(value: unknown, prefix: string, category: string, depth: number, out: MergeTag[]) {
48
+ if (depth >= DEPTH_CAP) {
49
+ return;
50
+ }
51
+ const v = unwrapSignal(value);
52
+ if (v === null || v === undefined || typeof v !== "object") {
53
+ return;
54
+ }
55
+ if (Array.isArray(v)) {
56
+ const token = `${prefix}.length`;
57
+ out.push({ category, hint: `number · ${v.length}`, label: token, token });
58
+ return;
59
+ }
60
+ const keys = Object.keys(v).slice(0, BREADTH_CAP);
61
+ for (const key of keys) {
62
+ if (key.startsWith("$")) {
63
+ continue; // Internal keys ($children, $ref, reactive plumbing) are not author-facing
64
+ }
65
+ const childVal = (v as Record<string, unknown>)[key];
66
+ const childPrefix = `${prefix}.${key}`;
67
+ out.push({ category, hint: previewHint(childVal), label: childPrefix, token: childPrefix });
68
+ walk(childVal, childPrefix, category, depth + 1, out);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Build the list of insertable merge tags for the current editing context.
74
+ *
75
+ * @param state - The document's `state` definitions (`tab.doc.document.state`), used for names and
76
+ * classification (functions are skipped — they are not text-insertable values).
77
+ * @param scope - The live resolved scope (`panel.liveCtx.scope`) for type/preview hints and the
78
+ * nested-property walk. May be null before the canvas has rendered.
79
+ * @param localScope - The editing element's recorded render scope (`elToScope.get(el)`). When it
80
+ * carries a `$map` (repeater) context, `item` / `item.*` / `index` tags are appended.
81
+ */
82
+ export function buildMergeTags(
83
+ state: Record<string, unknown> | null | undefined,
84
+ scope: Record<string, unknown> | null | undefined,
85
+ localScope: Record<string, unknown> | null = null,
86
+ ): MergeTag[] {
87
+ const out: MergeTag[] = [];
88
+ const defs = state ?? {};
89
+ const scp = scope ?? {};
90
+
91
+ for (const [name, def] of Object.entries(defs)) {
92
+ if (name.startsWith("$")) {
93
+ continue;
94
+ }
95
+ const category = defCategory(def);
96
+ if (category === "function") {
97
+ continue; // Handlers/functions aren't insertable as text values
98
+ }
99
+ const value = scp[name];
100
+ const token = `state.${name}`;
101
+ out.push({ category, hint: previewHint(value), label: token, token });
102
+ walk(value, token, category, 0, out);
103
+ }
104
+
105
+ if (localScope) {
106
+ const map = unwrapSignal(localScope.$map) as { item?: unknown; index?: unknown } | undefined;
107
+ if (map && typeof map === "object") {
108
+ out.push({ category: "repeater", hint: previewHint(map.item), label: "item", token: "item" });
109
+ walk(map.item, "item", "repeater", 0, out);
110
+ out.push({
111
+ category: "repeater",
112
+ hint: previewHint(map.index),
113
+ label: "index",
114
+ token: "index",
115
+ });
116
+ }
117
+ }
118
+
119
+ return out;
120
+ }
@@ -183,7 +183,7 @@ export function initShortcuts(
183
183
  const tabToClose = workspace.tabs.get(workspace.activeTabId);
184
184
  if (tabToClose?.doc.dirty) {
185
185
  const name = tabToClose.documentPath?.split("/").pop() || "Untitled";
186
- showConfirmDialog(
186
+ void showConfirmDialog(
187
187
  "Unsaved Changes",
188
188
  `"${name}" has unsaved changes. Close without saving?`,
189
189
  { confirmLabel: "Close", destructive: true },
@@ -232,17 +232,17 @@ export function initShortcuts(
232
232
  }
233
233
  case "c": {
234
234
  e.preventDefault();
235
- copyNode();
235
+ void copyNode();
236
236
  break;
237
237
  }
238
238
  case "x": {
239
239
  e.preventDefault();
240
- cutNode();
240
+ void cutNode();
241
241
  break;
242
242
  }
243
243
  case "v": {
244
244
  e.preventDefault();
245
- pasteNode();
245
+ void pasteNode();
246
246
  break;
247
247
  }
248
248
  case "0": {
@@ -3,6 +3,7 @@
3
3
  import { getPlatform } from "../platform";
4
4
  import { projectState } from "../store";
5
5
  import type { ComponentMeta } from "../types";
6
+ import type { JxMutableNode } from "@jxsuite/schema/types";
6
7
 
7
8
  /** A discovered component: project components carry a file path; package components may not. */
8
9
  export interface ComponentEntry extends Omit<ComponentMeta, "path"> {
@@ -25,6 +26,42 @@ export async function loadComponentRegistry() {
25
26
  }
26
27
  }
27
28
 
29
+ /**
30
+ * Build a fresh instance definition for a component: $props from prop defaults, plus the
31
+ * component's slot fallback children cloned in as starting content so editors can edit from the
32
+ * defaults. Children destined for a named slot carry a `slot` attribute (required for runtime
33
+ * distribution); fallback for the default slot is inserted as-is. When the component has no slot
34
+ * fallback, no `children` key is emitted so slotless instances stay atomic in the layers tree.
35
+ *
36
+ * @param {ComponentEntry} comp
37
+ * @returns {JxMutableNode}
38
+ */
39
+ export function buildComponentInstance(comp: ComponentEntry): JxMutableNode {
40
+ const $props = Object.fromEntries(
41
+ (comp.props ?? []).map((p) => [p.name, p.default !== undefined ? p.default : ""]),
42
+ );
43
+ const children: (JxMutableNode | string)[] = [];
44
+ for (const slot of comp.slots ?? []) {
45
+ for (const child of structuredClone(slot.fallback ?? [])) {
46
+ if (!slot.name) {
47
+ children.push(child);
48
+ } else if (typeof child === "object" && child !== null && typeof child.tagName === "string") {
49
+ // Preserve an author-set slot attribute (slot forwarding inside fallback).
50
+ child.attributes = { slot: slot.name, ...child.attributes };
51
+ children.push(child);
52
+ } else {
53
+ // Text and non-element nodes can't carry a slot attribute — wrap them.
54
+ children.push({ attributes: { slot: slot.name }, children: [child], tagName: "span" });
55
+ }
56
+ }
57
+ }
58
+ return {
59
+ $props,
60
+ tagName: comp.tagName,
61
+ ...(children.length > 0 ? { children } : {}),
62
+ };
63
+ }
64
+
28
65
  /**
29
66
  * @param {string | null} fromDocPath
30
67
  * @param {string} toCompPath