@jxsuite/studio 0.28.5 → 0.29.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 (81) hide show
  1. package/dist/studio.js +6769 -5399
  2. package/dist/studio.js.map +86 -79
  3. package/package.json +5 -4
  4. package/src/browse/browse-modal.ts +2 -2
  5. package/src/browse/browse.ts +20 -19
  6. package/src/canvas/canvas-diff.ts +3 -3
  7. package/src/canvas/canvas-helpers.ts +15 -2
  8. package/src/canvas/canvas-live-render.ts +168 -86
  9. package/src/canvas/canvas-patcher.ts +656 -0
  10. package/src/canvas/canvas-perf.ts +68 -0
  11. package/src/canvas/canvas-render.ts +135 -23
  12. package/src/canvas/canvas-subtree-render.ts +113 -0
  13. package/src/canvas/canvas-utils.ts +4 -0
  14. package/src/editor/component-inline-edit.ts +4 -35
  15. package/src/editor/context-menu.ts +92 -74
  16. package/src/editor/convert-to-component.ts +11 -2
  17. package/src/editor/convert-to-repeater.ts +4 -5
  18. package/src/editor/inline-edit.ts +9 -9
  19. package/src/editor/inline-format.ts +11 -11
  20. package/src/editor/merge-tags.ts +120 -0
  21. package/src/editor/shortcuts.ts +4 -4
  22. package/src/files/components.ts +37 -0
  23. package/src/files/file-ops.ts +28 -7
  24. package/src/files/files.ts +25 -21
  25. package/src/github/github-auth.ts +14 -2
  26. package/src/github/github-publish.ts +12 -2
  27. package/src/panels/activity-bar.ts +1 -1
  28. package/src/panels/ai-panel.ts +67 -39
  29. package/src/panels/block-action-bar.ts +72 -1
  30. package/src/panels/canvas-dnd.ts +55 -26
  31. package/src/panels/data-explorer.ts +5 -3
  32. package/src/panels/dnd.ts +12 -15
  33. package/src/panels/editors.ts +5 -23
  34. package/src/panels/elements-panel.ts +2 -12
  35. package/src/panels/events-panel.ts +1 -1
  36. package/src/panels/git-panel.ts +6 -6
  37. package/src/panels/head-panel.ts +1 -1
  38. package/src/panels/layers-panel.ts +177 -147
  39. package/src/panels/preview-render.ts +15 -0
  40. package/src/panels/properties-panel.ts +16 -27
  41. package/src/panels/quick-search.ts +2 -2
  42. package/src/panels/right-panel.ts +2 -6
  43. package/src/panels/shared.ts +3 -0
  44. package/src/panels/signals-panel.ts +24 -22
  45. package/src/panels/statusbar.ts +15 -6
  46. package/src/panels/style-panel.ts +3 -3
  47. package/src/panels/style-utils.ts +3 -3
  48. package/src/panels/stylebook-panel.ts +4 -4
  49. package/src/panels/tab-bar.ts +198 -0
  50. package/src/panels/tab-strip.ts +2 -2
  51. package/src/panels/toolbar.ts +6 -75
  52. package/src/platforms/devserver.ts +60 -34
  53. package/src/reactivity.ts +2 -0
  54. package/src/services/cem-export.ts +23 -2
  55. package/src/services/code-services.ts +16 -2
  56. package/src/services/monaco-setup.ts +2 -1
  57. package/src/settings/content-types-editor.ts +16 -16
  58. package/src/settings/css-vars-editor.ts +2 -2
  59. package/src/settings/defs-editor.ts +14 -14
  60. package/src/settings/general-settings.ts +6 -6
  61. package/src/settings/head-editor.ts +2 -2
  62. package/src/site-context.ts +1 -1
  63. package/src/state.ts +66 -12
  64. package/src/store.ts +15 -3
  65. package/src/studio.ts +73 -33
  66. package/src/tabs/patch-ops.ts +143 -0
  67. package/src/tabs/tab.ts +15 -1
  68. package/src/tabs/transact.ts +454 -27
  69. package/src/types.ts +41 -0
  70. package/src/ui/color-selector.ts +7 -7
  71. package/src/ui/icons.ts +0 -30
  72. package/src/ui/media-picker.ts +2 -2
  73. package/src/ui/panel-resize.ts +6 -1
  74. package/src/ui/unit-selector.ts +2 -2
  75. package/src/ui/value-selector.ts +3 -3
  76. package/src/utils/canvas-media.ts +6 -6
  77. package/src/utils/edit-display.ts +125 -83
  78. package/src/utils/google-fonts.ts +1 -1
  79. package/src/utils/inherited-style.ts +3 -2
  80. package/src/utils/studio-utils.ts +1 -1
  81. package/src/view.ts +4 -1
