@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
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Ai-panel.js — Claude AI Assistant tab for the right panel
3
+ *
4
+ * Uses QuikChat for the chat UI with Claude Agent SDK streaming via SSE.
5
+ */
6
+
7
+ import { html, nothing } from "lit-html";
8
+ import { ref } from "lit-html/directives/ref.js";
9
+ import quikchat from "quikchat/md";
10
+ import { getPlatform } from "../platform.js";
11
+ import { reloadFileInTab } from "../files/files.js";
12
+
13
+ // ─── State (module-level, persists across tab switches) ─────────────────────
14
+
15
+ /**
16
+ * @type {{
17
+ * role: string;
18
+ * content: string;
19
+ * toolUse?: { tool: string; input?: Record<string, unknown> };
20
+ * }[]}
21
+ */
22
+ let messages = [];
23
+ let streaming = false;
24
+ let sessionId = /** @type {string | null} */ (null);
25
+ let authStatus = /** @type {"authenticated" | "unauthenticated" | "checking" | "unknown"} */ (
26
+ "unknown"
27
+ );
28
+ let authError = "";
29
+ let currentAssistantText = "";
30
+ let eventSource = /** @type {EventSource | null} */ (null);
31
+ let mounted = false;
32
+
33
+ /** @type {any} */
34
+ let chatInstance = null;
35
+ /** @type {Element | null} */
36
+ let chatContainerEl = null;
37
+ /** @type {HTMLElement | null} */
38
+ let _quikChatEl = null;
39
+ let currentStreamMsgId = /** @type {number | null} */ (null);
40
+ let streamStarted = false;
41
+ /** @type {Set<string>} */
42
+ let pendingFileReloads = new Set();
43
+
44
+ // ─── Lifecycle ──────────────────────────────────────────────────────────────
45
+
46
+ export function mountAiPanel() {
47
+ if (mounted) return;
48
+ mounted = true;
49
+ checkAuth();
50
+ }
51
+
52
+ function rerenderPanel() {
53
+ const { render } = /** @type {any} */ (/** @type {any} */ (globalThis).__jxRightPanelRender) ||
54
+ {};
55
+ if (render) render();
56
+ requestAnimationFrame(() => mountQuikChat());
57
+ }
58
+
59
+ export function registerRightPanelRender(/** @type {Function} */ fn) {
60
+ /** @type {any} */ (globalThis).__jxRightPanelRender = { render: fn };
61
+ }
62
+
63
+ // ─── QuikChat Mount ────────────────────────────────────────────────────────
64
+
65
+ export function mountQuikChat() {
66
+ const container = _quikChatEl;
67
+ if (!container) return;
68
+ if (chatInstance && chatContainerEl === container) return;
69
+
70
+ chatInstance = new quikchat(
71
+ container,
72
+ (/** @type {any} */ _chat, /** @type {string} */ msg) => {
73
+ handleUserSend(msg);
74
+ },
75
+ {
76
+ theme: "quikchat-theme-dark",
77
+ titleArea: { show: false },
78
+ showTimestamps: false,
79
+ messagesArea: { alternating: false },
80
+ },
81
+ );
82
+ chatContainerEl = container;
83
+
84
+ replayMessages();
85
+
86
+ if (streaming) {
87
+ chatInstance.inputAreaSetEnabled(false);
88
+ }
89
+ }
90
+
91
+ function replayMessages() {
92
+ if (!chatInstance || !messages.length) return;
93
+ for (const msg of messages) {
94
+ if (msg.role === "user") {
95
+ chatInstance.messageAddNew(msg.content, "You", "right", "user");
96
+ } else if (msg.role === "tool" && msg.toolUse) {
97
+ chatInstance.messageAddNew(
98
+ formatToolLabel(msg.toolUse.tool, msg.toolUse.input),
99
+ "",
100
+ "left",
101
+ "tool",
102
+ );
103
+ } else {
104
+ chatInstance.messageAddNew(msg.content, "", "left", "assistant");
105
+ }
106
+ }
107
+ if (streaming && currentAssistantText) {
108
+ currentStreamMsgId = chatInstance.messageAddNew(currentAssistantText, "", "left", "assistant");
109
+ streamStarted = true;
110
+ }
111
+ }
112
+
113
+ // ─── Auth ───────────────────────────────────────────────────────────────────
114
+
115
+ async function checkAuth() {
116
+ authStatus = "checking";
117
+ rerenderPanel();
118
+ try {
119
+ const plat = getPlatform();
120
+ const result = await plat.aiAuthStatus();
121
+ authStatus = result.authenticated ? "authenticated" : "unauthenticated";
122
+ authError = result.error || "";
123
+ } catch (err) {
124
+ authStatus = "unauthenticated";
125
+ authError = String(err);
126
+ }
127
+ rerenderPanel();
128
+ }
129
+
130
+ // ─── Messaging ──────────────────────────────────────────────────────────────
131
+
132
+ /** @param {string} text */
133
+ async function handleUserSend(text) {
134
+ if (!text.trim() || streaming) return;
135
+
136
+ messages.push({ role: "user", content: text });
137
+ streaming = true;
138
+ currentAssistantText = "";
139
+ streamStarted = false;
140
+
141
+ if (chatInstance) {
142
+ currentStreamMsgId = chatInstance.messageAddTypingIndicator("");
143
+ chatInstance.inputAreaSetEnabled(false);
144
+ }
145
+
146
+ rerenderPanel();
147
+
148
+ const plat = getPlatform();
149
+
150
+ try {
151
+ if (!sessionId) {
152
+ const result = await plat.aiCreateSession({ message: text });
153
+ sessionId = result.id;
154
+ await connectStream(/** @type {string} */ (sessionId));
155
+ } else {
156
+ await plat.aiSendMessage(sessionId, text);
157
+ }
158
+ } catch (err) {
159
+ if (chatInstance && currentStreamMsgId != null) {
160
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${err}`);
161
+ }
162
+ messages.push({ role: "assistant", content: `Error: ${err}` });
163
+ streaming = false;
164
+ if (chatInstance) chatInstance.inputAreaSetEnabled(true);
165
+ rerenderPanel();
166
+ }
167
+ }
168
+
169
+ function stop() {
170
+ if (!sessionId) return;
171
+ const plat = getPlatform();
172
+ plat.aiStopSession(sessionId);
173
+ finishStream();
174
+ }
175
+
176
+ function newChat() {
177
+ if (sessionId) {
178
+ const plat = getPlatform();
179
+ plat.aiDeleteSession(sessionId);
180
+ }
181
+ disconnectStream();
182
+ messages = [];
183
+ sessionId = null;
184
+ streaming = false;
185
+ currentAssistantText = "";
186
+ currentStreamMsgId = null;
187
+ streamStarted = false;
188
+ pendingFileReloads.clear();
189
+ if (chatInstance) {
190
+ chatInstance.historyImport([]);
191
+ chatInstance.inputAreaSetEnabled(true);
192
+ }
193
+ rerenderPanel();
194
+ }
195
+
196
+ // ─── SSE Stream ─────────────────────────────────────────────────────────────
197
+
198
+ async function connectStream(/** @type {string} */ id) {
199
+ disconnectStream();
200
+ const plat = getPlatform();
201
+ const url = await Promise.resolve(plat.aiStreamUrl(id));
202
+ eventSource = new EventSource(url);
203
+
204
+ eventSource.addEventListener("stream_event", (e) => {
205
+ try {
206
+ const data = JSON.parse(e.data);
207
+ const evt = data.event;
208
+ if (evt?.type === "content_block_delta" && evt.delta?.type === "text_delta") {
209
+ const token = evt.delta.text;
210
+ currentAssistantText += token;
211
+ if (chatInstance && currentStreamMsgId != null) {
212
+ if (!streamStarted) {
213
+ chatInstance.messageReplaceContent(currentStreamMsgId, currentAssistantText);
214
+ streamStarted = true;
215
+ } else {
216
+ chatInstance.messageAppendContent(currentStreamMsgId, token);
217
+ }
218
+ }
219
+ }
220
+ } catch {}
221
+ });
222
+
223
+ eventSource.addEventListener("assistant", (e) => {
224
+ try {
225
+ handleAssistantMessage(JSON.parse(e.data));
226
+ } catch {}
227
+ });
228
+
229
+ eventSource.addEventListener("result", (e) => {
230
+ try {
231
+ const data = JSON.parse(e.data);
232
+ if (data.result && data.is_error) {
233
+ if (chatInstance && currentStreamMsgId != null) {
234
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.result}`);
235
+ }
236
+ currentAssistantText = `Error: ${data.result}`;
237
+ }
238
+ } catch {}
239
+ finishStream();
240
+ });
241
+
242
+ eventSource.addEventListener("done", () => {
243
+ finishStream();
244
+ });
245
+
246
+ eventSource.addEventListener("error", (e) => {
247
+ try {
248
+ const data = JSON.parse(/** @type {MessageEvent} */ (e).data);
249
+ if (data.error) {
250
+ if (chatInstance && currentStreamMsgId != null) {
251
+ chatInstance.messageReplaceContent(currentStreamMsgId, `Error: ${data.error}`);
252
+ }
253
+ currentAssistantText = `Error: ${data.error}`;
254
+ }
255
+ } catch {}
256
+ finishStream();
257
+ });
258
+
259
+ eventSource.onerror = () => {
260
+ finishStream();
261
+ };
262
+ }
263
+
264
+ function finishStream() {
265
+ if (!streaming) return;
266
+ if (currentAssistantText) {
267
+ messages.push({ role: "assistant", content: currentAssistantText });
268
+ currentAssistantText = "";
269
+ }
270
+ streaming = false;
271
+ currentStreamMsgId = null;
272
+ streamStarted = false;
273
+ if (chatInstance) chatInstance.inputAreaSetEnabled(true);
274
+
275
+ if (pendingFileReloads.size) {
276
+ for (const fp of pendingFileReloads) reloadFileInTab(fp);
277
+ pendingFileReloads.clear();
278
+ }
279
+
280
+ rerenderPanel();
281
+ }
282
+
283
+ function disconnectStream() {
284
+ if (eventSource) {
285
+ eventSource.close();
286
+ eventSource = null;
287
+ }
288
+ }
289
+
290
+ /** @param {any} data */
291
+ function handleAssistantMessage(data) {
292
+ const content = data.message?.content || data.content;
293
+ if (!content) return;
294
+
295
+ let text = "";
296
+ const toolBlocks = [];
297
+
298
+ for (const block of content) {
299
+ if (block.type === "text") {
300
+ text += block.text;
301
+ } else if (block.type === "tool_use") {
302
+ toolBlocks.push({ tool: block.name, input: block.input });
303
+ if ((block.name === "Edit" || block.name === "Write") && block.input) {
304
+ const fp = block.input.file_path || block.input.path;
305
+ if (fp) pendingFileReloads.add(String(fp));
306
+ }
307
+ }
308
+ }
309
+
310
+ if (toolBlocks.length) {
311
+ if (currentAssistantText) {
312
+ messages.push({ role: "assistant", content: currentAssistantText });
313
+ }
314
+ for (const t of toolBlocks) {
315
+ messages.push({ role: "tool", content: "", toolUse: t });
316
+ if (chatInstance) {
317
+ chatInstance.messageAddNew(formatToolLabel(t.tool, t.input), "", "left", "tool");
318
+ }
319
+ }
320
+ }
321
+
322
+ currentAssistantText = text;
323
+ if (chatInstance) {
324
+ currentStreamMsgId = chatInstance.messageAddNew(text || "", "", "left", "assistant");
325
+ streamStarted = !!text;
326
+ }
327
+ }
328
+
329
+ // ─── Tool Label Formatting ─────────────────────────────────────────────────
330
+
331
+ /**
332
+ * @param {string} tool
333
+ * @param {Record<string, unknown>} [input]
334
+ */
335
+ function formatToolLabel(tool, input) {
336
+ switch (tool) {
337
+ case "Edit":
338
+ case "Write":
339
+ return `📝 ${tool}: ${input?.file_path || input?.path || "file"}`;
340
+ case "Read":
341
+ return `📖 Read: ${input?.file_path || input?.path || "file"}`;
342
+ case "Bash":
343
+ return `⚡ Run: ${truncate(String(input?.command || ""), 50)}`;
344
+ case "Glob":
345
+ return `🔍 Glob: ${input?.pattern || ""}`;
346
+ case "Grep":
347
+ return `🔍 Grep: ${truncate(String(input?.pattern || ""), 40)}`;
348
+ default:
349
+ return `🔧 ${tool}`;
350
+ }
351
+ }
352
+
353
+ /** @param {string} s @param {number} max */
354
+ function truncate(s, max) {
355
+ return s.length > max ? s.slice(0, max) + "…" : s;
356
+ }
357
+
358
+ // ─── Template ───────────────────────────────────────────────────────────────
359
+
360
+ /** @returns {import("lit-html").TemplateResult} */
361
+ export function renderAiPanelTemplate() {
362
+ if (authStatus === "checking" || authStatus === "unknown") {
363
+ return html`<div class="ai-tab-body">
364
+ <div class="ai-status-center">Checking authentication...</div>
365
+ </div>`;
366
+ }
367
+
368
+ if (authStatus === "unauthenticated") {
369
+ return html`
370
+ <div class="ai-tab-body">
371
+ <div class="ai-status-center">
372
+ <sp-icon-artboard style="font-size:32px"></sp-icon-artboard>
373
+ <div>Claude authentication required</div>
374
+ <div style="font-size:11px;color:var(--spectrum-global-color-gray-600)">
375
+ Run the following in your terminal:
376
+ </div>
377
+ <code class="ai-code-snippet">npx @anthropic-ai/claude-code login</code>
378
+ ${authError
379
+ ? html`<sp-help-text variant="negative">${authError}</sp-help-text>`
380
+ : nothing}
381
+ <sp-button size="s" variant="primary" @click=${checkAuth}>Retry</sp-button>
382
+ </div>
383
+ </div>
384
+ `;
385
+ }
386
+
387
+ return html`
388
+ <div class="ai-tab-body">
389
+ <div class="ai-toolbar">
390
+ ${streaming
391
+ ? html`<sp-action-button size="xs" @click=${stop}>Stop</sp-action-button>`
392
+ : nothing}
393
+ <sp-action-button size="xs" quiet @click=${newChat}>
394
+ <sp-icon-add slot="icon"></sp-icon-add>
395
+ New Chat
396
+ </sp-action-button>
397
+ </div>
398
+ <div
399
+ id="ai-quikchat"
400
+ ${ref((el) => {
401
+ _quikChatEl = /** @type {HTMLElement | null} */ (el || null);
402
+ })}
403
+ ></div>
404
+ </div>
405
+ `;
406
+ }
@@ -5,11 +5,12 @@
5
5
 
