@jxsuite/studio 0.7.0 → 0.9.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.
@@ -34,12 +34,16 @@ const SLASH_COMMANDS = [
34
34
  const host = document.createElement("div");
35
35
  host.style.display = "contents";
36
36
 
37
- /** @type {{ onSelect: (cmd: any) => void } | null} */
37
+ /** @type {{ onSelect: (cmd: any) => void; showFilter?: boolean } | null} */
38
38
  let callbacks = null;
39
39
  let activeIdx = 0;
40
40
  /** @type {any[]} */
41
41
  let filteredItems = [];
42
42
  let open = false;
43
+ /** @type {HTMLElement | null} */
44
+ let _anchorEl = null;
45
+ /** @type {DOMRect | null} */
46
+ let _anchorRect = null;
43
47
 
44
48
  // ─── Public API ───────────────────────────────────────────────────────────────
45
49
 
@@ -53,7 +57,7 @@ export function isSlashMenuOpen() {
53
57
  *
54
58
  * @param {HTMLElement} anchorEl — the element being edited (for positioning)
55
59
  * @param {string} filter — current typed filter text (after the "/")
56
- * @param {{ onSelect: (cmd: any) => void }} cbs
60
+ * @param {{ onSelect: (cmd: any) => void; showFilter?: boolean }} cbs
57
61
  */
58
62
  export function showSlashMenu(anchorEl, filter, cbs) {
59
63
  // Lazily attach host to sp-theme
@@ -62,6 +66,8 @@ export function showSlashMenu(anchorEl, filter, cbs) {
62
66
  }
63
67
 
64
68
  callbacks = cbs;
69
+ _anchorEl = anchorEl;
70
+ _anchorRect = anchorEl.getBoundingClientRect();
65
71
 
66
72
  filteredItems = filter
67
73
  ? SLASH_COMMANDS.filter(
@@ -69,49 +75,28 @@ export function showSlashMenu(anchorEl, filter, cbs) {
69
75
  )
70
76
  : SLASH_COMMANDS;
71
77
 
72
- if (!filteredItems.length) {
78
+ if (!filteredItems.length && !cbs.showFilter) {
73
79
  dismissSlashMenu();
74
80
  return;
75
81
  }
76
82
 
77
83
  activeIdx = 0;
78
84
 
79
- const rect = anchorEl.getBoundingClientRect();
80
-
81
- litRender(
82
- html`
83
- <sp-popover
84
- open
85
- style="position:fixed;left:${rect.left}px;top:${rect.bottom +
86
- 4}px;z-index:9999;max-height:280px;overflow-y:auto"
87
- >
88
- <sp-menu style="min-width:220px">
89
- ${filteredItems.map(
90
- (cmd, i) => html`
91
- <sp-menu-item
92
- ?focused=${i === 0}
93
- @click=${(/** @type {Event} */ e) => {
94
- e.preventDefault();
95
- e.stopPropagation();
96
- select(cmd);
97
- }}
98
- >
99
- ${cmd.label}
100
- ${cmd.description
101
- ? html`<span slot="description">${cmd.description}</span>`
102
- : nothing}
103
- </sp-menu-item>
104
- `,
105
- )}
106
- </sp-menu>
107
- </sp-popover>
108
- `,
109
- host,
110
- );
85
+ render(anchorEl, cbs.showFilter || false);
111
86
 
112
87
  if (!open) {
113
88
  open = true;
114
89
  document.addEventListener("keydown", onKeydown, true); // capture phase
90
+ requestAnimationFrame(() => {
91
+ document.addEventListener("mousedown", onOutsideClick, true);
92
+ });
93
+ }
94
+
95
+ if (cbs.showFilter) {
96
+ requestAnimationFrame(() => {
97
+ const input = /** @type {HTMLInputElement | null} */ (host.querySelector(".slash-filter"));
98
+ if (input) input.focus();
99
+ });
115
100
  }
116
101
  }
117
102
 
@@ -119,13 +104,75 @@ export function dismissSlashMenu() {
119
104
  if (!open) return;
120
105
  open = false;
121
106
  callbacks = null;
107
+ _anchorEl = null;
108
+ _anchorRect = null;
122
109
  filteredItems = [];
123
110
  document.removeEventListener("keydown", onKeydown, true);
111
+ document.removeEventListener("mousedown", onOutsideClick, true);
124
112
  litRender(nothing, host);
125
113
  }
126
114
 
127
115
  // ─── Internal ─────────────────────────────────────────────────────────────────
128
116
 
117
+ /**
118
+ * @param {HTMLElement} anchorEl
119
+ * @param {boolean} showFilter
120
+ */
121
+ function render(anchorEl, showFilter) {
122
+ const rect = _anchorRect || anchorEl.getBoundingClientRect();
123
+
124
+ litRender(
125
+ html`
126
+ <sp-popover
127
+ open
128
+ style="position:fixed;left:${rect.left}px;top:${rect.bottom +
129
+ 4}px;z-index:9999;max-height:320px;overflow-y:auto"
130
+ >
131
+ ${showFilter
132
+ ? html`<input
133
+ class="slash-filter"
134
+ type="text"
135
+ placeholder="Filter…"
136
+ autocomplete="off"
137
+ 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"
138
+ @input=${onFilterInput}
139
+ />`
140
+ : nothing}
141
+ <sp-menu style="min-width:220px">
142
+ ${filteredItems.length
143
+ ? filteredItems.map(
144
+ (cmd, i) => html`
145
+ <sp-menu-item
146
+ ?focused=${i === 0}
147
+ @click=${(/** @type {Event} */ e) => {
148
+ e.preventDefault();
149
+ e.stopPropagation();
150
+ select(cmd);
151
+ }}
152
+ >
153
+ ${cmd.label}
154
+ ${cmd.description
155
+ ? html`<span slot="description">${cmd.description}</span>`
156
+ : nothing}
157
+ </sp-menu-item>
158
+ `,
159
+ )
160
+ : html`<sp-menu-item disabled>No matches</sp-menu-item>`}
161
+ </sp-menu>
162
+ </sp-popover>
163
+ `,
164
+ host,
165
+ );
166
+ }
167
+
168
+ /** @param {MouseEvent} e */
169
+ function onOutsideClick(e) {
170
+ const popover = host.querySelector("sp-popover");
171
+ if (popover && !popover.contains(/** @type {Node} */ (e.target))) {
172
+ dismissSlashMenu();
173
+ }
174
+ }
175
+
129
176
  /** @param {any} cmd */
130
177
  function select(cmd) {
131
178
  const cbs = callbacks;
@@ -133,16 +180,42 @@ function select(cmd) {
133
180
  cbs?.onSelect(cmd);
134
181
  }
135
182
 
183
+ /** @param {Event} e */
184
+ function onFilterInput(e) {
185
+ const input = /** @type {HTMLInputElement} */ (e.target);
186
+ const filter = input.value.toLowerCase();
187
+
188
+ filteredItems = filter
189
+ ? SLASH_COMMANDS.filter(
190
+ (c) => c.label.toLowerCase().includes(filter) || c.tag.toLowerCase().includes(filter),
191
+ )
192
+ : SLASH_COMMANDS;
193
+
194
+ activeIdx = 0;
195
+ if (_anchorEl) render(_anchorEl, true);
196
+
197
+ // Re-focus input after re-render
198
+ requestAnimationFrame(() => {
199
+ const el = /** @type {HTMLInputElement | null} */ (host.querySelector(".slash-filter"));
200
+ if (el && el !== document.activeElement) {
201
+ el.focus();
202
+ el.selectionStart = el.selectionEnd = el.value.length;
203
+ }
204
+ });
205
+ }
206
+
136
207
  /** @param {KeyboardEvent} e */
137
208
  function onKeydown(e) {
138
209
  if (!open) return;
139
210
 
140
- const items = /** @type {NodeListOf<Element>} */ (host.querySelectorAll("sp-menu-item"));
141
- if (!items.length) return;
211
+ const items = /** @type {NodeListOf<Element>} */ (
212
+ host.querySelectorAll("sp-menu-item:not([disabled])")
213
+ );
142
214
 
143
215
  if (e.key === "ArrowDown") {
144
216
  e.preventDefault();
145
217
  e.stopPropagation();
218
+ if (!items.length) return;
146
219
  items[activeIdx]?.removeAttribute("focused");
147
220
  activeIdx = (activeIdx + 1) % items.length;
148
221
  items[activeIdx]?.setAttribute("focused", "");
@@ -150,6 +223,7 @@ function onKeydown(e) {
150
223
  } else if (e.key === "ArrowUp") {
151
224
  e.preventDefault();
152
225
  e.stopPropagation();
226
+ if (!items.length) return;
153
227
  items[activeIdx]?.removeAttribute("focused");
154
228
  activeIdx = (activeIdx - 1 + items.length) % items.length;
155
229
  items[activeIdx]?.setAttribute("focused", "");
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Block action bar — extracted from studio.js (Phase 4h). Floating toolbar above selected elements
3
+ * with parent selector, move arrows, drag handle, component actions, and inline formatting.
4
+ */
5
+
6
+ import { html, render as litRender, nothing } from "lit-html";
7
+ import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
8
+
9
+ import {
10
+ getState,
11
+ update,
12
+ updateSession,
13
+ renderOnly,
14
+ selectNode,
15
+ moveNode,
16
+ getNodeAtPath,
17
+ nodeLabel,
18
+ parentElementPath,
19
+ childIndex,
20
+ } from "../store.js";
21
+ import { view } from "../view.js";
22
+ import { isEditing, getActiveElement, getInlineActions } from "../editor/inline-edit.js";
23
+ import { toggleInlineFormat, isTagActiveInSelection } from "../editor/inline-format.js";
24
+ import { componentRegistry } from "../files/components.js";
25
+ import { convertToComponent } from "../editor/convert-to-component.js";
26
+
27
+ /** @type {any} */
28
+ let _ctx = null;
29
+
30
+ /**
31
+ * Initialize the block action bar module.
32
+ *
33
+ * @param {{
34
+ * getCanvasMode: () => string;
35
+ * findCanvasElement: Function;
36
+ * getActivePanel: Function;
37
+ * navigateToComponent: Function;
38
+ * createFloatingContainer: Function;
39
+ * }} ctx
40
+ */
41
+ export function initBlockActionBar(ctx) {
42
+ _ctx = ctx;
43
+ view.linkPopoverHost = document.createElement("div");
44
+ view.linkPopoverHost.style.display = "contents";
45
+ (document.querySelector("sp-theme") || document.body).appendChild(view.linkPopoverHost);
46
+ }
47
+
48
+ /** Pre-built icon templates for inline format buttons (avoids unsafeStatic) */
49
+ const formatIconMap = /** @type {Record<string, any>} */ ({
50
+ "sp-icon-text-bold": html`<sp-icon-text-bold slot="icon"></sp-icon-text-bold>`,
51
+ "sp-icon-text-italic": html`<sp-icon-text-italic slot="icon"></sp-icon-text-italic>`,
52
+ "sp-icon-text-underline": html`<sp-icon-text-underline slot="icon"></sp-icon-text-underline>`,
53
+ "sp-icon-text-strikethrough": html`<sp-icon-text-strikethrough
54
+ slot="icon"
55
+ ></sp-icon-text-strikethrough>`,
56
+ "sp-icon-text-superscript": html`<sp-icon-text-superscript
57
+ slot="icon"
58
+ ></sp-icon-text-superscript>`,
59
+ "sp-icon-text-subscript": html`<sp-icon-text-subscript slot="icon"></sp-icon-text-subscript>`,
60
+ "sp-icon-code": html`<sp-icon-code slot="icon"></sp-icon-code>`,
61
+ "sp-icon-link": html`<sp-icon-link slot="icon"></sp-icon-link>`,
62
+ });
63
+
64
+ /**
65
+ * Prevent the bar from stealing focus from contenteditable
66
+ *
67
+ * @param {any} e
68
+ */
69
+ function onBarMousedown(e) {
70
+ if (e.target.closest("sp-textfield")) return;
71
+ if (e.target.closest(".bar-drag-handle")) return;
72
+ e.preventDefault();
73
+ }
74
+
75
+ /** Saved selection range for format button mousedown→click flow */
76
+ function captureSelectionRange() {
77
+ const sel = window.getSelection();
78
+ if (sel && sel.rangeCount) view.savedRange = sel.getRangeAt(0).cloneRange();
79
+ }
80
+
81
+ /**
82
+ * @param {any} e
83
+ * @param {any} action
84
+ */
85
+ function onFormatClick(e, action) {
86
+ e.stopPropagation();
87
+ if (action.command === "link") {
88
+ showLinkPopover(e.target.closest("sp-action-button"));
89
+ } else if (view.savedRange) {
90
+ const sel = /** @type {any} */ (window.getSelection());
91
+ const anchor = view.savedRange.startContainer;
92
+ const editableRoot = (
93
+ anchor?.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement
94
+ )?.closest("[contenteditable]");
95
+ if (editableRoot) {
96
+ editableRoot.focus();
97
+ sel.removeAllRanges();
98
+ sel.addRange(view.savedRange);
99
+ applyInlineFormat(action);
100
+ }
101
+ }
102
+ }
103
+
104
+ function renderParentSelector() {
105
+ const S = getState();
106
+ const pPath = parentElementPath(S.selection);
107
+ if (!pPath) return nothing;
108
+ const parentNode = getNodeAtPath(S.document, pPath);
109
+ return html`
110
+ <sp-action-button
111
+ size="xs"
112
+ quiet
113
+ title="Select parent: ${nodeLabel(parentNode)}"
114
+ @click=${(/** @type {any} */ e) => {
115
+ e.stopPropagation();
116
+ const S = getState();
117
+ update(selectNode(S, pPath));
118
+ }}
119
+ >
120
+ <sp-icon-back slot="icon"></sp-icon-back>
121
+ </sp-action-button>
122
+ `;
123
+ }
124
+
125
+ function renderMoveArrows() {
126
+ const S = getState();
127
+ const idx = /** @type {number} */ (childIndex(S.selection));
128
+ const pPath = parentElementPath(S.selection);
129
+ const parentNode = getNodeAtPath(S.document, /** @type {any} */ (pPath));
130
+ const siblings = parentNode?.children;
131
+ return html`
132
+ <sp-action-button
133
+ size="xs"
134
+ quiet
135
+ title="Move up"
136
+ ?disabled=${idx <= 0}
137
+ @click=${(/** @type {any} */ e) => {
138
+ e.stopPropagation();
139
+ moveSelectionUp();
140
+ }}
141
+ >
142
+ <sp-icon-arrow-up slot="icon"></sp-icon-arrow-up>
143
+ </sp-action-button>
144
+ <sp-action-button
145
+ size="xs"
146
+ quiet
147
+ title="Move down"
148
+ ?disabled=${!siblings || idx >= siblings.length - 1}
149
+ @click=${(/** @type {any} */ e) => {
150
+ e.stopPropagation();
151
+ moveSelectionDown();
152
+ }}
153
+ >
154
+ <sp-icon-arrow-down slot="icon"></sp-icon-arrow-down>
155
+ </sp-action-button>
156
+ `;
157
+ }
158
+
159
+ /**
160
+ * Apply an inline format action.
161
+ *
162
+ * @param {any} action
163
+ */
164
+ function applyInlineFormat(action) {
165
+ /** @type {Record<string, any>} */
166
+ const cmdToTag = {
167
+ bold: "strong",
168
+ italic: "em",
169
+ underline: "u",
170
+ strikethrough: "del",
171
+ superscript: "sup",
172
+ subscript: "sub",
173
+ code: "code",
174
+ };
175
+
176
+ const tag = cmdToTag[action.command];
177
+ if (tag) {
178
+ const editableRoot = getActiveElement();
179
+ toggleInlineFormat(tag, editableRoot);
180
+ }
181
+ requestAnimationFrame(() => renderBlockActionBar());
182
+ }
183
+
184
+ /** Dismiss the link popover if open. */
185
+ export function dismissLinkPopover() {
186
+ if (view.linkPopoverHost) litRender(nothing, view.linkPopoverHost);
187
+ }
188
+
189
+ /** @param {any} anchorBtn */
190
+ function showLinkPopover(anchorBtn) {
191
+ litRender(nothing, view.linkPopoverHost);
192
+
193
+ const sel = window.getSelection();
194
+ /** @type {any} */
195
+ let existingLink = null;
196
+ if (sel?.rangeCount) {
197
+ /** @type {any} */
198
+ let node = sel.anchorNode;
199
+ while (node && node !== document.body) {
200
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === "a") {
201
+ existingLink = node;
202
+ break;
203
+ }
204
+ node = node.parentNode;
205
+ }
206
+ }
207
+
208
+ const rect = anchorBtn.getBoundingClientRect();
209
+
210
+ const onApply = () => {
211
+ const field = view.linkPopoverHost.querySelector("sp-textfield");
212
+ const url = /** @type {any} */ (field)?.value;
213
+ if (existingLink) {
214
+ existingLink.setAttribute("href", url);
215
+ } else if (url) {
216
+ document.execCommand("createLink", false, url);
217
+ }
218
+ litRender(nothing, view.linkPopoverHost);
219
+ renderBlockActionBar();
220
+ };
221
+
222
+ const onRemove = () => {
223
+ const frag = document.createDocumentFragment();
224
+ while (existingLink.firstChild) frag.appendChild(existingLink.firstChild);
225
+ existingLink.parentNode.replaceChild(frag, existingLink);
226
+ litRender(nothing, view.linkPopoverHost);
227
+ renderBlockActionBar();
228
+ };
229
+
230
+ const onKeydown = (/** @type {any} */ e) => {
231
+ if (e.key === "Enter") onApply();
232
+ else if (e.key === "Escape") {
233
+ litRender(nothing, view.linkPopoverHost);
234
+ }
235
+ };
236
+
237
+ litRender(
238
+ html`
239
+ <sp-popover
240
+ class="link-popover"
241
+ open
242
+ style="position:fixed; left:${rect.left}px; top:${rect.bottom + 4}px; z-index:30"
243
+ >
244
+ <sp-textfield
245
+ placeholder="https://..."
246
+ size="s"
247
+ style="width:200px"
248
+ value=${existingLink?.getAttribute("href") || ""}
249
+ @keydown=${onKeydown}
250
+ ></sp-textfield>
251
+ <sp-action-button size="xs" @click=${onApply}>
252
+ ${existingLink ? "Update" : "Apply"}
253
+ </sp-action-button>
254
+ ${existingLink
255
+ ? html` <sp-action-button size="xs" @click=${onRemove}>Remove</sp-action-button> `
256
+ : nothing}
257
+ </sp-popover>
258
+ `,
259
+ view.linkPopoverHost,
260
+ );
261
+
262
+ requestAnimationFrame(
263
+ () =>
264
+ /** @type {HTMLElement | null} */ (
265
+ view.linkPopoverHost?.querySelector("sp-textfield")
266
+ )?.focus(),
267
+ );
268
+ }
269
+
270
+ /** Move the selected node up (swap with previous sibling). */
271
+ function moveSelectionUp() {
272
+ const S = getState();
273
+ if (!S.selection || S.selection.length < 2) return;
274
+ const idx = /** @type {number} */ (childIndex(S.selection));
275
+ if (idx <= 0) return;
276
+ const pPath = /** @type {any} */ (parentElementPath(S.selection));
277
+ update(moveNode(S, S.selection, pPath, idx - 1));
278
+ updateSession({ selection: [...pPath, "children", idx - 1] });
279
+ renderOnly("overlays");
280
+ }
281
+
282
+ /** Move the selected node down (swap with next sibling). */
283
+ function moveSelectionDown() {
284
+ const S = getState();
285
+ if (!S.selection || S.selection.length < 2) return;
286
+ const idx = /** @type {number} */ (childIndex(S.selection));
287
+ const pPath = /** @type {any} */ (parentElementPath(S.selection));
288
+ const parentNode = getNodeAtPath(S.document, pPath);
289
+ const siblings = parentNode?.children;
290
+ if (!siblings || idx >= siblings.length - 1) return;
291
+ update(moveNode(S, S.selection, pPath, idx + 2));
292
+ updateSession({ selection: [...pPath, "children", idx + 1] });
293
+ renderOnly("overlays");
294
+ }
295
+
296
+ /** Render the unified block action bar above the selected element. */
297
+ export function renderBlockActionBar() {
298
+ if (!view.blockActionBarEl) {
299
+ view.blockActionBarEl = _ctx.createFloatingContainer();
300
+ }
301
+
302
+ if (view.selDragCleanup) {
303
+ view.selDragCleanup();
304
+ view.selDragCleanup = null;
305
+ }
306
+
307
+ const S = getState();
308
+ const canvasMode = _ctx.getCanvasMode();
309
+
310
+ if (!S.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
311
+ litRender(nothing, view.blockActionBarEl);
312
+ return;
313
+ }
314
+
315
+ const activePanel = _ctx.getActivePanel();
316
+ if (!activePanel) {
317
+ litRender(nothing, view.blockActionBarEl);
318
+ return;
319
+ }
320
+ const el = _ctx.findCanvasElement(S.selection, activePanel.canvas);
321
+ const node = el && getNodeAtPath(S.document, S.selection);
322
+ if (!el || !node) {
323
+ litRender(nothing, view.blockActionBarEl);
324
+ return;
325
+ }
326
+
327
+ const tag = (node.tagName ?? "div").toLowerCase();
328
+ const elRect = el.getBoundingClientRect();
329
+ const topPos = elRect.top < 80 ? elRect.bottom + 4 : elRect.top - 38;
330
+
331
+ // Inline format state
332
+ const inlineEditing = isEditing() || el.contentEditable === "true";
333
+ const actions = getInlineActions(tag) || [];
334
+ const showFormat = inlineEditing && actions.length > 0;
335
+ const activeValues = showFormat
336
+ ? actions.filter((a) => isTagActiveInSelection(a.tag, el)).map((a) => a.tag)
337
+ : [];
338
+
339
+ litRender(
340
+ html`
341
+ <div
342
+ class="block-action-bar"
343
+ style="left:${elRect.left}px; top:${topPos}px"
344
+ @mousedown=${onBarMousedown}
345
+ >
346
+ ${S.selection.length >= 2 ? renderParentSelector() : nothing}
347
+
348
+ <span class="bar-tag">${node.$id || (node.tagName ?? "div")}</span>
349
+
350
+ ${S.selection.length >= 2
351
+ ? html`<span class="bar-drag-handle" title="Drag to reorder">⡇</span>`
352
+ : nothing}
353
+ ${S.selection.length >= 2 ? renderMoveArrows() : nothing}
354
+ ${S.selection.length >= 2 && node.tagName
355
+ ? (() => {
356
+ const isComp =
357
+ node.tagName.includes("-") &&
358
+ componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
359
+ if (isComp) {
360
+ const comp = componentRegistry.find(
361
+ (/** @type {any} */ c) => c.tagName === node.tagName,
362
+ );
363
+ return html`<sp-action-button
364
+ size="xs"
365
+ quiet
366
+ title="Edit Component"
367
+ @click=${() => _ctx.navigateToComponent(comp.path)}
368
+ ><sp-icon-edit slot="icon" size="xs"></sp-icon-edit
369
+ ></sp-action-button>`;
370
+ }
371
+ return html`<sp-action-button
372
+ size="xs"
373
+ quiet
374
+ title="Convert to Component"
375
+ @click=${() => convertToComponent(S)}
376
+ ><sp-icon-box slot="icon" size="xs"></sp-icon-box
377
+ ></sp-action-button>`;
378
+ })()
379
+ : nothing}
380
+ ${showFormat
381
+ ? html`
382
+ <sp-divider size="s" vertical></sp-divider>
383
+ <sp-action-group
384
+ size="xs"
385
+ compact
386
+ emphasized
387
+ selects="multiple"
388
+ selected=${activeValues.length ? JSON.stringify(activeValues) : nothing}
389
+ >
390
+ ${actions.map(
391
+ (action) => html`
392
+ <sp-action-button
393
+ size="xs"
394
+ value=${action.tag}
395
+ title="${action.label}${action.shortcut ? ` (${action.shortcut})` : ""}"
396
+ @mousedown=${captureSelectionRange}
397
+ @click=${(/** @type {any} */ e) => onFormatClick(e, action)}
398
+ >
399
+ ${formatIconMap[action.icon] ?? nothing}
400
+ </sp-action-button>
401
+ `,
402
+ )}
403
+ </sp-action-group>
404
+ `
405
+ : nothing}
406
+ </div>
407
+ `,
408
+ view.blockActionBarEl,
409
+ );
410
+
411
+ // Post-render side effects
412
+ requestAnimationFrame(() => {
413
+ const bar = view.blockActionBarEl?.firstElementChild;
414
+ if (!bar) return;
415
+ // Clamp to window
416
+ const barRect = bar.getBoundingClientRect();
417
+ if (barRect.right > window.innerWidth) {
418
+ bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
419
+ }
420
+ // Attach drag handle
421
+ const currentS = getState();
422
+ if (currentS.selection?.length >= 2) {
423
+ const handle = bar.querySelector(".bar-drag-handle");
424
+ if (handle) {
425
+ if (view.selDragCleanup) {
426
+ view.selDragCleanup();
427
+ view.selDragCleanup = null;
428
+ }
429
+ view.selDragCleanup = draggable({
430
+ element: handle,
431
+ getInitialData: () => ({ type: "tree-node", path: getState().selection }),
432
+ });
433
+ }
434
+ }
435
+ });
436
+ }