@jxsuite/studio 0.28.2 → 0.28.4

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 (104) hide show
  1. package/dist/studio.js +86144 -83971
  2. package/dist/studio.js.map +229 -219
  3. package/package.json +5 -5
  4. package/src/browse/browse-modal.ts +9 -3
  5. package/src/browse/browse.ts +139 -82
  6. package/src/canvas/canvas-diff.ts +35 -18
  7. package/src/canvas/canvas-helpers.ts +55 -21
  8. package/src/canvas/canvas-live-render.ts +140 -89
  9. package/src/canvas/canvas-render.ts +127 -82
  10. package/src/canvas/canvas-utils.ts +68 -42
  11. package/src/canvas/nested-site-style.ts +15 -7
  12. package/src/editor/component-inline-edit.ts +90 -51
  13. package/src/editor/content-inline-edit.ts +75 -72
  14. package/src/editor/context-menu.ts +125 -71
  15. package/src/editor/convert-targets.ts +17 -13
  16. package/src/editor/convert-to-component.ts +51 -28
  17. package/src/editor/convert-to-repeater.ts +44 -19
  18. package/src/editor/inline-edit.ts +107 -52
  19. package/src/editor/inline-format.ts +128 -60
  20. package/src/editor/insertion-helper.ts +26 -14
  21. package/src/editor/shortcuts.ts +89 -43
  22. package/src/editor/slash-menu.ts +41 -26
  23. package/src/files/components.ts +9 -7
  24. package/src/files/file-ops.ts +53 -33
  25. package/src/files/files.ts +246 -143
  26. package/src/format/constraints.ts +25 -15
  27. package/src/format/format-host.ts +33 -10
  28. package/src/github/github-auth.ts +24 -14
  29. package/src/github/github-publish.ts +20 -14
  30. package/src/new-project/new-project-modal.ts +27 -18
  31. package/src/panels/activity-bar.ts +27 -26
  32. package/src/panels/ai-panel.ts +100 -44
  33. package/src/panels/block-action-bar.ts +84 -52
  34. package/src/panels/canvas-dnd.ts +47 -28
  35. package/src/panels/data-explorer.ts +24 -11
  36. package/src/panels/dnd.ts +148 -100
  37. package/src/panels/editors.ts +54 -37
  38. package/src/panels/elements-panel.ts +23 -13
  39. package/src/panels/events-panel.ts +48 -38
  40. package/src/panels/git-panel.ts +95 -60
  41. package/src/panels/head-panel.ts +138 -84
  42. package/src/panels/imports-panel.ts +66 -29
  43. package/src/panels/layers-panel.ts +80 -36
  44. package/src/panels/left-panel.ts +86 -53
  45. package/src/panels/overlays.ts +36 -19
  46. package/src/panels/panel-events.ts +48 -27
  47. package/src/panels/panel-scheduler.ts +41 -23
  48. package/src/panels/preview-render.ts +26 -26
  49. package/src/panels/properties-panel.ts +155 -118
  50. package/src/panels/pseudo-preview.ts +31 -15
  51. package/src/panels/quick-search.ts +28 -13
  52. package/src/panels/right-panel.ts +35 -24
  53. package/src/panels/shared.ts +46 -29
  54. package/src/panels/signals-panel.ts +303 -186
  55. package/src/panels/statusbar.ts +27 -12
  56. package/src/panels/style-inputs.ts +30 -24
  57. package/src/panels/style-panel.ts +130 -90
  58. package/src/panels/style-utils.ts +63 -23
  59. package/src/panels/stylebook-layers-panel.ts +27 -30
  60. package/src/panels/stylebook-panel.ts +140 -72
  61. package/src/panels/tab-strip.ts +21 -10
  62. package/src/panels/toolbar.ts +52 -40
  63. package/src/panels/welcome-screen.ts +1 -1
  64. package/src/platform.ts +2 -1
  65. package/src/platforms/devserver.ts +221 -105
  66. package/src/recent-projects.ts +15 -7
  67. package/src/resize-edges.ts +7 -5
  68. package/src/services/cem-export.ts +73 -57
  69. package/src/services/code-services.ts +52 -25
  70. package/src/services/monaco-setup.ts +5 -5
  71. package/src/settings/content-types-editor.ts +133 -62
  72. package/src/settings/css-vars-editor.ts +31 -14
  73. package/src/settings/defs-editor.ts +125 -63
  74. package/src/settings/general-settings.ts +11 -6
  75. package/src/settings/head-editor.ts +30 -15
  76. package/src/settings/schema-field-ui.ts +75 -35
  77. package/src/settings/settings-modal.ts +35 -17
  78. package/src/site-context.ts +80 -47
  79. package/src/state.ts +98 -65
  80. package/src/store.ts +42 -23
  81. package/src/studio.ts +165 -144
  82. package/src/tabs/tab.ts +51 -45
  83. package/src/tabs/transact.ts +230 -109
  84. package/src/types.ts +60 -51
  85. package/src/ui/button-group.ts +3 -2
  86. package/src/ui/color-selector.ts +33 -15
  87. package/src/ui/expression-editor.ts +139 -86
  88. package/src/ui/field-input.ts +35 -18
  89. package/src/ui/field-row.ts +1 -1
  90. package/src/ui/icons.ts +5 -6
  91. package/src/ui/layers.ts +33 -26
  92. package/src/ui/media-picker.ts +13 -7
  93. package/src/ui/panel-resize.ts +39 -20
  94. package/src/ui/spectrum.ts +4 -2
  95. package/src/ui/unit-selector.ts +20 -11
  96. package/src/ui/value-selector.ts +13 -7
  97. package/src/ui/widgets.ts +34 -18
  98. package/src/utils/canvas-media.ts +66 -33
  99. package/src/utils/edit-display.ts +43 -44
  100. package/src/utils/google-fonts.ts +10 -6
  101. package/src/utils/inherited-style.ts +22 -8
  102. package/src/utils/studio-utils.ts +88 -45
  103. package/src/view.ts +9 -5
  104. package/src/workspace/workspace.ts +49 -29
