@jxsuite/studio 0.10.2 → 0.13.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 (53) hide show
  1. package/README.md +82 -0
  2. package/dist/studio.js +5114 -3424
  3. package/dist/studio.js.map +65 -49
  4. package/package.json +6 -3
  5. package/src/browse/browse.js +27 -3
  6. package/src/canvas/canvas-diff.js +184 -0
  7. package/src/canvas/canvas-helpers.js +10 -14
  8. package/src/canvas/canvas-live-render.js +136 -17
  9. package/src/canvas/canvas-render.js +154 -21
  10. package/src/canvas/canvas-utils.js +21 -23
  11. package/src/editor/component-inline-edit.js +54 -41
  12. package/src/editor/content-inline-edit.js +46 -47
  13. package/src/editor/context-menu.js +63 -39
  14. package/src/editor/convert-to-component.js +11 -14
  15. package/src/editor/insertion-helper.js +8 -10
  16. package/src/editor/shortcuts.js +69 -39
  17. package/src/files/components.js +15 -4
  18. package/src/files/files.js +72 -24
  19. package/src/panels/activity-bar.js +29 -7
  20. package/src/panels/block-action-bar.js +104 -80
  21. package/src/panels/canvas-dnd.js +132 -50
  22. package/src/panels/dnd.js +32 -28
  23. package/src/panels/editors.js +7 -14
  24. package/src/panels/elements-panel.js +9 -3
  25. package/src/panels/events-panel.js +44 -39
  26. package/src/panels/git-panel.js +45 -3
  27. package/src/panels/layers-panel.js +16 -11
  28. package/src/panels/left-panel.js +108 -43
  29. package/src/panels/overlays.js +97 -35
  30. package/src/panels/panel-events.js +18 -11
  31. package/src/panels/properties-panel.js +467 -98
  32. package/src/panels/right-panel.js +85 -37
  33. package/src/panels/shared.js +0 -22
  34. package/src/panels/signals-panel.js +125 -54
  35. package/src/panels/statusbar.js +23 -8
  36. package/src/panels/style-inputs.js +4 -2
  37. package/src/panels/style-panel.js +128 -105
  38. package/src/panels/stylebook-panel.js +42 -17
  39. package/src/panels/tab-strip.js +124 -0
  40. package/src/panels/toolbar.js +39 -9
  41. package/src/reactivity.js +28 -0
  42. package/src/settings/content-types-editor.js +78 -8
  43. package/src/settings/defs-editor.js +56 -7
  44. package/src/settings/schema-field-ui.js +99 -11
  45. package/src/site-context.js +105 -0
  46. package/src/state.js +0 -456
  47. package/src/store.js +105 -124
  48. package/src/studio.js +112 -121
  49. package/src/tabs/tab.js +153 -0
  50. package/src/tabs/transact.js +406 -0
  51. package/src/ui/spectrum.js +2 -0
  52. package/src/view.js +3 -0
  53. package/src/workspace/workspace.js +89 -0
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { html, nothing } from "lit-html";
7
7
 
8
- export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "date"];
8
+ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "reference"];
9
+
10
+ export const FORMAT_OPTIONS = ["", "image", "date", "color"];
9
11
 
10
12
  /**
11
13
  * @typedef {{
@@ -14,6 +16,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
14
16
  * required?: string[];
15
17
  * items?: any;
16
18
  * format?: string;
19
+ * $ref?: string;
17
20
  * }} SchemaProperty
18
21
  */
19
22
 
@@ -23,6 +26,8 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
23
26
  * onToggleRequired: (name: string) => void;
24
27
  * onRename: (oldName: string, newName: string) => void;
25
28
  * onChangeType: (name: string, newType: string) => void;
29
+ * onChangeFormat?: (name: string, format: string) => void;
30
+ * onChangeRefTarget?: (name: string, target: string) => void;
26
31
  * onAddNestedField?: (
27
32
  * parentName: string,
28
33
  * state: { name: string; type: string; required: boolean },
@@ -31,9 +36,32 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
31
36
  * onToggleNestedRequired?: (parentName: string, childName: string) => void;
32
37
  * onRenameNested?: (parentName: string, oldChild: string, newChild: string) => void;
33
38
  * onChangeNestedType?: (parentName: string, childName: string, newType: string) => void;
39
+ * onChangeNestedFormat?: (parentName: string, childName: string, format: string) => void;
34
40
  * }} FieldHandlers