@@ -10,6 +10,7 @@
10
10
  import { locateDocument } from "../services/code-services";
11
11
  import { errorMessage } from "@jxsuite/schema/parse";
12
12
  import { statusMessage } from "../panels/statusbar";
13
+ import { validateComponentSlots } from "../services/cem-export";
13
14
  import { getPlatform } from "../platform";
14
15
  import { activeTab, openTab } from "../workspace/workspace";
15
16
  import { isEditing, stopEditing } from "../editor/inline-edit";
@@ -94,7 +95,7 @@ export async function openFile() {
94
95
  sourceFormat: format.name,
95
96
  });
96
97
  } else if (name.endsWith(".json")) {
97
- const document = JSON.parse(text);
98
+ const document = JSON.parse(text) as Record<string, unknown>;
98
99
  openTab({ document, documentPath, fileHandle: handle, id: name });
99
100
  } else {
100
101
  throw noFormatError(name);
@@ -108,8 +109,8 @@ export async function openFile() {
108
109
  showOpenFilePicker: (options?: unknown) => Promise<FileSystemFileHandle[]>;
109
110
  }
110
111
  ).showOpenFilePicker({ types: pickerTypes });
111
- const file = await handle.getFile();
112
- await handleSource(handle.name, await file.text(), handle);
112
+ const file = await handle!.getFile();
113
+ await handleSource(handle!.name, await file.text(), handle!);
113
114
  } else {
114
115
  // Fallback: file input
115
116
  const input = document.createElement("input");
@@ -131,6 +132,26 @@ export async function openFile() {
131
132
  }
132
133
  }
133
134
 
135
+ /**
136
+ * Report a slot-validation warning for component documents (root tagName with a hyphen). The save
137
+ * still proceeds — the warning replaces the plain success message.
138
+ *
139
+ * @param {Tab} tab
140
+ * @param {string} savedMsg
141
+ */
142
+ function savedMessage(tab: Tab, savedMsg: string) {
143
+ const doc = tab.doc.document;
144
+ const warning =
145
+ typeof doc.tagName === "string" && doc.tagName.includes("-")
146
+ ? validateComponentSlots(doc)
147
+ : null;
148
+ if (warning) {
149
+ statusMessage(`${savedMsg} — Warning: ${warning}`, 6000);
150
+ } else {
151
+ statusMessage(savedMsg);
152
+ }
153
+ }
154
+
134
155
  /** Save the current document back to its source location. */
