@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
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tab strip — renders open tabs above the canvas area.
3
+ *
4
+ * Uses the reactive workspace model: reads from workspace.tabOrder, workspace.tabs,
5
+ * workspace.activeTabId. Clicks call activateTab/closeTab from workspace.js.
6
+ */
7
+
8
+ import { html, render as litRender, nothing } from "lit-html";
9
+ import { effect, effectScope } from "../reactivity.js";
10
+ import { workspace, activateTab, closeTab } from "../workspace/workspace.js";
11
+
12
+ /** @typedef {import("../tabs/tab.js").Tab} Tab */
13
+
14
+ /** @type {HTMLElement | null} */
15
+ let _host = null;
16
+
17
+ /** @type {import("@vue/reactivity").EffectScope | null} */
18
+ let _scope = null;
19
+
20
+ /**
21
+ * Mount the tab strip into the given host element.
22
+ *
23
+ * @param {HTMLElement} host
24
+ */
25
+ export function mount(host) {
26
+ _host = host;
27
+ _scope = effectScope();
28
+ _scope.run(() => {
29
+ effect(() => {
30
+ void workspace.tabOrder;
31
+ void workspace.activeTabId;
32
+ for (const tab of workspace.tabs.values()) {
33
+ void tab.doc.dirty;
34
+ void tab.documentPath;
35
+ }
36
+ render();
37
+ });
38
+ });
39
+ }
40
+
41
+ export function unmount() {
42
+ _scope?.stop();
43
+ _scope = null;
44
+ _host = null;
45
+ }
46
+
47
+ function render() {
48
+ if (!_host) return;
49
+
50
+ if (workspace.tabOrder.length <= 1) {
51
+ litRender(nothing, _host);
52
+ return;
53
+ }
54
+
55
+ litRender(
56
+ html`
57
+ <div class="tab-strip">
58
+ ${workspace.tabOrder.map((id) => {
59
+ const tab = workspace.tabs.get(id);
60
+ if (!tab) return nothing;
61
+ const isActive = id === workspace.activeTabId;
62
+ const isDirty = tab.doc.dirty;
63
+ const label = tabLabel(tab);
64
+ return html`
65
+ <div
66
+ class="tab-strip-tab ${isActive ? "active" : ""}"
67
+ @click=${() => activateTab(id)}
68
+ @auxclick=${(/** @type {MouseEvent} */ e) => {
69
+ if (e.button === 1) {
70
+ e.preventDefault();
71
+ requestClose(id);
72
+ }
73
+ }}
74
+ title=${tab.documentPath || "Untitled"}
75
+ >
76
+ <span class="tab-strip-label">${label}</span>
77
+ ${isDirty ? html`<span class="tab-strip-dirty">●</span>` : nothing}
78
+ <button
79
+ class="tab-strip-close"
80
+ @click=${(/** @type {Event} */ e) => {
81
+ e.stopPropagation();
82
+ requestClose(id);
83
+ }}
84
+ >
85
+ ×
86
+ </button>
87
+ </div>
88
+ `;
89
+ })}
90
+ </div>
91
+ `,
92
+ _host,
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Derive a short label from the tab's documentPath.
98
+ *
99
+ * @param {Tab} tab
100
+ * @returns {string}
101
+ */
102
+ function tabLabel(tab) {
103
+ const path = tab.documentPath;
104
+ if (!path) return "Untitled";
105
+ const parts = path.split("/");
106
+ return parts[parts.length - 1];
107
+ }
108
+
109
+ /**
110
+ * Close a tab, prompting if dirty.
111
+ *
112
+ * @param {string} id
113
+ */
114
+ function requestClose(id) {
115
+ const tab = workspace.tabs.get(id);
116
+ if (!tab) return;
117
+ if (tab.doc.dirty) {
118
+ const confirmed = window.confirm(
119
+ `"${tabLabel(tab)}" has unsaved changes. Close without saving?`,
120
+ );
121
+ if (!confirmed) return;
122
+ }
123
+ closeTab(id);
124
+ }
@@ -4,7 +4,10 @@
4
4
  */
5
5
 
6
6
  import { html, render as litRender, nothing } from "lit-html";
7
- import { getState, update, updateSession, updateUi, undo, redo, subscribe } from "../store.js";
7
+ import { updateSession, updateUi } from "../store.js";
8
+ import { undo as tabUndo, redo as tabRedo } from "../tabs/transact.js";
9
+ import { effect, effectScope } from "../reactivity.js";
10
+ import { activeTab } from "../workspace/workspace.js";
8
11
  import { getEffectiveMedia } from "../site-context.js";
9
12
  import { mediaDisplayName } from "./shared.js";
10
13
  import { view } from "../view.js";
@@ -15,8 +18,8 @@ let _rootEl = null;
15
18
  /** @type {any} */
16
19
  let _ctx = null;
17
20
 
18
- /** @type {(() => void) | null} */
19
- let _unsub = null;
21
+ /** @type {import("@vue/reactivity").EffectScope | null} */
22
+ let _scope = null;
20
23
 
21
24
  const toolbarIconMap = /** @type {Record<string, any>} */ ({
22
25
  "sp-icon-folder-open": html`<sp-icon-folder-open slot="icon"></sp-icon-folder-open>`,
@@ -59,12 +62,28 @@ function tbBtnTpl(label, onClick, iconTag) {
59
62
  export function mount(rootEl, ctx) {
60
63
  _rootEl = rootEl;
61
64
  _ctx = ctx;
62
- _unsub = subscribe(() => render());
65
+ _scope = effectScope();
66
+ _scope.run(() => {
67
+ effect(() => {
68
+ const tab = activeTab.value;
69
+ if (!tab) return;
70
+ // Read reactive properties to establish tracking
71
+ void tab.doc.document;
72
+ void tab.doc.dirty;
73
+ void tab.doc.mode;
74
+ void tab.session.selection;
75
+ void tab.session.ui.editingFunction;
76
+ void tab.session.ui.featureToggles;
77
+ void tab.session.ui.leftTab;
78
+ void tab.session.ui.rightTab;
79
+ render();
80
+ });
81
+ });
63
82
  }
64
83
 
65
84
  export function unmount() {
66
- _unsub?.();
67
- _unsub = null;
85
+ _scope?.stop();
86
+ _scope = null;
68
87
  _rootEl = null;
69
88
  _ctx = null;
70
89
  }
@@ -79,7 +98,18 @@ export function render() {
79
98
  }
80
99
 
81
100
  function toolbarTemplate() {
82
- const S = getState();
101
+ const tab = activeTab.value;
102
+ if (!tab) return html``;
103
+ const S = /** @type {any} */ ({
104
+ document: tab.doc.document,
105
+ ui: tab.session.ui,
106
+ mode: tab.doc.mode,
107
+ selection: tab.session.selection,
108
+ dirty: tab.doc.dirty,
109
+ documentPath: tab.documentPath,
110
+ fileHandle: tab.fileHandle,
111
+ documentStack: tab.session.documentStack,
112
+ });
83
113
  const canvasMode = _ctx.getCanvasMode();
84
114
  const hasStack = S.documentStack && S.documentStack.length > 0;
85
115
  const hasFunc = !!S.ui.editingFunction;
@@ -203,8 +233,8 @@ function toolbarTemplate() {
203
233
  ${tbBtnTpl("Save", _ctx.saveFile, "sp-icon-save-floppy")}
204
234
  </sp-action-group>
205
235
  <sp-action-group compact size="s">
206
- ${tbBtnTpl("Undo", () => update(undo(getState())), "sp-icon-undo")}
207
- ${tbBtnTpl("Redo", () => update(redo(getState())), "sp-icon-redo")}
236
+ ${tbBtnTpl("Undo", () => tabUndo(activeTab.value), "sp-icon-undo")}
237
+ ${tbBtnTpl("Redo", () => tabRedo(activeTab.value), "sp-icon-redo")}
208
238
  </sp-action-group>
209
239
  <div class="tb-spacer"></div>
210
240
  ${S.documentPath
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Re-exports from @vue/reactivity. All studio code imports reactivity primitives from here (not
3
+ * directly from the package) for grep-ability and potential future wrapping.
4
+ *
5
+ * Version is pinned to match @jxsuite/runtime — reactive proxies from one version don't track in
6
+ * effects from another. See migrate-reactivity-workspace.md for rationale.
7
+ *
8
+ * Batching note: Vue's reactivity batches synchronous effect re-runs within a microtask. Use
9
+ * queueMicrotask() or Promise.resolve().then() to wait for the flush if needed (@vue/reactivity
10
+ * standalone doesn't ship nextTick).
11
+ */
12
+ export {
13
+ reactive,
14
+ ref,
15
+ computed,
16
+ readonly,
17
+ shallowReactive,
18
+ shallowRef,
19
+ effect,
20
+ effectScope,
21
+ getCurrentScope,
22
+ onScopeDispose,
23
+ pauseTracking,
24
+ resetTracking,
25
+ toRaw,
26
+ isReactive,
27
+ isRef,
28
+ } from "@vue/reactivity";
@@ -8,14 +8,19 @@
8
8
  import { html, render as litRender } from "lit-html";
9
9
  import { getPlatform } from "../platform.js";
10
10
  import { projectState } from "../store.js";
11
- import { fieldCardTpl, addFieldFormTpl, schemaForType } from "./schema-field-ui.js";
11
+ import {
12
+ fieldCardTpl,
13
+ addFieldFormTpl,
14
+ schemaForType,
15
+ detectFieldFormat,
16
+ } from "./schema-field-ui.js";
12
17
 
13
18
  // ─── Module state ─────────────────────────────────────────────────────────────
14
19
 
15
20
  /** @type {string | null} */
16
21
  let selectedContentType = null;
17
22
  let showAddField = false;
18
- let newFieldState = { name: "", type: "string", required: false };
23
+ let newFieldState = { name: "", type: "string", format: "", required: false };
19
24
  let showNewContentType = false;
20
25
  let newContentTypeName = "";
21
26
 
@@ -76,7 +81,7 @@ function handleAddField(rerender) {
76
81
  if (!schema) return;
77
82
 
78
83
  if (!schema.properties) schema.properties = {};
79
- schema.properties[name] = schemaForType(newFieldState.type);
84
+ schema.properties[name] = schemaForType(newFieldState.type, newFieldState.format || undefined);
80
85
 
81
86
  if (newFieldState.required) {
82
87
  if (!schema.required) schema.required = [];
@@ -84,7 +89,7 @@ function handleAddField(rerender) {
84
89
  }
85
90
 
86
91
  showAddField = false;
87
- newFieldState = { name: "", type: "string", required: false };
92
+ newFieldState = { name: "", type: "string", format: "", required: false };
88
93
  rerender();
89
94
  saveProjectConfig();
90
95
  }
@@ -158,7 +163,41 @@ function handleChangeType(fieldName, newType, rerender) {
158
163
  const schema = getSelectedSchema();
159
164
  if (!schema?.properties?.[fieldName]) return;
160
165
 
161
- schema.properties[fieldName] = schemaForType(newType);
166
+ const oldFormat =
167
+ newType === "string" || newType === "array"
168
+ ? detectFieldFormat(schema.properties[fieldName])
169
+ : undefined;
170
+ schema.properties[fieldName] = schemaForType(newType, oldFormat || undefined);
171
+ rerender();
172
+ saveProjectConfig();
173
+ }
174
+
175
+ /**
176
+ * @param {string} fieldName
177
+ * @param {string} format
178
+ * @param {() => void} rerender
179
+ */
180
+ function handleChangeFormat(fieldName, format, rerender) {
181
+ const schema = getSelectedSchema();
182
+ if (!schema?.properties?.[fieldName]) return;
183
+
184
+ const prop = schema.properties[fieldName];
185
+ const type = prop.type || "string";
186
+ schema.properties[fieldName] = schemaForType(type, format || undefined);
187
+ rerender();
188
+ saveProjectConfig();
189
+ }
190
+
191
+ /**
192
+ * @param {string} fieldName
193
+ * @param {string} target
194
+ * @param {() => void} rerender
195
+ */
196
+ function handleChangeRefTarget(fieldName, target, rerender) {
197
+ const schema = getSelectedSchema();
198
+ if (!schema?.properties) return;
199
+
200
+ schema.properties[fieldName] = { $ref: `#/contentTypes/${target}` };
162
201
  rerender();
163
202
  saveProjectConfig();
164
203
  }
@@ -264,7 +303,29 @@ function handleChangeNestedType(parentName, childName, newType, rerender) {
264
303
  const parent = schema?.properties?.[parentName];
265
304
  if (!parent?.properties?.[childName]) return;
266
305
 
267
- parent.properties[childName] = schemaForType(newType);
306
+ const oldFormat =
307
+ newType === "string" || newType === "array"
308
+ ? detectFieldFormat(parent.properties[childName])
309
+ : undefined;
310
+ parent.properties[childName] = schemaForType(newType, oldFormat || undefined);
311
+ rerender();
312
+ saveProjectConfig();
313
+ }
314
+
315
+ /**
316
+ * @param {string} parentName
317
+ * @param {string} childName
318
+ * @param {string} format
319
+ * @param {() => void} rerender
320
+ */
321
+ function handleChangeNestedFormat(parentName, childName, format, rerender) {
322
+ const schema = getSelectedSchema();
323
+ const parent = schema?.properties?.[parentName];
324
+ if (!parent?.properties?.[childName]) return;
325
+
326
+ const prop = parent.properties[childName];
327
+ const type = prop.type || "string";
328
+ parent.properties[childName] = schemaForType(type, format || undefined);
268
329
  rerender();
269
330
  saveProjectConfig();
270
331
  }
@@ -367,15 +428,24 @@ export function renderContentTypesEditor(container) {
367
428
  onToggleRequired: (n) => handleToggleRequired(n, rerender),
368
429
  onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
369
430
  onChangeType: (n, t) => handleChangeType(n, t, rerender),
431
+ onChangeFormat: (n, f) => handleChangeFormat(n, f, rerender),
432
+ onChangeRefTarget: (n, target) => handleChangeRefTarget(n, target, rerender),
370
433
  onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
371
434
  onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
372
435
  onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
373
436
  onRenameNested: (p, o, n) => handleRenameNested(p, o, n, rerender),
374
437
  onChangeNestedType: (p, c, t) => handleChangeNestedType(p, c, t, rerender),
438
+ onChangeNestedFormat: (p, c, f) => handleChangeNestedFormat(p, c, f, rerender),
375
439
  };
376
440
 
377
441
  const fieldCards = Object.entries(properties).map(([name, def]) =>
378
- fieldCardTpl(name, /** @type {any} */ (def), required.includes(name), handlers),
442
+ fieldCardTpl(
443
+ name,
444
+ /** @type {any} */ (def),
445
+ required.includes(name),
446
+ handlers,
447
+ contentTypeNames,
448
+ ),
379
449
  );
380
450
 
381
451
  editorTpl = html`
@@ -402,7 +472,7 @@ export function renderContentTypesEditor(container) {
402
472
  onConfirm: () => handleAddField(rerender),
403
473
  onCancel: () => {
404
474
  showAddField = false;
405
- newFieldState = { name: "", type: "string", required: false };
475
+ newFieldState = { name: "", type: "string", format: "", required: false };
406
476
  rerender();
407
477
  },
408
478
  })
@@ -9,14 +9,19 @@
9
9
  import { html, render as litRender } from "lit-html";
10
10
  import { getPlatform } from "../platform.js";
11
11
  import { projectState } from "../store.js";
12
- import { fieldCardTpl, addFieldFormTpl, schemaForType } from "./schema-field-ui.js";
12
+ import {
13
+ fieldCardTpl,
14
+ addFieldFormTpl,
15
+ schemaForType,
16
+ detectFieldFormat,
17
+ } from "./schema-field-ui.js";
13
18
 
14
19
  // ─── Module state ─────────────────────────────────────────────────────────────
15
20
 
16
21
  /** @type {string | null} */
17
22
  let selectedDef = null;
18
23
  let showAddField = false;
19
- let newFieldState = { name: "", type: "string", required: false };
24
+ let newFieldState = { name: "", type: "string", format: "", required: false };
20
25
  let showNewDef = false;
21
26
  let newDefName = "";
22
27
 
@@ -70,7 +75,7 @@ function handleAddField(rerender) {
70
75
  if (!def) return;
71
76
 
72
77
  if (!def.properties) def.properties = {};
73
- def.properties[name] = schemaForType(newFieldState.type);
78
+ def.properties[name] = schemaForType(newFieldState.type, newFieldState.format || undefined);
74
79
 
75
80
  if (newFieldState.required) {
76
81
  if (!def.required) def.required = [];
@@ -78,7 +83,7 @@ function handleAddField(rerender) {
78
83
  }
79
84
 
80
85
  showAddField = false;
81
- newFieldState = { name: "", type: "string", required: false };
86
+ newFieldState = { name: "", type: "string", format: "", required: false };
82
87
  rerender();
83
88
  saveProjectConfig();
84
89
  }
@@ -150,7 +155,27 @@ function handleChangeType(fieldName, newType, rerender) {
150
155
  const def = getSelectedDef();
151
156
  if (!def?.properties?.[fieldName]) return;
152
157
 
153
- def.properties[fieldName] = schemaForType(newType);
158
+ const oldFormat =
159
+ newType === "string" || newType === "array"
160
+ ? detectFieldFormat(def.properties[fieldName])
161
+ : undefined;
162
+ def.properties[fieldName] = schemaForType(newType, oldFormat || undefined);
163
+ rerender();
164
+ saveProjectConfig();
165
+ }
166
+
167
+ /**
168
+ * @param {string} fieldName
169
+ * @param {string} format
170
+ * @param {() => void} rerender
171
+ */
172
+ function handleChangeFormat(fieldName, format, rerender) {
173
+ const def = getSelectedDef();
174
+ if (!def?.properties?.[fieldName]) return;
175
+
176
+ const prop = def.properties[fieldName];
177
+ const type = prop.type || "string";
178
+ def.properties[fieldName] = schemaForType(type, format || undefined);
154
179
  rerender();
155
180
  saveProjectConfig();
156
181
  }
@@ -256,7 +281,29 @@ function handleChangeNestedType(parentName, childName, newType, rerender) {
256
281
  const parent = def?.properties?.[parentName];
257
282
  if (!parent?.properties?.[childName]) return;
258
283
 
259
- parent.properties[childName] = schemaForType(newType);
284
+ const oldFormat =
285
+ newType === "string" || newType === "array"
286
+ ? detectFieldFormat(parent.properties[childName])
287
+ : undefined;
288
+ parent.properties[childName] = schemaForType(newType, oldFormat || undefined);
289
+ rerender();
290
+ saveProjectConfig();
291
+ }
292
+
293
+ /**
294
+ * @param {string} parentName
295
+ * @param {string} childName
296
+ * @param {string} format
297
+ * @param {() => void} rerender
298
+ */
299
+ function handleChangeNestedFormat(parentName, childName, format, rerender) {
300
+ const def = getSelectedDef();
301
+ const parent = def?.properties?.[parentName];
302
+ if (!parent?.properties?.[childName]) return;
303
+
304
+ const prop = parent.properties[childName];
305
+ const type = prop.type || "string";
306
+ parent.properties[childName] = schemaForType(type, format || undefined);
260
307
  rerender();
261
308
  saveProjectConfig();
262
309
  }
@@ -358,11 +405,13 @@ export function renderDefsEditor(container) {
358
405
  onToggleRequired: (n) => handleToggleRequired(n, rerender),
359
406
  onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
360
407
  onChangeType: (n, t) => handleChangeType(n, t, rerender),
408
+ onChangeFormat: (n, f) => handleChangeFormat(n, f, rerender),
361
409
  onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
362
410
  onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
363
411
  onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
364
412
  onRenameNested: (p, o, n) => handleRenameNested(p, o, n, rerender),
365
413
  onChangeNestedType: (p, c, t) => handleChangeNestedType(p, c, t, rerender),
414
+ onChangeNestedFormat: (p, c, f) => handleChangeNestedFormat(p, c, f, rerender),
366
415
  };
367
416
 
368
417
  const fieldCards = Object.entries(properties).map(([name, fieldDef]) =>
@@ -392,7 +441,7 @@ export function renderDefsEditor(container) {
392
441
  onConfirm: () => handleAddField(rerender),
393
442
  onCancel: () => {
394
443
  showAddField = false;
395
- newFieldState = { name: "", type: "string", required: false };
444
+ newFieldState = { name: "", type: "string", format: "", required: false };
396
445
  rerender();
397
446
  },
398
447
  })