@jxsuite/studio 0.33.0 → 0.35.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 (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  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 +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,66 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Canvas origin derivation. The canvas iframe may load same-origin (dev server / chromium /
4
+ * electrobun gate-off, where the platform canvasUrl is a relative path) or cross-origin (electrobun
5
+ * loopback, where canvasUrl is an absolute http://127.0.0.1:<port> URL).
6
+ *
7
+ * CanvasBaseOrigin() returns the origin the canvas document lives on, so callers that build
8
+ * absolute asset/doc URLs target the right server. For a relative canvasUrl this resolves to
9
+ * location.origin — IDENTITY for the same-origin paths, so dev/chromium/electrobun-gate-off are
10
+ * byte-identical to before.
11
+ */
12
+
13
+ import { getPlatform, hasPlatform } from "../platform";
14
+
15
+ /**
16
+ * The origin serving the canvas document. When the platform registers an absolute canvasUrl
17
+ * (electrobun loopback), this is that URL's origin; otherwise it falls back to the parent's own
18
+ * origin (a relative canvasUrl resolves against location.href to location.origin).
19
+ */
20
+ export function canvasBaseOrigin(): string {
21
+ const canvasUrl = hasPlatform() ? getPlatform().canvasUrl : undefined;
22
+ if (canvasUrl) {
23
+ return new URL(canvasUrl, location.href).origin;
24
+ }
25
+ return location.origin;
26
+ }
27
+
28
+ /**
29
+ * Build an absolute loopback-origin src for a PROJECT asset path referenced by a PARENT-realm
30
+ * (shell) preview <img>. On the views:// shell a relative "/images/foo.png" resolves to
31
+ * views://studio/images/foo.png, which the browser fetches immediately on paint — a stray request
32
+ * the desktop MutationObserver only rewrites asynchronously. Rendering the src loopback-absolute up
33
+ * front eliminates that race for direct-parent <img> sites.
34
+ *
35
+ * Guarded: this ONLY rewrites when a real cross-origin http(s) loopback is registered (electrobun
36
+ * loopback, canvasUrl set to an absolute http URL) whose origin differs from location.origin.
37
+ * Otherwise — dev server, chromium, electrobun gate-off, and the pre-activate window — it returns
38
+ * the ORIGINAL relative path unchanged, keeping those cases byte-identical (an absolute
39
+ * ${location.origin}/... on the shell would just reintroduce views://... — the exact bug).
40
+ *
41
+ * Only for PROJECT asset srcs; NEVER call it for shell-packaged views:// assets (sp-icons, bundled
42
+ * chrome). Already-absolute inputs (data:/blob:/http/views://) are returned untouched, so it is
43
+ * safe for user-typed media values too.
44
+ *
45
+ * @param {string} path — a project asset path (relative, e.g. "/public/logo.png" or "logo.png") or
46
+ * an already-absolute URL.
47
+ * @returns {string} The loopback-absolute src when a cross-origin loopback is active, else `path`.
48
+ */
49
+ export function loopbackAssetSrc(path: string): string {
50
+ if (
51
+ path.startsWith("data:") ||
52
+ path.startsWith("blob:") ||
53
+ path.startsWith("http") ||
54
+ path.startsWith("views://")
55
+ ) {
56
+ return path;
57
+ }
58
+ const origin = canvasBaseOrigin();
59
+ // Only rewrite when a real cross-origin http(s) loopback is registered. When canvasBaseOrigin()
60
+ // Yields location.origin (the views:// shell / dev / chromium / gate-off) an absolute rewrite
61
+ // Would produce views://... on the shell, so fall back to the relative path unchanged.
62
+ if ((origin.startsWith("http:") || origin.startsWith("https:")) && origin !== location.origin) {
63
+ return `${origin}/${path.replace(/^\.?\//, "")}`;
64
+ }
65
+ return path;
66
+ }
@@ -13,45 +13,23 @@
13
13
  * to the patched DOM.
14
14
  */
15
15
 
16
- import {
17
- canvasPanels,
18
- childIndex,
19
- elToPath,
20
- elToRenderScope,
21
- getNodeAtPath,
22
- parentElementPath,
23
- } from "../store";
24
- import { activeTab } from "../workspace/workspace";
16
+ import { canvasPanels, getNodeAtPath, parentElementPath } from "../store";
17
+ import { isTabActive } from "../workspace/workspace";
25
18
  import { view } from "../view";
26
19
  import { toRaw } from "../reactivity";
27
- import { elementStyleTags, reapplyStyle } from "@jxsuite/runtime";
28
- import { getEffectiveMedia } from "../site-context";
29
- import { findCanvasElement } from "./canvas-helpers";
30
- import { domChildReference, renderSubtree } from "./canvas-subtree-render";
31
- import { canvasPerf, perfLog, recordEscalation } from "./canvas-perf";
32
- import {
33
- computeEmptyPlaceholderClass,
34
- EMPTY_PLACEHOLDER_CLASSES,
35
- templateToEditDisplay,
36
- } from "../utils/edit-display";
37
- import { getActiveElement } from "../editor/inline-edit";
20
+ import { postPatchToHosts } from "./iframe-host";
21
+ import { canvasPerf, recordEscalation } from "./canvas-perf";
38
22
  import { setPatchConsumer } from "../tabs/patch-ops";
39
23
 
40
- import type { JxPatchOp } from "../tabs/patch-ops";
24
+ import type { JxPatchOp, TransactionRecord } from "../tabs/patch-ops";
41
25
  import type { Tab } from "../tabs/tab.js";
42
26
  import type { JxPath } from "../state";
43
- import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
44
- import type { CanvasPanel, InlineEditDef } from "../types";
45
27
 
46
28
  /** Render-side callbacks injected at init so this module stays free of heavy canvas imports. */
47
29
  interface CanvasPatcherCtx {
48
30
  getCanvasMode: () => string;
49
31
  scheduleCanvasRender: () => void;
50
- applyCanvasMediaOverrides: (canvasEl: Element, activeBreakpoints: Set<string>) => void;
51
32
  renderOverlays: () => void;
52
- updateForcedPseudoPreview: () => void;
53
- enterComponentInlineEdit: (el: HTMLElement, path: JxPath) => void;
54
- registerSubtreeDnD: (rootEl: HTMLElement) => void;
55
33
  }
56
34
 
57
35
  let _ctx: CanvasPatcherCtx | null = null;
@@ -101,12 +79,6 @@ export function escalateToFullRender(reason: string) {
101
79
 
102
80
  // ─── Classification ──────────────────────────────────────────────────────────
103
81
 
104
- /** Identity check that survives reactive proxy wrapping (activeTab.value is a proxy). */
105
- function isActiveTab(tab: Tab) {
106
- const active = activeTab.value;
107
- return active !== null && toRaw(active as object) === toRaw(tab as unknown as object);
108
- }
109
-
110
82
  /**
111
83
  * $switch cases render as a substituted first-case placeholder in edit mode, so their element paths
112
84
  * don't correspond to document paths — edits through "cases" escalate to a full render. (Non-
@@ -157,7 +129,17 @@ function containerVerdict(tab: Tab, parentPath: JxPath, requireArray = true): st
157
129
  return null;
158
130
  }
159
131
 
160
- /** Verdict for ops applied as a subtree replace at `path`. */
132
+ /**
133
+ * Verdict for ops applied as a subtree replace at `path`. Unlike the structural splices
134
+ * (insert/remove/move), a replace locates its target DIRECTLY by its stamped `data-jx-path`
135
+ * (iframe-patch `requireElement`) and swaps it in place — doc↔DOM child-index correspondence is
136
+ * never consulted, so a custom-element ancestor (slot redistribution) cannot break it and is NOT a
137
+ * reason to escalate. That matters for real content: markdown class-directive pages put every
138
+ * editable block inside a component, and rejecting those forced a full render (reloading embedded
139
+ * iframes) on every text commit. Should the element be un-queryable after all (e.g. a component
140
+ * rendered its children into shadow DOM), the iframe throws element-not-found and the parent
141
+ * escalates — the same outcome as rejecting here, without the false positives.
142
+ */
161
143
  function replaceVerdict(tab: Tab, path: JxPath): string | null {
162
144
  if (path.length === 0) {
163
145
  return "replace-root";
@@ -169,20 +151,22 @@ function replaceVerdict(tab: Tab, path: JxPath): string | null {
169
151
  if (!parentPath) {
170
152
  return "replace-no-parent";
171
153
  }
172
- const container = containerVerdict(tab, parentPath, false);
173
- if (container) {
174
- return container;
154
+ // A subtree re-render inside a repeater template can't be re-rendered 1:1 in the edit-mode
155
+ // Perimeter (mirrors containerVerdict's map rule).
156
+ if (parentPath.includes("map")) {
157
+ return "structure-on-map-path";
158
+ }
159
+ const parent = getNodeAtPath(tab.doc.document, parentPath);
160
+ if (!parent) {
161
+ return "node-not-found";
162
+ }
163
+ // An innerHTML parent renders opaque children — the target has no stamped element to swap.
164
+ if (parent.innerHTML) {
165
+ return "structure-with-innerhtml";
175
166
  }
176
167
  return getNodeAtPath(tab.doc.document, path) === undefined ? "node-not-found" : null;
177
168
  }
178
169
 
179
- const STRUCTURAL_OPS = new Set(["insert", "move", "remove", "replace", "set-attr", "set-prop"]);
180
-
181
- /** Whether an op changes DOM structure (vs in-place style/text writes). */
182
- function isStructuralOp(op: JxPatchOp) {
183
- return STRUCTURAL_OPS.has(op.op) && !(op.op === "set-prop" && op.isEvent);
184
- }
185
-
186
170
  /**
187
171
  * Per-op patchability in the current document. Returns null when patchable, else the reason.
188
172
  *
@@ -263,7 +247,7 @@ export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; r
263
247
  return { patchable: false, reason };
264
248
  };
265
249
 
266
- if (!isActiveTab(tab)) {
250
+ if (!isTabActive(tab)) {
267
251
  return reject("inactive-tab");
268
252
  }
269
253
  const canvasMode = _ctx ? _ctx.getCanvasMode() : "";
@@ -273,20 +257,39 @@ export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; r
273
257
  if (canvasPanels.length === 0) {
274
258
  return reject("no-panels");
275
259
  }
276
- if (!canvasPanels.every((p) => p.ready && p.liveCtx)) {
260
+ // The iframe canvas keeps its render context inside the iframe, so a panel is patchable as soon as
261
+ // It has rendered (`ready`) — there is no parent-side `liveCtx`.
262
+ if (!canvasPanels.every((p) => p.ready)) {
277
263
  return reject("panels-not-ready");
278
264
  }
279
- // Structural changes while an inline edit session is live would pull the DOM out from under
280
- // The editor rare (commit flows tear down first), so escalate conservatively.
281
- if (view.componentInlineEdit && ops.some((op) => isStructuralOp(op))) {
282
- return reject("inline-edit-active");
265
+ // A `set-text` on a node whose CHILDREN are replaced in the same batch is subsumed by that
266
+ // Subtree re-render (the iframe folds every forward op into the shadow doc first, then the
267
+ // Trailing children re-render draws the node from the final state — see iframe-patch.ts). This is
268
+ // The rich-text inline-commit shape (clear text + set children), which opVerdict alone would
269
+ // Reject as "text-with-children" because classification runs POST-mutation — the node already has
270
+ // Its new children by the time we look.
271
+ const subtreeReplacedPaths = new Set<string>();
272
+ for (const op of ops) {
273
+ if ((op.op === "set-prop" && op.key === "children") || op.op === "replace") {
274
+ subtreeReplacedPaths.add(JSON.stringify(op.path));
275
+ }
283
276
  }
277
+ // Inline editing now lives inside the iframe canvas, which the parent patcher can't observe; the
278
+ // Iframe escalates to a full render itself if a posted patch can't apply mid-edit. So there is no
279
+ // Parent-side inline-edit guard here anymore.
284
280
  for (const op of ops) {
281
+ if (op.op === "set-text" && subtreeReplacedPaths.has(JSON.stringify(op.path))) {
282
+ continue;
283
+ }
285
284
  const reason = opVerdict(tab, op);
286
285
  if (reason) {
287
286
  return reject(reason);
288
287
  }
289
288
  }
289
+ // Classification is host-agnostic: the iframe canvas applies the SAME op set surgically as the
290
+ // Legacy DOM patcher (in-place style/text, structural relocation, and subtree re-renders for
291
+ // Insert/replace/attr/prop — see iframe-patch.ts). An op the iframe somehow can't apply throws and
292
+ // The parent escalates to a full render — so a stricter gate here would only forgo optimization.
290
293
  return { patchable: true, reason: "" };
291
294
  }
292
295
 
@@ -298,359 +301,16 @@ export function classifyOps(tab: Tab, ops: JxPatchOp[]): { patchable: boolean; r
298
301
  *
299
302
  * @param {Tab} tab
300
303
  * @param {JxPatchOp[]} ops
304
+ * @param {TransactionRecord} [record] Value-carrying ops, used by the iframe host.
301
305
  */
302
- export function applyPatchBatch(tab: Tab, ops: JxPatchOp[]) {
303
- const doc = toRaw(tab.doc.document) as JxMutableNode;
304
- const mediaQueries = getEffectiveMedia(doc.$media || {});
305
-
306
- for (const op of ops) {
307
- for (const panel of canvasPanels) {
308
- applyOpToPanel(panel, doc, op, mediaQueries);
309
- }
310
- canvasPerf.patchedOps += 1;
311
- perfLog("patched", op);
312
- }
313
-
314
- // Hover targets may have moved or vanished after structural changes; the next mousemove
315
- // Recomputes it. (Selection is path-adjusted by the mutators themselves.)
316
- if (tab.session.hover && ops.some((op) => isStructuralOp(op))) {
317
- tab.session.hover = null;
318
- }
319
-
320
- _ctx?.renderOverlays();
321
- _ctx?.updateForcedPseudoPreview();
322
- schedulePendingInlineEditConsumption(tab);
323
- }
324
-
325
- /**
326
- * @param {CanvasPanel} panel
327
- * @param {JxMutableNode} doc
328
- * @param {JxPatchOp} op
329
- * @param {Record<string, string>} mediaQueries
330
- */
331
- function applyOpToPanel(
332
- panel: CanvasPanel,
333
- doc: JxMutableNode,
334
- op: JxPatchOp,
335
- mediaQueries: Record<string, string>,
336
- ) {
337
- switch (op.op) {
338
- case "set-style": {
339
- const el = requireElement(op.path, panel);
340
- const node = getNodeAtPath(doc, op.path);
341
- reapplyStyle(el, editModeStyle(node?.style), mediaQueries, {});
342
- restoreEditModePointerEvents(el);
343
- if (panel.activeBreakpoints && panel.activeBreakpoints.size > 0) {
344
- _ctx?.applyCanvasMediaOverrides(panel.canvas as HTMLElement, panel.activeBreakpoints);
345
- }
346
- return;
347
- }
348
- case "set-text": {
349
- const el = requireElement(op.path, panel);
350
- // The element being inline-edited already shows the committed text — don't clobber the
351
- // User's live DOM (this replaces the old destroy-and-restore full-render cycle).
352
- if (isBeingInlineEdited(el)) {
353
- return;
354
- }
355
- const node = getNodeAtPath(doc, op.path);
356
- el.textContent = textDisplayValue(node);
357
- for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
358
- el.classList.remove(cls);
359
- }
360
- const placeholder = node ? computeEmptyPlaceholderClass(node) : null;
361
- if (placeholder) {
362
- el.classList.add(placeholder);
363
- }
364
- restoreEditModePointerEvents(el);
365
- return;
366
- }
367
- case "set-prop": {
368
- if (op.isEvent) {
369
- // Event bindings are stripped from design/edit renders — nothing to do.
370
- return;
371
- }
372
- replaceSubtree(panel, doc, op.path);
373
- return;
374
- }
375
- case "set-attr":
376
- case "replace": {
377
- replaceSubtree(panel, doc, op.path);
378
- return;
379
- }
380
- case "insert": {
381
- insertChild(panel, doc, op.parentPath, op.index);
382
- return;
383
- }
384
- case "remove": {
385
- removeChild(panel, doc, op.path);
386
- return;
387
- }
388
- case "move": {
389
- moveChild(panel, doc, op.fromPath, op.toParentPath, op.toIndex);
390
- return;
391
- }
392
- default: {
393
- throw new Error(`unsupported-op:${(op as JxPatchOp).op}`);
394
- }
395
- }
396
- }
397
-
398
- // ─── Structural patching ─────────────────────────────────────────────────────
399
-
400
- /** Re-render the node at path and swap it into the DOM in place. */
401
- function replaceSubtree(panel: CanvasPanel, doc: JxMutableNode, path: JxPath) {
402
- const oldEl = requireElement(path, panel);
403
- const parentEl = oldEl.parentElement;
404
- if (!parentEl) {
405
- throw new Error("replace-missing-parent-element");
406
- }
407
- const newEl = renderSubtree(panel, doc, path, parentEl);
408
- disposeSubtree(oldEl);
409
- oldEl.replaceWith(newEl);
410
- afterSubtreeInserted(panel, newEl);
411
- }
412
-
413
- /** Render the inserted node and splice it into the parent's DOM, shifting sibling paths up. */
414
- function insertChild(panel: CanvasPanel, doc: JxMutableNode, parentPath: JxPath, index: number) {
415
- const parentEl = requireElement(parentPath, panel);
416
- const parentNode = getNodeAtPath(doc, parentPath);
417
- const newEl = renderSubtree(panel, doc, [...parentPath, "children", index], parentEl);
418
- // Remap existing siblings before attaching the new subtree so it isn't itself remapped.
419
- remapChildPaths(parentEl, parentPath, index, 1);
420
- insertAt(parentEl, newEl, domChildReference(parentEl, parentNode, index));
421
- syncContainerPlaceholder(parentEl, parentNode);
422
- afterSubtreeInserted(panel, newEl);
423
- }
424
-
425
- /** Remove the node's DOM and shift following sibling paths down. */
426
- function removeChild(panel: CanvasPanel, doc: JxMutableNode, path: JxPath) {
427
- const parentPath = parentElementPath(path) as JxPath;
428
- const idx = childIndex(path) as number;
429
- const el = requireElement(path, panel);
430
- const parentEl = el.parentElement;
431
- if (!parentEl) {
432
- throw new Error("remove-missing-parent-element");
433
- }
434
- disposeSubtree(el);
435
- el.remove();
436
- remapChildPaths(parentEl, parentPath, idx + 1, -1);
437
- syncContainerPlaceholder(parentEl, getNodeAtPath(doc, parentPath));
438
- }
439
-
440
- /**
441
- * Move = detach + path-shift both parents + rewrite the moved subtree's path prefix + reinsert.
442
- * fromPath is in pre-mutation coordinates (matching the DOM); toIndex is the post-mutation index
443
- * recorded by the mutator.
444
- */
445
- function moveChild(
446
- panel: CanvasPanel,
447
- doc: JxMutableNode,
448
- fromPath: JxPath,
449
- toParentPath: JxPath,
450
- toIndex: number,
451
- ) {
452
- const fromParentPath = parentElementPath(fromPath) as JxPath;
453
- const fromIdx = childIndex(fromPath) as number;
454
- // Resolve both elements against the pre-mutation DOM mappings before touching anything.
455
- const el = requireElement(fromPath, panel);
456
- const toParentEl = requireElement(toParentPath, panel);
457
- const fromParentEl = el.parentElement;
458
- if (!fromParentEl) {
459
- throw new Error("move-missing-parent-element");
460
- }
461
-
462
- el.remove();
463
- remapChildPaths(fromParentEl, fromParentPath, fromIdx + 1, -1);
464
- // The to-parent's own path may have shifted by the detach remap — read it fresh.
465
- const toPrefix = (elToPath.get(toParentEl) as JxPath | undefined) ?? toParentPath;
466
- remapChildPaths(toParentEl, toPrefix, toIndex, 1);
467
- rewriteSubtreePathPrefix(el, fromPath, [...toPrefix, "children", toIndex]);
468
-
469
- const toParentNode = getNodeAtPath(doc, toPrefix);
470
- insertAt(toParentEl, el, domChildReference(toParentEl, toParentNode, toIndex));
471
- syncContainerPlaceholder(
472
- fromParentEl,
473
- getNodeAtPath(doc, elToPath.get(fromParentEl) ?? fromParentPath),
474
- );
475
- syncContainerPlaceholder(toParentEl, toParentNode);
476
- }
477
-
478
- /** Insert before the reference node, or append when inserting at the end. */
479
- function insertAt(parentEl: Element, node: Node, ref: ChildNode | null) {
480
- if (ref) {
481
- ref.before(node);
482
- } else {
483
- parentEl.append(node);
484
- }
485
- }
486
-
487
- /**
488
- * Shift the child-index segment of elToPath entries for every element under parentEl whose path is
489
- * `[...parentPath, "children", i, ...]` with `i >= fromIndex`. Descendants of shifted siblings
490
- * carry the same segment and are rewritten too.
491
- */
492
- function remapChildPaths(parentEl: Element, parentPath: JxPath, fromIndex: number, delta: number) {
493
- const depth = parentPath.length;
494
- for (const el of parentEl.querySelectorAll("*")) {
495
- const p = elToPath.get(el);
496
- if (!p || p.length < depth + 2 || p[depth] !== "children") {
497
- continue;
498
- }
499
- const idx = p[depth + 1];
500
- if (typeof idx !== "number" || idx < fromIndex) {
501
- continue;
502
- }
503
- if (!parentPath.every((seg, i) => p[i] === seg)) {
504
- continue;
505
- }
506
- const np = [...p];
507
- np[depth + 1] = idx + delta;
508
- elToPath.set(el, np);
509
- }
510
- }
511
-
512
- /** Rewrite elToPath entries of el and its descendants, replacing oldPrefix with newPrefix. */
513
- function rewriteSubtreePathPrefix(el: Element, oldPrefix: JxPath, newPrefix: JxPath) {
514
- const targets = [el, ...el.querySelectorAll("*")];
515
- for (const t of targets) {
516
- const p = elToPath.get(t);
517
- if (!p || p.length < oldPrefix.length) {
518
- continue;
519
- }
520
- if (!oldPrefix.every((seg, i) => p[i] === seg)) {
521
- continue;
522
- }
523
- elToPath.set(t, [...newPrefix, ...p.slice(oldPrefix.length)]);
524
- }
525
- }
526
-
527
- /**
528
- * Release resources owned by a removed/replaced subtree: the scoped <style> tags the runtime
529
- * emitted for its elements, and the effect scopes of any surgical renders rooted inside it.
530
- */
531
- function disposeSubtree(el: Element) {
532
- const targets = [el, ...el.querySelectorAll("*")];
533
- for (const t of targets) {
534
- if (t instanceof HTMLElement) {
535
- const tag = elementStyleTags.get(t);
536
- if (tag) {
537
- tag.remove();
538
- elementStyleTags.delete(t);
539
- }
540
- }
541
- const scope = elToRenderScope.get(t);
542
- if (scope) {
543
- scope.stop();
544
- elToRenderScope.delete(t);
545
- }
546
- }
547
- }
548
-
549
- /** Keep the parent's empty-placeholder class in sync after its children changed. */
550
- function syncContainerPlaceholder(parentEl: Element, parentNode: JxMutableNode | undefined) {
551
- for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
552
- parentEl.classList.remove(cls);
553
- }
554
- const placeholder = parentNode ? computeEmptyPlaceholderClass(parentNode) : null;
555
- if (placeholder) {
556
- parentEl.classList.add(placeholder);
557
- }
558
- }
559
-
560
- /** Post-insertion bookkeeping for a freshly rendered subtree: DnD targets + media overrides. */
561
- function afterSubtreeInserted(panel: CanvasPanel, newEl: HTMLElement | Text) {
562
- if (newEl instanceof HTMLElement) {
563
- _ctx?.registerSubtreeDnD(newEl);
564
- }
565
- if (panel.activeBreakpoints && panel.activeBreakpoints.size > 0) {
566
- _ctx?.applyCanvasMediaOverrides(panel.canvas as HTMLElement, panel.activeBreakpoints);
567
- }
568
- }
569
-
570
- /**
571
- * @param {JxPath} path
572
- * @param {CanvasPanel} panel
573
- */
574
- function requireElement(path: JxPath, panel: CanvasPanel): HTMLElement {
575
- const el = findCanvasElement(path, panel.canvas as HTMLElement);
576
- if (!el) {
577
- throw new Error(`element-not-found:${path.join("/")}`);
578
- }
579
- return el;
580
- }
581
-
582
- /** Whether this element is the live target of an inline editing session. */
583
- function isBeingInlineEdited(el: HTMLElement) {
584
- return view.componentInlineEdit?.el === el || getActiveElement() === el;
585
- }
586
-
587
- /** Re-disable pointer events after a patch (full renders set this on every canvas element). */
588
- function restoreEditModePointerEvents(el: HTMLElement) {
589
- if (!isBeingInlineEdited(el)) {
590
- el.style.pointerEvents = "none";
591
- }
592
- }
593
-
594
- /**
595
- * Edit-mode style transform — mirrors prepareForEditMode's style rule: top-level template-string
596
- * values are blanked so no reactive bindings are created for them.
597
- *
598
- * @param {JxStyle | undefined} style
599
- */
600
- function editModeStyle(style: JxStyle | undefined): JxStyle | undefined {
601
- if (!style || typeof style !== "object") {
602
- return undefined;
306
+ export function applyPatchBatch(tab: Tab, _ops: JxPatchOp[], record?: TransactionRecord) {
307
+ // The canvas renders inside the iframe — the parent has no DOM to mutate. Post the value-carrying
308
+ // Forward ops over the bridge for the iframe to apply against its shadow doc only to hosts
309
+ // Rendering THIS tab's document (a background tab's iframe must never fold a foreign edit into
310
+ // Its shadow doc). Throwing when no host received it escalates to a full render (the suppressed
311
+ // Render then runs), so an edit is never dropped.
312
+ const forwardOps = (record?.docOps ?? []).map((pair) => pair.forward);
313
+ if (postPatchToHosts(forwardOps, view.renderGeneration, tab.id) === 0) {
314
+ throw new Error("no-ready-iframe-host");
603
315
  }
604
- const out: Record<string, unknown> = {};
605
- for (const [k, v] of Object.entries(style)) {
606
- out[k] = typeof v === "string" && v.includes("${") ? "" : v;
607
- }
608
- return out as JxStyle;
609
- }
610
-
611
- /**
612
- * Display text for a node's textContent in design/edit mode — mirrors prepareForEditMode's
613
- * template-string and $ref display rules.
614
- *
615
- * @param {JxMutableNode | undefined} node
616
- */
617
- function textDisplayValue(node: JxMutableNode | undefined): string {
618
- const v = node?.textContent as unknown;
619
- if (v == null) {
620
- return "";
621
- }
622
- if (typeof v === "string") {
623
- return v.includes("${") ? templateToEditDisplay(v) : v;
624
- }
625
- if (typeof v === "object" && (v as Record<string, unknown>).$ref) {
626
- const ref = String((v as Record<string, unknown>).$ref);
627
- const label = ref.startsWith("#/state/") ? ref.slice(8) : ref;
628
- return `{${label}}`;
629
- }
630
- return String(v);
631
- }
632
-
633
- /**
634
- * Consume a pendingInlineEdit after patches apply. The full render that used to consume it was
635
- * skipped, and some flows (splitParagraph) set it right after transactDoc returns — so consume in a
636
- * microtask, after the calling handler finished.
637
- *
638
- * @param {Tab} tab
639
- */
640
- function schedulePendingInlineEditConsumption(tab: Tab) {
641
- queueMicrotask(() => {
642
- if (!isActiveTab(tab) || !tab.session.ui?.pendingInlineEdit) {
643
- return;
644
- }
645
- const { path, mediaName } = tab.session.ui.pendingInlineEdit as InlineEditDef;
646
- tab.session.ui.pendingInlineEdit = null;
647
- const targetPanel = canvasPanels.find((p) => p.mediaName === mediaName) || canvasPanels[0];
648
- if (!targetPanel) {
649
- return;
650
- }
651
- const el = findCanvasElement(path, targetPanel.canvas as HTMLElement);
652
- if (el) {
653
- _ctx?.enterComponentInlineEdit(el, path);
654
- }
655
- });
656
316
  }