@jxsuite/studio 0.20.0 → 0.21.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 (37) hide show
  1. package/dist/studio.js +1229 -477
  2. package/dist/studio.js.map +41 -39
  3. package/package.json +6 -5
  4. package/src/browse/browse-modal.js +16 -13
  5. package/src/browse/browse.js +19 -16
  6. package/src/canvas/canvas-live-render.js +19 -0
  7. package/src/canvas/canvas-utils.js +13 -6
  8. package/src/editor/context-menu.js +27 -13
  9. package/src/editor/convert-targets.js +60 -0
  10. package/src/editor/convert-to-component.js +9 -10
  11. package/src/editor/convert-to-repeater.js +226 -0
  12. package/src/editor/inline-edit.js +3 -0
  13. package/src/editor/shortcuts.js +10 -2
  14. package/src/editor/slash-menu.js +36 -17
  15. package/src/files/files.js +244 -16
  16. package/src/github/github-publish.js +26 -15
  17. package/src/panels/activity-bar.js +5 -5
  18. package/src/panels/ai-panel.js +10 -3
  19. package/src/panels/block-action-bar.js +73 -24
  20. package/src/panels/git-panel.js +7 -2
  21. package/src/panels/imports-panel.js +6 -1
  22. package/src/panels/left-panel.js +20 -1
  23. package/src/panels/properties-panel.js +16 -11
  24. package/src/panels/quick-search.js +4 -4
  25. package/src/panels/right-panel.js +16 -14
  26. package/src/panels/signals-panel.js +120 -0
  27. package/src/panels/statusbar.js +6 -2
  28. package/src/panels/style-panel.js +6 -2
  29. package/src/panels/tab-strip.js +5 -2
  30. package/src/settings/content-types-editor.js +1 -1
  31. package/src/settings/settings-modal.js +12 -9
  32. package/src/store.js +15 -9
  33. package/src/studio.js +17 -9
  34. package/src/ui/layers.js +31 -1
  35. package/src/utils/edit-display.js +1 -6
  36. package/src/utils/studio-utils.js +11 -11
  37. package/src/workspace/workspace.js +19 -0
@@ -5,11 +5,12 @@
5
5
 
6
6
  import { html, render as litRender, nothing } from "lit-html";
7
7
  import { styleMap } from "lit-html/directives/style-map.js";
8
+ import { ref } from "lit-html/directives/ref.js";
8
9
  import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
9
10
 
10
11
  import { getNodeAtPath, nodeLabel, parentElementPath, childIndex } from "../store.js";
11
12
  import { activeTab } from "../workspace/workspace.js";
12
- import { transactDoc, mutateMoveNode } from "../tabs/transact.js";
13
+ import { transactDoc, mutateMoveNode, mutateUpdateProperty } from "../tabs/transact.js";
13
14
  import { view } from "../view.js";
14
15
  import { isEditing, getActiveElement, getInlineActions } from "../editor/inline-edit.js";
15
16
  import { toggleInlineFormat, isTagActiveInSelection } from "../editor/inline-format.js";
@@ -17,6 +18,8 @@ import { componentRegistry } from "../files/components.js";
17
18
  import { convertToComponent } from "../editor/convert-to-component.js";
18
19
  import { findCanvasElement, getActivePanel } from "../canvas/canvas-helpers.js";
19
20
  import { getLayerSlot } from "../ui/layers.js";
21
+ import { showSlashMenu } from "../editor/slash-menu.js";
22
+ import { getConvertTargets } from "../editor/convert-targets.js";
20
23
 
21
24
  /**
22
25
  * @typedef {import("../state.js").StudioState} StudioState
@@ -70,9 +73,29 @@ const formatIconMap = /** @type {Record<string, import("lit-html").TemplateResul
70
73
  function onBarMousedown(e) {
71
74
  if (/** @type {HTMLElement} */ (e.target).closest("sp-textfield")) return;
72
75
  if (/** @type {HTMLElement} */ (e.target).closest(".bar-drag-handle")) return;
76
+ if (/** @type {HTMLElement} */ (e.target).closest(".bar-tag--interactive")) return;
73
77
  e.preventDefault();
74
78
  }
75
79
 