6
6
  import { html, render as litRender, nothing } from "lit-html";
7
7
  import { styleMap } from "lit-html/directives/style-map.js";
8
+ import { ref } from "lit-html/directives/ref.js";
8
9
  import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
9
10
 
10
11
  import { getNodeAtPath, nodeLabel, parentElementPath, childIndex } from "../store.js";
11
12
  import { activeTab } from "../workspace/workspace.js";
12
- import { transactDoc, mutateMoveNode } from "../tabs/transact.js";
13
+ import { transactDoc, mutateMoveNode, mutateUpdateProperty } from "../tabs/transact.js";
13
14
  import { view } from "../view.js";
14
15
  import { isEditing, getActiveElement, getInlineActions } from "../editor/inline-edit.js";
15
16
  import { toggleInlineFormat, isTagActiveInSelection } from "../editor/inline-format.js";
@@ -17,13 +18,15 @@ import { componentRegistry } from "../files/components.js";
17
18
  import { convertToComponent } from "../editor/convert-to-component.js";
18
19
  import { findCanvasElement, getActivePanel } from "../canvas/canvas-helpers.js";
19
20
  import { getLayerSlot } from "../ui/layers.js";
21
+ import { showSlashMenu } from "../editor/slash-menu.js";
22
+ import { getConvertTargets } from "../editor/convert-targets.js";
20
23
 