35
41
  */
36
42
 
43
+ /**
44
+ * Detect the studio field type from a JSON Schema property definition.
45
+ *
46
+ * @param {SchemaProperty} schema
47
+ * @returns {string}
48
+ */
49
+ export function detectFieldType(schema) {
50
+ if (schema.$ref) return "reference";
51
+ return schema.type || "string";
52
+ }
53
+
54
+ /**
55
+ * Detect the format from a JSON Schema property definition.
56
+ *
57
+ * @param {SchemaProperty} schema
58
+ * @returns {string}
59
+ */
60
+ export function detectFieldFormat(schema) {
61
+ if (schema.type === "array" && schema.items?.format) return schema.items.format;
62
+ return schema.format || "";
63
+ }
64
+
37
65
  /**
38
66
  * Render a single schema field as an inline-editable form row.
39
67
  *
@@ -41,13 +69,17 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
41
69
  * @param {SchemaProperty} fieldSchema — JSON Schema property definition
42
70
  * @param {boolean} isRequired
43
71
  * @param {FieldHandlers} handlers
72
+ * @param {string[]} [contentTypeNames] - Available content type names for reference target picker
44
73
  * @returns {any}
45
74
  */
46
- export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
47
- const type = fieldSchema.format === "date" ? "date" : fieldSchema.type || "string";
75
+ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers, contentTypeNames = []) {
76
+ const type = detectFieldType(fieldSchema);
77
+ const format = detectFieldFormat(fieldSchema);
48
78
  const isNested = type === "object";
79
+ const isRef = type === "reference";
49
80
  const nestedProps = fieldSchema.properties || {};
50
81
  const nestedRequired = fieldSchema.required || [];
82
+ const refTarget = fieldSchema.$ref ? fieldSchema.$ref.replace("#/contentTypes/", "") : "";
51
83
 
52
84
  return html`
53
85
  <div class="schema-field-card">
@@ -71,6 +103,11 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
71
103
  }}
72
104
  ></sp-textfield>
73
105
  ${typePickerTpl(type, (newType) => handlers.onChangeType(fieldName, newType))}
106
+ ${type === "string" || type === "array"
107
+ ? formatPickerTpl(format, (f) => {
108
+ if (handlers.onChangeFormat) handlers.onChangeFormat(fieldName, f);
109
+ })
110
+ : nothing}
74
111
  <sp-switch
75
112
  size="s"
76
113
  ?checked=${isRequired}
@@ -87,6 +124,23 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
87
124
  <sp-icon-delete slot="icon"></sp-icon-delete>
88
125
  </sp-action-button>
89
126
  </div>
127
+ ${isRef && contentTypeNames.length
128
+ ? html`
129
+ <div class="schema-field-ref-target">
130
+ <sp-picker
131
+ size="s"
132
+ label="Target"
133
+ value=${refTarget}
134
+ @change=${(/** @type {any} */ e) => {
135
+ if (handlers.onChangeRefTarget)
136
+ handlers.onChangeRefTarget(fieldName, e.target.value);
137
+ }}
138
+ >
139
+ ${contentTypeNames.map((n) => html`<sp-menu-item value=${n}>${n}</sp-menu-item>`)}
140
+ </sp-picker>
141
+ </div>
142
+ `
143
+ : nothing}
90
144
  ${isNested
91
145
  ? html`
92
146
  <div class="schema-field-nested">
@@ -119,7 +173,8 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
119
173
  * @returns {any}
120
174
  */
121
175
  function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, handlers) {
122
- const type = childSchema.format === "date" ? "date" : childSchema.type || "string";
176
+ const type = detectFieldType(childSchema);
177
+ const format = detectFieldFormat(childSchema);
123
178
 
124
179
  return html`
125
180
  <div class="schema-field-card schema-field-card--nested">
@@ -149,6 +204,12 @@ function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, hand
149
204
  if (handlers.onChangeNestedType)
150
205
  handlers.onChangeNestedType(parentName, childName, newType);
151
206
  })}
207
+ ${type === "string" || type === "array"
208
+ ? formatPickerTpl(format, (f) => {
209
+ if (handlers.onChangeNestedFormat)
210
+ handlers.onChangeNestedFormat(parentName, childName, f);
211
+ })
212
+ : nothing}
152
213
  <sp-switch
153
214
  size="s"
154
215
  ?checked=${isRequired}
@@ -244,10 +305,30 @@ export function typePickerTpl(value, onChange) {
244
305
  `;
245
306
  }