80
+ /**
81
+ * @param {MouseEvent} e
82
+ * @param {import("../editor/convert-targets.js").SlashCommand[]} targets
83
+ * @param {JxPath} selection
84
+ */
85
+ function onTagBadgeClick(e, targets, selection) {
86
+ e.stopPropagation();
87
+ const anchorEl = /** @type {HTMLElement} */ (e.currentTarget);
88
+ showSlashMenu(anchorEl, "", {
89
+ showFilter: targets.length > 6,
90
+ commands: targets,
91
+ onSelect: (cmd) => {
92
+ transactDoc(activeTab.value, (t) => {
93
+ mutateUpdateProperty(t, selection, "tagName", cmd.tag);
94
+ });
95
+ },
96
+ });
97
+ }
98
+
76
99
  /** Saved selection range for format button mousedown→click flow */
77
100
  function captureSelectionRange() {
78
101
  const sel = window.getSelection();
@@ -226,9 +249,11 @@ function showLinkPopover(anchorBtn) {
226
249
 
227
250
  const rect = anchorBtn.getBoundingClientRect();
228
251
 
252
+ /** @type {HTMLInputElement | null} */
253
+ let _linkField = null;
254
+
229
255
  const onApply = () => {
230
- const field = /** @type {HTMLInputElement | null} */ (host.querySelector("sp-textfield"));
231
- const url = field?.value || "";
256
+ const url = _linkField?.value || "";
232
257
  if (existingLink) {
233
258
  existingLink.setAttribute("href", url);
234
259
  } else if (url) {
@@ -272,6 +297,10 @@ function showLinkPopover(anchorBtn) {
272
297
  style="width:200px"
273
298
  value=${existingLink?.getAttribute("href") || ""}
274
299
  @keydown=${onKeydown}
300
+ ${ref((el) => {
301
+ _linkField = /** @type {HTMLInputElement | null} */ (el || null);
302
+ if (el) requestAnimationFrame(() => /** @type {HTMLElement} */ (el).focus());
303
+ })}
275
304
  ></sp-textfield>
276
305
  <sp-action-button size="xs" @click=${onApply}>
277
306
  ${existingLink ? "Update" : "Apply"}
@@ -283,10 +312,6 @@ function showLinkPopover(anchorBtn) {
283
312
  `,
284
313
  host,
285
314
  );
286
-
287
- requestAnimationFrame(
288
- () => /** @type {HTMLElement | null} */ (host.querySelector("sp-textfield"))?.focus(),
289
- );
290
315
  }
291
316
 
292
317
  /** Move the selected node up (swap with previous sibling). */
@@ -360,6 +385,21 @@ export function renderBlockActionBar() {
360
385
  ? actions.filter((a) => isTagActiveInSelection(a.tag, el)).map((a) => a.tag)
361
386
  : [];
362
387
 
388
+ // Conversion targets for badge click
389
+ const isComponent =
390
+ node.tagName?.includes("-") &&
391
+ componentRegistry.some((/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName);
392
+ const isEmpty =
393
+ !node.textContent &&
394
+ (!node.children ||
395
+ node.children.length === 0 ||
396
+ (Array.isArray(node.children) &&
397
+ node.children.length === 1 &&
398
+ typeof node.children[0] === "object" &&
399
+ node.children[0]?.tagName === "br"));
400
+ const convertTargets = !isComponent ? getConvertTargets(tag, isEmpty) : [];
401
+ const badgeInteractive = convertTargets.length > 0;
402
+
363
403
  litRender(
364
404
  html`
365
405
  <div
@@ -369,10 +409,34 @@ export function renderBlockActionBar() {
369
409
  >
370
410
  ${selection.length >= 2 ? renderParentSelector() : nothing}
371
411
 
372
- <span class="bar-tag">${node.$id || (node.tagName ?? "div")}</span>
412
+ <span
413
+ class="bar-tag${badgeInteractive ? " bar-tag--interactive" : ""}"
414
+ @click=${badgeInteractive
415
+ ? (/** @type {MouseEvent} */ e) => onTagBadgeClick(e, convertTargets, selection)
416
+ : nothing}
417
+ >${node.$id || (node.tagName ?? "div")}</span
418
+ >
373
419
 
374
420
  ${selection.length >= 2
375
- ? html`<span class="bar-drag-handle" title="Drag to reorder">⠿</span>`
421
+ ? html`<span
422
+ class="bar-drag-handle"
423
+ title="Drag to reorder"
424
+ ${ref((el) => {
425
+ if (!el) return;
426
+ if (view.selDragCleanup) {
427
+ view.selDragCleanup();
428
+ view.selDragCleanup = null;
429
+ }
430
+ view.selDragCleanup = draggable({
431
+ element: /** @type {HTMLElement} */ (el),
432
+ getInitialData: () => ({
433
+ type: "tree-node",
434
+ path: activeTab.value?.session.selection,
435
+ }),
436
+ });
437
+ })}
438
+ >⠿</span
439
+ >`
376
440
  : nothing}
377
441
  ${selection.length >= 2 ? renderMoveArrows() : nothing}
378
442
  ${selection.length >= 2 && node.tagName
@@ -444,20 +508,5 @@ export function renderBlockActionBar() {
444
508
  if (barRect.right > window.innerWidth) {
445
509
  bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
446
510
  }
447
- // Attach drag handle
448
- const currentTab = activeTab.value;
449
- if (currentTab?.session.selection && currentTab.session.selection.length >= 2) {
450
- const handle = /** @type {HTMLElement | null} */ (bar.querySelector(".bar-drag-handle"));
451
- if (handle) {
452
- if (view.selDragCleanup) {
453
- view.selDragCleanup();
454
- view.selDragCleanup = null;
455
- }
456
- view.selDragCleanup = draggable({
457
- element: handle,
458
- getInitialData: () => ({ type: "tree-node", path: activeTab.value?.session.selection }),
459
- });
460
- }
461
- }
462
511
  });
463
512
  }
@@ -10,7 +10,7 @@ import { getPlatform } from "../platform.js";
10
10
  import { updateUi, renderOnly, projectState } from "../store.js";
11
11
  import { activeTab } from "../workspace/workspace.js";
12
12
  import { view } from "../view.js";
13
- import { showDialog } from "../ui/layers.js";
13
+ import { showDialog, showConfirmDialog } from "../ui/layers.js";
14
14
  import { statusMessage } from "./statusbar.js";
15
15
  import { publishToGithub } from "../github/github-publish.js";
16
16
 
@@ -531,7 +531,12 @@ export function renderGitPanel(S, ctx) {
531
531
  title="Discard changes"
532
532
  @click=${async () => {
533
533
  if (file.status === "U") return;
534
- if (!confirm(`Discard changes to ${file.path}?`)) return;
534
+ const confirmed = await showConfirmDialog(
535
+ "Discard Changes",
536
+ `Discard changes to ${file.path}?`,
537
+ { confirmLabel: "Discard", destructive: true },
538
+ );
539
+ if (!confirmed) return;
535
540
  await gitAction("gitDiscard", [file.path]);
536
541
  }}
537
542
  ?disabled=${file.status === "U"}
@@ -12,6 +12,7 @@ import { componentRegistry, computeRelativePath } from "../files/components.js";
12
12
  import { projectState } from "../store.js";
13
13
  import { updateSiteConfig } from "../site-context.js";
14
14
  import { getPlatform } from "../platform.js";
15
+ import { showConfirmDialog } from "../ui/layers.js";
15
16
 
16
17
  /** @typedef {import("../files/components.js").ComponentEntry} ComponentEntry */
17
18
 
@@ -185,7 +186,11 @@ function renderSiteLevelImports(renderLeftPanel) {
185
186
  size="xs"
186
187
  title="Remove package"
187
188
  @click=${async () => {
188
- if (!confirm("Remove " + pkg + "?")) return;
189
+ const confirmed = await showConfirmDialog("Remove Package", `Remove ${pkg}?`, {
190
+ confirmLabel: "Remove",
191
+ destructive: true,
192
+ });
193
+ if (!confirmed) return;
189
194
  try {
190
195
  const platform = getPlatform();
191
196
  await platform.removePackage(pkg);
@@ -40,6 +40,7 @@ import { selectStylebookTag, stylebookMeta } from "./stylebook-panel.js";
40
40
  * registerElementsDnD: () => void;
41
41
  * registerComponentsDnD: () => void;
42
42
  * setupTreeKeyboard: (tree: HTMLElement) => void;
43
+ * registerFileTreeDnD: (ctx: { renderLeftPanel: () => void }) => void;
43
44
  * setGitDiffState: (state: import("../canvas/canvas-render.js").GitDiffState | null) => void;
44
45
  * cloneRepository?: () => void;
45
46
  * }} LeftPanelCtx
@@ -53,6 +54,16 @@ let _scope = null;
53
54
 
54
55
  let _rendering = false;
55
56
  let _scheduled = false;
57
+ let _hasFocus = false;
58
+
59
+ function _onFocusIn() {
60
+ _hasFocus = true;
61
+ }
62
+
63
+ function _onFocusOut() {
64
+ _hasFocus = false;
65
+ render();
66
+ }
56
67
 
57
68
  /**
58
69
  * Mount the left panel orchestrator.
@@ -61,6 +72,8 @@ let _scheduled = false;
61
72
  */
62
73
  export function mount(ctx) {
63
74
  _ctx = ctx;
75
+ leftPanel.addEventListener("focusin", _onFocusIn);
76
+ leftPanel.addEventListener("focusout", _onFocusOut);
64
77
  _scope = effectScope();
65
78
  _scope.run(() => {
66
79
  effect(() => {
@@ -75,7 +88,9 @@ export function mount(ctx) {
75
88
  void tab.session.ui.gitLoading;
76
89
  void tab.session.ui.gitError;
77
90
  }
78
- render();
91
+ if (!_hasFocus) {
92
+ render();
93
+ }
79
94
  });
80
95
  });
81
96
  }
@@ -84,6 +99,9 @@ export function unmount() {
84
99
  _scope?.stop();
85
100
  _scope = null;
86
101
  _ctx = null;
102
+ leftPanel.removeEventListener("focusin", _onFocusIn);
103
+ leftPanel.removeEventListener("focusout", _onFocusOut);
104
+ _hasFocus = false;
87
105
  }
88
106
 
89
107
  export function render() {
@@ -132,6 +150,7 @@ function _render() {
132
150
  litRender(html`<div class="panel-body">${ctx.renderFilesTemplate()}</div>`, leftPanel);
133
151
  const tree = /** @type {HTMLElement | null} */ (leftPanel.querySelector(".file-tree"));
134
152
  if (tree) ctx.setupTreeKeyboard(tree);
153
+ ctx.registerFileTreeDnD({ renderLeftPanel: render });
135
154
  return;
136
155
  }
137
156
 
@@ -97,14 +97,19 @@ function bindableFieldRow(
97
97
  const staticVal = isBound ? "" : (rawValue ?? "");
98
98
  const staticTpl =
99
99
  type === "textarea"
100
- ? html`<sp-textfield multiline size="s" value=${staticVal} @input=${onInput}></sp-textfield>`
100
+ ? html`<sp-textfield
101
+ multiline
102
+ size="s"
103
+ .value=${live(staticVal)}
104
+ @input=${onInput}
105
+ ></sp-textfield>`
101
106
  : type === "checkbox"
102
107
  ? html`<sp-checkbox
103
108
  ?checked=${!!staticVal}
104
109
  @change=${(/** @type {Event} */ e) =>
105
110
  onChange(/** @type {HTMLInputElement} */ (e.target).checked)}
106
111
  ></sp-checkbox>`
107
- : html`<sp-textfield size="s" value=${staticVal} @input=${onInput}></sp-textfield>`;
112
+ : html`<sp-textfield size="s" .value=${live(staticVal)} @input=${onInput}></sp-textfield>`;
108
113
 
109
114
  const boundTpl = html`
110
115
  <sp-picker
@@ -189,7 +194,7 @@ function kvRow(
189
194
  <sp-textfield
190
195
  size="s"
191
196
  class="kv-key"
192
- value=${key}
197
+ .value=${live(key)}
193
198
  @input=${(/** @type {Event} */ e) => {
194
199
  currentKey = /** @type {HTMLInputElement} */ (e.target).value;
195
200
  commit();
@@ -210,7 +215,7 @@ function kvRow(
210
215
  <sp-textfield
211
216
  size="s"
212
217
  class="kv-val"
213
- value=${value}
218
+ .value=${live(value)}
214
219
  placeholder=${placeholder}
215
220
  @input=${(/** @type {Event} */ e) => {
216
221
  currentVal = /** @type {HTMLInputElement} */ (e.target).value;
@@ -316,7 +321,7 @@ function renderSwitchFieldsTemplate(
316
321
  <div class="field-row" style="display:flex;align-items:center;gap:4px;margin-bottom:3px">
317
322
  <input
318
323
  class="field-input"
319
- value=${caseName}
324
+ .value=${live(caseName)}
320
325
  style="flex:1"
321
326
  @input=${(/** @type {Event} */ e) => {
322
327
  clearTimeout(debounce);
@@ -476,7 +481,7 @@ function renderComponentPropsFieldsTemplate(
476
481
  widgetTpl = html`<sp-textfield
477
482
  size="s"
478
483
  placeholder="YYYY-MM-DD"
479
- value=${staticVal}
484
+ .value=${live(staticVal)}
480
485
  @input=${(/** @type {Event} */ e) => {
481
486
  clearTimeout(debounce);
482
487
  debounce = setTimeout(
@@ -495,7 +500,7 @@ function renderComponentPropsFieldsTemplate(
495
500
  } else if (parsed.kind === "number") {
496
501
  widgetTpl = html`<sp-number-field
497
502
  size="s"
498
- value=${staticVal}
503
+ .value=${live(staticVal)}
499
504
  @input=${(/** @type {Event} */ e) => {
500
505
  clearTimeout(debounce);
501
506
  debounce = setTimeout(
@@ -519,7 +524,7 @@ function renderComponentPropsFieldsTemplate(
519
524
  } else {
520
525
  widgetTpl = html`<sp-textfield
521
526
  size="s"
522
- value=${staticVal}
527
+ .value=${live(staticVal)}
523
528
  @input=${(/** @type {Event} */ e) => {
524
529
  clearTimeout(debounce);
525
530
  debounce = setTimeout(
@@ -612,7 +617,7 @@ function renderMediaFieldsTemplate(/** @type {JxMutableNode} */ node) {
612
617
  class="field-input"
613
618
  style="width:70px;flex:none"
614
619
  placeholder="320px"
615
- value=${media["--"] || ""}
620
+ .value=${live(media["--"] || "")}
616
621
  @input=${(/** @type {Event} */ e) => {
617
622
  clearTimeout(baseDebounce);
618
623
  baseDebounce = setTimeout(() => {
@@ -716,7 +721,7 @@ function mediaBreakpointRowTemplate(/** @type {string} */ name, /** @type {strin
716
721
  <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
717
722
  <input
718
723
  class="field-input"
719
- value=${mediaDisplayName(name)}
724
+ .value=${live(mediaDisplayName(name))}
720
725
  style="flex:1;font-weight:600;font-size:12px"
721
726
  @input=${(/** @type {Event} */ e) => {
722
727
  const newKey = friendlyNameToMedia(/** @type {HTMLInputElement} */ (e.target).value);
@@ -755,7 +760,7 @@ function mediaBreakpointRowTemplate(/** @type {string} */ name, /** @type {strin
755
760
  <div style="display:flex;gap:4px;align-items:center">
756
761
  <input
757
762
  class="field-input bp-query-input"
758
- value=${query}
763
+ .value=${live(query)}
759
764
  style="flex:1"
760
765
  @input=${(/** @type {Event} */ e) => {
761
766
  clearTimeout(debounceTimer);
@@ -1,6 +1,7 @@
1
1
  import { html, render as litRender, nothing } from "lit-html";
2
2
  import { classMap } from "lit-html/directives/class-map.js";
3
3
  import { live } from "lit-html/directives/live.js";
4
+ import { ref } from "lit-html/directives/ref.js";
4
5
  import { getPlatform } from "../platform.js";
5
6
  import { openFileInTab } from "../files/files.js";
6
7
  import { getRecentFiles, trackRecentFile } from "../recent-projects.js";
@@ -28,10 +29,6 @@ export function openQuickSearch() {
28
29
  _results = [];
29
30
  _selectedIndex = 0;
30
31
  renderOverlay();
31
- requestAnimationFrame(() => {
32
- const input = getContainer().querySelector(".quick-search-input");
33
- if (input) /** @type {HTMLInputElement} */ (input).focus();
34
- });
35
32
  }
36
33
 
37
34
  export function closeQuickSearch() {
@@ -136,6 +133,9 @@ function renderOverlay() {
136
133
  .value=${live(_query)}
137
134
  @input=${onInput}
138
135
  @keydown=${onKeydown}
136
+ ${ref((el) => {
137
+ if (el) requestAnimationFrame(() => /** @type {HTMLInputElement} */ (el).focus());
138
+ })}
139
139
  />
140
140
  <div class="quick-search-results">
141
141
  ${items.length === 0 && _query.trim()
@@ -39,6 +39,16 @@ let _scope = null;
39
39
 
40
40
  let _rendering = false;
41
41
  let _scheduled = false;
42
+ let _hasFocus = false;
43
+
44
+ function _onFocusIn() {
45
+ _hasFocus = true;
46
+ }
47
+
48
+ function _onFocusOut() {
49
+ _hasFocus = false;
50
+ render();
51
+ }
42
52
 
43
53
  /**
44
54
  * Mount the right panel.
@@ -49,6 +59,8 @@ export function mount(ctx) {
49
59
  _ctx = ctx;
50
60
  mountAiPanel();
51
61
  registerRightPanelRender(render);
62
+ rightPanel.addEventListener("focusin", _onFocusIn);
63
+ rightPanel.addEventListener("focusout", _onFocusOut);
52
64
  _scope = effectScope();
53
65
  _scope.run(() => {
54
66
  effect(() => {
@@ -66,20 +78,7 @@ export function mount(ctx) {
66
78
  void tab.session.ui.styleFilterActive;
67
79
  void tab.session.ui.inspectorSections;
68
80
 
69
- const colorPopoverOpen = isColorPopoverOpen();
70
- const activeTag = document.activeElement?.tagName;
71
- const rightHasFocus =
72
- !colorPopoverOpen &&
73
- rightPanel.contains(document.activeElement) &&
74
- (activeTag === "INPUT" ||
75
- activeTag === "TEXTAREA" ||
76
- activeTag === "SP-TEXTFIELD" ||
77
- activeTag === "SP-NUMBER-FIELD" ||
78
- activeTag === "SP-PICKER" ||
79
- activeTag === "SP-COMBOBOX" ||
80
- activeTag === "SP-SEARCH");
81
-
82
- if (!rightHasFocus) {
81
+ if (!_hasFocus && !isColorPopoverOpen()) {
83
82
  render();
84
83
  }
85
84
  });
@@ -90,6 +89,9 @@ export function unmount() {
90
89
  _scope?.stop();
91
90
  _scope = null;
92
91
  _ctx = null;
92
+ rightPanel.removeEventListener("focusin", _onFocusIn);
93
+ rightPanel.removeEventListener("focusout", _onFocusOut);
94
+ _hasFocus = false;
93
95
  }
94
96
 
95
97
  export function render() {
@@ -8,6 +8,7 @@ import { html, nothing } from "lit-html";
8
8
  import { classMap } from "lit-html/directives/class-map.js";
9
9
  import { ifDefined } from "lit-html/directives/if-defined.js";
10
10
  import { styleMap } from "lit-html/directives/style-map.js";
11
+ import { projectState } from "../state.js";
11
12
  import { activeTab } from "../workspace/workspace.js";
12
13
  import {
13
14
  transactDoc,
@@ -59,6 +60,11 @@ import { fetchPluginSchema, pluginSchemaCache } from "../services/code-services.
59
60
  * parameters?: Record<string, unknown>[];
60
61
  * emits?: Record<string, unknown>[];
61
62
  * fields?: unknown;
63
+ * contentType?: string;
64
+ * id?: unknown;
65
+ * filter?: Record<string, unknown>;
66
+ * sort?: { field?: string; order?: string };
67
+ * limit?: number;
62
68
  * }} SignalDef
63
69
  *
64
70
  * @typedef {{ name: string; type?: { text?: string }; description?: string; optional?: boolean }} CemParameter
@@ -730,6 +736,9 @@ function renderDataSourceFields(
730
736
  )}
731
737
  `;
732
738
  }
739
+ if (proto === "ContentEntry" || proto === "ContentCollection") {
740
+ return renderContentPrototypeFields(name, def, proto);
741
+ }
733
742
  if (proto === "Set" || proto === "Map" || proto === "FormData") {
734
743
  const fieldName = proto === "FormData" ? "fields" : "default";
735
744
  const fieldLabel = proto === "FormData" ? "Fields" : "Default";
@@ -751,6 +760,117 @@ function renderDataSourceFields(
751
760
  return renderExternalPrototypeEditorTemplate(S, name, def, ctx);
752
761
  }
753
762
 
763
+ /** ContentEntry/ContentCollection fields for signal editor */
764
+ function renderContentPrototypeFields(
765
+ /** @type {string} */ name,
766
+ /** @type {SignalDef} */ def,
767
+ /** @type {string} */ proto,
768
+ ) {
769
+ const contentTypes = projectState?.projectConfig?.contentTypes;
770
+ const typeNames = contentTypes ? Object.keys(contentTypes) : [];
771
+ const currentType = def.contentType || "";
772
+ const schema = /** @type {ContentTypeSchema | undefined} */ (
773
+ currentType && contentTypes?.[currentType]?.schema
774
+ ) ||
775
+ undefined;
776
+
777
+ const contentTypeField = typeNames.length
778
+ ? html`
779
+ <div class="signal-field-row">
780
+ <label class="signal-field-label">Content Type</label>
781
+ <sp-picker
782
+ size="s"
783
+ value=${currentType}
784
+ style="width:100%"
785
+ @change=${(/** @type {Event} */ e) => {
786
+ const v = /** @type {HTMLSelectElement} */ (e.target).value;
787
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { contentType: v }));
788
+ }}
789
+ >
790
+ ${typeNames.map((t) => html`<sp-menu-item value=${t}>${t}</sp-menu-item>`)}
791
+ </sp-picker>
792
+ </div>
793
+ `
794
+ : signalFieldRow("Content Type", currentType, (/** @type {string} */ v) =>
795
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { contentType: v })),
796
+ );
797
+
798
+ /** @type {import("lit-html").TemplateResult | symbol} */
799
+ let entryFields = nothing;
800
+ if (proto === "ContentEntry") {
801
+ const idVal = def.id;
802
+ const idStr =
803
+ idVal && typeof idVal === "object" && /** @type {Record<string, unknown>} */ (idVal).$ref
804
+ ? /** @type {string} */ (/** @type {Record<string, unknown>} */ (idVal).$ref)
805
+ : typeof idVal === "string"
806
+ ? idVal
807
+ : "";
808
+ entryFields = signalFieldRow("ID", idStr, (/** @type {string} */ v) => {
809
+ const val = v.startsWith("#/") ? { $ref: v } : v;
810
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { id: val }));
811
+ });
812
+ } else {
813
+ const filterStr = def.filter ? JSON.stringify(def.filter) : "";
814
+ const sortStr = def.sort ? `${def.sort.field || ""} ${def.sort.order || ""}`.trim() : "";
815
+ const limitStr = def.limit != null ? String(def.limit) : "";
816
+ entryFields = html`
817
+ ${signalFieldRow("Filter", filterStr, (/** @type {string} */ v) => {
818
+ try {
819
+ const parsed = v ? JSON.parse(v) : undefined;
820
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { filter: parsed }));
821
+ } catch {}
822
+ })}
823
+ ${signalFieldRow("Sort", sortStr, (/** @type {string} */ v) => {
824
+ if (!v) {
825
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { sort: undefined }));
826
+ return;
827
+ }
828
+ const [field, order] = v.split(" ");
829
+ transactDoc(activeTab.value, (t) =>
830
+ mutateUpdateDef(t, name, { sort: { field, order: order || "asc" } }),
831
+ );
832
+ })}
833
+ ${signalFieldRow("Limit", limitStr, (/** @type {string} */ v) => {
834
+ const num = v ? parseInt(v, 10) : undefined;
835
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { limit: num }));
836
+ })}
837
+ `;
838
+ }
839
+
840
+ /** @type {import("lit-html").TemplateResult | symbol} */
841
+ let schemaFields = nothing;
842
+ if (schema?.properties) {
843
+ const fields = Object.entries(schema.properties);
844
+ const required = new Set(schema.required || []);
845
+ schemaFields = html`
846
+ <div style="margin-top:8px;padding:6px 0;border-top:1px solid var(--border-dim)">
847
+ <div style="font-size:11px;color:var(--fg-dim);margin-bottom:4px;font-weight:500">
848
+ Fields (${currentType})
849
+ </div>
850
+ ${fields.map(
851
+ ([field, fieldDef]) => html`
852
+ <div style="font-size:11px;padding:2px 0;display:flex;gap:4px;align-items:center">
853
+ <code style="color:var(--fg-default)">${field}</code>
854
+ <span style="color:var(--fg-dim)">${/** @type {any} */ (fieldDef).type || ""}</span>
855
+ ${required.has(field)
856
+ ? html`<span style="color:var(--color-accent,#f36f32);font-size:9px">req</span>`
857
+ : nothing}
858
+ </div>
859
+ `,
860
+ )}
861
+ </div>
862
+ `;
863
+ }
864
+
865
+ return html`
866
+ ${contentTypeField}
867
+ ${signalFieldRow("Prototype", proto, (/** @type {string} */ v) =>
868
+ transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { $prototype: v })),
869
+ )}
870
+ ${entryFields} ${schemaFields}
871
+ `;
872
+ }
873
+
754
874
  /** Function fields for signal editor */
755
875
  function renderFunctionFields(
756
876
  /** @type {SignalsPanelState} */ S,
@@ -38,11 +38,14 @@ export function mountStatusbar() {
38
38
  renderStatusbar();
39
39
  });
40
40
  });
41
+
42
+ statusbarEl?.addEventListener("click", _onStatusbarClick);
41
43
  }
42
44
 
43
45
  export function unmountStatusbar() {
44
46
  _scope?.stop();
45
47
  _scope = null;
48
+ statusbarEl?.removeEventListener("click", _onStatusbarClick);
46
49
  }
47
50
 
48
51
  // ─── Statusbar ───────────────────────────────────────────────────────────────
@@ -81,7 +84,8 @@ export function renderStatusbar() {
81
84
  statusbarEl.innerHTML = parts.join(" | ") || "Jx Studio";
82
85
  }
83
86
 
84
- statusbarEl?.addEventListener("click", (e) => {
87
+ /** @param {Event} e */
88
+ function _onStatusbarClick(e) {
85
89
  const target = /** @type {HTMLElement} */ (e.target);
86
90
  if (!target.classList.contains("sb-path-seg")) return;
87
91
  const pathStr = target.dataset.path;
@@ -93,7 +97,7 @@ statusbarEl?.addEventListener("click", (e) => {
93
97
  } catch {
94
98
  // ignore
95
99
  }
96
- });
100
+ }
97
101
 
98
102
  /**
99
103
  * Show a temporary status message.
@@ -872,7 +872,7 @@ export function _fieldRow(
872
872
  ? html`<sp-textfield
873
873
  multiline
874
874
  size="s"
875
- value=${value ?? ""}
875
+ .value=${live(value ?? "")}
876
876
  @input=${onInput}
877
877
  ></sp-textfield>`
878
878
  : type === "checkbox"
@@ -881,7 +881,11 @@ export function _fieldRow(
881
881
  @change=${(/** @type {Event} */ e) =>
882
882
  onChange(/** @type {HTMLInputElement} */ (e.target).checked)}
883
883
  ></sp-checkbox>`
884
- : html`<sp-textfield size="s" value=${value ?? ""} @input=${onInput}></sp-textfield>`;
884
+ : html`<sp-textfield
885
+ size="s"
886
+ .value=${live(value ?? "")}
887
+ @input=${onInput}
888
+ ></sp-textfield>`;
885
889
  return html`
886
890
  <div class="field-row">
887
891
  <sp-field-label size="s">${label}</sp-field-label>