@developer_tribe/react-builder 1.2.18 → 1.2.20

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 (62) hide show
  1. package/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +2 -1
  2. package/dist/build-components/patterns.generated.d.ts +23 -8
  3. package/dist/index.cjs.js +3 -3
  4. package/dist/index.cjs.js.map +1 -1
  5. package/dist/index.esm.js +1 -1
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/index.web.cjs.js +4 -4
  8. package/dist/index.web.cjs.js.map +1 -1
  9. package/dist/index.web.esm.js +3 -3
  10. package/dist/index.web.esm.js.map +1 -1
  11. package/dist/pages/ProjectDebug.d.ts +9 -1
  12. package/dist/pages/ProjectMigrationPage.d.ts +3 -1
  13. package/dist/pages/ProjectValidationPage.d.ts +3 -2
  14. package/dist/styles.css +1 -1
  15. package/dist/utils/applyJsonTransform.d.ts +13 -0
  16. package/dist/utils/repairNodeKeys.d.ts +11 -0
  17. package/dist/utils/safeJsonStringify.d.ts +1 -0
  18. package/dist/utils/wrapNodeInMain.d.ts +2 -0
  19. package/package.json +1 -1
  20. package/src/RenderPage.tsx +17 -46
  21. package/src/assets/meta.json +1 -1
  22. package/src/assets/samples/carousel-sample.json +51 -51
  23. package/src/assets/samples/paywall-1.json +77 -77
  24. package/src/assets/samples/paywall-2.json +76 -76
  25. package/src/assets/samples/simple-1.json +13 -13
  26. package/src/assets/samples/simple-2.json +97 -97
  27. package/src/assets/samples/unmigrated-builder-1.1.1.json +25 -25
  28. package/src/assets/samples/unmigrated-builder1.json +1 -1
  29. package/src/assets/samples/unvalidated-builder1.json +15 -15
  30. package/src/assets/samples/unvalidated-crash1.json +4 -4
  31. package/src/assets/samples/vpn-onboard-1.json +100 -78
  32. package/src/assets/samples/vpn-onboard-2.json +97 -75
  33. package/src/assets/samples/vpn-onboard-3.json +103 -79
  34. package/src/assets/samples/vpn-onboard-4.json +103 -79
  35. package/src/assets/samples/vpn-onboard-5.json +139 -108
  36. package/src/assets/samples/vpn-onboard-6.json +100 -81
  37. package/src/build-components/CarouselDots/CarouselDots.tsx +112 -12
  38. package/src/build-components/OnboardDot/OnboardDot.tsx +74 -40
  39. package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +2 -1
  40. package/src/build-components/OnboardDot/pattern.json +28 -10
  41. package/src/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
  42. package/src/build-components/Text/Text.tsx +4 -9
  43. package/src/build-components/patterns.generated.ts +23 -8
  44. package/src/build-components/useNode.ts +20 -4
  45. package/src/components/AttributesEditorPanel.tsx +13 -1
  46. package/src/components/Builder.tsx +19 -5
  47. package/src/components/EditorHeader.tsx +16 -6
  48. package/src/components/JsonTextEditor.tsx +41 -0
  49. package/src/pages/DebugJsonPage.tsx +104 -4
  50. package/src/pages/ProjectDebug.tsx +66 -28
  51. package/src/pages/ProjectMigrationPage.tsx +15 -0
  52. package/src/pages/ProjectPage.tsx +160 -23
  53. package/src/pages/ProjectValidationPage.tsx +64 -1
  54. package/src/styles/layout/_project-validation.scss +29 -0
  55. package/src/styles/utilities/_carousel.scss +0 -32
  56. package/src/utils/__special_exceptions.ts +9 -3
  57. package/src/utils/analyseNodeByPatterns.ts +16 -6
  58. package/src/utils/applyJsonTransform.ts +19 -0
  59. package/src/utils/novaToJson.ts +7 -3
  60. package/src/utils/repairNodeKeys.ts +90 -0
  61. package/src/utils/safeJsonStringify.ts +18 -0
  62. package/src/utils/wrapNodeInMain.ts +67 -0