246
307
 
308
+ /**
309
+ * Render the format picker as an sp-picker dropdown.
310
+ *
311
+ * @param {string} value
312
+ * @param {(format: string) => void} onChange
313
+ * @returns {any}
314
+ */
315
+ export function formatPickerTpl(value, onChange) {
316
+ return html`
317
+ <sp-picker
318
+ size="s"
319
+ label="Format"
320
+ value=${value}
321
+ @change=${(/** @type {any} */ e) => onChange(e.target.value)}
322
+ >
323
+ ${FORMAT_OPTIONS.map((f) => html`<sp-menu-item value=${f}>${f || "(none)"}</sp-menu-item>`)}
324
+ </sp-picker>
325
+ `;
326
+ }
327
+
247
328
  /**
248
329
  * Render the add-field form (inline, not a dialog).
249
330
  *
250
- * @param {{ name: string; type: string; required: boolean }} state
331
+ * @param {{ name: string; type: string; format: string; required: boolean }} state
251
332
  * @param {{
252
333
  * onInput: (field: string, value: any) => void;
253
334
  * onConfirm: () => void;
@@ -269,6 +350,9 @@ export function addFieldFormTpl(state, handlers) {
269
350
  }}
270
351
  ></sp-textfield>
271
352
  ${typePickerTpl(state.type, (t) => handlers.onInput("type", t))}
353
+ ${state.type === "string" || state.type === "array"
354
+ ? formatPickerTpl(state.format || "", (f) => handlers.onInput("format", f))
355
+ : nothing}
272
356
  <sp-switch
273
357
  size="s"
274
358
  ?checked=${state.required}
@@ -283,25 +367,28 @@ export function addFieldFormTpl(state, handlers) {
283
367
  }
284
368
 
285
369
  /**
286
- * Build a JSON Schema property definition from a type string.
370
+ * Build a JSON Schema property definition from a type and optional format.
287
371
  *
288
372
  * @param {string} type
373
+ * @param {string} [format]
289
374
  * @returns {object}
290
375
  */
