@jxsuite/studio 0.19.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 (99) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +13476 -12330
  3. package/dist/studio.js.map +152 -148
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +19 -7
  8. package/src/browse/browse-modal.js +16 -13
  9. package/src/browse/browse.js +44 -35
  10. package/src/canvas/canvas-diff.js +24 -16
  11. package/src/canvas/canvas-helpers.js +24 -19
  12. package/src/canvas/canvas-live-render.js +113 -39
  13. package/src/canvas/canvas-render.js +119 -73
  14. package/src/canvas/canvas-utils.js +51 -21
  15. package/src/canvas/nested-site-style.js +50 -0
  16. package/src/editor/component-inline-edit.js +14 -13
  17. package/src/editor/content-inline-edit.js +22 -10
  18. package/src/editor/context-menu.js +28 -14
  19. package/src/editor/convert-targets.js +60 -0
  20. package/src/editor/convert-to-component.js +20 -19
  21. package/src/editor/convert-to-repeater.js +226 -0
  22. package/src/editor/inline-edit.js +69 -28
  23. package/src/editor/insertion-helper.js +16 -12
  24. package/src/editor/shortcuts.js +14 -4
  25. package/src/editor/slash-menu.js +40 -19
  26. package/src/files/components.js +1 -1
  27. package/src/files/file-ops.js +37 -16
  28. package/src/files/files.js +322 -68
  29. package/src/github/github-auth.js +122 -0
  30. package/src/github/github-publish.js +126 -0
  31. package/src/markdown/md-convert.js +172 -107
  32. package/src/new-project/new-project-modal.js +204 -0
  33. package/src/panels/activity-bar.js +27 -25
  34. package/src/panels/ai-panel.js +406 -0
  35. package/src/panels/block-action-bar.js +77 -28
  36. package/src/panels/canvas-dnd.js +8 -2
  37. package/src/panels/data-explorer.js +19 -13
  38. package/src/panels/dnd.js +144 -92
  39. package/src/panels/editors.js +55 -35
  40. package/src/panels/elements-panel.js +33 -19
  41. package/src/panels/events-panel.js +15 -10
  42. package/src/panels/git-panel.js +475 -99
  43. package/src/panels/head-panel.js +121 -81
  44. package/src/panels/imports-panel.js +94 -65
  45. package/src/panels/layers-panel.js +64 -43
  46. package/src/panels/left-panel.js +105 -48
  47. package/src/panels/overlays.js +1 -0
  48. package/src/panels/panel-events.js +45 -30
  49. package/src/panels/preview-render.js +23 -18
  50. package/src/panels/properties-panel.js +378 -257
  51. package/src/panels/pseudo-preview.js +2 -2
  52. package/src/panels/quick-search.js +11 -9
  53. package/src/panels/right-panel.js +42 -16
  54. package/src/panels/shared.js +4 -4
  55. package/src/panels/signals-panel.js +415 -166
  56. package/src/panels/statusbar.js +10 -6
  57. package/src/panels/style-inputs.js +67 -35
  58. package/src/panels/style-panel.js +239 -134
  59. package/src/panels/style-utils.js +34 -16
  60. package/src/panels/stylebook-layers-panel.js +12 -10
  61. package/src/panels/stylebook-panel.js +134 -66
  62. package/src/panels/tab-strip.js +5 -2
  63. package/src/panels/toolbar.js +333 -109
  64. package/src/panels/welcome-screen.js +121 -0
  65. package/src/platform.js +2 -4
  66. package/src/platforms/devserver.js +113 -7
  67. package/src/resize-edges.js +98 -0
  68. package/src/services/cem-export.js +12 -7
  69. package/src/services/code-services.js +30 -12
  70. package/src/settings/content-types-editor.js +8 -8
  71. package/src/settings/css-vars-editor.js +30 -24
  72. package/src/settings/defs-editor.js +12 -7
  73. package/src/settings/general-settings.js +85 -3
  74. package/src/settings/head-editor.js +30 -24
  75. package/src/settings/schema-field-ui.js +58 -39
  76. package/src/settings/settings-modal.js +12 -9
  77. package/src/site-context.js +41 -31
  78. package/src/state.js +70 -29
  79. package/src/store.js +36 -35
  80. package/src/studio.js +120 -90
  81. package/src/tabs/tab.js +67 -43
  82. package/src/tabs/transact.js +23 -13
  83. package/src/ui/button-group.js +24 -24
  84. package/src/ui/color-selector.js +28 -23
  85. package/src/ui/field-row.js +3 -3
  86. package/src/ui/icons.js +2 -2
  87. package/src/ui/layers.js +31 -1
  88. package/src/ui/media-picker.js +7 -7
  89. package/src/ui/spectrum.js +5 -1
  90. package/src/ui/unit-selector.js +18 -16
  91. package/src/ui/value-selector.js +21 -15
  92. package/src/ui/widgets.js +22 -19
  93. package/src/utils/canvas-media.js +5 -4
  94. package/src/utils/edit-display.js +32 -26
  95. package/src/utils/google-fonts.js +11 -11
  96. package/src/utils/inherited-style.js +9 -8
  97. package/src/utils/studio-utils.js +18 -18
  98. package/src/view.js +58 -1
  99. package/src/workspace/workspace.js +29 -7