21
24
  /**
22
25
  * @typedef {import("../state.js").StudioState} StudioState
23
26
  *
24
27
  * @typedef {import("../state.js").JxPath} JxPath
25
28
  *
26
- * @typedef {{ command: string; tag: string; label: string; icon: string; shortcut?: string }} InlineAction
29
+ * @typedef {import("../editor/inline-edit.js").InlineAction} InlineAction
27
30
  */
28
31
 
29
32
  /**
@@ -70,9 +73,29 @@ const formatIconMap = /** @type {Record<string, import("lit-html").TemplateResul
70
73
  function onBarMousedown(e) {
71
74
  if (/** @type {HTMLElement} */ (e.target).closest("sp-textfield")) return;
72
75
  if (/** @type {HTMLElement} */ (e.target).closest(".bar-drag-handle")) return;
76
+ if (/** @type {HTMLElement} */ (e.target).closest(".bar-tag--interactive")) return;
73
77
  e.preventDefault();
74
78
  }
75
79
 
80
+ /**
81
+ * @param {MouseEvent} e
82
+ * @param {import("../editor/convert-targets.js").SlashCommand[]} targets
83
+ * @param {JxPath} selection
84
+ */
85
+ function onTagBadgeClick(e, targets, selection) {
86
+ e.stopPropagation();
87
+ const anchorEl = /** @type {HTMLElement} */ (e.currentTarget);
88
+ showSlashMenu(anchorEl, "", {
89
+ showFilter: targets.length > 6,
90
+ commands: targets,
91
+ onSelect: (cmd) => {
92
+ transactDoc(activeTab.value, (t) => {
93
+ mutateUpdateProperty(t, selection, "tagName", cmd.tag);
94
+ });
95
+ },
96
+ });
97
+ }
98
+
76
99
  /** Saved selection range for format button mousedown→click flow */