@@ -1,4 +1,5 @@
1
1
  /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
2
3
  /** Canvas media/breakpoint utilities — pure functions extracted for testability. */
3
4
 
4
5
  import type { JxStyle } from "@jxsuite/schema/types";
@@ -14,25 +15,31 @@ import type { JxStyle } from "@jxsuite/schema/types";
14
15
  * baseWidth: number;
15
16
  * }}
16
17
  */
17
- export function parseMediaEntries(mediaDef: Record<string, string> | null | undefined) {
18
- if (!mediaDef) return { sizeBreakpoints: [], featureQueries: [], baseWidth: 320 };
19
- const sizes = [],
20
- features = [];
18
+ export function parseMediaEntries(mediaDef?: Record<string, string> | null) {
19
+ if (!mediaDef) {
20
+ return { baseWidth: 320, featureQueries: [], sizeBreakpoints: [] };
21
+ }
22
+ const features = [];
23
+ const sizes = [];
21
24
  let baseWidth = 320;
22
25
  for (const [name, query] of Object.entries(mediaDef)) {
23
26
  if (name === "--") {
24
27
  const wm = String(query).match(/^(\d+)\s*px$/);
25
- baseWidth = wm ? parseFloat(wm[1]) : 320;
28
+ baseWidth = wm ? Number.parseFloat(wm[1]) : 320;
26
29
  continue;
27
30
  }
28
31
  const minMatch = query.match(/min-width:\s*([\d.]+)px/);
29
32
  const maxMatch = query.match(/max-width:\s*([\d.]+)px/);
30
- if (minMatch) sizes.push({ name, query, width: parseFloat(minMatch[1]), type: "min" });
31
- else if (maxMatch) sizes.push({ name, query, width: parseFloat(maxMatch[1]), type: "max" });
32
- else features.push({ name, query });
33
+ if (minMatch) {
34
+ sizes.push({ name, query, type: "min", width: Number.parseFloat(minMatch[1]) });
35
+ } else if (maxMatch) {
36
+ sizes.push({ name, query, type: "max", width: Number.parseFloat(maxMatch[1]) });
37
+ } else {
38
+ features.push({ name, query });
39
+ }
33
40
  }
34
41
  sizes.sort((a, b) => (a.type === "min" ? a.width - b.width : b.width - a.width));
35
- return { sizeBreakpoints: sizes, featureQueries: features, baseWidth };
42
+ return { baseWidth, featureQueries: features, sizeBreakpoints: sizes };
36
43
  }
37
44
 
38
45
  /**
@@ -48,8 +55,11 @@ export function activeBreakpointsForWidth(
48
55
  ) {
49
56
  const active = new Set<string>();
50
57
  for (const bp of sizeBreakpoints) {
51
- if (bp.type === "min" && canvasWidth >= bp.width) active.add(bp.name);
52
- else if (bp.type === "max" && canvasWidth <= bp.width) active.add(bp.name);
58
+ if (bp.type === "min" && canvasWidth >= bp.width) {
59
+ active.add(bp.name);
60
+ } else if (bp.type === "max" && canvasWidth <= bp.width) {
61
+ active.add(bp.name);
62
+ }
53
63
  }
54
64
  return active;
55
65
  }
@@ -69,25 +79,37 @@ export function applyCanvasStyle(
69
79
  activeBreakpoints: Set<string>,
70
80
  featureToggles: Record<string, boolean>,
71
81
  ) {
72
- if (!styleDef || typeof styleDef !== "object") return;
82
+ if (!styleDef || typeof styleDef !== "object") {
83
+ return;
84
+ }
73
85
  for (const [prop, val] of Object.entries(styleDef)) {
74
86
  if (typeof val === "string" || typeof val === "number") {
75
87
  try {
76
- if (prop.startsWith("--")) el.style.setProperty(prop, String(val));
77
- else (el.style as unknown as Record<string, unknown>)[prop] = val;
88
+ if (prop.startsWith("--")) {
89
+ el.style.setProperty(prop, String(val));
90
+ } else {
91
+ (el.style as unknown as Record<string, unknown>)[prop] = val;
92
+ }
78
93
  } catch {}
79
94
  }
80
95
  }
81
96
  for (const [key, val] of Object.entries(styleDef)) {
82
- if (!key.startsWith("@") || typeof val !== "object" || val === null) continue;
97
+ if (!key.startsWith("@") || typeof val !== "object" || val === null) {
98
+ continue;
99
+ }
83
100
  const mediaName = key.slice(1);
84
- if (mediaName === "--") continue;
101
+ if (mediaName === "--") {
102
+ continue;
103
+ }
85
104
  if (activeBreakpoints.has(mediaName) || featureToggles[mediaName]) {
86
105
  for (const [prop, v] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
87
106
  if (typeof v === "string" || typeof v === "number") {
88
107
  try {
89
- if (prop.startsWith("--")) el.style.setProperty(prop, String(v));
90
- else (el.style as unknown as Record<string, unknown>)[prop] = v;
108
+ if (prop.startsWith("--")) {
109
+ el.style.setProperty(prop, String(v));
110
+ } else {
111
+ (el.style as unknown as Record<string, unknown>)[prop] = v;
112
+ }
91
113
  } catch {}
92
114
  }
93
115
  }
@@ -109,8 +131,10 @@ export function collectMediaOverrides(
109
131
  styleSheets: Iterable<CSSStyleSheet>,
110
132
  activeBreakpoints: Set<string>,
111
133
  ) {
112
- const overrides: Map<string, Map<string, string>> = new Map();
113
- if (!activeBreakpoints.size) return overrides;
134
+ const overrides = new Map<string, Map<string, string>>();
135
+ if (activeBreakpoints.size === 0) {
136
+ return overrides;
137
+ }
114
138
 
115
139
  for (const sheet of styleSheets) {
116
140
  /** @type {CSSRuleList | null} */
@@ -120,22 +144,31 @@ export function collectMediaOverrides(
120
144
  } catch {
121
145
  continue;
122
146
  }
123
- if (!rules) continue;
124
- for (let ri = 0; ri < rules.length; ri++) {
125
- const rule = rules[ri];
126
- if (!(rule instanceof CSSMediaRule)) continue;
127
- if (!activeBreakpoints.has(rule.conditionText)) continue;
128
- for (let mi = 0; mi < rule.cssRules.length; mi++) {
129
- const mediaRule = rule.cssRules[mi];
130
- if (!(mediaRule instanceof CSSStyleRule)) continue;
147
+ if (!rules) {
148
+ continue;
149
+ }
150
+ for (const rule of rules) {
151
+ if (!(rule instanceof CSSMediaRule)) {
152
+ continue;
153
+ }
154
+ if (!activeBreakpoints.has(rule.conditionText)) {
155
+ continue;
156
+ }
157
+ for (const mediaRule of rule.cssRules) {
158
+ if (!(mediaRule instanceof CSSStyleRule)) {
159
+ continue;
160
+ }
131
161
  const selector = mediaRule.selectorText;
132
162
  const jxMatch = selector.match(/\[data-jx="([^"]+)"\]/);
133
- if (!jxMatch) continue;
134
- const uid = jxMatch[1];
135
- if (!overrides.has(uid)) overrides.set(uid, new Map());
163
+ if (!jxMatch) {
164
+ continue;
165
+ }
166
+ const [, uid] = jxMatch;
167
+ if (!overrides.has(uid)) {
168
+ overrides.set(uid, new Map());
169
+ }
136
170
  const props = overrides.get(uid) as Map<string, string>;
137
- for (let i = 0; i < mediaRule.style.length; i++) {
138
- const prop = mediaRule.style[i];
171
+ for (const prop of mediaRule.style) {
139
172
  props.set(prop, mediaRule.style.getPropertyValue(prop));
140
173
  }
141
174
  }
@@ -18,7 +18,7 @@ const TRANSPARENT_PX =
18
18
  * @param {string} str
19
19
  */
20
20
  export function templateToEditDisplay(str: string) {
21
- return str.replace(/\$\{([^}]+)\}/g, "\u276A $1 \u276B");
21
+ return str.replaceAll(/\$\{([^}]+)\}/g, "\u276A $1 \u276B");
22
22
  }
23
23
 
24
24
  /**
@@ -32,7 +32,7 @@ export function restoreTemplateExpressions(el: HTMLElement) {
32
32
  while (walker.nextNode()) {
33
33
  const node = walker.currentNode as Text;
34
34
  if (node.data.includes("\u276A")) {
35
- node.data = node.data.replace(/\u276A\s*(.*?)\s*\u276B/g, "${$1}");
35
+ node.data = node.data.replaceAll(/\u276A\s*(.*?)\s*\u276B/g, "${$1}");
36
36
  }
37
37
  }
38
38
  }
@@ -46,8 +46,13 @@ export function restoreTemplateExpressions(el: HTMLElement) {
46
46
  * @returns {JxMutableNode}
47
47
  */
48
48
  export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
49
- if (!node || typeof node !== "object") return node;
50
- if (Array.isArray(node)) return node.map(prepareForEditMode);
49
+ if (!node || typeof node !== "object") {
50
+ return node;
51
+ }
52
+ if (Array.isArray(node)) {
53
+ // Arrays of nodes round-trip element-wise; the array itself is not a node.
54
+ return node.map((n) => prepareForEditMode(n)) as unknown as JxMutableNode;
55
+ }
51
56
 
52
57
  const /** @type {Record<string, unknown>} */ obj = node as Record<string, unknown>;
53
58
 
@@ -71,7 +76,7 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
71
76
 
72
77
  for (const [k, v] of Object.entries(obj)) {
73
78
  if (k === "state" || k === "$media" || k === "$elements") {
74
- out[k] = v; // preserve as-is for runtime resolution
79
+ out[k] = v; // Preserve as-is for runtime resolution
75
80
  } else if (k === "$props" && v && typeof v === "object") {
76
81
  // Process $props values: convert template strings to display format
77
82
  const propsOut: Record<string, unknown> = {};
@@ -90,7 +95,7 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
90
95
  out[k] = propsOut;
91
96
  } else if (k === "children") {
92
97
  if (Array.isArray(v)) {
93
- out.children = v.map(prepareForEditMode);
98
+ out.children = v.map((c) => prepareForEditMode(c));
94
99
  } else if (
95
100
  v &&
96
101
  typeof v === "object" &&
@@ -99,17 +104,16 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
99
104
  // Wrap the map template in a visual repeater perimeter
100
105
  const vObj = v as Record<string, unknown>;
101
106
  const template = vObj.map;
102
- if (template && typeof template === "object") {
103
- out.children = [
104
- {
105
- tagName: "div",
106
- className: "repeater-perimeter",
107
- children: [prepareForEditMode(template as JxMutableNode)],
108
- },
109
- ];
110
- } else {
111
- out.children = [];
112
- }
107
+ out.children =
108
+ template && typeof template === "object"
109
+ ? [
110
+ {
111
+ children: [prepareForEditMode(template as JxMutableNode)],
112
+ className: "repeater-perimeter",
113
+ tagName: "div",
114
+ },
115
+ ]
116
+ : [];
113
117
  } else {
114
118
  out.children = prepareForEditMode(v as JxMutableNode);
115
119
  }
@@ -118,30 +122,25 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
118
122
  const caseKeys = Object.keys(v);
119
123
  if (caseKeys.length > 0) {
120
124
  const firstCase = (v as Record<string, unknown>)[caseKeys[0]];
121
- if (
122
- firstCase &&
123
- typeof firstCase === "object" &&
124
- !(firstCase as Record<string, unknown>).$ref
125
- ) {
126
- out.children = [prepareForEditMode(firstCase)];
127
- } else {
128
- out.children = [
129
- {
130
- tagName: "div",
131
- textContent: `[$switch: ${caseKeys.join(" | ")}]`,
132
- style: {
133
- fontFamily: "'SF Mono', 'Fira Code', monospace",
134
- fontSize: "11px",
135
- padding: "6px 10px",
136
- background: "color-mix(in srgb, var(--danger) 8%, transparent)",
137
- border: "1px dashed color-mix(in srgb, var(--danger) 40%, transparent)",
138
- borderRadius: "4px",
139
- color: "var(--danger)",
140
- fontStyle: "italic",
141
- },
142
- },
143
- ];
144
- }
125
+ out.children =
126
+ firstCase && typeof firstCase === "object" && !(firstCase as Record<string, unknown>).$ref
127
+ ? [prepareForEditMode(firstCase as JxMutableNode)]
128
+ : [
129
+ {
130
+ style: {
131
+ background: "color-mix(in srgb, var(--danger) 8%, transparent)",
132
+ border: "1px dashed color-mix(in srgb, var(--danger) 40%, transparent)",
133
+ borderRadius: "4px",
134
+ color: "var(--danger)",
135
+ fontFamily: "'SF Mono', 'Fira Code', monospace",
136
+ fontSize: "11px",
137
+ fontStyle: "italic",
138
+ padding: "6px 10px",
139
+ },
140
+ tagName: "div",
141
+ textContent: `[$switch: ${caseKeys.join(" | ")}]`,
142
+ },
143
+ ];
145
144
  }
146
145
  } else if (k === "attributes" && isMediaElement && v && typeof v === "object") {
147
146
  // Process attributes for media elements: replace src/poster with transparent pixel
@@ -276,11 +275,11 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
276
275
  ]);
277
276
  if (textTags.has(tag)) {
278
277
  out.className = out.className
279
- ? out.className + " empty-text-placeholder"
278
+ ? `${out.className} empty-text-placeholder`
280
279
  : "empty-text-placeholder";
281
280
  } else if (containerTags.has(tag)) {
282
281
  out.className = out.className
283
- ? out.className + " empty-container-placeholder"
282
+ ? `${out.className} empty-container-placeholder`
284
283
  : "empty-container-placeholder";
285
284
  }
286
285
  }
@@ -290,7 +289,7 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
290
289
  if (needsMediaPlaceholder) {
291
290
  const cls = (out.className as string) || "";
292
291
  if (!cls.includes("empty-media-placeholder")) {
293
- out.className = cls ? cls + " empty-media-placeholder" : "empty-media-placeholder";
292
+ out.className = cls ? `${cls} empty-media-placeholder` : "empty-media-placeholder";
294
293
  }
295
294
  }
296
295
 
@@ -45,8 +45,10 @@ export function isGoogleFontPreconnect(entry: JxHeadEntry) {
45
45
  */
46
46
  export function extractFontFamily(href: string) {
47
47
  const match = href.match(/family=([^&:]+)/);
48
- if (!match) return "";
49
- return decodeURIComponent(match[1].replace(/\+/g, " "));
48
+ if (!match) {
49
+ return "";
50
+ }
51
+ return decodeURIComponent(match[1].replaceAll("+", " "));
50
52
  }
51
53
 
52
54
  /**
@@ -56,7 +58,7 @@ export function extractFontFamily(href: string) {
56
58
  * @returns {string}
57
59
  */
58
60
  export function buildGoogleFontUrl(family: string) {
59
- return `${GFONTS_CSS_PREFIX}family=${encodeURIComponent(family).replace(/%20/g, "+")}&display=swap`;
61
+ return `${GFONTS_CSS_PREFIX}family=${encodeURIComponent(family).replaceAll("%20", "+")}&display=swap`;
60
62
  }
61
63
 
62
64
  /**
@@ -74,11 +76,13 @@ export function ensureGoogleFontPreconnects(head: JxHeadEntry[]) {
74
76
  );
75
77
  if (!exists) {
76
78
  const attrs: Record<string, string | boolean> = {
77
- rel: "preconnect",
78
79
  href: origin,
80
+ rel: "preconnect",
79
81
  };
80
- if (origin === "https://fonts.gstatic.com") attrs.crossorigin = "";
81
- head.push({ tagName: "link", attributes: attrs });
82
+ if (origin === "https://fonts.gstatic.com") {
83
+ attrs.crossorigin = "";
84
+ }
85
+ head.push({ attributes: attrs, tagName: "link" });
82
86
  }
83
87
  }
84
88
  }
@@ -24,34 +24,48 @@ export function computeInheritedStyle(
24
24
  activeTab: string | null,
25
25
  activeSelector: string | null = null,
26
26
  ) {
27
- if (activeTab === null || mediaNames.length === 0) return {};
27
+ if (activeTab === null || mediaNames.length === 0) {
28
+ return {};
29
+ }
28
30
 
29
- let inherited: Record<string, string | number> = {};
31
+ const inherited: Record<string, string | number> = {};
30
32
 
31
33
  if (!activeSelector) {
32
34
  // Start with base flat props
33
35
  for (const [p, v] of Object.entries(style)) {
34
- if (typeof v !== "object") inherited[p] = (v as string | number) ?? "";
36
+ if (typeof v !== "object") {
37
+ inherited[p] = (v as string | number) ?? "";
38
+ }
35
39
  }
36
40
  // Layer each media block in order until current tab
37
41
  for (const name of mediaNames) {
38
- if (name === activeTab) break;
42
+ if (name === activeTab) {
43
+ break;
44
+ }
39
45
  const block = (style[`@${name}`] || {}) as JxStyle;
40
46
  for (const [p, v] of Object.entries(block)) {
41
- if (typeof v !== "object") inherited[p] = (v as string | number) ?? "";
47
+ if (typeof v !== "object") {
48
+ inherited[p] = (v as string | number) ?? "";
49
+ }
42
50
  }
43
51
  }
44
52
  } else {
45
53
  // Selector inheritance: base selector → each media's selector block in order
46
54
  const baseSel = (style[activeSelector] || {}) as JxStyle;
47
55
  for (const [p, v] of Object.entries(baseSel)) {
48
- if (typeof v !== "object") inherited[p] = (v as string | number) ?? "";
56
+ if (typeof v !== "object") {
57
+ inherited[p] = (v as string | number) ?? "";
58
+ }
49
59
  }
50
60
  for (const name of mediaNames) {
51
- if (name === activeTab) break;
61
+ if (name === activeTab) {
62
+ break;
63
+ }
52
64
  const selBlock = ((style[`@${name}`] || {}) as Record<string, unknown>)[activeSelector] || {};
53
65
  for (const [p, v] of Object.entries(selBlock)) {
54
- if (typeof v !== "object") inherited[p] = v ?? "";
66
+ if (typeof v !== "object") {
67
+ inherited[p] = v ?? "";
68
+ }
55
69
  }
56
70
  }
57
71
  }
@@ -4,8 +4,8 @@
4
4
  * These are all side-effect-free functions used by style/properties/events panels.
5
5
  */
6
6
 
7
- import { formatByName, defaultContentFormat } from "../format/format-host";
8
- import type { ProjectConfig, ContentTypeDef } from "@jxsuite/schema/types";
7
+ import { defaultContentFormat, formatByName } from "../format/format-host";
8
+ import type { ContentTypeDef, ProjectConfig } from "@jxsuite/schema/types";
9
9
 
10
10
  /**
11
11
  * CamelCase → kebab-case for inline style attributes
@@ -14,7 +14,7 @@ import type { ProjectConfig, ContentTypeDef } from "@jxsuite/schema/types";
14
14
  * @returns {string}
15
15
  */
16
16
  export function camelToKebab(str: string) {
17
- return str.replace(/[A-Z]/g, (c: string) => "-" + c.toLowerCase());
17
+ return str.replaceAll(/[A-Z]/g, (c: string) => `-${c.toLowerCase()}`);
18
18
  }
19
19
 
20
20
  /**
@@ -25,12 +25,12 @@ export function camelToKebab(str: string) {
25
25
  * @returns {string}
26
26
  */
27
27
  export function camelToLabel(prop: string) {
28
- return prop.replace(/([A-Z])/g, " $1").replace(/^./, (c: string) => c.toUpperCase());
28
+ return prop.replaceAll(/([A-Z])/g, " $1").replace(/^./, (c: string) => c.toUpperCase());
29
29
  }
30
30
 
31
31
  export function toCamelCase(str: string): string {
32
32
  return str
33
- .replace(/[^a-zA-Z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
33
+ .replaceAll(/[^a-zA-Z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
34
34
  .replace(/^[A-Z]/, (c) => c.toLowerCase());
35
35
  }
36
36
 
@@ -42,7 +42,7 @@ export function toCamelCase(str: string): string {
42
42
  * @returns {string}
43
43
  */
44
44
  export function kebabToLabel(val: string) {
45
- return val.replace(
45
+ return val.replaceAll(
46
46
  /(^|-)(\w)/g,
47
47
  (_: string, sep: string, c: string) => (sep ? " " : "") + c.toUpperCase(),
48
48
  );
@@ -73,12 +73,15 @@ export function attrLabel(
73
73
  entry: { $label?: string; [key: string]: unknown } | null | undefined,
74
74
  attr: string,
75
75
  ) {
76
- if (entry?.$label) return entry.$label;
77
- if (attr.includes("-"))
78
- return attr.replace(
76
+ if (entry?.$label) {
77
+ return entry.$label;
78
+ }
79
+ if (attr.includes("-")) {
80
+ return attr.replaceAll(
79
81
  /(^|-)(\w)/g,
80
82
  (_: string, sep: string, c: string) => (sep ? " " : "") + c.toUpperCase(),
81
83
  );
84
+ }
82
85
  return camelToLabel(attr);
83
86
  }
84
87
 
@@ -90,25 +93,25 @@ export function attrLabel(
90
93
  */
91
94
  export function abbreviateValue(val: string) {
92
95
  const map: Record<string, string> = {
96
+ baseline: "base",
97
+ column: "col",
98
+ "column-reverse": "col-r",
99
+ contents: "cnt",
100
+ "flex-end": "end",
101
+ "flex-start": "start",
102
+ "flow-root": "flow",
93
103
  inline: "inl",
94
104
  "inline-block": "i-blk",
95
105
  "inline-flex": "i-flx",
96
106
  "inline-grid": "i-grd",
97
- contents: "cnt",
98
- "flow-root": "flow",
107
+ normal: "norm",
99
108
  nowrap: "no-wr",
100
- "wrap-reverse": "wr-rev",
101
- "flex-start": "start",
102
- "flex-end": "end",
103
- "space-between": "betw",
109
+ "row-reverse": "row-r",
104
110
  "space-around": "arnd",
111
+ "space-between": "betw",
105
112
  "space-evenly": "even",
106
113
  stretch: "str",
107
- baseline: "base",
108
- normal: "norm",
109
- "row-reverse": "row-r",
110
- "column-reverse": "col-r",
111
- column: "col",
114
+ "wrap-reverse": "wr-rev",
112
115
  };
113
116
  return map[val] || val;
114
117
  }
@@ -120,15 +123,33 @@ export function abbreviateValue(val: string) {
120
123
  * @returns {string}
121
124
  */
122
125
  export function inferInputType(entry: Record<string, unknown>) {
123
- if (entry.$shorthand === true) return "shorthand";
124
- if (entry.$input === "button-group") return "button-group";
125
- if (entry.$input === "media") return "media";
126
- if (entry.format === "color") return "color";
127
- if (entry.format === "uri-reference") return "media";
128
- if (entry.$units !== undefined) return "number-unit";
129
- if (entry.type === "number") return "number";
130
- if (Array.isArray(entry.enum)) return "select";
131
- if (Array.isArray(entry.examples) || Array.isArray(entry.presets)) return "combobox";
126
+ if (entry.$shorthand === true) {
127
+ return "shorthand";
128
+ }
129
+ if (entry.$input === "button-group") {
130
+ return "button-group";
131
+ }
132
+ if (entry.$input === "media") {
133
+ return "media";
134
+ }
135
+ if (entry.format === "color") {
136
+ return "color";
137
+ }
138
+ if (entry.format === "uri-reference") {
139
+ return "media";
140
+ }
141
+ if (entry.$units !== undefined) {
142
+ return "number-unit";
143
+ }
144
+ if (entry.type === "number") {
145
+ return "number";
146
+ }
147
+ if (Array.isArray(entry.enum)) {
148
+ return "select";
149
+ }
150
+ if (Array.isArray(entry.examples) || Array.isArray(entry.presets)) {
151
+ return "combobox";
152
+ }
132
153
  return "text";
133
154
  }
134
155
 
@@ -144,15 +165,19 @@ export function findContentTypeSchema(
144
165
  documentPath: string | null,
145
166
  projectConfig: ProjectConfig | null | undefined,
146
167
  ) {
147
- if (!documentPath || !projectConfig?.contentTypes) return null;
168
+ if (!documentPath || !projectConfig?.contentTypes) {
169
+ return null;
170
+ }
148
171
  for (const [name, def] of Object.entries(
149
172
  projectConfig.contentTypes as Record<string, ContentTypeDef>,
150
173
  )) {
151
- if (!def.source || !def.schema) continue;
174
+ if (!def.source || !def.schema) {
175
+ continue;
176
+ }
152
177
  const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
153
178
  const hasExt = src.includes(".") && !src.endsWith("/");
154
179
  if (hasExt) {
155
- if (documentPath === src || documentPath.endsWith("/" + src)) {
180
+ if (documentPath === src || documentPath.endsWith(`/${src}`)) {
156
181
  return { name, schema: def.schema };
157
182
  }
158
183
  } else {
@@ -162,7 +187,7 @@ export function findContentTypeSchema(
162
187
  : (formatByName(def.format)?.extensions[0] ??
163
188
  defaultContentFormat()?.extensions[0] ??
164
189
  ".json");
165
- if (documentPath.startsWith(src + "/") && documentPath.endsWith(ext)) {
190
+ if (documentPath.startsWith(`${src}/`) && documentPath.endsWith(ext)) {
166
191
  return { name, schema: def.schema };
167
192
  }
168
193
  }
@@ -182,11 +207,13 @@ export function friendlyNameToVar(name: string, prefix: string) {
182
207
  const slug = name
183
208
  .trim()
184
209
  .toLowerCase()
185
- .replace(/[^a-z0-9\s-]/g, "")
186
- .replace(/\s+/g, "-")
187
- .replace(/-+/g, "-")
188
- .replace(/^-|-$/g, "");
189
- if (!slug) return "";
210
+ .replaceAll(/[^a-z0-9\s-]/g, "")
211
+ .replaceAll(/\s+/g, "-")
212
+ .replaceAll(/-+/g, "-")
213
+ .replaceAll(/^-|-$/g, "");
214
+ if (!slug) {
215
+ return "";
216
+ }
190
217
  return `${prefix}${slug}`;
191
218
  }
192
219
 
@@ -201,10 +228,10 @@ export function friendlyNameToVar(name: string, prefix: string) {
201
228
  export function varDisplayName(varName: string, prefix: string) {
202
229
  return (
203
230
  varName
204
- .replace(new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), "")
231
+ .replace(new RegExp(`^${prefix.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)}`), "")
205
232
  .replace(/^--/, "")
206
- .replace(/-/g, " ")
207
- .replace(/\b\w/g, (c: string) => c.toUpperCase()) || varName
233
+ .replaceAll("-", " ")
234
+ .replaceAll(/\b\w/g, (c: string) => c.toUpperCase()) || varName
208
235
  );
209
236
  }
210
237
 
@@ -218,13 +245,19 @@ export function varDisplayName(varName: string, prefix: string) {
218
245
  * | { kind: "text" }}
219
246
  */
220
247
  export function parseCemType(typeText: string | undefined | null) {
221
- if (!typeText) return { kind: "text" };
248
+ if (!typeText) {
249
+ return { kind: "text" };
250
+ }
222
251
  const t = typeText
223
252
  .trim()
224
- .replace(/\s*\|\s*undefined\b/g, "")
253
+ .replaceAll(/\s*\|\s*undefined\b/g, "")
225
254
  .trim();
226
- if (t === "boolean") return { kind: "boolean" };
227
- if (t === "number") return { kind: "number" };
255
+ if (t === "boolean") {
256
+ return { kind: "boolean" };
257
+ }
258
+ if (t === "number") {
259
+ return { kind: "number" };
260
+ }
228
261
  // Detect enum: "'a' | 'b' | 'c'" — pipe-separated quoted literals
229
262
  const enumMatch = t.match(/^'[^']*'(\s*\|\s*'[^']*')+$/);
230
263
  if (enumMatch) {
@@ -233,3 +266,13 @@ export function parseCemType(typeText: string | undefined | null) {
233
266
  }
234
267
  return { kind: "text" };
235
268
  }
269
+
270
+ /**
271
+ * Clone a (possibly reactive) document tree via JSON round-trip. Unlike structuredClone, this works
272
+ * on Vue reactive proxies and JSON-normalizes the tree (drops undefined values and functions) —
273
+ * both required for document snapshots and clipboard payloads.
274
+ */
275
+ export function jsonClone<T>(value: T): T {
276
+ // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
277
+ return JSON.parse(JSON.stringify(value)) as T;
278
+ }