@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,15 +24,16 @@
24
24
  "scripts": {
25
25
  "build": "bun build ./src/studio.js --outdir dist --target browser --sourcemap=linked && bun build ./node_modules/monaco-editor/esm/vs/editor/editor.worker.js --outdir dist/workers --target browser --entry-naming editor.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/json/json.worker.js --outdir dist/workers --target browser --entry-naming json.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js --outdir dist/workers --target browser --entry-naming ts.worker.js",
26
26
  "gen:webdata": "bun run scripts/gen-webdata.js",
27
- "test": "bun test",
27
+ "test": "bun test --isolate",
28
+ "test:coverage": "bun test --isolate --coverage",
28
29
  "upgrade": "bunx npm-check-updates -u && bun install"
29
30
  },
30
31
  "dependencies": {
31
32
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
32
33
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
33
- "@jxsuite/parser": "^0.20.0",
34
- "@jxsuite/runtime": "^0.20.0",
35
- "@jxsuite/schema": "^0.20.0",
34
+ "@jxsuite/parser": "^0.21.0",
35
+ "@jxsuite/runtime": "^0.21.0",
36
+ "@jxsuite/schema": "^0.21.0",
36
37
  "@spectrum-web-components/accordion": "^1.12.1",
37
38
  "@spectrum-web-components/action-bar": "1.12.1",
38
39
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { html } from "lit-html";
7
+ import { ref } from "lit-html/directives/ref.js";
7
8
  import { renderBrowse } from "./browse.js";
8
9
  import { openFileInTab } from "../files/files.js";
9
10
  import { openModal } from "../ui/layers.js";
@@ -31,23 +32,25 @@ export function openBrowseModal() {
31
32
  <sp-icon-close slot="icon"></sp-icon-close>
32
33
  </sp-action-button>
33
34
  </div>
34
- <div class="browse-modal-content"></div>
35
+ <div
36
+ class="browse-modal-content"
37
+ ${ref((el) => {
38
+ if (el) {
39
+ requestAnimationFrame(() => {
40
+ renderBrowse(/** @type {HTMLElement} */ (el), {
41
+ openFile: (/** @type {string} */ path) => {
42
+ closeBrowseModal();
43
+ openFileInTab(path);
44
+ },
45
+ });
46
+ });
47
+ }
48
+ })}
49
+ ></div>
35
50
  </div>
36
51
  `;
37
52
 
38
53
  _handle = openModal(tpl);
39
-
40
- requestAnimationFrame(() => {
41
- const container = _handle?.host.querySelector(".browse-modal-content");
42
- if (container) {
43
- renderBrowse(/** @type {HTMLElement} */ (container), {
44
- openFile: (/** @type {string} */ path) => {
45
- closeBrowseModal();
46
- openFileInTab(path);
47
- },
48
- });
49
- }
50
- });
51
54
  }
52
55
 
53
56
  export function closeBrowseModal() {
@@ -154,7 +154,7 @@ function contentTypeFor(filePath) {
154
154
  for (const [name, def] of Object.entries(config.contentTypes)) {
155
155
  const d = /** @type {ContentTypeDef} */ (def);
156
156
  if (!d.source) continue;
157
- const prefix = d.source.replace(/^\.\//, "").split("/**")[0].split("/*")[0];
157
+ const prefix = d.source.replace(/^\.\//, "").replace(/\/$/, "");
158
158
  if (filePath.startsWith(prefix + "/") || filePath === prefix) {
159
159
  return name.charAt(0).toUpperCase() + name.slice(1);
160
160
  }
@@ -233,12 +233,12 @@ function getContentTypeTypes() {
233
233
  if (!config?.contentTypes) return [];
234
234
  return Object.entries(config.contentTypes).map(([name, def]) => {
235
235
  const d = /** @type {ContentTypeDef} */ (def);
236
- const dir = d.source ? d.source.replace(/^\.\//, "").split("/")[0] : name;
236
+ const dir = d.source ? d.source.replace(/^\.\//, "").replace(/\/$/, "") : name;
237
237
  return {
238
238
  key: `contentType:${name}`,
239
239
  label: name.charAt(0).toUpperCase() + name.slice(1),
240
240
  dir,
241
- ext: ".md",
241
+ ext: `.${d.format || "md"}`,
242
242
  contentTypeName: name,
243
243
  };
244
244
  });
@@ -362,7 +362,22 @@ function showBrowseContextMenu(e, file, container, ctx) {
362
362
  y = e.clientY;
363
363
 
364
364
  _browseCtxHandle = renderPopover(
365
- html`<sp-popover open style="position:fixed;left:${x}px;top:${y}px">
365
+ html`<sp-popover
366
+ open
367
+ style="position:fixed;left:${x}px;top:${y}px"
368
+ ${ref((el) => {
369
+ if (!el) return;
370
+ requestAnimationFrame(() => {
371
+ const popover = /** @type {HTMLElement} */ (el);
372
+ const menuRect = popover.getBoundingClientRect();
373
+ if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
374
+ if (y + menuRect.height > window.innerHeight)
375
+ y = window.innerHeight - menuRect.height - 4;
376
+ popover.style.left = `${x}px`;
377
+ popover.style.top = `${y}px`;
378
+ });
379
+ })}
380
+ >
366
381
  <sp-menu>
367
382
  ${items.map((item) =>
368
383
  item.label === "\u2014"
@@ -386,18 +401,6 @@ function showBrowseContextMenu(e, file, container, ctx) {
386
401
  layer: "dialog",
387
402
  },
388
403
  );
389
-
390
- requestAnimationFrame(() => {
391
- const popover = /** @type {HTMLElement | null} */ (
392
- _browseCtxHandle?.host.querySelector("sp-popover")
393
- );
394
- if (!popover) return;
395
- const menuRect = popover.getBoundingClientRect();
396
- if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
397
- if (y + menuRect.height > window.innerHeight) y = window.innerHeight - menuRect.height - 4;
398
- popover.style.left = `${x}px`;
399
- popover.style.top = `${y}px`;
400
- });
401
404
  }
402
405
 
403
406
  /**
@@ -13,6 +13,7 @@ import {
13
13
  buildScope,
14
14
  defineElement,
15
15
  setSkipServerFunctions,
16
+ setSkipContentResolution,
16
17
  } from "@jxsuite/runtime";
17
18
  import {
18
19
  getEffectiveElements,
@@ -30,6 +31,16 @@ import { buildNestedSiteCSS } from "./nested-site-style.js";
30
31
 
31
32
  export { buildNestedSiteCSS } from "./nested-site-style.js";
32
33
 
34
+ /** @param {Event} e */
35
+ function _preventNav(e) {
36
+ if (/** @type {HTMLElement} */ (e.target).closest("a[href]")) {
37
+ e.preventDefault();
38
+ }
39
+ }
40
+
41
+ /** Canvas elements that already have the delegated nav guard listener. */
42
+ const _navGuarded = new WeakSet();
43
+
33
44
  /** @type {{ getCanvasMode: () => string } | null} */
34
45
  let _ctx = null;
35
46
 
@@ -128,6 +139,7 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
128
139
  // failed proxy calls and infinite reactive retries (also covers
129
140
  // async custom element connectedCallbacks that run after this function returns)
130
141
  setSkipServerFunctions(canvasMode !== "preview");
142
+ setSkipContentResolution(canvasMode !== "preview");
131
143
 
132
144
  let renderDoc =
133
145
  canvasMode === "preview"
@@ -460,6 +472,13 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
460
472
 
461
473
  canvasEl.appendChild(el);
462
474
 
475
+ // Delegated click handler prevents link navigation in all canvas modes.
476
+ // Attached once per canvasEl (survives reactive re-renders that replace children).
477
+ if (!_navGuarded.has(canvasEl)) {
478
+ canvasEl.addEventListener("click", _preventNav);
479
+ _navGuarded.add(canvasEl);
480
+ }
481
+
463
482
  if (canvasMode === "design" || canvasMode === "edit") {
464
483
  requestAnimationFrame(() => {
465
484
  const editingEl = getActiveElement();
@@ -25,6 +25,9 @@ import { getActivePanel, findCanvasElement } from "./canvas-helpers.js";
25
25
  */
26
26
  let _ctx;
27
27
 
28
+ /** @type {HTMLElement | null} */
29
+ let _zoomIndicatorEl = null;
30
+
28
31
  /**
29
32
  * Initialize the canvas utils module.
30
33
  *
@@ -288,7 +291,12 @@ export function renderZoomIndicator() {
288
291
  const host = getLayerSlot("popover", "zoom-indicator");
289
292
  litRender(
290
293
  html`
291
- <div class="zoom-indicator">
294
+ <div
295
+ class="zoom-indicator"
296
+ ${ref((el) => {
297
+ _zoomIndicatorEl = /** @type {HTMLElement | null} */ (el || null);
298
+ })}
299
+ >
292
300
  <span class="zoom-indicator-action" title="Reset to 100%" @click=${resetZoom}>
293
301
  <svg
294
302
  width="14"
@@ -325,12 +333,11 @@ export function renderZoomIndicator() {
325
333
 
326
334
  /** Position the zoom indicator relative to canvas-wrap bounds. */
327
335
  export function positionZoomIndicator() {
328
- const indicator = /** @type {HTMLElement | null} */ (document.querySelector(".zoom-indicator"));
329
- if (!indicator) return;
336
+ if (!_zoomIndicatorEl) return;
330
337
  const rect = canvasWrap.getBoundingClientRect();
331
- indicator.style.left = `${rect.left + rect.width / 2}px`;
332
- indicator.style.top = `${rect.bottom - 32}px`;
333
- indicator.style.transform = "translateX(-50%)";
338
+ _zoomIndicatorEl.style.left = `${rect.left + rect.width / 2}px`;
339
+ _zoomIndicatorEl.style.top = `${rect.bottom - 32}px`;
340
+ _zoomIndicatorEl.style.transform = "translateX(-50%)";
334
341
  }
335
342
 
336
343
  /** Toggle "active" class on canvas panel headers based on activeMedia. */
@@ -1,5 +1,6 @@
1
1
  // ─── Clipboard & Context Menu ─────────────────────────────────────────────────
2
2
  import { html } from "lit-html";
3
+ import { ref } from "lit-html/directives/ref.js";
3
4
  import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
4
5
  import { activeTab, workspace } from "../workspace/workspace.js";
5
6
  import {
@@ -12,6 +13,7 @@ import {
12
13
  } from "../tabs/transact.js";
13
14
  import { statusMessage } from "../panels/statusbar.js";
14
15
  import { convertToComponent } from "./convert-to-component.js";
16
+ import { convertToRepeater } from "./convert-to-repeater.js";
15
17
  import { componentRegistry } from "../files/components.js";
16
18
  import { renderPopover } from "../ui/layers.js";
17
19
  import { startLayerTitleEdit } from "../panels/layers-panel.js";
@@ -169,6 +171,15 @@ export function showContextMenu(e, path, opts = {}) {
169
171
  label: "Wrap in Div",
170
172
  action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
171
173
  });
174
+ // Don't show Repeat if already inside a repeater (path ends with "children", "map")
175
+ if (
176
+ !(path.length >= 2 && path[path.length - 2] === "children" && path[path.length - 1] === "map")
177
+ ) {
178
+ items.push({
179
+ label: "Repeat...",
180
+ action: () => convertToRepeater(),
181
+ });
182
+ }
172
183
  items.push({
173
184
  label: "Set Title",
174
185
  action: () => {
@@ -231,7 +242,22 @@ export function showContextMenu(e, path, opts = {}) {
231
242
  y = e.clientY;
232
243
 
233
244
  _ctxHandle = renderPopover(
234
- html`<sp-popover open style="position:fixed;z-index:10000;left:${x}px;top:${y}px">
245
+ html`<sp-popover
246
+ open
247
+ style="position:fixed;z-index:10000;left:${x}px;top:${y}px"
248
+ ${ref((el) => {
249
+ if (!el) return;
250
+ requestAnimationFrame(() => {
251
+ const popover = /** @type {HTMLElement} */ (el);
252
+ const menuRect = popover.getBoundingClientRect();
253
+ if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
254
+ if (y + menuRect.height > window.innerHeight)
255
+ y = window.innerHeight - menuRect.height - 4;
256
+ popover.style.left = `${x}px`;
257
+ popover.style.top = `${y}px`;
258
+ });
259
+ })}
260
+ >
235
261
  <sp-menu>
236
262
  ${items.map((item) =>
237
263
  item.label === "—"
@@ -254,16 +280,4 @@ export function showContextMenu(e, path, opts = {}) {
254
280
  },
255
281
  },
256
282
  );
257
-
258
- requestAnimationFrame(() => {
259
- const popover = /** @type {HTMLElement | null} */ (
260
- _ctxHandle?.host.querySelector("sp-popover")
261
- );
262
- if (!popover) return;
263
- const menuRect = popover.getBoundingClientRect();
264
- if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
265
- if (y + menuRect.height > window.innerHeight) y = window.innerHeight - menuRect.height - 4;
266
- popover.style.left = `${x}px`;
267
- popover.style.top = `${y}px`;
268
- });
269
283
  }
@@ -0,0 +1,60 @@
1
+ import elementsMeta from "../../data/elements-meta.json";
2
+
3
+ /** @typedef {{ label: string; tag: string; description: string }} SlashCommand */
4
+
5
+ const TAG_LABELS = /** @type {Record<string, string>} */ ({
6
+ p: "Paragraph",
7
+ h1: "Heading 1",
8
+ h2: "Heading 2",
9
+ h3: "Heading 3",
10
+ h4: "Heading 4",
11
+ h5: "Heading 5",
12
+ h6: "Heading 6",
13
+ blockquote: "Blockquote",
14
+ div: "Div",
15
+ section: "Section",
16
+ article: "Article",
17
+ aside: "Aside",
18
+ main: "Main",
19
+ header: "Header",
20
+ footer: "Footer",
21
+ nav: "Nav",
22
+ search: "Search",
23
+ ul: "Bulleted List",
24
+ ol: "Numbered List",
25
+ });
26
+
27
+ const groups = /** @type {Record<string, string[]>} */ (elementsMeta.$convertGroups || {});
28
+
29
+ /**
30
+ * Get the list of tags the current element can be converted to.
31
+ *
32
+ * @param {string} currentTag
33
+ * @param {boolean} isEmpty
34
+ * @returns {SlashCommand[]}
35
+ */
36
+ export function getConvertTargets(currentTag, isEmpty) {
37
+ const def = /** @type {Record<string, unknown> | undefined} */ (
38
+ /** @type {Record<string, Record<string, unknown>>} */ (elementsMeta.$defs)?.[currentTag]
39
+ );
40
+ if (!def?.$convertTo) return [];
41
+
42
+ const groupNames =
43
+ isEmpty && def.$convertToWhenEmpty
44
+ ? /** @type {string[]} */ (def.$convertToWhenEmpty)
45
+ : [/** @type {string} */ (def.$convertTo)];
46
+
47
+ /** @type {Set<string>} */
48
+ const tags = new Set();
49
+ for (const name of groupNames) {
50
+ for (const tag of groups[name] || []) {
51
+ if (tag !== currentTag) tags.add(tag);
52
+ }
53
+ }
54
+
55
+ return [...tags].map((tag) => ({
56
+ label: TAG_LABELS[tag] || tag.charAt(0).toUpperCase() + tag.slice(1),
57
+ tag,
58
+ description: "",
59
+ }));
60
+ }
@@ -1,5 +1,6 @@
1
1
  // ─── Convert to Component ─────────────────────────────────────────────────────
2
2
  import { html, render as litRender } from "lit-html";
3
+ import { ref } from "lit-html/directives/ref.js";
3
4
  import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
4
5
  import { activeTab } from "../workspace/workspace.js";
5
6
  import { transact } from "../tabs/transact.js";
@@ -172,6 +173,14 @@ function promptComponentName(defaultName) {
172
173
  ?negative=${!!error}
173
174
  @input=${onInput}
174
175
  @keydown=${onKeydown}
176
+ ${ref((el) => {
177
+ if (el)
178
+ requestAnimationFrame(() => {
179
+ /** @type {HTMLElement} */ (el).focus();
180
+ const input = /** @type {HTMLElement} */ (el).shadowRoot?.querySelector("input");
181
+ if (input) input.select();
182
+ });
183
+ })}
175
184
  >
176
185
  <sp-help-text slot="negative-help-text">${error}</sp-help-text>
177
186
  </sp-textfield>
@@ -179,16 +188,6 @@ function promptComponentName(defaultName) {
179
188
  `;
180
189
  }
181
190
 
182
- requestAnimationFrame(() => {
183
- const layer = document.getElementById("layer-dialog");
184
- const tf = /** @type {HTMLElement | null} */ (layer?.querySelector("sp-textfield"));
185
- if (tf) {
186
- tf.focus();
187
- const input = tf.shadowRoot?.querySelector("input");
188
- if (input) input.select();
189
- }
190
- });
191
-
192
191
  return buildTpl();
193
192
  });
194
193
  }
@@ -0,0 +1,226 @@
1
+ // ─── Convert to Repeater ──────────────────────────────────────────────────────
2
+ import { html, render as litRender, nothing } from "lit-html";
3
+ import { ref } from "lit-html/directives/ref.js";
4
+ import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
5
+ import { activeTab } from "../workspace/workspace.js";
6
+ import { transactDoc } from "../tabs/transact.js";
7
+ import { showDialog } from "../ui/layers.js";
8
+ import { defCategory } from "../panels/signals-panel.js";
9
+
10
+ /**
11
+ * @typedef {{
12
+ * items: { $ref: string } | unknown[];
13
+ * filter?: { $ref: string };
14
+ * sort?: { $ref: string };
15
+ * newDef?: { name: string };
16
+ * }} RepeaterConfig
17
+ */
18
+
19
+ /** Convert the currently selected element into a repeater template. */
20
+ export async function convertToRepeater() {
21
+ const tab = activeTab.value;
22
+ if (!tab?.session.selection || tab.session.selection.length < 2) return;
23
+
24
+ const path = tab.session.selection;
25
+ const node = getNodeAtPath(tab.doc.document, path);
26
+ if (!node) return;
27
+
28
+ const defs = tab.doc.document.state || {};
29
+ const config = await promptRepeaterConfig(defs);
30
+ if (!config) return;
31
+
32
+ transactDoc(tab, (t) => {
33
+ const doc = t.doc.document;
34
+ if (config.newDef) {
35
+ if (!doc.state) doc.state = {};
36
+ doc.state[config.newDef.name] = { type: "array", default: [] };
37
+ }
38
+ const pp = parentElementPath(path);
39
+ if (!pp) return;
40
+ const idx = /** @type {number} */ (childIndex(path));
41
+ const parent = getNodeAtPath(doc, pp);
42
+ if (!parent?.children) return;
43
+ const element = parent.children[idx];
44
+
45
+ /** @type {Record<string, unknown>} */
46
+ const repeater = {
47
+ $prototype: "Array",
48
+ items: config.items,
49
+ map: element,
50
+ };
51
+ if (config.filter) repeater.filter = config.filter;
52
+ if (config.sort) repeater.sort = config.sort;
53
+
54
+ /** @type {(string | JxMutableNode)[]} */ (parent.children)[idx] = {
55
+ tagName: "div",
56
+ children: /** @type {any} */ (repeater),
57
+ };
58
+ });
59
+ }
60
+
61
+ /**
62
+ * @param {Record<string, unknown>} defs
63
+ * @returns {Promise<RepeaterConfig | null>}
64
+ */
65
+ function promptRepeaterConfig(defs) {
66
+ const arrayDefs = Object.entries(defs).filter(
67
+ ([, d]) =>
68
+ /** @type {any} */ (d)?.type === "array" ||
69
+ Array.isArray(/** @type {any} */ (d)?.default) ||
70
+ /** @type {any} */ (d)?.$prototype === "Array",
71
+ );
72
+ const fnDefs = Object.entries(defs).filter(([, d]) => defCategory(d) === "function");
73
+
74
+ let source = arrayDefs.length > 0 ? arrayDefs[0][0] : "__new__";
75
+ let newDefName = "";
76
+ let filterDef = "";
77
+ let sortDef = "";
78
+ let error = "";
79
+
80
+ return showDialog((done) => {
81
+ function confirm() {
82
+ if (source === "__new__") {
83
+ const name = newDefName.trim();
84
+ if (!name) {
85
+ error = "Enter a name for the new state definition.";
86
+ rerender();
87
+ return;
88
+ }
89
+ if (defs[name]) {
90
+ error = `"${name}" already exists.`;
91
+ rerender();
92
+ return;
93
+ }
94
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
95
+ error = "Invalid identifier name.";
96
+ rerender();
97
+ return;
98
+ }
99
+ done({
100
+ items: { $ref: `#/$defs/${name}` },
101
+ filter: filterDef ? { $ref: `#/$defs/${filterDef}` } : undefined,
102
+ sort: sortDef ? { $ref: `#/$defs/${sortDef}` } : undefined,
103
+ newDef: { name },
104
+ });
105
+ } else {
106
+ done({
107
+ items: { $ref: `#/$defs/${source}` },
108
+ filter: filterDef ? { $ref: `#/$defs/${filterDef}` } : undefined,
109
+ sort: sortDef ? { $ref: `#/$defs/${sortDef}` } : undefined,
110
+ });
111
+ }
112
+ }
113
+
114
+ function rerender() {
115
+ const layer = document.getElementById("layer-dialog");
116
+ const slot = layer?.lastElementChild;
117
+ if (slot) litRender(buildTpl(), /** @type {HTMLElement} */ (slot));
118
+ }
119
+
120
+ function buildTpl() {
121
+ return html`
122
+ <sp-dialog-wrapper
123
+ open
124
+ underlay
125
+ headline="Repeat..."
126
+ confirm-label="Create Repeater"
127
+ cancel-label="Cancel"
128
+ size="s"
129
+ @confirm=${confirm}
130
+ @cancel=${() => done(null)}
131
+ @close=${() => done(null)}
132
+ >
133
+ <div style="display:flex;flex-direction:column;gap:12px">
134
+ <label>
135
+ <sp-field-label size="s">Items source</sp-field-label>
136
+ <sp-picker
137
+ label="Items source"
138
+ size="s"
139
+ .value=${source}
140
+ @change=${(/** @type {Event} */ e) => {
141
+ source = /** @type {HTMLInputElement} */ (e.target).value;
142
+ error = "";
143
+ rerender();
144
+ }}
145
+ >
146
+ ${arrayDefs.map(
147
+ ([name]) => html`<sp-menu-item value=${name}>${name}</sp-menu-item>`,
148
+ )}
149
+ <sp-menu-divider></sp-menu-divider>
150
+ <sp-menu-item value="__new__">Create new...</sp-menu-item>
151
+ </sp-picker>
152
+ </label>
153
+
154
+ ${source === "__new__"
155
+ ? html`
156
+ <label>
157
+ <sp-field-label size="s">New definition name</sp-field-label>
158
+ <sp-textfield
159
+ size="s"
160
+ placeholder="myItems"
161
+ .value=${newDefName}
162
+ ?negative=${!!error}
163
+ @input=${(/** @type {Event} */ e) => {
164
+ newDefName = /** @type {HTMLInputElement} */ (e.target).value || "";
165
+ error = "";
166
+ rerender();
167
+ }}
168
+ @keydown=${(/** @type {KeyboardEvent} */ e) => {
169
+ if (e.key === "Enter") confirm();
170
+ }}
171
+ ${ref((el) => {
172
+ if (el && source === "__new__")
173
+ requestAnimationFrame(() => /** @type {HTMLElement} */ (el).focus());
174
+ })}
175
+ >
176
+ <sp-help-text slot="negative-help-text">${error}</sp-help-text>
177
+ </sp-textfield>
178
+ </label>
179
+ `
180
+ : nothing}
181
+ ${fnDefs.length > 0
182
+ ? html`
183
+ <label>
184
+ <sp-field-label size="s">Filter (optional)</sp-field-label>
185
+ <sp-picker
186
+ label="Filter"
187
+ size="s"
188
+ .value=${filterDef}
189
+ @change=${(/** @type {Event} */ e) => {
190
+ filterDef = /** @type {HTMLInputElement} */ (e.target).value;
191
+ rerender();
192
+ }}
193
+ >
194
+ <sp-menu-item value="">None</sp-menu-item>
195
+ ${fnDefs.map(
196
+ ([name]) => html`<sp-menu-item value=${name}>${name}</sp-menu-item>`,
197
+ )}
198
+ </sp-picker>
199
+ </label>
200
+ <label>
201
+ <sp-field-label size="s">Sort (optional)</sp-field-label>
202
+ <sp-picker
203
+ label="Sort"
204
+ size="s"
205
+ .value=${sortDef}
206
+ @change=${(/** @type {Event} */ e) => {
207
+ sortDef = /** @type {HTMLInputElement} */ (e.target).value;
208
+ rerender();
209
+ }}
210
+ >
211
+ <sp-menu-item value="">None</sp-menu-item>
212
+ ${fnDefs.map(
213
+ ([name]) => html`<sp-menu-item value=${name}>${name}</sp-menu-item>`,
214
+ )}
215
+ </sp-picker>
216
+ </label>
217
+ `
218
+ : nothing}
219
+ </div>
220
+ </sp-dialog-wrapper>
221
+ `;
222
+ }
223
+
224
+ return buildTpl();
225
+ });
226
+ }
@@ -56,6 +56,9 @@ const EDITABLE_BLOCKS = new Set([
56
56
  "td",
57
57
  "th",
58
58
  "blockquote",
59
+ "span",
60
+ "a",
61
+ "label",
59
62
  ]);
60
63
 
61
64
  // ─── Context-aware inline scoping ─────────────────────────────────────────
@@ -18,6 +18,7 @@ import {
18
18
  import { isEditing, stopEditing } from "./inline-edit.js";
19
19
  import { copyNode, cutNode, pasteNode } from "./context-menu.js";
20
20
  import { openQuickSearch } from "../panels/quick-search.js";
21
+ import { showConfirmDialog } from "../ui/layers.js";
21
22
 
22
23
  /** @typedef {import("../state.js").JxPath} JxPath */
23
24
 
@@ -160,9 +161,16 @@ export function initShortcuts(getContext) {
160
161
  const tab = workspace.tabs.get(workspace.activeTabId);
161
162
  if (tab?.doc.dirty) {
162
163
  const name = tab.documentPath?.split("/").pop() || "Untitled";
163
- if (!window.confirm(`"${name}" has unsaved changes. Close without saving?`)) break;
164
+ showConfirmDialog(
165
+ "Unsaved Changes",
166
+ `"${name}" has unsaved changes. Close without saving?`,
167
+ { confirmLabel: "Close", destructive: true },
168
+ ).then((confirmed) => {
169
+ if (confirmed && workspace.activeTabId) closeTab(workspace.activeTabId);
170
+ });
171
+ } else {
172
+ closeTab(workspace.activeTabId);
164
173
  }
165
- closeTab(workspace.activeTabId);
166
174
  }
167
175
  break;
168
176
  case "o":