@jxsuite/studio 0.19.0 → 0.20.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 (93) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +12577 -12183
  3. package/dist/studio.js.map +146 -144
  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 +17 -6
  8. package/src/browse/browse.js +25 -19
  9. package/src/canvas/canvas-diff.js +24 -16
  10. package/src/canvas/canvas-helpers.js +24 -19
  11. package/src/canvas/canvas-live-render.js +94 -39
  12. package/src/canvas/canvas-render.js +119 -73
  13. package/src/canvas/canvas-utils.js +38 -15
  14. package/src/canvas/nested-site-style.js +50 -0
  15. package/src/editor/component-inline-edit.js +14 -13
  16. package/src/editor/content-inline-edit.js +22 -10
  17. package/src/editor/context-menu.js +1 -1
  18. package/src/editor/convert-to-component.js +12 -10
  19. package/src/editor/inline-edit.js +66 -28
  20. package/src/editor/insertion-helper.js +16 -12
  21. package/src/editor/shortcuts.js +4 -2
  22. package/src/editor/slash-menu.js +6 -4
  23. package/src/files/components.js +1 -1
  24. package/src/files/file-ops.js +37 -16
  25. package/src/files/files.js +78 -52
  26. package/src/github/github-auth.js +122 -0
  27. package/src/github/github-publish.js +115 -0
  28. package/src/markdown/md-convert.js +172 -107
  29. package/src/new-project/new-project-modal.js +204 -0
  30. package/src/panels/activity-bar.js +22 -20
  31. package/src/panels/ai-panel.js +399 -0
  32. package/src/panels/block-action-bar.js +5 -5
  33. package/src/panels/canvas-dnd.js +8 -2
  34. package/src/panels/data-explorer.js +19 -13
  35. package/src/panels/dnd.js +144 -92
  36. package/src/panels/editors.js +55 -35
  37. package/src/panels/elements-panel.js +33 -19
  38. package/src/panels/events-panel.js +15 -10
  39. package/src/panels/git-panel.js +469 -98
  40. package/src/panels/head-panel.js +121 -81
  41. package/src/panels/imports-panel.js +88 -64
  42. package/src/panels/layers-panel.js +64 -43
  43. package/src/panels/left-panel.js +85 -47
  44. package/src/panels/overlays.js +1 -0
  45. package/src/panels/panel-events.js +45 -30
  46. package/src/panels/preview-render.js +23 -18
  47. package/src/panels/properties-panel.js +365 -249
  48. package/src/panels/pseudo-preview.js +2 -2
  49. package/src/panels/quick-search.js +7 -5
  50. package/src/panels/right-panel.js +26 -2
  51. package/src/panels/shared.js +4 -4
  52. package/src/panels/signals-panel.js +295 -166
  53. package/src/panels/statusbar.js +4 -4
  54. package/src/panels/style-inputs.js +67 -35
  55. package/src/panels/style-panel.js +233 -132
  56. package/src/panels/style-utils.js +34 -16
  57. package/src/panels/stylebook-layers-panel.js +12 -10
  58. package/src/panels/stylebook-panel.js +134 -66
  59. package/src/panels/toolbar.js +333 -109
  60. package/src/panels/welcome-screen.js +121 -0
  61. package/src/platform.js +2 -4
  62. package/src/platforms/devserver.js +113 -7
  63. package/src/resize-edges.js +98 -0
  64. package/src/services/cem-export.js +12 -7
  65. package/src/services/code-services.js +30 -12
  66. package/src/settings/content-types-editor.js +7 -7
  67. package/src/settings/css-vars-editor.js +30 -24
  68. package/src/settings/defs-editor.js +12 -7
  69. package/src/settings/general-settings.js +85 -3
  70. package/src/settings/head-editor.js +30 -24
  71. package/src/settings/schema-field-ui.js +58 -39
  72. package/src/site-context.js +41 -31
  73. package/src/state.js +70 -29
  74. package/src/store.js +21 -26
  75. package/src/studio.js +103 -81
  76. package/src/tabs/tab.js +67 -43
  77. package/src/tabs/transact.js +23 -13
  78. package/src/ui/button-group.js +24 -24
  79. package/src/ui/color-selector.js +28 -23
  80. package/src/ui/field-row.js +3 -3
  81. package/src/ui/icons.js +2 -2
  82. package/src/ui/media-picker.js +7 -7
  83. package/src/ui/spectrum.js +5 -1
  84. package/src/ui/unit-selector.js +18 -16
  85. package/src/ui/value-selector.js +21 -15
  86. package/src/ui/widgets.js +22 -19
  87. package/src/utils/canvas-media.js +5 -4
  88. package/src/utils/edit-display.js +31 -20
  89. package/src/utils/google-fonts.js +11 -11
  90. package/src/utils/inherited-style.js +9 -8
  91. package/src/utils/studio-utils.js +7 -7
  92. package/src/view.js +58 -1
  93. package/src/workspace/workspace.js +10 -7