291
- export function schemaForType(type) {
376
+ export function schemaForType(type, format) {
292
377
  switch (type) {
293
378
  case "number":
294
379
  return { type: "number" };
295
380
  case "boolean":
296
381
  return { type: "boolean" };
297
382
  case "array":
298
- return { type: "array", items: { type: "string" } };
383
+ return format
384
+ ? { type: "array", items: { type: "string", format } }
385
+ : { type: "array", items: { type: "string" } };
299
386
  case "object":
300
387
  return { type: "object", properties: {}, required: [] };
301
- case "date":
302
- return { type: "string", format: "date" };
388
+ case "reference":
389
+ return { $ref: "#/contentTypes/" };
303
390
  default:
304
- return { type: "string" };
391
+ return format ? { type: "string", format } : { type: "string" };
305
392
  }
306
393
  }
307
394
 
@@ -314,6 +401,7 @@ export function schemaForType(type) {
314
401
  */
315
402
  export function yamlDefault(type, format) {
316
403
  if (format === "date") return new Date().toISOString().split("T")[0];
404
+ if (format === "image") return '""';
317
405
  switch (type) {
318
406
  case "boolean":
319
407
  return "false";
@@ -109,6 +109,111 @@ export function getEffectiveHead(docHead) {
109
109
  return merged;
110
110
  }
111
111
 
112
+ // ─── Layout resolution ──────────────────────────────────────────────────────
113
+
114
+ /** @type {Map<string, any>} */
115
+ const layoutCache = new Map();
116
+
117
+ export function invalidateLayoutCache() {
118
+ layoutCache.clear();
119
+ }
120
+
121
+ /**
122
+ * Determine the effective layout path for a document.
123
+ *
124
+ * @param {any} docLayout - The document's $layout value (string path, false, or undefined)
125
+ * @returns {string | null} The layout path, or null if no layout applies
126
+ */
127
+ export function getEffectiveLayoutPath(docLayout) {
128
+ if (docLayout === false) return null;
129
+ const defaultLayout = projectState?.projectConfig?.defaults?.layout;
130
+ return docLayout || defaultLayout || null;
131
+ }
132
+
133
+ /**
134
+ * Resolve a layout document by path. Fetches and caches the parsed JSON.
135
+ *
136
+ * @param {string} layoutPath - Relative path to the layout file (e.g., "./layouts/base.json")
137
+ * @returns {Promise<any | null>} The parsed layout document, or null on failure
138
+ */
139
+ export async function resolveLayoutDoc(layoutPath) {
140
+ const normalized = layoutPath.replace(/^\.\//, "");
141
+ if (layoutCache.has(normalized)) return structuredClone(layoutCache.get(normalized));
142
+
143
+ try {
144
+ const platform = getPlatform();
145
+ const content = await platform.readFile(normalized);
146
+ const doc = JSON.parse(content);
147
+ layoutCache.set(normalized, doc);
148
+ return structuredClone(doc);
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Distribute page children into a layout document's <slot> elements. Returns the merged document
156
+ * with page content injected into slots.
157
+ *
158
+ * @param {any} layoutDoc - Deep-cloned layout document
159
+ * @param {any} pageDoc - The page document
160
+ * @returns {any} The merged document
161
+ */
162
+ export function distributePageIntoLayout(layoutDoc, pageDoc) {
163
+ const pageChildren = pageDoc.children ?? [];
164
+ const children = typeof pageChildren === "string" ? [pageChildren] : pageChildren;
165
+
166
+ const named = new Map();
167
+ const defaults = [];
168
+
169
+ for (const child of children) {
170
+ if (child && typeof child === "object" && child.attributes?.slot) {
171
+ const slotName = child.attributes.slot;
172
+ if (!named.has(slotName)) named.set(slotName, []);
173
+ named.get(slotName).push(child);
174
+ } else {
175
+ defaults.push(child);
176
+ }
177
+ }
178
+
179
+ fillSlots(layoutDoc, named, defaults);
180
+
181
+ if (pageDoc.state) layoutDoc.state = { ...layoutDoc.state, ...pageDoc.state };
182
+ if (pageDoc.$media) layoutDoc.$media = { ...layoutDoc.$media, ...pageDoc.$media };
183
+ if (pageDoc.style) layoutDoc.style = { ...layoutDoc.style, ...pageDoc.style };
184
+ if (pageDoc.attributes) layoutDoc.attributes = { ...layoutDoc.attributes, ...pageDoc.attributes };
185
+
186
+ return layoutDoc;
187
+ }
188
+
189
+ function fillSlots(
190
+ /** @type {any} */ node,
191
+ /** @type {Map<string, any[]>} */ named,
192
+ /** @type {any[]} */ defaults,
193
+ ) {
194
+ if (!node || typeof node !== "object") return;
195
+ if (!Array.isArray(node.children)) return;
196
+
197
+ const newChildren = [];
198
+ for (const child of node.children) {
199
+ if (child && typeof child === "object" && child.tagName === "slot") {
200
+ const slotName = child.attributes?.name;
201
+ if (slotName && named.has(slotName)) {
202
+ newChildren.push(.../** @type {any[]} */ (named.get(slotName)));
203
+ named.delete(slotName);
204
+ } else if (!slotName && defaults.length > 0) {
205
+ newChildren.push(...defaults);
206
+ } else if (child.children) {
207
+ newChildren.push(...child.children);
208
+ }
209
+ } else {
210
+ fillSlots(child, named, defaults);
211
+ newChildren.push(child);
212
+ }
213
+ }
214
+ node.children = newChildren;
215
+ }
216
+
112
217
  /**
113
218
  * Update the project's project.json with a partial patch and persist to disk.
114
219
  *