@jxsuite/studio 0.33.0 → 0.34.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 (85) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +10754 -11060
  4. package/dist/studio.js.map +83 -72
  5. package/package.json +7 -7
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +5 -7
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +15 -0
  58. package/src/services/monaco-setup.ts +12 -0
  59. package/src/services/render-critic.ts +9 -9
  60. package/src/settings/css-vars-editor.ts +2 -2
  61. package/src/store.ts +4 -62
  62. package/src/studio.ts +90 -40
  63. package/src/tabs/doc-op-apply.ts +89 -0
  64. package/src/tabs/patch-ops.ts +6 -2
  65. package/src/tabs/tab.ts +23 -4
  66. package/src/tabs/transact.ts +2 -74
  67. package/src/types.ts +14 -18
  68. package/src/ui/jx-theme.ts +63 -0
  69. package/src/ui/media-picker.ts +6 -4
  70. package/src/ui/spectrum.ts +5 -0
  71. package/src/utils/canvas-media.ts +0 -137
  72. package/src/utils/edit-display.ts +23 -3
  73. package/src/utils/geometry.ts +43 -0
  74. package/src/utils/link-target.ts +93 -0
  75. package/src/utils/strip-events.ts +54 -0
  76. package/src/view.ts +0 -23
  77. package/src/workspace/workspace.ts +14 -1
  78. package/src/canvas/canvas-subtree-render.ts +0 -113
  79. package/src/editor/component-inline-edit.ts +0 -349
  80. package/src/editor/content-inline-edit.ts +0 -207
  81. package/src/editor/insertion-helper.ts +0 -308
  82. package/src/panels/canvas-dnd.ts +0 -329
  83. package/src/panels/panel-events.ts +0 -306
  84. package/src/panels/preview-render.ts +0 -132
  85. package/src/panels/pseudo-preview.ts +0 -75
@@ -8,11 +8,12 @@ import { html, render as litRender, nothing } from "lit-html";
8
8
  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
+ import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
11
12
 
