@jxsuite/studio 0.11.0 → 0.14.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 (55) hide show
  1. package/dist/studio.js +6517 -5870
  2. package/dist/studio.js.map +57 -51
  3. package/package.json +5 -3
  4. package/src/canvas/canvas-diff.js +184 -0
  5. package/src/canvas/canvas-helpers.js +10 -14
  6. package/src/canvas/canvas-live-render.js +28 -2
  7. package/src/canvas/canvas-render.js +170 -20
  8. package/src/canvas/canvas-utils.js +22 -25
  9. package/src/editor/component-inline-edit.js +55 -44
  10. package/src/editor/content-inline-edit.js +47 -50
  11. package/src/editor/context-menu.js +78 -53
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -11
  14. package/src/editor/shortcuts.js +88 -64
  15. package/src/files/components.js +15 -4
  16. package/src/files/file-ops.js +57 -108
  17. package/src/files/files.js +81 -28
  18. package/src/panels/activity-bar.js +31 -7
  19. package/src/panels/block-action-bar.js +104 -80
  20. package/src/panels/canvas-dnd.js +4 -10
  21. package/src/panels/dnd.js +77 -35
  22. package/src/panels/editors.js +17 -26
  23. package/src/panels/elements-panel.js +17 -11
  24. package/src/panels/events-panel.js +44 -39
  25. package/src/panels/git-panel.js +47 -4
  26. package/src/panels/layers-panel.js +25 -21
  27. package/src/panels/left-panel.js +109 -44
  28. package/src/panels/overlays.js +91 -41
  29. package/src/panels/panel-events.js +28 -37
  30. package/src/panels/preview-render.js +7 -3
  31. package/src/panels/properties-panel.js +179 -104
  32. package/src/panels/pseudo-preview.js +9 -8
  33. package/src/panels/right-panel.js +85 -37
  34. package/src/panels/shared.js +0 -22
  35. package/src/panels/signals-panel.js +125 -54
  36. package/src/panels/statusbar.js +26 -19
  37. package/src/panels/style-inputs.js +5 -4
  38. package/src/panels/style-panel.js +128 -105
  39. package/src/panels/style-utils.js +8 -6
  40. package/src/panels/stylebook-layers-panel.js +5 -5
  41. package/src/panels/stylebook-panel.js +27 -31
  42. package/src/panels/tab-strip.js +124 -0
  43. package/src/panels/toolbar.js +40 -10
  44. package/src/reactivity.js +28 -0
  45. package/src/settings/content-types-editor.js +56 -7
  46. package/src/settings/defs-editor.js +56 -7
  47. package/src/settings/schema-field-ui.js +60 -25
  48. package/src/state.js +2 -459
  49. package/src/store.js +61 -219
  50. package/src/studio.js +163 -300
  51. package/src/tabs/tab.js +168 -0
  52. package/src/tabs/transact.js +406 -0
  53. package/src/ui/color-selector.js +4 -5
  54. package/src/view.js +3 -0
  55. package/src/workspace/workspace.js +90 -0
@@ -8,9 +8,6 @@ import { ref } from "lit-html/directives/ref.js";
8
8
  import { styleMap } from "lit-html/directives/style-map.js";
9
9
 