@@ -21,7 +21,7 @@ import { startLayerTitleEdit } from "../panels/layers-panel.js";
21
21
  *
22
22
  * @typedef {import("../state.js").JxPath} JxPath
23
23
  *
24
- * @typedef {import("../state.js").JxNode} JxNode
24
+ * @typedef {JxMutableNode} JxNode
25
25
  */
26
26
 
27
27
  // ─── Clipboard ────────────────────────────────────────────────────────────────
@@ -39,15 +39,17 @@ export async function convertToComponent() {
39
39
  transact(tab, (doc) => {
40
40
  // Navigate to parent's children array and replace the node
41
41
  const pp = parentElementPath(selectionPath) ?? [];
42
- const idx = childIndex(selectionPath);
42
+ const idx = /** @type {number} */ (childIndex(selectionPath));
43
43
  let parent = doc;
44
44
  for (const seg of pp) parent = parent[seg];
45
+ if (!parent.children) parent.children = [];
45
46
  parent.children[idx] = { tagName: name };
46
47
 
47
48
  // Ensure $elements exists and add the $ref
48
49
  if (!doc.$elements) doc.$elements = [];
49
50
  const alreadyReferenced = doc.$elements.some(
50
- (/** @type {any} */ el) => el && el.$ref === refPath,
51
+ (/** @type {JxMutableNode | string | { $ref: string }} */ el) =>
52
+ el && typeof el === "object" && "$ref" in el && el.$ref === refPath,
51
53
  );
52
54
  if (!alreadyReferenced) {
53
55
  doc.$elements.push({ $ref: refPath });
@@ -60,15 +62,15 @@ export async function convertToComponent() {
60
62
  await platform.writeFile(componentFile, JSON.stringify(componentDef, null, 2));
61
63
  await loadComponentRegistry();
62
64
  statusMessage(`Converted to <${name}>`);
63
- } catch (/** @type {any} */ err) {
64
- statusMessage(`Error saving component: ${err.message}`);
65
+ } catch (/** @type {unknown} */ err) {
66
+ statusMessage(`Error saving component: ${/** @type {Error} */ (err).message}`);
65
67
  }
66
68
  }
67
69
 
68
70
  /**
69
71
  * Derive a default tag name from a node.
70
72
  *
71
- * @param {any} node
73
+ * @param {JxMutableNode} node
72
74
  * @returns {string}
73
75
  */
74
76
  function deriveDefaultName(node) {
@@ -80,8 +82,8 @@ function deriveDefaultName(node) {
80
82
  /**
81
83
  * Deep clone a node and strip page-specific keys.
82
84
  *
83
- * @param {any} node
84
- * @returns {any}
85
+ * @param {JxMutableNode} node
86
+ * @returns {JxMutableNode}
85
87
  */
86
88
  function extractComponentDef(node) {
87
89
  const clone = structuredClone(node);
@@ -105,7 +107,7 @@ function validateName(val) {
105
107
  if (!VALID_NAME.test(val)) {
106
108
  return { valid: false, error: "Lowercase letters, digits, and hyphens only" };
107
109
  }
108
- const exists = componentRegistry.some((/** @type {any} */ c) => c.tagName === val);
110
+ const exists = componentRegistry.some((/** @type {JxMutableNode} */ c) => c.tagName === val);
109
111
  if (exists) {
110
112
  return { valid: false, error: `Component <${val}> already exists` };
111
113
  }
@@ -134,7 +136,7 @@ function promptComponentName(defaultName) {
134
136
  }
135
137
 
136
138
  function onInput(/** @type {Event} */ e) {
137
- value = /** @type {any} */ (e.target).value || "";
139
+ value = /** @type {HTMLInputElement} */ (e.target).value || "";
138
140
  const result = validateName(value);
139
141
  error = result.valid ? "" : result.error;
140
142
  rerender();
@@ -179,7 +181,7 @@ function promptComponentName(defaultName) {
179
181
 
180
182
  requestAnimationFrame(() => {
181
183
  const layer = document.getElementById("layer-dialog");
182
- const tf = /** @type {any} */ (layer?.querySelector("sp-textfield"));
184
+ const tf = /** @type {HTMLElement | null} */ (layer?.querySelector("sp-textfield"));
183
185
  if (tf) {
184
186
  tf.focus();
185
187
  const input = tf.shadowRoot?.querySelector("input");
@@ -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 {
@@ -60,7 +70,7 @@ const EDITABLE_BLOCKS = new Set([
60
70
  */
61
71
  export function isInlineInContext(childTag, parentTag) {
62
72
  if (!parentTag) return INLINE_TAGS.has(childTag);
63
- const parentDef = /** @type {Record<string, any>} */ (elementsMeta.$defs)[parentTag];
73
+ const parentDef = /** @type {Record<string, ElementDef>} */ (elementsMeta.$defs)[parentTag];
64
74
  if (!parentDef || !parentDef.$inlineChildren) return false;
65
75
  return parentDef.$inlineChildren.includes(childTag);
66
76
  }
@@ -70,15 +80,15 @@ export function isInlineInContext(childTag, parentTag) {
70
80
  * look up h1's actions).
71
81
  *
72
82
  * @param {string} tag
73
- * @returns {any[] | null}
83
+ * @returns {InlineAction[] | null}
74
84
  */
75
85
  export function getInlineActions(tag) {
76
- const def = /** @type {Record<string, any>} */ (elementsMeta.$defs)[tag];
86
+ const def = /** @type {Record<string, ElementDef>} */ (elementsMeta.$defs)[tag];
77
87
  if (!def) return null;
78
88
  let actions = def.$inlineActions;
79
89
  if (typeof actions === "string") {
80
- const refDef = /** @type {Record<string, any>} */ (elementsMeta.$defs)[actions];
81
- actions = refDef?.$inlineActions ?? null;
90
+ const refDef = /** @type {Record<string, ElementDef>} */ (elementsMeta.$defs)[actions];
91
+ actions = refDef?.$inlineActions ?? undefined;
82
92
  }
83
93
  if (!Array.isArray(actions)) return null;
84
94
  return actions;
@@ -88,13 +98,26 @@ export function getInlineActions(tag) {
88
98
 
89
99
  /** @type {HTMLElement | null} */
90
100
  let activeEl = null; // currently contenteditable element
91
- /** @type {any[] | null} */
101
+ /** @type {JxPath | null} */
92
102
  let activePath = null; // JSON path to the active element
93
- /** @type {((path: any[], children: any, textContent: any) => void) | null} */
103
+ /**
104
+ * @type {((
105
+ * path: JxPath,
106
+ * children: (JxMutableNode | string)[] | null,
107
+ * textContent: string | null,
108
+ * ) => void)
109
+ * | null}
110
+ */
94
111
  let commitFn = null; // function(path, newChildren, newTextContent) to commit changes
95
- /** @type {((path: any[], beforeChildren: any, afterChildren: any) => void) | null} */
112
+ /**
113
+ * @type {((path: JxPath, beforeChildren: JxContentResult, afterChildren: JxContentResult) => void)
114
+ * | null}
115
+ */
96
116
  let splitFn = null; // function(path, beforeChildren, afterChildren) to split paragraph
97
- /** @type {((path: any[], elementDef: any, commitData?: any) => void) | null} */
117
+ /**
118
+ * @type {((path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void)
119
+ * | null}
120
+ */
98
121
  let insertFn = null; // function(path, elementDef, commitData?) to insert after current block
99
122
  /** @type {(() => void) | null} */
100
123
  let endFn = null; // function() called when editing stops
@@ -113,8 +136,8 @@ export function isEditableBlock(el) {
113
136
  * Check if a node is an inline child. When parentNode is provided, uses context-aware scoping from
114
137
  * metadata. Without parent, uses the fallback INLINE_TAGS set.
115
138
  *
116
- * @param {any} node
117
- * @param {any} [parentNode]
139
+ * @param {JxMutableNode} node
140
+ * @param {JxMutableNode} [parentNode]
118
141
  * @returns {boolean}
119
142
  */
120
143
  export function isInlineElement(node, parentNode) {
@@ -131,11 +154,21 @@ export function isInlineElement(node, parentNode) {
131
154
  * Start inline editing on a canvas element.
132
155
  *
133
156
  * @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)
157
+ * @param {JxPath} path - JSON path to the element
158
+ * @param {{
159
+ * onCommit: (
160
+ * path: JxPath,
161
+ * children: (JxMutableNode | string)[] | null,
162
+ * textContent: string | null,
163
+ * ) => void;
164
+ * onSplit: (
165
+ * path: JxPath,
166
+ * beforeChildren: JxContentResult,
167
+ * afterChildren: JxContentResult,
168
+ * ) => void;
169
+ * onInsert: (path: JxPath, elementDef: SlashCommand, commitData?: JxContentResult) => void;
170
+ * onEnd: () => void;
171
+ * }} callbacks
139
172
  */
140
173
  export function startEditing(el, path, callbacks) {
141
174
  if (activeEl) stopEditing();
@@ -373,8 +406,8 @@ function commitChanges() {
373
406
  * Normalize a node's children array: merge adjacent text nodes and fold all-text children into
374
407
  * textContent. Returns `{ textContent }` or `{ children }`.
375
408
  *
376
- * @param {{ children?: any[] }} node
377
- * @returns {{ textContent?: string | null; children?: any[] }}
409
+ * @param {{ children?: (JxMutableNode | string)[] }} node
410
+ * @returns {JxContentResult}
378
411
  */
379
412
  export function normalizeChildren(node) {
380
413
  if (!Array.isArray(node.children) || node.children.length === 0) return { textContent: "" };
@@ -394,7 +427,7 @@ export function normalizeChildren(node) {
394
427
  }
395
428
 
396
429
  // Step 2: If all children are text, fold into textContent
397
- if (merged.every((/** @type {any} */ c) => typeof c === "string")) {
430
+ if (merged.every((/** @type {JxMutableNode | string} */ c) => typeof c === "string")) {
398
431
  return { textContent: merged.join("") };
399
432
  }
400
433
 
@@ -406,7 +439,7 @@ export function normalizeChildren(node) {
406
439
  * for plain text or { children } for rich content.
407
440
  *
408
441
  * @param {HTMLElement} el
409
- * @returns {{ textContent?: string | null; children?: any[] }}
442
+ * @returns {JxContentResult}
410
443
  */
411
444
  function elementToJx(el) {
412
445
  const nodes = el.childNodes;
@@ -418,7 +451,7 @@ function elementToJx(el) {
418
451
  }
419
452
 
420
453
  // Mixed content → children array
421
- /** @type {any[]} */
454
+ /** @type {(JxMutableNode | string)[]} */
422
455
  const children = [];
423
456
  for (const child of nodes) {
424
457
  const jsx = domNodeToJx(child);
@@ -433,7 +466,7 @@ function elementToJx(el) {
433
466
  * Convert a DOM node to a Jx element definition.
434
467
  *
435
468
  * @param {Node} node
436
- * @returns {any}
469
+ * @returns {JxMutableNode | string | null}
437
470
  */
438
471
  function domNodeToJx(node) {
439
472
  if (node.nodeType === Node.TEXT_NODE) {
@@ -446,7 +479,7 @@ function domNodeToJx(node) {
446
479
 
447
480
  const el = /** @type {HTMLElement} */ (node);
448
481
  const tag = el.tagName.toLowerCase();
449
- /** @type {Record<string, any>} */
482
+ /** @type {JxMutableNode} */
450
483
  const result = { tagName: tag };
451
484
 
452
485
  // Map browser execCommand output to our tag conventions
@@ -475,7 +508,7 @@ function domNodeToJx(node) {
475
508
  result.children = [];
476
509
  for (const child of childNodes) {
477
510
  const jsx = domNodeToJx(child);
478
- if (jsx) result.children.push(jsx);
511
+ if (jsx) result.children.push(/** @type {JxMutableNode} */ (jsx));
479
512
  }
480
513
  }
481
514
 
@@ -486,7 +519,7 @@ function domNodeToJx(node) {
486
519
  * Convert a DocumentFragment to a Jx-compatible structure. Returns { textContent } or { children }.
487
520
  *
488
521
  * @param {DocumentFragment} frag
489
- * @returns {{ textContent?: string | null; children?: any[] }}
522
+ * @returns {JxContentResult}
490
523
  */
491
524
  function fragmentToJx(frag) {
492
525
  const nodes = frag.childNodes;
@@ -495,14 +528,19 @@ function fragmentToJx(frag) {
495
528
  return { textContent: nodes[0].textContent };
496
529
  }
497
530
 
498
- /** @type {any[]} */
531
+ /** @type {(JxMutableNode | string)[]} */
499
532
  const children = [];
500
533
  for (const child of nodes) {
501
534
  const jsx = domNodeToJx(child);
502
535
  if (jsx) children.push(jsx);
503
536
  }
504
537
 
505
- if (children.length === 1 && children[0].tagName === "span" && children[0].textContent != null) {
538
+ if (
539
+ children.length === 1 &&
540
+ typeof children[0] !== "string" &&
541
+ children[0].tagName === "span" &&
542
+ children[0].textContent != null
543
+ ) {
506
544
  return { textContent: children[0].textContent };
507
545
  }
508
546
 
@@ -560,7 +598,7 @@ function updateSlashMenu() {
560
598
  sharedShowSlashMenu(activeEl, filter, { onSelect: handleSlashSelect });
561
599
  }
562
600
 
563
- /** @param {any} cmd */
601
+ /** @param {SlashCommand} cmd */
564
602
  function handleSlashSelect(cmd) {
565
603
  if (!activeEl || !insertFn || !activePath) return;
566
604
 
@@ -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,7 +15,7 @@ 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
21
 
@@ -132,6 +132,7 @@ export function initShortcuts(getContext) {
132
132
  if (isEditing()) {
133
133
  if (mod && e.key === "s") {
134
134
  e.preventDefault();
135
+ stopEditing();
135
136
  saveFile();
136
137
  }
137
138
  if (mod && e.key === "w") {
@@ -142,6 +143,7 @@ export function initShortcuts(getContext) {
142
143
  if (componentInlineEdit) {
143
144
  if (mod && e.key === "s") {
144
145
  e.preventDefault();
146
+ stopEditing();
145
147
  saveFile();
146
148
  }
147
149
  if (mod && e.key === "w") {
@@ -266,7 +268,7 @@ export function initShortcuts(getContext) {
266
268
  e.preventDefault();
267
269
  if (tab?.session.selection) {
268
270
  const node = getNodeAtPath(tab.doc.document, tab.session.selection);
269
- if (node?.children?.length > 0) {
271
+ if ((node?.children?.length ?? 0) > 0) {
270
272
  activeTab.value.session.selection = [...tab.session.selection, "children", 0];
271
273
  }
272
274
  }
@@ -30,12 +30,14 @@ const SLASH_COMMANDS = [
30
30
  { label: "Section", tag: "section", description: "Section container" },
31
31
  ];
32
32
 
33
+ /** @typedef {{ label: string; tag: string; description: string }} SlashCommand */
34
+
33
35
  // ─── State ────────────────────────────────────────────────────────────────────
34
36
 
35
- /** @type {{ onSelect: (cmd: any) => void; showFilter?: boolean } | null} */
37
+ /** @type {{ onSelect: (cmd: SlashCommand) => void; showFilter?: boolean } | null} */
36
38
  let callbacks = null;
37
39
  let activeIdx = 0;
38
- /** @type {any[]} */
40
+ /** @type {SlashCommand[]} */
39
41
  let filteredItems = [];
40
42
  let open = false;
41
43
  /** @type {HTMLElement | null} */
@@ -60,7 +62,7 @@ export function isSlashMenuOpen() {
60
62
  *
61
63
  * @param {HTMLElement} anchorEl — the element being edited (for positioning)
62
64
  * @param {string} filter — current typed filter text (after the "/")
63
- * @param {{ onSelect: (cmd: any) => void; showFilter?: boolean }} cbs
65
+ * @param {{ onSelect: (cmd: SlashCommand) => void; showFilter?: boolean }} cbs
64
66
  */
65
67
  export function showSlashMenu(anchorEl, filter, cbs) {
66
68
  callbacks = cbs;
@@ -174,7 +176,7 @@ function onOutsideClick(e) {
174
176
  }
175
177
  }
176
178
 
177
- /** @param {any} cmd */
179
+ /** @param {SlashCommand} cmd */
178
180
  function select(cmd) {
179
181
  const cbs = callbacks;
180
182
  dismissSlashMenu();
@@ -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;