12
13
  import {
14
+ canvasWrap,
13
15
  childIndex,
14
16
  childList,
15
- elToScope,
16
17
  getNodeAtPath,
17
18
  nodeLabel,
18
19
  parentElementPath,
@@ -20,21 +21,27 @@ import {
20
21
  import { activeTab } from "../workspace/workspace";
21
22
  import { mutateMoveNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
22
23
  import { view } from "../view";
23
- import { getActiveElement, getInlineActions, isEditing } from "../editor/inline-edit";
24
+ import { getInlineActions } from "../editor/inline-edit";
24
25
  import type { InlineAction } from "../editor/inline-edit";
25
- import { isTagActiveInSelection, toggleInlineFormat } from "../editor/inline-format";
26
- import { buildMergeTags } from "../editor/merge-tags";
26
+ import { buildMergeTags, buildRepeaterTagsFromFields } from "../editor/merge-tags";
27
+ import { findEnclosingRepeater, resolveRepeaterItemFields } from "../editor/repeater-scope";
28
+ import { projectState } from "../state";
27
29
  import { componentRegistry } from "../files/components";
28
30
  import { convertToComponent } from "../editor/convert-to-component";
29
- import { findCanvasElement, getActivePanel } from "../canvas/canvas-helpers";
31
+ import { getEditBarAnchorRect, getEditSnapshot, postApplyFormat } from "../canvas/iframe-host";
30
32
  import { getLayerSlot } from "../ui/layers";
31
33
  import { showSlashMenu } from "../editor/slash-menu";
32
34
  import { getConvertTargets } from "../editor/convert-targets";
35
+ import { rectOf } from "../utils/geometry";
33
36
 
37
+ import type { ApplyFormatIntent } from "../canvas/iframe-protocol";
34
38
  import type { JxPath } from "../state";
35
39
  import type { TemplateResult } from "lit-html";
36
40
  import type { SlashCommand } from "../editor/convert-targets.js";
37
41
 
42
+ /** The plain format commands (everything an action button posts except link/insertData). */
43
+ type FormatCommand = Extract<ApplyFormatIntent, { command: "bold" }>["command"];
44
+
38
45
  /**
39
46
  * @type {{
40
47
  * getCanvasMode: () => string;
@@ -59,6 +66,149 @@ export function initBlockActionBar(ctx: {
59
66
  navigateToComponent: (path: string) => void;
60
67
  }) {
61
68
  _ctx = ctx;
69
+ if (!_formatShortcutBound) {
70
+ document.addEventListener("keydown", handleParentFormatShortcut);
71
+ // `scroll` doesn't bubble but IS deliverable capture-phase at the document — one app-lifetime
72
+ // Listener covers .content-edit-canvas (edit mode), window scrolls, and any future scroll
73
+ // Container, keeping the position:fixed bar tracking its (scrolling) anchor.
74
+ document.addEventListener("scroll", onCanvasScroll, { capture: true, passive: true });
75
+ _formatShortcutBound = true;
76
+ }
77
+ }
78
+
79
+ /** Register the parent-document format-shortcut + scroll handlers exactly once. */
80
+ let _formatShortcutBound = false;
81
+
82
+ /** One reposition per frame, no matter how many scroll events land in it. */
83
+ let _scrollRafPending = false;
84
+
85
+ /**
86
+ * Reposition the bar on a canvas-area scroll — rAF-throttled and via the style fast path ({@link
87
+ * repositionBlockActionBar}), NOT a full lit re-render (which would tear down and re-create the
88
+ * drag-handle's pragmatic-dnd registration every frame). Exported for tests.
89
+ *
90
+ * @param {Event} e
91
+ */
92
+ export function onCanvasScroll(e: Event): void {
93
+ if (!_ctx || _linkPopoverOpen) {
94
+ return;
95
+ }
96
+ if (!activeTab.value?.session.selection) {
97
+ return;
98
+ }
99
+ const mode = _ctx.getCanvasMode();
100
+ if (mode !== "design" && mode !== "edit") {
101
+ return;
102
+ }
103
+ // Only canvas-area scrolls (or the window itself) move the anchor; left/right panel scrolling
104
+ // Never does.
105
+ const isCanvasScroll =
106
+ e.target === document ||
107
+ (canvasWrap != null && e.target instanceof Node && canvasWrap.contains(e.target));
108
+ if (!isCanvasScroll || _scrollRafPending) {
109
+ return;
110
+ }
111
+ _scrollRafPending = true;
112
+ requestAnimationFrame(() => {
113
+ _scrollRafPending = false;
114
+ repositionBlockActionBar();
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Style-only fast path: move the existing bar to the anchor's current position (fresh
120
+ * {@link getEditBarAnchorRect} — the iframe's GBCR moves with the scroll). Hides via `visibility`
121
+ * when the anchor left the canvas area so the lit tree (and the drag handle's dnd registration)
122
+ * survives; a bar hidden by the full render path (`nothing`) is resurrected with a full render.
123
+ */
124
+ function repositionBlockActionBar(): void {
125
+ const bar = view.blockActionBarEl?.firstElementChild as HTMLElement | null;
126
+ if (!bar) {
127
+ renderBlockActionBar();
128
+ return;
129
+ }
130
+ const anchor = getEditBarAnchorRect();
131
+ const pos = anchor ? barPosition(anchor) : null;
132
+ if (!pos) {
133
+ bar.style.visibility = "hidden";
134
+ return;
135
+ }
136
+ bar.style.visibility = "";
137
+ bar.style.left = `${pos.left}px`;
138
+ bar.style.top = `${pos.top}px`;
139
+ clampBarToWindow(bar);
140
+ }
141
+
142
+ /**
143
+ * The bar's position for `anchor` (flipping below the element near the top edge), or null when the
144
+ * anchor's vertical extent has left the canvas area — the popover layer has no clipping, so a
145
+ * clamped bar would float detached over the app toolbar / panel headers.
146
+ *
147
+ * @param {{ left: number; top: number; height: number }} anchor
148
+ */
149
+ function barPosition(anchor: {
150
+ left: number;
151
+ top: number;
152
+ height: number;
153
+ }): { left: number; top: number } | null {
154
+ if (canvasWrap) {
155
+ const wrap = rectOf(canvasWrap);
156
+ if (anchor.top + anchor.height < wrap.top || anchor.top > wrap.bottom) {
157
+ return null;
158
+ }
159
+ }
160
+ return {
161
+ left: anchor.left,
162
+ top: anchor.top < 80 ? anchor.top + anchor.height + 4 : anchor.top - 38,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Pull the bar back inside the window's right edge.
168
+ *
169
+ * @param {HTMLElement} bar
170
+ */
171
+ function clampBarToWindow(bar: HTMLElement): void {
172
+ const barRect = rectOf(bar);
173
+ if (barRect.right > window.innerWidth) {
174
+ bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Route Ctrl/Cmd+B/I/`/K to the iframe while an inline-edit session is live but focus is on the
180
+ * PARENT (the format toolbar or its link popover) — the keystroke never reaches the iframe's own
181
+ * contenteditable handler. When focus is inside the canvas iframe, do nothing (the iframe handles
182
+ * it and forwards globals via `forwardKey`). Exported so the unit test can dispatch it directly.
183
+ *
184
+ * @param {KeyboardEvent} e
185
+ */
186
+ export function handleParentFormatShortcut(e: KeyboardEvent): void {
187
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) {
188
+ return;
189
+ }
190
+ if (!getEditSnapshot().editing) {
191
+ return;
192
+ }
193
+ // Focus inside the cross-origin canvas iframe surfaces as the <iframe> element being active.
194
+ const active = document.activeElement;
195
+ if (active instanceof HTMLIFrameElement && active.classList.contains("jx-canvas-iframe")) {
196
+ return;
197
+ }
198
+ const key = e.key.toLowerCase();
199
+ if (key === "b") {
200
+ e.preventDefault();
201
+ postApplyFormat({ command: "bold" });
202
+ } else if (key === "i") {
203
+ e.preventDefault();
204
+ postApplyFormat({ command: "italic" });
205
+ } else if (key === "`") {
206
+ e.preventDefault();
207
+ postApplyFormat({ command: "code" });
208
+ } else if (key === "k") {
209
+ e.preventDefault();
210
+ openLinkPopoverFromShortcut();
211
+ }
62
212
  }
63
213
 
64
214
  /** Pre-built icon templates for inline format buttons (avoids unsafeStatic) */
@@ -114,15 +264,10 @@ function onTagBadgeClick(e: MouseEvent, targets: SlashCommand[], selection: JxPa
114
264
  });
115
265
  }
116
266
 
117
- /** Saved selection range for format button mousedown→click flow */
118
- function captureSelectionRange() {
119
- const sel = window.getSelection();
120
- if (sel && sel.rangeCount) {
121
- view.savedRange = sel.getRangeAt(0).cloneRange();
122
- }
123
- }
124
-
125
267
  /**
268
+ * Handle a format-button click. The iframe owns the Selection — link opens the parent popover;
269
+ * every other command posts an `applyFormat` intent across the bridge.
270
+ *
126
271
  * @param {MouseEvent} e
127
272
  * @param {InlineAction} action
128
273
  */
@@ -130,18 +275,8 @@ function onFormatClick(e: MouseEvent, action: InlineAction) {
130
275
  e.stopPropagation();
131
276
  if (action.command === "link") {
132
277
  showLinkPopover((e.target as HTMLElement).closest("sp-action-button") as HTMLElement);
133
- } else if (view.savedRange) {
134
- const sel = window.getSelection();
135
- const anchor = view.savedRange.startContainer;
136
- const editableRoot = (
137
- anchor?.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor?.parentElement
138
- )?.closest("[contenteditable]");
139
- if (editableRoot) {
140
- (editableRoot as HTMLElement).focus();
141
- sel?.removeAllRanges();
142
- sel?.addRange(view.savedRange);
143
- applyInlineFormat(action);
144
- }
278
+ } else if (action.command) {
279
+ postApplyFormat({ command: action.command as FormatCommand });
145
280
  }
146
281
  }
147
282
 
@@ -208,33 +343,10 @@ function renderMoveArrows() {
208
343
  `;
209
344
  }
210
345
 
211
- /**
212
- * Apply an inline format action.
213
- *
214
- * @param {InlineAction} action
215
- */
216
- function applyInlineFormat(action: InlineAction) {
217
- const cmdToTag: Record<string, string> = {
218
- bold: "strong",
219
- code: "code",
220
- italic: "em",
221
- strikethrough: "del",
222
- subscript: "sub",
223
- superscript: "sup",
224
- underline: "u",
225
- };
226
-
227
- const tag = action.command ? cmdToTag[action.command] : undefined;
228
- if (tag) {
229
- const editableRoot = getActiveElement();
230
- toggleInlineFormat(tag, editableRoot);
231
- }
232
- requestAnimationFrame(() => renderBlockActionBar());
233
- }
234
-
235
346
  /**
236
347
  * Open the merge-tag menu — a searchable list of `${…}` template tokens for the data available in
237
348
  * the current state. Reuses the shared slash-menu popover (filter + keyboard nav + dismiss).
349
+ * Selecting a token posts an `insertData` intent the iframe applies at its caret.
238
350
  *
239
351
  * @param {MouseEvent} e
240
352
  */
@@ -242,12 +354,26 @@ function onMergeTagClick(e: MouseEvent) {
242
354
  e.stopPropagation();
243
355
  const anchorEl = e.currentTarget as HTMLElement;
244
356
  const tab = activeTab.value;
245
- const editable = getActiveElement();
246
357
  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;
358
+ // The live resolved scope lives inside the iframe realm and is not threaded out, so the parent
359
+ // Offers only top-level `state.*` tokens (buildMergeTags tolerates the null scopes).
360
+ const tags = buildMergeTags(state, null, null);
361
+
362
+ // When the caret sits inside a repeater, offer its local scope (item/index + item fields). There is
363
+ // No live `$map` in edit mode — the perimeter renders one glyph template — so fields resolve
364
+ // Parent-side from schema, keyed off the selected element's doc path (which carries a `map` segment).
365
+ const selPath = getEditSnapshot().snapshot?.path ?? tab?.session.selection;
366
+ const arrayNode = tab && selPath ? findEnclosingRepeater(tab.doc.document, selPath) : null;
367
+ if (arrayNode) {
368
+ const tokens = resolveRepeaterItemFields(
369
+ arrayNode,
370
+ tab!.doc.document.state as Record<string, unknown>,
371
+ projectState?.projectConfig,
372
+ );
373
+ tags.push(...buildRepeaterTagsFromFields(tokens));
374
+ }
249
375
 
250
- const commands = buildMergeTags(state, scope, localScope).map((t) => ({
376
+ const commands = tags.map((t) => ({
251
377
  description: t.hint,
252
378
  label: t.label,
253
379
  tag: t.token,
@@ -255,39 +381,14 @@ function onMergeTagClick(e: MouseEvent) {
255
381
 
256
382
  showSlashMenu(anchorEl, "", {
257
383
  commands,
258
- onSelect: (cmd) => insertMergeTag(cmd.tag),
384
+ onSelect: (cmd) => postApplyFormat({ command: "insertData", token: cmd.tag }),
259
385
  showFilter: true,
260
386
  });
261
387
  }
262
388
 
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
-
289
389
  /** Dismiss the link popover if open. */
290
390
  export function dismissLinkPopover() {
391
+ _linkPopoverOpen = false;
291
392
  const host = getLayerSlot("popover", "link-popover");
292
393
  litRender(nothing, host);
293
394
  }
@@ -299,60 +400,81 @@ export function dismissBlockActionBar() {
299
400
  }
300
401
  }
301
402
 
302
- /** @param {HTMLElement} anchorBtn */
403
+ /**
404
+ * Whether the link popover is open. A snapshot-driven {@link renderBlockActionBar} must NOT
405
+ * re-render (and so re-mount) the open popover — typing the URL would re-create the field and lose
406
+ * focus/caret. Guarded around the toolbar re-render.
407
+ */
408
+ let _linkPopoverOpen = false;
409
+
410
+ /** True while the link URL popover is open (so the toolbar refresh skips a disruptive re-render). */
411
+ export function isLinkPopoverOpen(): boolean {
412
+ return _linkPopoverOpen;
413
+ }
414
+
415
+ /**
416
+ * Whether `target` sits inside the edit-session chrome — the block action bar, its link popover, or
417
+ * the slash menu. The parent's pointerdown commit-guard must NOT end the inline-edit session for
418
+ * clicks on these (they operate ON the session); any other parent-chrome press commits it. This
419
+ * module owns those popover roots, hence the helper lives here.
420
+ *
421
+ * @param {EventTarget | null} target
422
+ */
423
+ export function isEditChromeTarget(target: EventTarget | null): boolean {
424
+ if (!(target instanceof Node)) {
425
+ return false;
426
+ }
427
+ const roots = [
428
+ view.blockActionBarEl,
429
+ getLayerSlot("popover", "link-popover"),
430
+ getLayerSlot("popover", "slash-menu"),
431
+ ];
432
+ return roots.some((root) => root != null && root.contains(target));
433
+ }
434
+
435
+ /**
436
+ * Show the link URL popover. The iframe owns the Selection, so the existing-link state comes from
437
+ * the latest selection snapshot; Apply/Remove post `applyFormat` link intents the iframe applies.
438
+ *
439
+ * @param {HTMLElement} anchorBtn
440
+ */
303
441
  function showLinkPopover(anchorBtn: HTMLElement) {
304
442
  const host = getLayerSlot("popover", "link-popover");
305
443
  litRender(nothing, host);
306
444
 
307
- const sel = window.getSelection();
308
- let existingLink: HTMLAnchorElement | null = null;
309
- if (sel?.rangeCount) {
310
- let node: Node | null = sel.anchorNode;
311
- while (node && node !== document.body) {
312
- if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "a") {
313
- existingLink = node as HTMLAnchorElement;
314
- break;
315
- }
316
- node = node.parentNode;
317
- }
318
- }
445
+ const link = getEditSnapshot().snapshot?.link ?? { active: false, href: null };
446
+ const existing = link.active;
319
447
 
320
- const rect = anchorBtn.getBoundingClientRect();
448
+ const rect = rectOf(anchorBtn);
321
449
 
322
450
  let _linkField: HTMLInputElement | null = null;
323
451
 
452
+ const close = () => {
453
+ _linkPopoverOpen = false;
454
+ litRender(nothing, host);
455
+ };
456
+
324
457
  const onApply = () => {
325
458
  const url = _linkField?.value || "";
326
- if (existingLink) {
327
- existingLink.setAttribute("href", url);
328
- } else if (url) {
329
- document.execCommand("createLink", false, url);
330
- }
331
- litRender(nothing, host);
332
- renderBlockActionBar();
459
+ // Apply then let the popover close itself (do not steal focus back into the iframe here).
460
+ postApplyFormat({ command: "link", href: url || "" });
461
+ close();
333
462
  };
334
463
 
335
464
  const onRemove = () => {
336
- if (!existingLink?.parentNode) {
337
- return;
338
- }
339
- const frag = document.createDocumentFragment();
340
- while (existingLink.firstChild) {
341
- frag.append(existingLink.firstChild);
342
- }
343
- existingLink.parentNode.replaceChild(frag, existingLink);
344
- litRender(nothing, host);
345
- renderBlockActionBar();
465
+ postApplyFormat({ command: "link", href: null });
466
+ close();
346
467
  };
347
468
 
348
469
  const onKeydown = (e: KeyboardEvent) => {
349
470
  if (e.key === "Enter") {
350
471
  onApply();
351
472
  } else if (e.key === "Escape") {
352
- litRender(nothing, host);
473
+ close();
353
474
  }
354
475
  };
355
476
 
477
+ _linkPopoverOpen = true;
356
478
  litRender(
357
479
  html`
358
480
  <sp-popover
@@ -369,7 +491,7 @@ function showLinkPopover(anchorBtn: HTMLElement) {
369
491
  placeholder="https://..."
370
492
  size="s"
371
493
  style="width:200px"
372
- value=${existingLink?.getAttribute("href") || ""}
494
+ value=${link.href || ""}
373
495
  @keydown=${onKeydown}
374
496
  ${ref((el) => {
375
497
  _linkField = (el as HTMLInputElement | null) || null;
@@ -379,9 +501,9 @@ function showLinkPopover(anchorBtn: HTMLElement) {
379
501
  })}
380
502
  ></sp-textfield>
381
503
  <sp-action-button size="xs" @click=${onApply}>
382
- ${existingLink ? "Update" : "Apply"}
504
+ ${existing ? "Update" : "Apply"}
383
505
  </sp-action-button>
384
- ${existingLink
506
+ ${existing
385
507
  ? html` <sp-action-button size="xs" @click=${onRemove}>Remove</sp-action-button> `
386
508
  : nothing}
387
509
  </sp-popover>
@@ -390,6 +512,19 @@ function showLinkPopover(anchorBtn: HTMLElement) {
390
512
  );
391
513
  }
392
514
 
515
+ /**
516
+ * Open the link popover from the Ctrl/Cmd+K shortcut (anchored to the toolbar's Link button if it
517
+ * is on screen, else the bar itself). Used by the parent-focus format-shortcut handler.
518
+ */
519
+ export function openLinkPopoverFromShortcut(): void {
520
+ const bar = view.blockActionBarEl?.querySelector(".block-action-bar") as HTMLElement | null;
521
+ const linkBtn =
522
+ (bar?.querySelector('sp-action-button[title^="Link"]') as HTMLElement | null) ?? bar;
523
+ if (linkBtn) {
524
+ showLinkPopover(linkBtn);
525
+ }
526
+ }
527
+
393
528
  /** Move the selected node up (swap with previous sibling). */
394
529
  function moveSelectionUp() {
395
530
  const tab = activeTab.value;
@@ -446,30 +581,41 @@ export function renderBlockActionBar() {
446
581
  return;
447
582
  }
448
583
 
584
+ // A snapshot-driven refresh must not re-mount an open link popover (it would re-create the URL
585
+ // Field and lose the caret) — preserve it by skipping this render pass.
586
+ if (_linkPopoverOpen) {
587
+ return;
588
+ }
589
+
449
590
  const { selection } = tab.session;
450
- const activePanel = getActivePanel();
451
- if (!activePanel) {
591
+ const node = getNodeAtPath(tab.doc.document, selection);
592
+ if (!node) {
452
593
  litRender(nothing, view.blockActionBarEl);
453
594
  return;
454
595
  }
455
- const el = findCanvasElement(selection, activePanel.canvas);
456
- const node = el && getNodeAtPath(tab.doc.document, selection);
457
- if (!el || !node) {
596
+
597
+ // Position from the iframe-host's viewport-space anchor (the bar is position:fixed). The parent
598
+ // Never reads the iframe DOM, so geometry crosses the bridge as the selection snapshot's rect.
599
+ const anchor = getEditBarAnchorRect();
600
+ const pos = anchor ? barPosition(anchor) : null;
601
+ if (!pos) {
458
602
  litRender(nothing, view.blockActionBarEl);
459
603
  return;
460
604
  }
461
605
 
462
606
  const tag = (node.tagName ?? "div").toLowerCase();
463
- const elRect = el.getBoundingClientRect();
464
- const topPos = elRect.top < 80 ? elRect.bottom + 4 : elRect.top - 38;
465
607
 
466
- // Inline format state
467
- const inlineEditing = isEditing() || el.contentEditable === "true";
608
+ // Inline format state, sourced from the iframe's selection snapshot.
609
+ const { editing, snapshot } = getEditSnapshot();
610
+ const inlineEditing = editing;
468
611
  const actions = getInlineActions(tag) || [];
469
612
  const showFormat = inlineEditing && actions.length > 0;
470
- const activeValues = showFormat
471
- ? actions.filter((a) => isTagActiveInSelection(a.tag, el)).map((a) => a.tag)
472
- : [];
613
+ const activeValues =
614
+ showFormat && snapshot
615
+ ? actions.filter((a) => snapshot.activeTags.includes(a.tag)).map((a) => a.tag)
616
+ : [];
617
+ // A collapsed caret can't format a range — disable format buttons (link/insertData stay enabled).
618
+ const formatDisabled = snapshot?.collapsed ?? false;
473
619
 
474
620
  // Conversion targets for badge click
475
621
  const isComponent =
@@ -480,14 +626,17 @@ export function renderBlockActionBar() {
480
626
  !node.textContent &&
481
627
  (children.length === 0 ||
482
628
  (children.length === 1 && typeof children[0] === "object" && children[0]?.tagName === "br"));
483
- const convertTargets = !isComponent ? getConvertTargets(tag, isEmpty) : [];
629
+ // Repeater ($prototype:"Array") pseudo-elements have no tagName show the "Repeater → items" label
630
+ // (not a bare "div") and don't offer tag-conversion targets, which are meaningless for a repeater.
631
+ const isRepeater = node.$prototype === "Array";
632
+ const convertTargets = !isComponent && !isRepeater ? getConvertTargets(tag, isEmpty) : [];
484
633
  const badgeInteractive = convertTargets.length > 0;
485
634
 
486
635
  litRender(
487
636
  html`
488
637
  <div
489
638
  class="block-action-bar"
490
- style=${styleMap({ left: `${elRect.left}px`, top: `${topPos}px` })}
639
+ style=${styleMap({ left: `${pos.left}px`, top: `${pos.top}px` })}
491
640
  @mousedown=${onBarMousedown}
492
641
  >
493
642
  ${selection.length >= 2 ? renderParentSelector() : nothing}
@@ -497,7 +646,7 @@ export function renderBlockActionBar() {
497
646
  @click=${badgeInteractive
498
647
  ? (e: MouseEvent) => onTagBadgeClick(e, convertTargets, selection)
499
648
  : nothing}
500
- >${node.$id || (node.tagName ?? "div")}</span
649
+ >${isRepeater ? nodeLabel(node) : node.$id || (node.tagName ?? "div")}</span
501
650
  >
502
651
 
503
652
  ${selection.length >= 2
@@ -515,9 +664,21 @@ export function renderBlockActionBar() {
515
664
  view.selDragCleanup = draggable({
516
665
  element: handleEl as HTMLElement,
517
666
  getInitialData: () => ({
518
- path: activeTab.value?.session.selection,
667
+ // Snapshot the selection: the live array is a Vue reactive proxy, which
668
+ // Structured clone rejects when the src crosses postMessage (DataCloneError
669
+ // Killed the whole handle drag), and a live reference would also mutate the
670
+ // Retained srcData if the selection changed mid-drag.
671
+ path: [...(activeTab.value?.session.selection ?? [])],
519
672
  type: "tree-node",
520
673
  }),
674
+ onGenerateDragPreview: ({
675
+ nativeSetDragImage,
676
+ }: {
677
+ nativeSetDragImage: ((image: Element, x: number, y: number) => void) | null;
678
+ }) => {
679
+ // Suppress the native drag image; the cross-frame ghost is the drag affordance.
680
+ disableNativeDragPreview({ nativeSetDragImage });
681
+ },
521
682
  });
522
683
  })}
523
684
  >⠿</span
@@ -569,7 +730,8 @@ export function renderBlockActionBar() {
569
730
  size="xs"
570
731
  value=${action.tag}
571
732
  title="${action.label}${action.shortcut ? ` (${action.shortcut})` : ""}"
572
- @mousedown=${captureSelectionRange}
733
+ ?disabled=${formatDisabled && action.command !== "link"}
734
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
573
735
  @click=${(e: MouseEvent) => onFormatClick(e, action)}
574
736
  >
575
737
  ${action.icon ? (formatIconMap[action.icon] ?? nothing) : nothing}
@@ -581,7 +743,7 @@ export function renderBlockActionBar() {
581
743
  size="xs"
582
744
  quiet
583
745
  title="Insert data"
584
- @mousedown=${captureSelectionRange}
746
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
585
747
  @click=${onMergeTagClick}
586
748
  >
587
749
  <sp-icon-data slot="icon"></sp-icon-data>
@@ -599,10 +761,6 @@ export function renderBlockActionBar() {
599
761
  if (!bar) {
600
762
  return;
601
763
  }
602
- // Clamp to window
603
- const barRect = bar.getBoundingClientRect();
604
- if (barRect.right > window.innerWidth) {
605
- bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
606
- }
764
+ clampBarToWindow(bar);
607
765
  });
608
766
  }