10
10
  import {
11
- getState,
12
- update,
13
- updateStyle,
14
11
  updateSession,
15
12
  updateUi,
16
13
  canvasWrap,
@@ -18,6 +15,8 @@ import {
18
15
  elToPath,
19
16
  projectState,
20
17
  } from "../store.js";
18
+ import { activeTab } from "../workspace/workspace.js";
19
+ import { transactDoc, mutateUpdateStyle } from "../tabs/transact.js";
21
20
  import { view } from "../view.js";
22
21
  import { defineElement, setSkipServerFunctions } from "@jxsuite/runtime";
23
22
  import { componentRegistry } from "../files/components.js";
@@ -49,9 +48,8 @@ let _ctx = null;
49
48
  */
50
49
  export function renderStylebookMode(ctx) {
51
50
  _ctx = ctx;
52
- const S = getState();
53
51
 
54
- const settingsTab = S.ui.settingsTab || "stylebook";
52
+ const settingsTab = activeTab.value?.session.ui.settingsTab || "stylebook";
55
53
 
56
54
  const settingsChromeBarTpl = html`
57
55
  <div
@@ -94,11 +92,14 @@ export function renderStylebookMode(ctx) {
94
92
 
95
93
  // Stylebook tab — element catalog / variables
96
94
  view.stylebookElToTag = new WeakMap();
97
- const rootStyle = getEffectiveStyle(S.document.style);
98
- const filter = (S.ui.stylebookFilter || "").toLowerCase();
99
- const customizedOnly = S.ui.stylebookCustomizedOnly;
95
+ const tab = activeTab.value;
96
+ const rootStyle = getEffectiveStyle(tab?.doc.document?.style);
97
+ const filter = (tab?.session.ui.stylebookFilter || "").toLowerCase();
98
+ const customizedOnly = tab?.session.ui.stylebookCustomizedOnly;
100
99
 
101
- const { sizeBreakpoints, baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
100
+ const { sizeBreakpoints, baseWidth } = parseMediaEntries(
101
+ getEffectiveMedia(tab?.doc.document?.$media),
102
+ );
102
103
  const hasMedia = sizeBreakpoints.length > 0;
103
104
 
104
105
  const onTabClick = (/** @type {string} */ t) => {
@@ -110,7 +111,7 @@ export function renderStylebookMode(ctx) {
110
111
  };
111
112
 
112
113
  const onCustomizedToggle = () => {
113
- updateUi("stylebookCustomizedOnly", !S.ui.stylebookCustomizedOnly);
114
+ updateUi("stylebookCustomizedOnly", !tab?.session.ui.stylebookCustomizedOnly);
114
115
  };
115
116
 
116
117
  const chromeBarTpl = html`
@@ -121,7 +122,7 @@ export function renderStylebookMode(ctx) {
121
122
  >
122
123
  <sp-tabs
123
124
  size="s"
124
- selected=${S.ui.stylebookTab || "elements"}
125
+ selected=${tab?.session.ui.stylebookTab || "elements"}
125
126
  @change=${(/** @type {any} */ e) => {
126
127
  onTabClick(e.target.selected);
127
128
  }}
@@ -132,17 +133,17 @@ export function renderStylebookMode(ctx) {
132
133
  `,
133
134
  )}
134
135
  </sp-tabs>
135
- ${S.ui.stylebookTab === "elements"
136
+ ${tab?.session.ui.stylebookTab === "elements"
136
137
  ? html`
137
138
  <input
138
139
  class="field-input"
139
140
  style="flex:1;max-width:200px;margin-left:8px"
140
141
  placeholder="Filter…"
141
- .value=${S.ui.stylebookFilter}
142
+ .value=${tab?.session.ui.stylebookFilter}
142
143
  @input=${onFilterInput}
143
144
  />
144
145
  <button
145
- class="tb-toggle${S.ui.stylebookCustomizedOnly ? " active" : ""}"
146
+ class="tb-toggle${tab?.session.ui.stylebookCustomizedOnly ? " active" : ""}"
146
147
  style="margin-left:4px"
147
148
  @click=${onCustomizedToggle}
148
149
  >
@@ -176,7 +177,7 @@ export function renderStylebookMode(ctx) {
176
177
 
177
178
  const renderIntoPanel = (/** @type {any} */ panel, /** @type {any} */ activeBreakpoints) => {
178
179
  panel.canvas.classList.add("sb-canvas");
179
- if (S.ui.stylebookTab === "elements") {
180
+ if (tab?.session.ui.stylebookTab === "elements") {
180
181
  renderStylebookElementsIntoCanvas(
181
182
  panel.canvas,
182
183
  rootStyle,
@@ -197,7 +198,7 @@ export function renderStylebookMode(ctx) {
197
198
  /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
198
199
  let panelEntries;
199
200
  if (!hasMedia) {
200
- const effectiveMedia = getEffectiveMedia(S.document.$media);
201
+ const effectiveMedia = getEffectiveMedia(tab?.doc.document?.$media);
201
202
  const hasBaseWidth = effectiveMedia && effectiveMedia["--"];
202
203
  const label = hasBaseWidth ? `${mediaDisplayName("--")} (${baseWidth}px)` : null;
203
204
  const entry = ctx.canvasPanelTemplate(
@@ -268,11 +269,10 @@ export function renderStylebookOverlays() {
268
269
  if (!_ctx) return;
269
270
  if (canvasPanels.length === 0) return;
270
271
 
271
- const S = getState();
272
- const selectedTag = S.ui.stylebookSelection;
272
+ const selectedTag = activeTab.value?.session.ui.stylebookSelection;
273
273
 
274
274
  for (const panel of canvasPanels) {
275
- const hoverTag = panel._lastHoverTag;
275
+ const hoverTag = /** @type {any} */ (panel)._lastHoverTag;
276
276
  /**
277
277
  * @type {{
278
278
  * cls: string;
@@ -691,8 +691,9 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
691
691
  const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
692
692
  if (swatch) swatch.style.backgroundColor = colorPicker.value;
693
693
  if (!isNew && varName) {
694
- const S = getState();
695
- update(updateStyle(S, [], varName, colorPicker.value));
694
+ transactDoc(activeTab.value, (t) =>
695
+ mutateUpdateStyle(t, [], varName, colorPicker.value),
696
+ );
696
697
  }
697
698
  }}
698
699
  />
@@ -751,8 +752,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
751
752
  const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
752
753
  if (swatch) swatch.style.backgroundColor = v;
753
754
  if (!isNew && varName) {
754
- const S = getState();
755
- update(updateStyle(S, [], varName, v));
755
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, v));
756
756
  }
757
757
  }, 400);
758
758
  }}
@@ -765,8 +765,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
765
765
  const bar = /** @type {any} */ (row.querySelector(".sb-var-size-bar"));
766
766
  if (bar) bar.style.width = newVal;
767
767
  if (!isNew && varName) {
768
- const S = getState();
769
- update(updateStyle(S, [], varName, newVal));
768
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, newVal));
770
769
  }
771
770
  },
772
771
  });
@@ -798,8 +797,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
798
797
  const v = textFieldEl.value;
799
798
  const fontPrev = /** @type {any} */ (row.querySelector(".sb-var-font-preview"));
800
799
  if (fontPrev) fontPrev.style.fontFamily = v;
801
- const S = getState();
802
- update(updateStyle(S, [], varName, v));
800
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, v));
803
801
  }, 400);
804
802
  }}
805
803
  ></sp-textfield>
@@ -824,8 +822,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
824
822
  const val = getValueFn();
825
823
  const generatedVar = friendlyNameToVar(name, catMeta.prefix);
826
824
  if (!generatedVar || !val) return;
827
- const S = getState();
828
- update(updateStyle(S, [], generatedVar, val));
825
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], generatedVar, val));
829
826
  }}