77
100
  function captureSelectionRange() {
78
101
  const sel = window.getSelection();
@@ -182,7 +205,7 @@ function applyInlineFormat(action) {
182
205
  code: "code",
183
206
  };
184
207
 
185
- const tag = cmdToTag[action.command];
208
+ const tag = action.command ? cmdToTag[action.command] : undefined;
186
209
  if (tag) {
187
210
  const editableRoot = getActiveElement();
188
211
  toggleInlineFormat(tag, editableRoot);
@@ -226,9 +249,11 @@ function showLinkPopover(anchorBtn) {
226
249
 
227
250
  const rect = anchorBtn.getBoundingClientRect();
228
251
 
252
+ /** @type {HTMLInputElement | null} */
253
+ let _linkField = null;
254
+
229
255
  const onApply = () => {
230
- const field = /** @type {HTMLInputElement | null} */ (host.querySelector("sp-textfield"));
231
- const url = field?.value || "";
256
+ const url = _linkField?.value || "";
232
257
  if (existingLink) {
233
258
  existingLink.setAttribute("href", url);
234
259
  } else if (url) {
@@ -272,6 +297,10 @@ function showLinkPopover(anchorBtn) {
272
297
  style="width:200px"
273
298
  value=${existingLink?.getAttribute("href") || ""}
274
299
  @keydown=${onKeydown}
300
+ ${ref((el) => {
301
+ _linkField = /** @type {HTMLInputElement | null} */ (el || null);
302
+ if (el) requestAnimationFrame(() => /** @type {HTMLElement} */ (el).focus());
303
+ })}
275
304
  ></sp-textfield>
276
305
  <sp-action-button size="xs" @click=${onApply}>
277
306
  ${existingLink ? "Update" : "Apply"}
@@ -283,10 +312,6 @@ function showLinkPopover(anchorBtn) {
283
312
  `,
284
313
  host,
285
314
  );
286
-
287
- requestAnimationFrame(
288
- () => /** @type {HTMLElement | null} */ (host.querySelector("sp-textfield"))?.focus(),
289
- );
290
315
  }
291
316
 
292
317
  /** Move the selected node up (swap with previous sibling). */
@@ -360,6 +385,21 @@ export function renderBlockActionBar() {
360
385
  ? actions.filter((a) => isTagActiveInSelection(a.tag, el)).map((a) => a.tag)
361
386
  : [];
362
387
 
388
+ // Conversion targets for badge click
389
+ const isComponent =
390
+ node.tagName?.includes("-") &&
391
+ componentRegistry.some((/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName);
392
+ const isEmpty =
393
+ !node.textContent &&
394
+ (!node.children ||
395
+ node.children.length === 0 ||
396
+ (Array.isArray(node.children) &&
397
+ node.children.length === 1 &&
398
+ typeof node.children[0] === "object" &&
399
+ node.children[0]?.tagName === "br"));
400
+ const convertTargets = !isComponent ? getConvertTargets(tag, isEmpty) : [];
401
+ const badgeInteractive = convertTargets.length > 0;
402
+
363
403
  litRender(
364
404
  html`
365
405
  <div
@@ -369,10 +409,34 @@ export function renderBlockActionBar() {
369
409
  >
370
410
  ${selection.length >= 2 ? renderParentSelector() : nothing}
371
411
 
372
- <span class="bar-tag">${node.$id || (node.tagName ?? "div")}</span>
412
+ <span
413
+ class="bar-tag${badgeInteractive ? " bar-tag--interactive" : ""}"
414
+ @click=${badgeInteractive
415
+ ? (/** @type {MouseEvent} */ e) => onTagBadgeClick(e, convertTargets, selection)
416
+ : nothing}
417
+ >${node.$id || (node.tagName ?? "div")}</span
418
+ >
373
419
 
374
420
  ${selection.length >= 2
375
- ? html`<span class="bar-drag-handle" title="Drag to reorder">⠿</span>`
421
+ ? html`<span
422
+ class="bar-drag-handle"
423
+ title="Drag to reorder"
424
+ ${ref((el) => {
425
+ if (!el) return;
426
+ if (view.selDragCleanup) {
427
+ view.selDragCleanup();
428
+ view.selDragCleanup = null;
429
+ }
430
+ view.selDragCleanup = draggable({
431
+ element: /** @type {HTMLElement} */ (el),
432
+ getInitialData: () => ({
433
+ type: "tree-node",
434
+ path: activeTab.value?.session.selection,
435
+ }),
436
+ });
437
+ })}
438
+ >⠿</span
439
+ >`
376
440
  : nothing}
377
441
  ${selection.length >= 2 ? renderMoveArrows() : nothing}
378
442
  ${selection.length >= 2 && node.tagName
@@ -423,7 +487,7 @@ export function renderBlockActionBar() {
423
487
  @mousedown=${captureSelectionRange}
424
488
  @click=${(/** @type {MouseEvent} */ e) => onFormatClick(e, action)}
425
489
  >
426
- ${formatIconMap[action.icon] ?? nothing}
490
+ ${action.icon ? (formatIconMap[action.icon] ?? nothing) : nothing}
427
491
  </sp-action-button>
428
492
  `,
429
493
  )}
@@ -437,27 +501,12 @@ export function renderBlockActionBar() {
437
501
 
438
502
  // Post-render side effects
439
503
  requestAnimationFrame(() => {
440
- const bar = view.blockActionBarEl?.firstElementChild;
504
+ const bar = /** @type {HTMLElement | null} */ (view.blockActionBarEl?.firstElementChild);
441
505
  if (!bar) return;
442
506
  // Clamp to window
443
507
  const barRect = bar.getBoundingClientRect();
444
508
  if (barRect.right > window.innerWidth) {
445
509
  bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
446
510
  }
447
- // Attach drag handle
448
- const currentTab = activeTab.value;
449
- if (currentTab?.session.selection && currentTab.session.selection.length >= 2) {
450
- const handle = bar.querySelector(".bar-drag-handle");
451
- if (handle) {
452
- if (view.selDragCleanup) {
453
- view.selDragCleanup();
454
- view.selDragCleanup = null;
455
- }
456
- view.selDragCleanup = draggable({
457
- element: handle,
458
- getInitialData: () => ({ type: "tree-node", path: activeTab.value?.session.selection }),
459
- });
460
- }
461
- }
462
511
  });
463
512
  }
@@ -16,11 +16,15 @@ import { effectiveZoom } from "../canvas/canvas-helpers.js";
16
16
 
17
17
  /**
18
18
  * @typedef {{
19
+ * mediaName: string | null;
20
+ * element: HTMLElement | null;
19
21
  * canvas: HTMLElement;
20
- * overlayClk: HTMLElement;
21
22
  * overlay: HTMLElement;
23
+ * overlayClk: HTMLElement;
22
24
  * viewport: HTMLElement;
25
+ * scrollContainer: HTMLElement | null;
23
26
  * dropLine: HTMLElement;
27
+ * _width: number | null;
24
28
  * }} CanvasPanel
25
29
  *
26
30
  * @typedef {(string | number)[]} JxPath
@@ -56,7 +60,9 @@ export function registerPanelDnD(panel) {
56
60
  onDrop() {
57
61
  _activeDropEl?.classList.remove("canvas-drop-target");
58
62
  _activeDropEl = null;
59
- for (const p of canvasPanels) p.dropLine.style.display = "none";
63
+ for (const p of canvasPanels) {
64
+ if (p.dropLine) p.dropLine.style.display = "none";
65
+ }
60
66
  view.lastDragInput = null;
61
67
  for (const el of canvas.querySelectorAll("*")) {
62
68
  /** @type {HTMLElement} */ (el).style.pointerEvents = "none";