135
156
  export async function saveFile() {
136
157
  if (isEditing()) {
@@ -147,7 +168,7 @@ export async function saveFile() {
147
168
  const platform = getPlatform();
148
169
  await platform.writeFile(tab.documentPath, output);
149
170
  tab.doc.dirty = false;
150
- statusMessage("Saved");
171
+ savedMessage(tab, "Saved");
151
172
  } else if (tab.fileHandle && "createWritable" in tab.fileHandle) {
152
173
  const writable =
153
174
  await /**
@@ -161,7 +182,7 @@ export async function saveFile() {
161
182
  await writable.write(output);
162
183
  await writable.close();
163
184
  tab.doc.dirty = false;
164
- statusMessage("Saved");
185
+ savedMessage(tab, "Saved");
165
186
  } else {
166
187
  statusMessage("No save target — use Export");
167
188
  }
@@ -209,7 +230,7 @@ export async function exportFile() {
209
230
  await writable.write(output);
210
231
  await writable.close();
211
232
  tab.doc.dirty = false;
212
- statusMessage(`Exported as ${handle.name}`);
233
+ savedMessage(tab, `Exported as ${handle.name}`);
213
234
  } else {
214
235
  // Fallback: download
215
236
  const blob = new Blob([output], { type: mimeType });
@@ -220,7 +241,7 @@ export async function exportFile() {
220
241
  a.click();
221
242
  URL.revokeObjectURL(url);
222
243
  tab.doc.dirty = false;
223
- statusMessage("Downloaded");
244
+ savedMessage(tab, "Downloaded");
224
245
  }
225
246
  } catch (error) {
226
247
  if (!(error instanceof Error && error.name === "AbortError")) {
@@ -41,6 +41,7 @@ import {
41
41
  import { view } from "../view";
42
42
  import { addRecentProject, trackRecentFile } from "../recent-projects";
43
43
  import type { TemplateResult } from "lit-html";
44
+ import type { JxMutableNode } from "@jxsuite/schema/types";
44
45
  import type { StudioState } from "../state.js";
45
46
  import type { Tab } from "../tabs/tab.js";
46
47
 
@@ -339,7 +340,7 @@ function renderTreeLevelTemplate(
339
340
  ): TemplateResult | TemplateResult[] {
340
341
  const entries = requireProjectState().dirs.get(dirPath);
341
342
  if (!entries) {
342
- loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
343
+ void loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
343
344
  return html`<div
344
345
  class="file-tree-item"
345
346
  style="padding-left:${8 + depth * 16}px;color:var(--fg-dim);font-style:italic"
@@ -427,13 +428,13 @@ export function setupTreeKeyboard(tree: HTMLElement) {
427
428
  switch (e.key) {
428
429
  case "ArrowDown": {
429
430
  if (idx < items.length - 1) {
430
- items[idx + 1].focus();
431
+ items[idx + 1]!.focus();
431
432
  }
432
433
  break;
433
434
  }
434
435
  case "ArrowUp": {
435
436
  if (idx > 0) {
436
- items[idx - 1].focus();
437
+ items[idx - 1]!.focus();
437
438
  }
438
439
  break;
439
440
  }
@@ -442,7 +443,7 @@ export function setupTreeKeyboard(tree: HTMLElement) {
442
443
  const path = focused.dataset.path as string;
443
444
  if (!requireProjectState().expanded.has(path)) {
444
445
  requireProjectState().expanded.add(path);
445
- loadDirectory(path).then(() => {
446
+ void loadDirectory(path).then(() => {
446
447
  const panel = tree.closest(".panel-body");
447
448
  if (panel) {
448
449
  (panel.querySelector(".file-tree-item:focus") as HTMLElement | null)?.click();
@@ -619,7 +620,7 @@ export function registerFileTreeDnD({ renderLeftPanel }: { renderLeftPanel: () =
619
620
  return;
620
621
  }
621
622
 
622
- moveFileEntry(srcPath, newPath!, renderLeftPanel);
623
+ void moveFileEntry(srcPath, newPath!, renderLeftPanel);
623
624
  },
624
625
  });
625
626
  _fileTreeDndCleanups.push(monitorCleanup);
@@ -707,16 +708,18 @@ function showFileContextMenu(
707
708
  label: "New File\u2026",
708
709
  });
709
710
  }
710
- items.push({ label: "\u2014" });
711
- items.push({
712
- action: () => renameFile(entry, ctx.renderLeftPanel),
713
- label: "Rename\u2026",
714
- });
715
- items.push({
716
- action: () => deleteFile(entry, ctx.renderLeftPanel),
717
- danger: true,
718
- label: "Delete",
719
- });
711
+ items.push(
712
+ { label: "\u2014" },
713
+ {
714
+ action: () => renameFile(entry, ctx.renderLeftPanel),
715
+ label: "Rename\u2026",
716
+ },
717
+ {
718
+ action: () => deleteFile(entry, ctx.renderLeftPanel),
719
+ danger: true,
720
+ label: "Delete",
721
+ },
722
+ );
720
723
 
721
724
  let x = e.clientX,
722
725
  y = e.clientY;
@@ -751,7 +754,7 @@ function showFileContextMenu(
751
754
  style=${item.danger ? "color: var(--danger)" : ""}
752
755
  @click=${() => {
753
756
  dismissFileContextMenu();
754
- item.action?.();
757
+ void item.action?.();
755
758
  }}
756
759
  >${item.label}</sp-menu-item
757
760
  >`,
@@ -953,12 +956,12 @@ export async function openFileFromTree(
953
956
  }
954
957
 
955
958
  if (formatForPath(path)) {
956
- await ctx.loadMarkdown(content, null);
959
+ ctx.loadMarkdown(content, null);
957
960
  ctx.S.documentPath = path;
958
961
  ctx.S.dirty = false;
959
962
  ctx.commit(ctx.S);
960
963
  } else if (path.endsWith(".json")) {
961
- const doc = JSON.parse(content);
964
+ const doc = JSON.parse(content) as JxMutableNode;
962
965
  const newS = createState(doc);
963
966
  newS.documentPath = path;
964
967
  newS.dirty = false;
@@ -999,14 +1002,15 @@ export async function openFileInTab(path: string) {
999
1002
  }
1000
1003
 
1001
1004
  await loadFormats();
1002
- let document, frontmatter;
1005
+ let document: Record<string, unknown>;
1006
+ let frontmatter: Record<string, unknown> | undefined;
1003
1007
  const format = formatForPath(path);
1004
1008
  if (format) {
1005
1009
  const result = await parseSourceForPath(path, content);
1006
1010
  ({ document } = result);
1007
1011
  ({ frontmatter } = result);
1008
1012
  } else if (path.endsWith(".json")) {
1009
- document = JSON.parse(content);
1013
+ document = JSON.parse(content) as Record<string, unknown>;
1010
1014
  } else {
1011
1015
  throw noFormatError(path);
1012
1016
  }
@@ -1056,7 +1060,7 @@ export async function reloadFileInTab(path: string) {
1056
1060
  tab.doc.document = document;
1057
1061
  tab.doc.content.frontmatter = frontmatter;
1058
1062
  } else if (path.endsWith(".json")) {
1059
- tab.doc.document = JSON.parse(content);
1063
+ tab.doc.document = JSON.parse(content) as JxMutableNode;
1060
1064
  }
1061
1065
  tab.doc.dirty = false;
1062
1066
  } catch {}
@@ -12,6 +12,18 @@ import { showDialog } from "../ui/layers";
12
12
  const CLIENT_ID = "Ov23liYVlMFpgjOEPXJH";
13
13
  const STORAGE_KEY = "jx_github_token";
14
14
 
15
+ interface DeviceCodeResponse {
16
+ device_code: string;
17
+ user_code: string;
18
+ verification_uri: string;
19
+ interval?: number;
20
+ }
21
+
22
+ interface TokenResponse {
23
+ access_token?: string;
24
+ error?: string;
25
+ }
26
+
15
27
  export function getGithubToken() {
16
28
  return localStorage.getItem(STORAGE_KEY);
17
29
  }
@@ -44,7 +56,7 @@ export async function authenticateGithub() {
44
56
  if (!codeRes.ok) {
45
57
  throw new Error("Failed to initiate GitHub device flow");
46
58
  }
47
- const codeData = await codeRes.json();
59
+ const codeData = (await codeRes.json()) as DeviceCodeResponse;
48
60
 
49
61
  const { device_code, user_code, verification_uri, interval = 5 } = codeData;
50
62
 
@@ -69,7 +81,7 @@ export async function authenticateGithub() {
69
81
  },
70
82
  method: "POST",
71
83
  });
72
- const tokenData = await tokenRes.json();
84
+ const tokenData = (await tokenRes.json()) as TokenResponse;
73
85
 
74
86
  if (tokenData.access_token) {
75
87
  localStorage.setItem(STORAGE_KEY, tokenData.access_token);
@@ -9,6 +9,16 @@ import { authenticateGithub } from "./github-auth";
9
9
  import { getPlatform } from "../platform";
10
10
  import { statusMessage } from "../panels/statusbar";
11
11
 
12
+ interface GithubErrorResponse {
13
+ errors?: { message?: string }[];
14
+ message?: string;
15
+ }
16
+
17
+ interface GithubRepoResponse {
18
+ clone_url: string;
19
+ html_url: string;
20
+ }
21
+
12
22
  /**
13
23
  * Full "Publish to GitHub" flow: 1. Authenticate (or reuse stored token) 2. Prompt for repo name /
14
24
  * visibility 3. Create the repo via GitHub API 4. Add remote + push
@@ -105,13 +115,13 @@ export async function publishToGithub({ projectName }: { projectName: string })
105
115
  });
106
116
 
107
117
  if (!createRes.ok) {
108
- const err = await createRes.json();
118
+ const err = (await createRes.json()) as GithubErrorResponse;
109
119
  const msg = err.errors?.[0]?.message || err.message || "Failed to create repository";
110
120
  statusMessage(`Error: ${msg}`);
111
121
  return false;
112
122
  }
113
123
 
114
- const repo = await createRes.json();
124
+ const repo = (await createRes.json()) as GithubRepoResponse;
115
125
  const platform = getPlatform();
116
126
 
117
127
  statusMessage("Setting remote and pushing…");
@@ -21,7 +21,7 @@ export function mount() {
21
21
  if (tab) {
22
22
  const gs = tab.session.ui.gitStatus;
23
23
  if (!gs && !tab.session.ui.gitLoading) {
24
- refreshGitStatus();
24
+ void refreshGitStatus();
25
25
  }
26
26
  }
27
27
  renderActivityBar();
@@ -43,6 +43,14 @@ interface QuikChatInstance {
43
43
  historyImport: (history: unknown[]) => void;
44
44
  }
45
45
 
46
+ type QuikChatCtor = new (
47
+ container: HTMLElement,
48
+ onSend: (chat: unknown, msg: string) => void,
49
+ opts: Record<string, unknown>,
50
+ ) => QuikChatInstance;
51
+
52
+ const QuikChat = quikchat as unknown as QuikChatCtor;
53
+
46
54
  let chatInstance: QuikChatInstance | null = null;
47
55
  let chatContainerEl: Element | null = null;
48
56
  let _quikChatEl: HTMLElement | null = null;
@@ -57,7 +65,7 @@ export function mountAiPanel() {
57
65
  return;
58
66
  }
59
67
  mounted = true;
60
- checkAuth();
68
+ void checkAuth();
61
69
  }
62
70
 
63
71
  const _g = globalThis as unknown as {
@@ -87,10 +95,10 @@ export function mountQuikChat() {
87
95
  return;
88
96
  }
89
97
 
90
- chatInstance = new quikchat(
98
+ chatInstance = new QuikChat(
91
99
  container,
92
100
  (_chat: unknown, msg: string) => {
93
- handleUserSend(msg);
101
+ void handleUserSend(msg);
94
102
  },
95
103
  {
96
104
  messagesArea: { alternating: false },
@@ -197,14 +205,14 @@ function stop() {
197
205
  return;
198
206
  }
199
207
  const plat = getPlatform();
200
- plat.aiStopSession(sessionId);
208
+ void plat.aiStopSession(sessionId);
201
209
  finishStream();
202
210
  }
203
211
 
204
212
  function newChat() {
205
213
  if (sessionId) {
206
214
  const plat = getPlatform();
207
- plat.aiDeleteSession(sessionId);
215
+ void plat.aiDeleteSession(sessionId);
208
216
  }
209
217
  disconnectStream();
210
218
  messages = [];
@@ -223,6 +231,31 @@ function newChat() {
223
231
 
224
232
  // ─── SSE Stream ─────────────────────────────────────────────────────────────
225
233
 
234
+ /** SSE `stream_event` payload (subset used here). */
235
+ interface StreamEventData {
236
+ event?: { type?: string; delta?: { type?: string; text?: string } };
237
+ }
238
+
239
+ /** SSE `result` payload (subset used here). */
240
+ interface ResultData {
241
+ result?: string;
242
+ is_error?: boolean;
243
+ }
244
+
245
+ /** SSE `error` payload (subset used here). */
246
+ interface ErrorData {
247
+ error?: string;
248
+ }
249
+
250
+ /** Parse SSE JSON into the expected shape. Returns null on malformed input. */
251
+ function parseSse<T>(raw: unknown): T | null {
252
+ try {
253
+ return JSON.parse(String(raw)) as T;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
226
259
  async function connectStream(id: string) {
227
260
  disconnectStream();
228
261
  const plat = getPlatform();
@@ -230,40 +263,37 @@ async function connectStream(id: string) {
230
263
  eventSource = new EventSource(url);
231
264
 
232
265
  eventSource.addEventListener("stream_event", (e) => {
233
- try {
234
- const data = JSON.parse(e.data);
235
- const evt = data.event;
236
- if (evt?.type === "content_block_delta" && evt.delta?.type === "text_delta") {
237
- const token = evt.delta.text;
238
- currentAssistantText += token;
239
- if (chatInstance && currentStreamMsgId != null) {
240
- if (!streamStarted) {
241
- chatInstance.messageReplaceContent(currentStreamMsgId, currentAssistantText);
242
- streamStarted = true;
243
- } else {
244
- chatInstance.messageAppendContent(currentStreamMsgId, token);
245
- }
266
+ const data = parseSse<StreamEventData>((e as MessageEvent).data);
267
+ const evt = data?.event;
268
+ if (evt?.type === "content_block_delta" && evt.delta?.type === "text_delta") {
269
+ const token = evt.delta.text ?? "";
270
+ currentAssistantText += token;
271
+ if (chatInstance && currentStreamMsgId != null) {
272
+ if (!streamStarted) {
273
+ chatInstance.messageReplaceContent(currentStreamMsgId, currentAssistantText);
274
+ streamStarted = true;
275
+ } else {
276
+ chatInstance.messageAppendContent(currentStreamMsgId, token);
246
277
  }
247
278
  }
248
- } catch {}
279
+ }
249
280
  });
250
281
 
251
282
  eventSource.addEventListener("assistant", (e) => {
252
- try {
253
- handleAssistantMessage(JSON.parse(e.data));
254
- } catch {}
283
+ const data = parseSse<AssistantMessageData>((e as MessageEvent).data);
284
+ if (data) {
285
+ handleAssistantMessage(data);
286
+ }
255
287
  });
256
288
 
257
289
  eventSource.addEventListener("result", (e) => {
258
- try {
259
- const data = JSON.parse(e.data);
260
- if (data.result && data.is_error) {
261
- if (chatInstance && currentStreamMsgId != null) {
262
- chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.result}`);
263
- }
264
- currentAssistantText = `Error: ${data.result}`;
290
+ const data = parseSse<ResultData>((e as MessageEvent).data);
291
+ if (data?.result && data.is_error) {
292
+ if (chatInstance && currentStreamMsgId != null) {
293
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.result}`);
265
294
  }
266
- } catch {}
295
+ currentAssistantText = `Error: ${data.result}`;
296
+ }
267
297
  finishStream();
268
298
  });
269
299
 
@@ -272,15 +302,13 @@ async function connectStream(id: string) {
272
302
  });
273
303
 
274
304
  eventSource.addEventListener("error", (e) => {
275
- try {
276
- const data = JSON.parse((e as MessageEvent).data);
277
- if (data.error) {
278
- if (chatInstance && currentStreamMsgId != null) {
279
- chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.error}`);
280
- }
281
- currentAssistantText = `Error: ${data.error}`;
305
+ const data = parseSse<ErrorData>((e as MessageEvent).data);
306
+ if (data?.error) {
307
+ if (chatInstance && currentStreamMsgId != null) {
308
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.error}`);
282
309
  }
283
- } catch {}
310
+ currentAssistantText = `Error: ${data.error}`;
311
+ }
284
312
  finishStream();
285
313
  });
286
314
 
@@ -306,7 +334,7 @@ function finishStream() {
306
334
 
307
335
  if (pendingFileReloads.size > 0) {
308
336
  for (const fp of pendingFileReloads) {
309
- reloadFileInTab(fp);
337
+ void reloadFileInTab(fp);
310
338
  }
311
339
  pendingFileReloads.clear();
312
340
  }
@@ -9,13 +9,21 @@ import { styleMap } from "lit-html/directives/style-map.js";
9
9
  import { ref } from "lit-html/directives/ref.js";
10
10
  import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
11
11
 
12
- import { childIndex, childList, getNodeAtPath, nodeLabel, parentElementPath } from "../store";
12
+ import {
13
+ childIndex,
14
+ childList,
15
+ elToScope,
16
+ getNodeAtPath,
17
+ nodeLabel,
18
+ parentElementPath,
19
+ } from "../store";
13
20
  import { activeTab } from "../workspace/workspace";
14
21
  import { mutateMoveNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
15
22
  import { view } from "../view";
16
23
  import { getActiveElement, getInlineActions, isEditing } from "../editor/inline-edit";
17
24
  import type { InlineAction } from "../editor/inline-edit";
18
25
  import { isTagActiveInSelection, toggleInlineFormat } from "../editor/inline-format";
26
+ import { buildMergeTags } from "../editor/merge-tags";
19
27
  import { componentRegistry } from "../files/components";
20
28
  import { convertToComponent } from "../editor/convert-to-component";
21
29
  import { findCanvasElement, getActivePanel } from "../canvas/canvas-helpers";
@@ -224,6 +232,60 @@ function applyInlineFormat(action: InlineAction) {
224
232
  requestAnimationFrame(() => renderBlockActionBar());
225
233
  }
226
234
 
235
+ /**
236
+ * Open the merge-tag menu — a searchable list of `${…}` template tokens for the data available in
237
+ * the current state. Reuses the shared slash-menu popover (filter + keyboard nav + dismiss).
238
+ *
239
+ * @param {MouseEvent} e
240
+ */
241
+ function onMergeTagClick(e: MouseEvent) {
242
+ e.stopPropagation();
243
+ const anchorEl = e.currentTarget as HTMLElement;
244
+ const tab = activeTab.value;
245
+ const editable = getActiveElement();
246
+ const state = (tab?.doc.document.state ?? {}) as Record<string, unknown>;
247
+ const scope = getActivePanel()?.liveCtx?.scope ?? null;
248
+ const localScope = editable ? (elToScope.get(editable) ?? null) : null;
249
+
250
+ const commands = buildMergeTags(state, scope, localScope).map((t) => ({
251
+ description: t.hint,
252
+ label: t.label,
253
+ tag: t.token,
254
+ }));
255
+
256
+ showSlashMenu(anchorEl, "", {
257
+ commands,
258
+ onSelect: (cmd) => insertMergeTag(cmd.tag),
259
+ showFilter: true,
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Insert a `${token}` template expression at the saved selection inside the active contenteditable.
265
+ * Mirrors onFormatClick's range-restore flow and inserts via execCommand so it joins the
266
+ * contenteditable's native undo stack (like paste).
267
+ *
268
+ * @param {string} token
269
+ */
270
+ function insertMergeTag(token: string) {
271
+ if (!view.savedRange) {
272
+ return;
273
+ }
274
+ const anchor = view.savedRange.startContainer;
275
+ const editableRoot = (
276
+ anchor?.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor?.parentElement
277
+ )?.closest("[contenteditable]");
278
+ if (!editableRoot) {
279
+ return;
280
+ }
281
+ const sel = window.getSelection();
282
+ (editableRoot as HTMLElement).focus();
283
+ sel?.removeAllRanges();
284
+ sel?.addRange(view.savedRange);
285
+ document.execCommand("insertText", false, `\${${token}}`);
286
+ requestAnimationFrame(() => renderBlockActionBar());
287
+ }
288
+
227
289
  /** Dismiss the link popover if open. */
228
290
  export function dismissLinkPopover() {
229
291
  const host = getLayerSlot("popover", "link-popover");
@@ -515,6 +577,15 @@ export function renderBlockActionBar() {
515
577
  `,
516
578
  )}
517
579
  </sp-action-group>
580
+ <sp-action-button
581
+ size="xs"
582
+ quiet
583
+ title="Insert data"
584
+ @mousedown=${captureSelectionRange}
585
+ @click=${onMergeTagClick}
586
+ >
587
+ <sp-icon-data slot="icon"></sp-icon-data>
588
+ </sp-action-button>
518
589
  `
519
590
  : nothing}
520
591
  </div>