830
827
  >Add</sp-action-button
831
828
  >
@@ -858,8 +855,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
858
855
  class="sb-var-del"
859
856
  style="pointer-events:auto"
860
857
  @click=${() => {
861
- const S = getState();
862
- update(updateStyle(S, [], varName, undefined));
858
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, undefined));
863
859
  }}
864
860
  >
865
861
  <sp-icon-delete slot="icon"></sp-icon-delete>
@@ -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.canvasMode;
76
+ void tab.session.ui.editingFunction;
77
+ void tab.session.ui.featureToggles;
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;
@@ -183,7 +213,7 @@ function toolbarTemplate() {
183
213
  /** @type {Record<string, any>} */
184
214
  const uiPatch = { editingFunction: null };
185
215
  if (m.key === "settings") uiPatch.rightTab = "style";
186
- if (m.key === "manage") uiPatch.leftTab = "files";
216
+ if (m.key === "manage") view.leftTab = "files";
187
217
  updateSession({ ui: uiPatch });
188
218
  _ctx.renderCanvas();
189
219
  _ctx.safeRenderRightPanel();
@@ -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,27 @@ 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);
162
187
  rerender();
163
188
  saveProjectConfig();
164
189
  }
@@ -278,7 +303,29 @@ function handleChangeNestedType(parentName, childName, newType, rerender) {
278
303
  const parent = schema?.properties?.[parentName];
279
304
  if (!parent?.properties?.[childName]) return;
280
305
 
281
- 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);
282
329
  rerender();
283
330
  saveProjectConfig();
284
331
  }
@@ -381,12 +428,14 @@ export function renderContentTypesEditor(container) {
381
428
  onToggleRequired: (n) => handleToggleRequired(n, rerender),
382
429
  onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
383
430
  onChangeType: (n, t) => handleChangeType(n, t, rerender),
431
+ onChangeFormat: (n, f) => handleChangeFormat(n, f, rerender),
384
432
  onChangeRefTarget: (n, target) => handleChangeRefTarget(n, target, rerender),
385
433
  onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
386
434
  onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
387
435
  onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
388
436
  onRenameNested: (p, o, n) => handleRenameNested(p, o, n, rerender),
389
437
  onChangeNestedType: (p, c, t) => handleChangeNestedType(p, c, t, rerender),
438
+ onChangeNestedFormat: (p, c, f) => handleChangeNestedFormat(p, c, f, rerender),
390
439
  };
391
440
 
392
441
  const fieldCards = Object.entries(properties).map(([name, def]) =>
@@ -423,7 +472,7 @@ export function renderContentTypesEditor(container) {
423
472
  onConfirm: () => handleAddField(rerender),
424
473
  onCancel: () => {
425
474
  showAddField = false;
426
- newFieldState = { name: "", type: "string", required: false };
475
+ newFieldState = { name: "", type: "string", format: "", required: false };
427
476
  rerender();
428
477
  },
429
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
  })