@@ -20,8 +20,12 @@ function OnboardDot({ node }: OnboardDotComponentProps) {
20
20
  const attributeName = node.type ?? 'OnboardDot';
21
21
  const attributeKey = node.key ?? generatedId;
22
22
  const attrs = node.attributes;
23
- const styleBag = attrs?.style;
24
- const dotType = attrs?.dotType || 'normal_dot';
23
+ const stylesBag =
24
+ ((attrs as any)?.styles as Record<string, unknown> | undefined) ??
25
+ ((attrs as any)?.style as Record<string, unknown> | undefined) ??
26
+ undefined;
27
+ const dotType =
28
+ (stylesBag?.dotType as any) ?? (attrs as any)?.dotType ?? 'normal_dot';
25
29
  const GHOST_DOT_DARK_COLOR = '#E4E5E7';
26
30
  const GHOST_DOT_LIGHT_COLOR = '#F7F7F9';
27
31
  const {
@@ -37,12 +41,32 @@ function OnboardDot({ node }: OnboardDotComponentProps) {
37
41
  const inactiveDotColor = isDark
38
42
  ? GHOST_DOT_DARK_COLOR
39
43
  : GHOST_DOT_LIGHT_COLOR;
40
- const inactiveDotOpacity = attrs?.inactive_dot_opacity ?? 0.3;
41
- const activeDotColor = attrs?.active_dot_color;
44
+ const inactiveDotOpacity =
45
+ (stylesBag?.inactive_dot_opacity as number | undefined) ??
46
+ (attrs as any)?.inactive_dot_opacity ??
47
+ 0.3;
48
+ const inactiveDotColorOverride =
49
+ (stylesBag?.inactive_dot_color as string | undefined) ??
50
+ (attrs as any)?.inactive_dot_color;
51
+ const activeDotColor =
52
+ (stylesBag?.active_dot_color as string | undefined) ??
53
+ (attrs as any)?.active_dot_color;
42
54
  const resolvedActiveDotColor = useMemo(
43
55
  () => parseColor(activeDotColor, { theme: appConfig.theme, projectColors }),
44
56
  [activeDotColor, appConfig.theme, projectColors],
45
57
  );
58
+ const resolvedInactiveDotColor = useMemo(() => {
59
+ const parsed = parseColor(inactiveDotColorOverride, {
60
+ theme: appConfig.theme,
61
+ projectColors,
62
+ });
63
+ return parsed ?? inactiveDotColor;
64
+ }, [
65
+ inactiveDotColor,
66
+ inactiveDotColorOverride,
67
+ appConfig.theme,
68
+ projectColors,
69
+ ]);
46
70
 
47
71
  const extractedStyle = useExtractViewStyle(node);
48
72
  const baseStyle = useMemo(() => {
@@ -58,27 +82,31 @@ function OnboardDot({ node }: OnboardDotComponentProps) {
58
82
  baseStyle,
59
83
  isSelected ? SELECTED_OUTLINE_STYLE : undefined,
60
84
  );
61
- const expandingDotWidthRaw = attrs?.expanding_dot_width;
62
- const expandingDotWidthOverride = useMemo(() => {
63
- const parsed = parseSize(expandingDotWidthRaw);
64
- if (parsed === undefined) return undefined;
65
-
66
- // `parseSize` may return number (px) or a string (e.g. "50%").
67
- const cssWidth =
68
- typeof parsed === 'number'
69
- ? `${parsed}px`
70
- : typeof parsed === 'string'
71
- ? parsed
72
- : undefined;
73
-
74
- if (!cssWidth) return undefined;
75
-
76
- return {
77
- // Controls the actual dot diameter (`.embla__dot:after`).
78
- '--embla-dot-size': cssWidth,
79
- } as React.CSSProperties & Record<`--${string}`, string>;
80
- }, [expandingDotWidthRaw]);
81
- const containerStyle = useMergedStyle(style, expandingDotWidthOverride);
85
+ const dotThicknessRaw =
86
+ (stylesBag?.dot_thickness as any) ?? (attrs as any)?.dot_thickness;
87
+ const dotSizeCss = useMemo((): string => {
88
+ const parsed = parseSize(dotThicknessRaw);
89
+ if (parsed === undefined) return '10px';
90
+ if (typeof parsed === 'number') return `${parsed}px`;
91
+ if (typeof parsed === 'string' && parsed.trim()) return parsed;
92
+ return '10px';
93
+ }, [dotThicknessRaw]);
94
+ const dotGapCss = useMemo((): string => {
95
+ // Prefer px math when possible; otherwise fall back to 10px/3.
96
+ const px =
97
+ typeof dotSizeCss === 'string' && dotSizeCss.trim().endsWith('px')
98
+ ? Number.parseFloat(dotSizeCss)
99
+ : Number.NaN;
100
+ const n = Number.isFinite(px) ? px : 10;
101
+ return `${Math.max(0, n / 3)}px`;
102
+ }, [dotSizeCss]);
103
+ const gapValue = (style as any)?.gap ?? dotGapCss;
104
+ const containerStyle = useMergedStyle(style, {
105
+ display: 'flex',
106
+ flexWrap: 'wrap',
107
+ gap: gapValue,
108
+ alignItems: 'center',
109
+ });
82
110
 
83
111
  const onboardApi = useContext(onboardContext);
84
112
  const emblaApi = onboardApi?.emblaApi;
@@ -115,16 +143,12 @@ function OnboardDot({ node }: OnboardDotComponentProps) {
115
143
  >
116
144
  {scrollSnaps.map((snap, index) => {
117
145
  const isDotSelected = selectedIndex === index;
118
- const dotStyles: React.CSSProperties & Record<`--${string}`, string> = {
119
- opacity: isDotSelected ? 1 : inactiveDotOpacity,
120
- };
121
-
122
- if (resolvedActiveDotColor && isDotSelected) {
123
- // Style the actual visual dot (rendered via `.embla__dot:after`) using a CSS variable.
124
- dotStyles['--embla-dot-color'] = resolvedActiveDotColor;
125
- } else if (!isDotSelected) {
126
- dotStyles['--embla-dot-color'] = inactiveDotColor;
127
- }
146
+ const resolvedColor =
147
+ isDotSelected && resolvedActiveDotColor
148
+ ? resolvedActiveDotColor
149
+ : resolvedInactiveDotColor;
150
+ const activeFallback = '#007AFF';
151
+ const dotColor = resolvedColor ?? activeFallback;
128
152
 
129
153
  return (
130
154
  <button
@@ -132,13 +156,23 @@ function OnboardDot({ node }: OnboardDotComponentProps) {
132
156
  onClick={() => {
133
157
  emblaApi?.scrollTo(snap);
134
158
  }}
135
- className={`embla__dot ${isDotSelected ? 'embla__dot--selected' : ''}`}
136
- style={dotStyles}
159
+ className="embla__dot"
160
+ style={{
161
+ width: dotSizeCss,
162
+ height: dotSizeCss,
163
+ backgroundColor: dotColor,
164
+ opacity: isDotSelected ? 1 : inactiveDotOpacity,
165
+ borderRadius: '9999px',
166
+ border: 0,
167
+ padding: 0,
168
+ margin: 0,
169
+ display: 'inline-block',
170
+ cursor: 'pointer',
171
+ boxSizing: 'border-box',
172
+ }}
137
173
  aria-label={`Go to slide ${index + 1}`}
138
174
  aria-current={isDotSelected ? 'true' : undefined}
139
- >
140
- {/* Dot visuals are rendered via CSS (`.embla__dot:after`). */}
141
- </button>
175
+ />
142
176
  );
143
177
  })}
144
178
  </div>
@@ -69,8 +69,9 @@ export interface OnboardDotPropsGenerated {
69
69
  title?: string;
70
70
  description?: string;
71
71
  dotType?: DotTypeOptionType;
72
+ dot_thickness?: string;
72
73
  inactive_dot_opacity?: number;
73
- expanding_dot_width?: string;
74
+ inactive_dot_color?: string;
74
75
  active_dot_color?: string;
75
76
  flexDirection?: never;
76
77
  alignItems?: never;
@@ -15,18 +15,29 @@
15
15
  "sliding_dot",
16
16
  "liquid_like"
17
17
  ],
18
+ "dot_thickness": "size",
18
19
  "inactive_dot_opacity": "number",
19
- "expanding_dot_width": "size",
20
+ "inactive_dot_color": "color",
20
21
  "active_dot_color": "color",
21
22
  "flexDirection": "never",
22
23
  "alignItems": "never",
23
24
  "justifyContent": "never"
24
25
  }
25
26
  },
27
+ "defaults": {
28
+ "dotType": "expanding_dot",
29
+ "dot_thickness": 10,
30
+ "inactive_dot_opacity": 0.3,
31
+ "active_dot_color": "#007AFF",
32
+ "style": {
33
+ "flexDirection": "row",
34
+ "alignItems": "center",
35
+ "justifyContent": "center",
36
+ "gap": "12@s"
37
+ }
38
+ },
26
39
  "meta": {
27
- "desiredParent": [
28
- ">OnboardProvider"
29
- ],
40
+ "desiredParent": [">OnboardProvider"],
30
41
  "label": "Onboard Dot",
31
42
  "description": "Renders onboarding progress dots.",
32
43
  "styles": {
@@ -42,21 +53,28 @@
42
53
  "description": "Opacity for inactive dots.",
43
54
  "category": "style",
44
55
  "specialCategory": null,
45
- "sort": 2
56
+ "sort": 3
46
57
  },
47
- "expanding_dot_width": {
48
- "label": "Expanding Dot Width",
49
- "description": "Width used while expanding.",
58
+ "inactive_dot_color": {
59
+ "label": "Inactive Dot Color",
60
+ "description": "Color of inactive dots.",
50
61
  "category": "style",
51
62
  "specialCategory": null,
52
- "sort": 3
63
+ "sort": 4
64
+ },
65
+ "dot_thickness": {
66
+ "label": "Dot Thickness",
67
+ "description": "Dot size/diameter.",
68
+ "category": "style",
69
+ "specialCategory": null,
70
+ "sort": 2
53
71
  },
54
72
  "active_dot_color": {
55
73
  "label": "Active Dot Color",
56
74
  "description": "Color of the active dot.",
57
75
  "category": "style",
58
76
  "specialCategory": null,
59
- "sort": 4
77
+ "sort": 5
60
78
  }
61
79
  }
62
80
  }
@@ -62,6 +62,8 @@ function PaywallProvider({ node }: PaywallProviderComponentProps) {
62
62
 
63
63
  const [selectedProductId, setSelectedProductId] = useState<string>('');
64
64
  const [isBackAllowed, setIsBackAllowed] = useState<boolean>(false);
65
+ useChangeDelayByPaywall(node, setIsBackAllowed);
66
+ useMockOSBackHandler(isBackAllowed);
65
67
  useEffect(() => {
66
68
  const list = Array.isArray(products) ? products : [];
67
69
  if (list.length === 0) {
@@ -75,9 +77,6 @@ function PaywallProvider({ node }: PaywallProviderComponentProps) {
75
77
  }
76
78
  }, [products, selectedProductId]);
77
79
 
78
- useChangeDelayByPaywall(node, setIsBackAllowed);
79
- useMockOSBackHandler(isBackAllowed);
80
-
81
80
  const selectedProduct = useMemo(() => {
82
81
  const list = Array.isArray(products) ? products : [];
83
82
  return (
@@ -1,10 +1,4 @@
1
- import React, {
2
- useId,
3
- useLayoutEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- } from 'react';
1
+ import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
8
2
  import type { TextComponentProps } from './TextProps.generated';
9
3
  import useNode from '../useNode';
10
4
  import { useBuilderParams } from '../../components/BuilderProvider';
@@ -17,9 +11,10 @@ import { useLocalize } from '../../hooks/useLocalize';
17
11
  function Text({ node }: TextComponentProps) {
18
12
  useLogRender('Text');
19
13
  node = useNode(node);
20
- const generatedId = useId();
21
14
  const attributeName = node.sourceType ?? node.type ?? 'text';
22
- const attributeKey = node.key ?? generatedId;
15
+ // Only use real node keys for selection. `useId()` values (e.g. ":r13:") are
16
+ // not present in the persisted node tree, so they break click-to-select.
17
+ const attributeKey = node.key;
23
18
  const textRef = useRef<HTMLParagraphElement | null>(null);
24
19
  const [autoFontSizePx, setAutoFontSizePx] = useState<number | null>(null);
25
20
  const { appConfig, previewMode, selectedKey } = useBuilderParams();
@@ -6103,8 +6103,9 @@ export const patterns = [
6103
6103
  'sliding_dot',
6104
6104
  'liquid_like',
6105
6105
  ],
6106
+ dot_thickness: 'size',
6106
6107
  inactive_dot_opacity: 'number',
6107
- expanding_dot_width: 'size',
6108
+ inactive_dot_color: 'color',
6108
6109
  active_dot_color: 'color',
6109
6110
  flexDirection: 'never',
6110
6111
  alignItems: 'never',
@@ -6169,21 +6170,28 @@ export const patterns = [
6169
6170
  description: 'Opacity for inactive dots.',
6170
6171
  category: 'style',
6171
6172
  specialCategory: null,
6172
- sort: 2,
6173
+ sort: 3,
6173
6174
  },
6174
- expanding_dot_width: {
6175
- label: 'Expanding Dot Width',
6176
- description: 'Width used while expanding.',
6175
+ inactive_dot_color: {
6176
+ label: 'Inactive Dot Color',
6177
+ description: 'Color of inactive dots.',
6177
6178
  category: 'style',
6178
6179
  specialCategory: null,
6179
- sort: 3,
6180
+ sort: 4,
6181
+ },
6182
+ dot_thickness: {
6183
+ label: 'Dot Thickness',
6184
+ description: 'Dot size/diameter.',
6185
+ category: 'style',
6186
+ specialCategory: null,
6187
+ sort: 2,
6180
6188
  },
6181
6189
  active_dot_color: {
6182
6190
  label: 'Active Dot Color',
6183
6191
  description: 'Color of the active dot.',
6184
6192
  category: 'style',
6185
6193
  specialCategory: null,
6186
- sort: 4,
6194
+ sort: 5,
6187
6195
  },
6188
6196
  },
6189
6197
  attributes: {
@@ -6440,13 +6448,20 @@ export const patterns = [
6440
6448
  },
6441
6449
  defaults: {
6442
6450
  style: {
6443
- flexDirection: 'column',
6451
+ flexDirection: 'row',
6444
6452
  position: 'relative',
6445
6453
  zIndex: 1,
6446
6454
  alignSelf: 'flex-start',
6447
6455
  flexGrow: 0,
6448
6456
  flexShrink: 0,
6457
+ alignItems: 'center',
6458
+ justifyContent: 'center',
6459
+ gap: '12@s',
6449
6460
  },
6461
+ dotType: 'expanding_dot',
6462
+ dot_thickness: 10,
6463
+ inactive_dot_opacity: 0.3,
6464
+ active_dot_color: '#007AFF',
6450
6465
  },
6451
6466
  types: {},
6452
6467
  },
@@ -9,18 +9,33 @@ export default function useNode<
9
9
  if (!defaults) return node;
10
10
  const nodeAttributes = ((node.attributes as T) ?? ({} as T)) as T & {
11
11
  style?: Record<string, unknown>;
12
+ styles?: Record<string, unknown>;
12
13
  };
13
14
  const defaultAttributes = defaults as T as T & {
14
15
  style?: Record<string, unknown>;
16
+ styles?: Record<string, unknown>;
17
+ };
18
+ // Merge style from both defaults.style and defaults.styles (for schemaVersion=2 compatibility)
19
+ const defaultStyle = {
20
+ ...(defaultAttributes?.styles ?? {}),
21
+ ...(defaultAttributes?.style ?? {}),
22
+ };
23
+ // Merge node style from both node.attributes.style and node.attributes.styles
24
+ const nodeStyle = {
25
+ ...(nodeAttributes?.styles ?? {}),
26
+ ...(nodeAttributes?.style ?? {}),
27
+ };
28
+ const mergedStyle = {
29
+ ...defaultStyle,
30
+ ...nodeStyle,
15
31
  };
16
32
  const mergedAttributes: T = {
17
33
  ...(defaultAttributes as T),
18
34
  ...(nodeAttributes as T),
19
35
  // Deep merge `style` so default style values aren't lost when the node provides partial style overrides.
20
- style: {
21
- ...(defaultAttributes?.style ?? {}),
22
- ...(nodeAttributes?.style ?? {}),
23
- },
36
+ // Keep both `style` (for runtime back-compat) and `styles` (for editor schemaVersion=2) in sync.
37
+ style: mergedStyle,
38
+ styles: mergedStyle,
24
39
  } as T;
25
40
  if (
26
41
  mergedAttributes &&
@@ -29,6 +44,7 @@ export default function useNode<
29
44
  Object.keys((mergedAttributes as any).style).length === 0
30
45
  ) {
31
46
  delete (mergedAttributes as any).style;
47
+ delete (mergedAttributes as any).styles;
32
48
  }
33
49
  return { ...node, attributes: mergedAttributes };
34
50
  }
@@ -3,6 +3,7 @@ import type { Node } from '../types/Node';
3
3
  import type { ProjectColors } from '../types/Project';
4
4
  import { useLogRender } from '../utils/useLogRender';
5
5
  import { useRenderStore } from '../store';
6
+ import { findNodeByKey } from '../utils/nodeTree';
6
7
 
7
8
  interface AttributesEditorPanelProps {
8
9
  attributes: any;
@@ -22,6 +23,16 @@ export function AttributesEditorPanel({
22
23
  }));
23
24
  if (!current) return null;
24
25
 
26
+ const currentKey =
27
+ typeof current === 'object' && !Array.isArray(current) && 'key' in current
28
+ ? ((current as any).key as string | undefined)
29
+ : undefined;
30
+ const resolvedCurrent =
31
+ currentKey && attributes
32
+ ? findNodeByKey(attributes as Node, currentKey)
33
+ : null;
34
+ const nodeForEditor = resolvedCurrent ?? current;
35
+
25
36
  function replaceNode(root: Node, target: Node, next: Node): Node {
26
37
  if (root === target) return next;
27
38
  if (root === null || root === undefined) return root;
@@ -58,7 +69,8 @@ export function AttributesEditorPanel({
58
69
  return (
59
70
  <div className="attributes-editor-panel">
60
71
  <AttributesEditor
61
- node={current}
72
+ key={currentKey ?? undefined}
73
+ node={nodeForEditor}
62
74
  onChange={handleAttributesChange}
63
75
  projectColors={projectColors}
64
76
  />
@@ -11,6 +11,8 @@ import { useLogRender } from '../utils/useLogRender';
11
11
  import { getDefaultsForType, getPatternByType } from '../utils/patterns';
12
12
  import { AddComponentModal } from '../modals/AddComponentModal';
13
13
  import { BuilderButton } from './BuilderButton';
14
+ import { generateRandomKeyForNode } from '../utils/generateRandomKeyForNode';
15
+ import { collectNodeKeys } from '../utils/repairNodeKeys';
14
16
 
15
17
  type BuilderEditorProps = {
16
18
  data: Node;
@@ -156,6 +158,7 @@ export function Builder({
156
158
  }: BuilderEditorProps) {
157
159
  useLogRender('Builder');
158
160
  const [isAddModalOpen, setIsAddModalOpen] = useState(false);
161
+ const usedKeys = useMemo(() => collectNodeKeys(data), [data]);
159
162
  const breadcrumbPath = useMemo(() => {
160
163
  const path = findNodePath(data, current);
161
164
  if (path.length) return path;
@@ -190,7 +193,8 @@ export function Builder({
190
193
 
191
194
  const handleAddChild = useCallback(
192
195
  (type: string) => {
193
- const nextChild = createDefaultNode(type);
196
+ const nextUsedKeys = new Set(usedKeys);
197
+ const nextChild = createDefaultNode(type, nextUsedKeys);
194
198
 
195
199
  // Root (or selection) can be empty/null-ish: allow creating the first node.
196
200
  if (isNodeNullOrUndefined(current)) {
@@ -247,7 +251,7 @@ export function Builder({
247
251
  setData(updatedRoot);
248
252
  setCurrent(updatedParent);
249
253
  },
250
- [current, data, setData, setCurrent],
254
+ [current, data, setData, setCurrent, usedKeys],
251
255
  );
252
256
 
253
257
  const allowedChildTypes = useMemo(
@@ -383,17 +387,26 @@ export function Builder({
383
387
  return root;
384
388
  }
385
389
 
386
- function createDefaultNode(type: string): NodeData<NodeDefaultAttribute> {
390
+ function createDefaultNode(
391
+ type: string,
392
+ nextUsedKeys: Set<string>,
393
+ ): NodeData<NodeDefaultAttribute> {
387
394
  const pattern = getPatternByType(type)?.pattern;
388
395
  const defaults = getDefaultsForType(type) ?? {};
389
396
  const childrenSchema = pattern?.children as unknown;
397
+ let key = '';
398
+ do {
399
+ key = generateRandomKeyForNode(type);
400
+ } while (nextUsedKeys.has(key));
401
+ nextUsedKeys.add(key);
390
402
 
391
403
  // Special-case: CarouselProvider MUST contain a Carousel container inside the viewport
392
404
  // otherwise embla-carousel will crash (it expects viewport.firstChild.children).
393
405
  if (type === 'CarouselProvider') {
394
406
  return {
395
407
  type,
396
- children: createDefaultNode('Carousel'),
408
+ key,
409
+ children: createDefaultNode('Carousel', nextUsedKeys),
397
410
  attributes: { ...defaults },
398
411
  } as NodeData<NodeDefaultAttribute>;
399
412
  }
@@ -412,13 +425,14 @@ export function Builder({
412
425
  children = null;
413
426
  } else if (typeof childrenSchema === 'string') {
414
427
  // Specific child type like 'CarouselItem' – seed with one child to match the pattern.
415
- children = [createDefaultNode(childrenSchema)];
428
+ children = [createDefaultNode(childrenSchema, nextUsedKeys)];
416
429
  } else {
417
430
  children = null;
418
431
  }
419
432
 
420
433
  return {
421
434
  type,
435
+ key,
422
436
  children,
423
437
  attributes: { ...defaults },
424
438
  } as NodeData<NodeDefaultAttribute>;
@@ -3,6 +3,7 @@ import type { Device } from '../types/Device';
3
3
  import type { Node } from '../types/Node';
4
4
  import { copyNode } from '../utils/copyNode';
5
5
  import { getDevices } from '../utils/getDevices';
6
+ import { collectNodeKeys, repairNodeKeys } from '../utils/repairNodeKeys';
6
7
  import { useRenderStore } from '../store';
7
8
  import { useLogRender } from '../utils/useLogRender';
8
9
  import { DeviceButton } from './DeviceButton';
@@ -120,7 +121,9 @@ export function EditorHeader({
120
121
  if (!current || !editorData || !setEditorData) return;
121
122
  if (!copiedNode) return;
122
123
  const cloned = JSON.parse(JSON.stringify(copiedNode)) as Node;
123
- const updated = replaceNode(editorData, current, cloned);
124
+ const usedKeys = collectNodeKeys(editorData);
125
+ const repaired = repairNodeKeys(cloned, usedKeys);
126
+ const updated = replaceNode(editorData, current, repaired);
124
127
  useRenderStore.setState({
125
128
  copiedNode: null,
126
129
  });
@@ -129,7 +132,7 @@ export function EditorHeader({
129
132
  // Important: selection is stored by reference. After replacing `current` in the tree,
130
133
  // we must point selection to the new (cloned) node reference to keep "current node"
131
134
  // in sync with what’s rendered/edited.
132
- setCurrent(cloned);
135
+ setCurrent(repaired);
133
136
  };
134
137
 
135
138
  const cloneNode = (node: Node): Node =>
@@ -137,7 +140,7 @@ export function EditorHeader({
137
140
 
138
141
  const handleReplaceFromSample = (sample: Project) => {
139
142
  if (!setEditorData) return;
140
- const next = cloneNode(sample.data);
143
+ const next = repairNodeKeys(cloneNode(sample.data));
141
144
  setEditorData(next);
142
145
  setCurrent(next);
143
146
  if (sample.appConfig) setAppConfig(sample.appConfig);
@@ -148,6 +151,7 @@ export function EditorHeader({
148
151
  const handlePasteFromSample = (sample: Project) => {
149
152
  if (!current || !editorData || !setEditorData) return;
150
153
  const incoming = cloneNode(sample.data);
154
+ const usedKeys = collectNodeKeys(editorData);
151
155
 
152
156
  const isRecord = (v: unknown): v is Record<string, unknown> =>
153
157
  typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -181,13 +185,19 @@ export function EditorHeader({
181
185
  toast.error('Sample has no children to paste');
182
186
  return;
183
187
  }
188
+ const repairedPasteNodes = pasteNodes.map((node) =>
189
+ repairNodeKeys(node, usedKeys),
190
+ );
184
191
  let nextChildren: Node;
185
192
  if (!prevChildren) {
186
- nextChildren = pasteNodes.length === 1 ? pasteNodes[0] : pasteNodes;
193
+ nextChildren =
194
+ repairedPasteNodes.length === 1
195
+ ? repairedPasteNodes[0]
196
+ : repairedPasteNodes;
187
197
  } else if (Array.isArray(prevChildren)) {
188
- nextChildren = [...prevChildren, ...pasteNodes];
198
+ nextChildren = [...prevChildren, ...repairedPasteNodes];
189
199
  } else {
190
- nextChildren = [prevChildren, ...pasteNodes];
200
+ nextChildren = [prevChildren, ...repairedPasteNodes];
191
201
  }
192
202
 
193
203
  const nextNode: Node = { ...current, children: nextChildren } as Node;
@@ -1,4 +1,6 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
+ import type { Node } from '../types/Node';
3
+ import { wrapNodeInMain } from '../utils/wrapNodeInMain';
2
4
 
3
5
  type JsonTextEditorProps = {
4
6
  value: unknown;
@@ -74,6 +76,34 @@ export function JsonTextEditor({
74
76
  }
75
77
  };
76
78
 
79
+ const handleWrapInMain = () => {
80
+ try {
81
+ const parsed = JSON.parse(text) as unknown;
82
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
83
+ typeof v === 'object' && v !== null && !Array.isArray(v);
84
+
85
+ // Support both:
86
+ // - node JSON (wrap root)
87
+ // - project JSON (wrap `data`)
88
+ const nextValue =
89
+ isRecord(parsed) && 'data' in parsed
90
+ ? {
91
+ ...(parsed as any),
92
+ data: wrapNodeInMain((parsed as any).data as Node),
93
+ }
94
+ : wrapNodeInMain(parsed as Node);
95
+
96
+ setText(JSON.stringify(nextValue, null, 2));
97
+ setParseError(null);
98
+ setApplyError(null);
99
+ setParsedValue(nextValue);
100
+ // Intentionally NOT calling onChange here:
101
+ // user can review diff and press "Apply" explicitly.
102
+ } catch (e) {
103
+ setParseError(e instanceof Error ? e.message : 'Invalid JSON');
104
+ }
105
+ };
106
+
77
107
  const headerLabel = rootName ? `${rootName}.json` : 'data.json';
78
108
 
79
109
  return (
@@ -108,6 +138,17 @@ export function JsonTextEditor({
108
138
  >
109
139
  Format
110
140
  </button>
141
+ {!readOnly && (
142
+ <button
143
+ type="button"
144
+ className="editor-button"
145
+ onClick={handleWrapInMain}
146
+ disabled={!onChange}
147
+ title={onChange ? 'Wrap root in Main and apply' : 'Read only'}
148
+ >
149
+ Wrap in Main
150
+ </button>
151
+ )}
111
152
  {!readOnly && (
112
153
  <button
113
154
  type="button"