@@ -5,6 +5,16 @@
5
5
  * text formatting, Enter for new paragraphs, and slash commands for inserting elements.
6
6
  */
7
7
 
8
+ /**
9
+ * @typedef {{ tag: string; label: string; icon?: string; command?: string; shortcut?: string }} InlineAction
10
+ *
11
+ * @typedef {{ $inlineChildren?: string[]; $inlineActions?: InlineAction[] | string }} ElementDef
12
+ *
13
+ * @typedef {{ label: string; tag: string; description: string }} SlashCommand
14
+ *
15
+ * @typedef {{ textContent?: string | null; children?: (JxMutableNode | string)[] }} JxContentResult
16
+ */
17
+
8
18
  import elementsMeta from "../../data/elements-meta.json";
9
19
  import { toggleInlineFormat, normalizeInlineContent } from "./inline-format.js";
10
20
  import {
@@ -46,6 +56,9 @@ const EDITABLE_BLOCKS = new Set([
46
56
  "td",
47
57
  "th",
48
58
  "blockquote",
59
+ "span",
60
+ "a",
61
+ "label",
49
62
  ]);
50
63
 
51
64
  // ─── Context-aware inline scoping ─────────────────────────────────────────
@@ -60,7 +73,7 @@ const EDITABLE_BLOCKS = new Set([
60
73
  */
61
74
  export function isInlineInContext(childTag, parentTag) {
62
75
  if (!parentTag) return INLINE_TAGS.has(childTag);
63
- const parentDef = /** @type {Record<string, any>} */ (elementsMeta.$defs)[parentTag];
76
+ const parentDef = /** @type {Record<string, ElementDef>} */ (elementsMeta.$defs)[parentTag];
64
77
  if (!parentDef || !parentDef.$inlineChildren) return false;
65
78
  return parentDef.$inlineChildren.includes(childTag);
66
79
  }
@@ -70,15 +83,15 @@ export function isInlineInContext(childTag, parentTag) {
70
83
  * look up h1's actions).
71
84
  *
72
85
  * @param {string} tag
73
- * @returns {any[] | null}
86
+ * @returns {InlineAction[] | null}
74
87
  */
75
88
  export function getInlineActions(tag) {
76
- const def = /** @type {Record<string, any>} */ (elementsMeta.$defs)[tag];
89
+ const def = /** @type {Record<string, ElementDef>} */ (elementsMeta.$defs)[tag];
77
90
  if (!def) return null;
78
91
  let actions = def.$inlineActions;
79
92
  if (typeof actions === "string") {
80
- const refDef = /** @type {Record<string, any>} */ (elementsMeta.$defs)[actions];
81
- actions = refDef?.$inlineActions ?? null;
93
+ const refDef = /** @type {Record<string, ElementDef>} */ (elementsMeta.$defs)[actions];
94
+ actions = refDef?.$inlineActions ?? undefined;
82
95
  }
83
96
  if (!Array.isArray(actions)) return null;
84
97
  return actions;
@@ -88,13 +101,26 @@ export function getInlineActions(tag) {
88
101
 
89
102
  /** @type {HTMLElement | null} */
90
103
  let activeEl = null; // currently contenteditable element
91
- /** @type {any[] | null} */
104
+ /** @type {JxPath | null} */
92
105
  let activePath = null; // JSON path to the active element
93
- /** @type {((path: any[], children: any, textContent: any) => void) | null} */
106
+ /**
107
+ * @type {((
108
+ * path: JxPath,
109
+ * children: (JxMutableNode | string)[] | null,
110
+ * textContent: string | null,
111
+ * ) => void)
112
+ * | null}
113
+ */
94
114
  let commitFn = null; // function(path, newChildren, newTextContent) to commit changes
95
- /** @type {((path: any[], beforeChildren: any, afterChildren: any) => void) | null} */
115
+ /**
116
+ * @type {((path: JxPath, beforeChildren: JxContentResult, afterChildren: JxContentResult) => void)
117
+ * | null}
118
+ */
96
119
  let splitFn = null; // function(path, beforeChildren, afterChildren) to split paragraph
97
- /** @type {((path: any[], elementDef: any, commitData?: any) => void) | null} */
120
+ /**
121
+ * @type {((path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void)
122
+ * | null}
123
+ */
98
124
  let insertFn = null; // function(path, elementDef, commitData?) to insert after current block
99
125
  /** @type {(() => void) | null} */
100
126
  let endFn = null; // function() called when editing stops
@@ -113,8 +139,8 @@ export function isEditableBlock(el) {
113
139
  * Check if a node is an inline child. When parentNode is provided, uses context-aware scoping from
114
140
  * metadata. Without parent, uses the fallback INLINE_TAGS set.
115
141
  *
116
- * @param {any} node
117
- * @param {any} [parentNode]
142
+ * @param {JxMutableNode} node
143
+ * @param {JxMutableNode} [parentNode]
118
144
  * @returns {boolean}
119
145
  */
120
146
  export function isInlineElement(node, parentNode) {
@@ -131,11 +157,21 @@ export function isInlineElement(node, parentNode) {
131
157
  * Start inline editing on a canvas element.
132
158
  *
133
159
  * @param {HTMLElement} el - The canvas DOM element to edit
134
- * @param {any[]} path - JSON path to the element
135
- * @param {Record<string, any>} callbacks - { onCommit, onSplit, onInsert, onEnd } onCommit(path,
136
- * children|null, textContent|null) — save inline content onSplit(path, beforeChildren,
137
- * afterChildren) — Enter key: split block onInsert(path, elementDef) — slash command: insert
138
- * after onEnd() called when editing stops (for overlay restoration)
160
+ * @param {JxPath} path - JSON path to the element
161
+ * @param {{
162
+ * onCommit: (
163
+ * path: JxPath,
164
+ * children: (JxMutableNode | string)[] | null,
165
+ * textContent: string | null,
166
+ * ) => void;
167
+ * onSplit: (
168
+ * path: JxPath,
169
+ * beforeChildren: JxContentResult,
170
+ * afterChildren: JxContentResult,
171
+ * ) => void;
172
+ * onInsert: (path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void;
173
+ * onEnd: () => void;
174
+ * }} callbacks
139
175
  */
140
176
  export function startEditing(el, path, callbacks) {
141
177
  if (activeEl) stopEditing();
@@ -373,8 +409,8 @@ function commitChanges() {
373
409
  * Normalize a node's children array: merge adjacent text nodes and fold all-text children into
374
410
  * textContent. Returns `{ textContent }` or `{ children }`.
375
411
  *
376
- * @param {{ children?: any[] }} node
377
- * @returns {{ textContent?: string | null; children?: any[] }}
412
+ * @param {{ children?: (JxMutableNode | string)[] }} node
413
+ * @returns {JxContentResult}
378
414
  */
379
415
  export function normalizeChildren(node) {
380
416
  if (!Array.isArray(node.children) || node.children.length === 0) return { textContent: "" };
@@ -394,7 +430,7 @@ export function normalizeChildren(node) {
394
430
  }
395
431
 
396
432
  // Step 2: If all children are text, fold into textContent
397
- if (merged.every((/** @type {any} */ c) => typeof c === "string")) {
433
+ if (merged.every((/** @type {JxMutableNode | string} */ c) => typeof c === "string")) {
398
434
  return { textContent: merged.join("") };
399
435
  }
400
436
 
@@ -406,7 +442,7 @@ export function normalizeChildren(node) {
406
442
  * for plain text or { children } for rich content.
407
443
  *
408
444
  * @param {HTMLElement} el
409
- * @returns {{ textContent?: string | null; children?: any[] }}
445
+ * @returns {JxContentResult}
410
446
  */
411
447
  function elementToJx(el) {
412
448
  const nodes = el.childNodes;
@@ -418,7 +454,7 @@ function elementToJx(el) {
418
454
  }
419
455
 
420
456
  // Mixed content → children array
421
- /** @type {any[]} */
457
+ /** @type {(JxMutableNode | string)[]} */
422
458
  const children = [];
423
459
  for (const child of nodes) {
424
460
  const jsx = domNodeToJx(child);
@@ -433,7 +469,7 @@ function elementToJx(el) {
433
469
  * Convert a DOM node to a Jx element definition.
434
470
  *
435
471
  * @param {Node} node
436
- * @returns {any}
472
+ * @returns {JxMutableNode | string | null}
437
473
  */
438
474
  function domNodeToJx(node) {
439
475
  if (node.nodeType === Node.TEXT_NODE) {
@@ -446,7 +482,7 @@ function domNodeToJx(node) {
446
482
 
447
483
  const el = /** @type {HTMLElement} */ (node);
448
484
  const tag = el.tagName.toLowerCase();
449
- /** @type {Record<string, any>} */
485
+ /** @type {JxMutableNode} */
450
486
  const result = { tagName: tag };
451
487
 
452
488
  // Map browser execCommand output to our tag conventions
@@ -475,7 +511,7 @@ function domNodeToJx(node) {
475
511
  result.children = [];
476
512
  for (const child of childNodes) {
477
513
  const jsx = domNodeToJx(child);
478
- if (jsx) result.children.push(jsx);
514
+ if (jsx) result.children.push(/** @type {JxMutableNode} */ (jsx));
479
515
  }
480
516
  }
481
517
 
@@ -486,7 +522,7 @@ function domNodeToJx(node) {
486
522
  * Convert a DocumentFragment to a Jx-compatible structure. Returns { textContent } or { children }.
487
523
  *
488
524
  * @param {DocumentFragment} frag
489
- * @returns {{ textContent?: string | null; children?: any[] }}
525
+ * @returns {JxContentResult}
490
526
  */
491
527
  function fragmentToJx(frag) {
492
528
  const nodes = frag.childNodes;
@@ -495,14 +531,19 @@ function fragmentToJx(frag) {
495
531
  return { textContent: nodes[0].textContent };
496
532
  }
497
533
 
498
- /** @type {any[]} */
534
+ /** @type {(JxMutableNode | string)[]} */
499
535
  const children = [];
500
536
  for (const child of nodes) {
501
537
  const jsx = domNodeToJx(child);
502
538
  if (jsx) children.push(jsx);
503
539
  }
504
540
 
505
- if (children.length === 1 && children[0].tagName === "span" && children[0].textContent != null) {
541
+ if (
542
+ children.length === 1 &&
543
+ typeof children[0] !== "string" &&
544
+ children[0].tagName === "span" &&
545
+ children[0].textContent != null
546
+ ) {
506
547
  return { textContent: children[0].textContent };
507
548
  }
508
549
 
@@ -560,7 +601,7 @@ function updateSlashMenu() {
560
601
  sharedShowSlashMenu(activeEl, filter, { onSelect: handleSlashSelect });
561
602
  }
562
603
 
563
- /** @param {any} cmd */
604
+ /** @param {SlashCommand} cmd */
564
605
  function handleSlashSelect(cmd) {
565
606
  if (!activeEl || !insertFn || !activePath) return;
566
607
 
@@ -34,16 +34,18 @@ import { transactDoc, mutateInsertNode } from "../tabs/transact.js";
34
34
  /**
35
35
  * @typedef {Object} InsertionHelperContext
36
36
  * @property {() => string} getCanvasMode - Returns the active canvas mode.
37
- * @property {(fn: Function) => any} withPanelPointerEvents - Executes fn with pointer-events
37
+ * @property {(fn: Function) => unknown} withPanelPointerEvents - Executes fn with pointer-events
38
38
  * temporarily enabled on the canvas.
39
39
  * @property {() => number} effectiveZoom - Returns the current zoom scale factor.
40
40
  * @property {(tag: string) => Object} defaultDef - Creates a default element definition for a tag.
41
- * @property {(path: any[]) => any[] | null} parentElementPath - Returns the parent element path, or
42
- * null for root.
43
- * @property {(path: any[]) => string | number} childIndex - Returns the child index within the
41
+ * @property {(path: JxPath) => JxPath | null} parentElementPath - Returns the parent element path,
42
+ * or null for root.
43
+ * @property {(path: JxPath) => string | number} childIndex - Returns the child index within the
44
44
  * parent.
45
- * @property {(doc: any, path: any[]) => any} getNodeAtPath - Retrieves the node at a document path.
46
- * @property {WeakMap<any, any[]>} elToPath - Maps rendered DOM elements to their document paths.
45
+ * @property {(doc: JxMutableNode, path: JxPath) => JxMutableNode | null} getNodeAtPath - Retrieves
46
+ * the node at a document path.
47
+ * @property {WeakMap<Element, JxPath>} elToPath - Maps rendered DOM elements to their document
48
+ * paths.
47
49
  * @property {CanvasPanel} panel - The active canvas panel.
48
50
  */
49
51
 
@@ -58,7 +60,7 @@ let _helper = null;
58
60
  /** @type {HTMLElement | null} */
59
61
  let _currentAnchor = null;
60
62
 
61
- /** @type {{ edge: string; path: any[]; parentPath: any[]; idx: number } | null} */
63
+ /** @type {{ edge: string; path: JxPath; parentPath: JxPath; idx: number } | null} */
62
64
  let _insertionPoint = null;
63
65
 
64
66
  /** @type {AbortController | null} */
@@ -133,7 +135,9 @@ function onMouseMove(e) {
133
135
  }
134
136
 
135
137
  const { panel, withPanelPointerEvents, elToPath } = _ctx;
136
- const el = withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY));
138
+ const el = /** @type {HTMLElement | null} */ (
139
+ withPanelPointerEvents(() => document.elementFromPoint(e.clientX, e.clientY))
140
+ );
137
141
 
138
142
  if (!el || !panel.canvas.contains(el)) {
139
143
  hide();
@@ -208,8 +212,8 @@ function onMouseMove(e) {
208
212
  /**
209
213
  * @param {HTMLElement} el
210
214
  * @param {string} edge
211
- * @param {any[]} path
212
- * @param {any[]} parentPath
215
+ * @param {JxPath} path
216
+ * @param {JxPath} parentPath
213
217
  * @param {number} idx
214
218
  */
215
219
  function showAt(el, edge, path, parentPath, idx) {
@@ -275,8 +279,8 @@ function onHelperClick(/** @type {MouseEvent} */ e) {
275
279
  }
276
280
 
277
281
  /**
278
- * @param {any} cmd
279
- * @param {{ edge: string; path: any[]; parentPath: any[]; idx: number }} point
282
+ * @param {{ label: string; tag: string; description?: string }} cmd
283
+ * @param {{ edge: string; path: JxPath; parentPath: JxPath; idx: number }} point
280
284
  */
281
285
  function onSlashSelect(cmd, point) {
282
286
  if (!_ctx) return;
@@ -15,9 +15,10 @@ import {
15
15
  undo as tabUndo,
16
16
  redo as tabRedo,
17
17
  } from "../tabs/transact.js";
18
- import { isEditing } from "./inline-edit.js";
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
 
@@ -132,6 +133,7 @@ export function initShortcuts(getContext) {
132
133
  if (isEditing()) {
133
134
  if (mod && e.key === "s") {
134
135
  e.preventDefault();
136
+ stopEditing();
135
137
  saveFile();
136
138
  }
137
139
  if (mod && e.key === "w") {
@@ -142,6 +144,7 @@ export function initShortcuts(getContext) {
142
144
  if (componentInlineEdit) {
143
145
  if (mod && e.key === "s") {
144
146
  e.preventDefault();
147
+ stopEditing();
145
148
  saveFile();
146
149
  }
147
150
  if (mod && e.key === "w") {
@@ -158,9 +161,16 @@ export function initShortcuts(getContext) {
158
161
  const tab = workspace.tabs.get(workspace.activeTabId);
159
162
  if (tab?.doc.dirty) {
160
163
  const name = tab.documentPath?.split("/").pop() || "Untitled";
161
- 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);
162
173
  }
163
- closeTab(workspace.activeTabId);
164
174
  }
165
175
  break;
166
176
  case "o":
@@ -266,7 +276,7 @@ export function initShortcuts(getContext) {
266
276
  e.preventDefault();
267
277
  if (tab?.session.selection) {
268
278
  const node = getNodeAtPath(tab.doc.document, tab.session.selection);
269
- if (node?.children?.length > 0) {
279
+ if ((node?.children?.length ?? 0) > 0) {
270
280
  activeTab.value.session.selection = [...tab.session.selection, "children", 0];
271
281
  }
272
282
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { html, render as litRender, nothing } from "lit-html";
11
+ import { ref } from "lit-html/directives/ref.js";
11
12
  import { getLayerSlot } from "../ui/layers.js";
12
13
 
13
14
  // ─── Commands ─────────────────────────────────────────────────────────────────
@@ -30,18 +31,30 @@ const SLASH_COMMANDS = [
30
31
  { label: "Section", tag: "section", description: "Section container" },
31
32
  ];
32
33
 
34
+ /** @typedef {{ label: string; tag: string; description: string }} SlashCommand */
35
+
33
36
  // ─── State ────────────────────────────────────────────────────────────────────
34
37
 
35
- /** @type {{ onSelect: (cmd: any) => void; showFilter?: boolean } | null} */
38
+ /**
39
+ * @type {{
40
+ * onSelect: (cmd: SlashCommand) => void;
41
+ * showFilter?: boolean;
42
+ * commands?: SlashCommand[];
43
+ * } | null}
44
+ */
36
45
  let callbacks = null;
37
46
  let activeIdx = 0;
38
- /** @type {any[]} */
47
+ /** @type {SlashCommand[]} */
39
48
  let filteredItems = [];
40
49
  let open = false;
41
50
  /** @type {HTMLElement | null} */
42
51
  let _anchorEl = null;
43
52
  /** @type {DOMRect | null} */
44
53
  let _anchorRect = null;
54
+ /** @type {HTMLInputElement | null} */
55
+ let _filterEl = null;
56
+ /** @type {HTMLElement | null} */
57
+ let _popoverEl = null;
45
58
 
46
59
  /** @returns {HTMLElement} */
47
60
  function getHost() {
@@ -60,18 +73,23 @@ export function isSlashMenuOpen() {
60
73
  *
61
74
  * @param {HTMLElement} anchorEl — the element being edited (for positioning)
62
75
  * @param {string} filter — current typed filter text (after the "/")
63
- * @param {{ onSelect: (cmd: any) => void; showFilter?: boolean }} cbs
76
+ * @param {{
77
+ * onSelect: (cmd: SlashCommand) => void;
78
+ * showFilter?: boolean;
79
+ * commands?: SlashCommand[];
80
+ * }} cbs
64
81
  */
65
82
  export function showSlashMenu(anchorEl, filter, cbs) {
66
83
  callbacks = cbs;
67
84
  _anchorEl = anchorEl;
68
85
  _anchorRect = anchorEl.getBoundingClientRect();
69
86
 
87
+ const source = cbs.commands || SLASH_COMMANDS;
70
88
  filteredItems = filter
71
- ? SLASH_COMMANDS.filter(
89
+ ? source.filter(
72
90
  (c) => c.label.toLowerCase().includes(filter) || c.tag.toLowerCase().includes(filter),
73
91
  )
74
- : SLASH_COMMANDS;
92
+ : source;
75
93
 
76
94
  if (!filteredItems.length && !cbs.showFilter) {
77
95
  dismissSlashMenu();
@@ -92,10 +110,7 @@ export function showSlashMenu(anchorEl, filter, cbs) {
92
110
 
93
111
  if (cbs.showFilter) {
94
112
  requestAnimationFrame(() => {
95
- const input = /** @type {HTMLInputElement | null} */ (
96
- getHost().querySelector(".slash-filter")
97
- );
98
- if (input) input.focus();
113
+ if (_filterEl) _filterEl.focus();
99
114
  });
100
115
  }
101
116
  }
@@ -106,6 +121,8 @@ export function dismissSlashMenu() {
106
121
  callbacks = null;
107
122
  _anchorEl = null;
108
123
  _anchorRect = null;
124
+ _filterEl = null;
125
+ _popoverEl = null;
109
126
  filteredItems = [];
110
127
  document.removeEventListener("keydown", onKeydown, true);
111
128
  document.removeEventListener("mousedown", onOutsideClick, true);
@@ -125,6 +142,9 @@ function render(anchorEl, showFilter) {
125
142
  html`
126
143
  <sp-popover
127
144
  open
145
+ ${ref((el) => {
146
+ _popoverEl = /** @type {HTMLElement | null} */ (el || null);
147
+ })}
128
148
  style="position:fixed;left:${rect.left}px;top:${rect.bottom +
129
149
  4}px;z-index:9999;max-height:320px;overflow-y:auto"
130
150
  >
@@ -135,6 +155,9 @@ function render(anchorEl, showFilter) {
135
155
  placeholder="Filter…"
136
156
  autocomplete="off"
137
157
  style="display:block;width:100%;box-sizing:border-box;padding:6px 10px;border:none;border-bottom:1px solid var(--border, #444);outline:none;font-size:13px;background:transparent;color:inherit"
158
+ ${ref((el) => {
159
+ _filterEl = /** @type {HTMLInputElement | null} */ (el || null);
160
+ })}
138
161
  @input=${onFilterInput}
139
162
  />`
140
163
  : nothing}
@@ -167,14 +190,12 @@ function render(anchorEl, showFilter) {
167
190
 
168
191
  /** @param {MouseEvent} e */
169
192
  function onOutsideClick(e) {
170
- const host = getHost();
171
- const popover = host.querySelector("sp-popover");
172
- if (popover && !popover.contains(/** @type {Node} */ (e.target))) {
193
+ if (_popoverEl && !_popoverEl.contains(/** @type {Node} */ (e.target))) {
173
194
  dismissSlashMenu();
174
195
  }
175
196
  }
176
197
 
177
- /** @param {any} cmd */
198
+ /** @param {SlashCommand} cmd */
178
199
  function select(cmd) {
179
200
  const cbs = callbacks;
180
201
  dismissSlashMenu();
@@ -186,21 +207,21 @@ function onFilterInput(e) {
186
207
  const input = /** @type {HTMLInputElement} */ (e.target);
187
208
  const filter = input.value.toLowerCase();
188
209
 
210
+ const source = callbacks?.commands || SLASH_COMMANDS;
189
211
  filteredItems = filter
190
- ? SLASH_COMMANDS.filter(
212
+ ? source.filter(
191
213
  (c) => c.label.toLowerCase().includes(filter) || c.tag.toLowerCase().includes(filter),
192
214
  )
193
- : SLASH_COMMANDS;
215
+ : source;
194
216
 
195
217
  activeIdx = 0;
196
218
  if (_anchorEl) render(_anchorEl, true);
197
219
 
198
220
  // Re-focus input after re-render
199
221
  requestAnimationFrame(() => {
200
- const el = /** @type {HTMLInputElement | null} */ (getHost().querySelector(".slash-filter"));
201
- if (el && el !== document.activeElement) {
202
- el.focus();
203
- el.selectionStart = el.selectionEnd = el.value.length;
222
+ if (_filterEl && _filterEl !== document.activeElement) {
223
+ _filterEl.focus();
224
+ _filterEl.selectionStart = _filterEl.selectionEnd = _filterEl.value.length;
204
225
  }
205
226
  });
206
227
  }
@@ -7,7 +7,7 @@ import { projectState } from "../store.js";
7
7
  * @typedef {{
8
8
  * tagName: string;
9
9
  * path: string;
10
- * props?: { name: string; type?: string; [k: string]: any }[];
10
+ * props?: { name: string; type?: string; [k: string]: unknown }[];
11
11
  * source?: string;
12
12
  * package?: string;
13
13
  * modulePath?: string;
@@ -7,18 +7,22 @@
7
7
  import { unified } from "unified";
8
8
  import remarkStringify from "remark-stringify";
9
9
  import remarkDirective from "remark-directive";
10
+ import remarkGfm from "remark-gfm";
10
11
  import { stringify as stringifyYaml } from "yaml";
11
12
  import { jxToMd, jxDocToMd } from "../markdown/md-convert.js";
12
13
  import { locateDocument } from "../services/code-services.js";
13
14
  import { statusMessage } from "../panels/statusbar.js";
14
15
  import { getPlatform } from "../platform.js";
15
16
  import { activeTab, openTab } from "../workspace/workspace.js";
17
+ import { isEditing, stopEditing } from "../editor/inline-edit.js";
16
18
 
17
19
  /** Open a file via the File System Access API (or fallback input). */
18
20
  export async function openFile() {
19
21
  try {
20
22
  if ("showOpenFilePicker" in window) {
21
- const [handle] = await /** @type {any} */ (window).showOpenFilePicker({
23
+ const [handle] = await /** @type {{ showOpenFilePicker: Function }} */ (
24
+ /** @type {unknown} */ (window)
25
+ ).showOpenFilePicker({
22
26
  types: [
23
27
  { description: "Jx Component", accept: { "application/json": [".json"] } },
24
28
  { description: "Markdown Content", accept: { "text/markdown": [".md"] } },
@@ -66,8 +70,9 @@ export async function openFile() {
66
70
  };
67
71
  input.click();
68
72
  }
69
- } catch (/** @type {any} */ e) {
70
- if (e.name !== "AbortError") statusMessage(`Error: ${e.message}`);
73
+ } catch (/** @type {unknown} */ e) {
74
+ if (/** @type {Error} */ (e).name !== "AbortError")
75
+ statusMessage(`Error: ${/** @type {Error} */ (e).message}`);
71
76
  }
72
77
  }
73
78
 
@@ -75,11 +80,11 @@ export async function openFile() {
75
80
  * Parse a markdown string into document + frontmatter (pure — no side effects).
76
81
  *
77
82
  * @param {string} source Markdown text
78
- * @returns {Promise<{ document: any; frontmatter: Record<string, any> }>}
83
+ * @returns {Promise<{ document: JxMutableNode; frontmatter: Record<string, unknown> }>}
79
84
  */
80
85
  export async function loadMarkdown(source) {
81
86
  const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
82
- const doc = /** @type {any} */ (transpileJxMarkdown(source));
87
+ const doc = /** @type {JxMutableNode} */ (transpileJxMarkdown(source));
83
88
 
84
89
  const isComponent = doc.tagName && String(doc.tagName).includes("-");
85
90
 
@@ -88,9 +93,11 @@ export async function loadMarkdown(source) {
88
93
  }
89
94
 
90
95
  // Content markdown — children form the root-level document body
91
- const contentDoc = { children: doc.children ?? [] };
96
+ const children = doc.children ?? [];
97
+ if (children.length === 0) children.push({ tagName: "p", children: [] });
98
+ const contentDoc = { children };
92
99
 
93
- /** @type {Record<string, any>} */
100
+ /** @type {Record<string, unknown>} */
94
101
  const frontmatter = {};
95
102
  for (const [key, value] of Object.entries(doc)) {
96
103
  if (key !== "children") frontmatter[key] = value;
@@ -101,6 +108,7 @@ export async function loadMarkdown(source) {
101
108
 
102
109
  /** Save the current document back to its source location. */
103
110
  export async function saveFile() {
111
+ if (isEditing()) stopEditing();
104
112
  const tab = activeTab.value;
105
113
  if (!tab) return;
106
114
  try {
@@ -112,7 +120,15 @@ export async function saveFile() {
112
120
  tab.doc.dirty = false;
113
121
  statusMessage("Saved");
114
122
  } else if (tab.fileHandle && "createWritable" in tab.fileHandle) {
115
- const writable = await /** @type {any} */ (tab.fileHandle).createWritable();
123
+ const writable =
124
+ await /**
125
+ * @type {{
126
+ * createWritable: () => Promise<{
127
+ * write: (s: string) => Promise<void>;
128
+ * close: () => Promise<void>;
129
+ * }>;
130
+ * }}
131
+ */ (tab.fileHandle).createWritable();
116
132
  await writable.write(output);
117
133
  await writable.close();
118
134
  tab.doc.dirty = false;
@@ -120,8 +136,9 @@ export async function saveFile() {
120
136
  } else {
121
137
  statusMessage("No save target — use Export");
122
138
  }
123
- } catch (/** @type {any} */ e) {
124
- if (e.name !== "AbortError") statusMessage(`Save error: ${e.message}`);
139
+ } catch (/** @type {unknown} */ e) {
140
+ if (/** @type {Error} */ (e).name !== "AbortError")
141
+ statusMessage(`Save error: ${/** @type {Error} */ (e).message}`);
125
142
  }
126
143
  }
127
144
 
@@ -142,7 +159,9 @@ export async function exportFile() {
142
159
  : isContent
143
160
  ? "content.md"
144
161
  : "component.json";
145
- const handle = await /** @type {any} */ (window).showSaveFilePicker({
162
+ const handle = await /** @type {{ showSaveFilePicker: Function }} */ (
163
+ /** @type {unknown} */ (window)
164
+ ).showSaveFilePicker({
146
165
  suggestedName,
147
166
  types: [{ description, accept: { [mimeType]: [ext] } }],
148
167
  });
@@ -163,8 +182,9 @@ export async function exportFile() {
163
182
  tab.doc.dirty = false;
164
183
  statusMessage("Downloaded");
165
184
  }
166
- } catch (/** @type {any} */ e) {
167
- if (e.name !== "AbortError") statusMessage(`Export error: ${e.message}`);
185
+ } catch (/** @type {unknown} */ e) {
186
+ if (/** @type {Error} */ (e).name !== "AbortError")
187
+ statusMessage(`Export error: ${/** @type {Error} */ (e).message}`);
168
188
  }
169
189
  }
170
190
 
@@ -178,14 +198,15 @@ export function serializeDocument(tab) {
178
198
  if (tab.doc.sourceFormat === "md") {
179
199
  const fm = tab.doc.content?.frontmatter || {};
180
200
  const fullDoc = { ...fm, children: tab.doc.document.children ?? [] };
181
- return jxDocToMd(fullDoc);
201
+ return jxDocToMd(/** @type {JxMutableNode} */ (fullDoc));
182
202
  }
183
203
  if (tab.doc.mode === "content") {
184
- const mdast = jxToMd(tab.doc.document);
204
+ const mdast = jxToMd(/** @type {JxElement} */ (tab.doc.document));
185
205
  const md = unified()
206
+ .use(remarkGfm)
186
207
  .use(remarkDirective)
187
208
  .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
188
- .stringify(mdast);
209
+ .stringify(/** @type {import("mdast").Root} */ (/** @type {unknown} */ (mdast)));
189
210
  const fm = tab.doc.content?.frontmatter;
190
211
  const hasFrontmatter = fm && Object.keys(fm).length > 0;
191
212
  return hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;