@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
@@ -0,0 +1,600 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Canvas iframe entry — runs INSIDE the canvas iframe. It opens a postMessage channel to the parent
4
+ * editor, announces `ready`, and renders the documents the parent posts via `render`. Kept tiny: it
5
+ * pulls in only the render core, so the iframe bundle stays small.
6
+ */
7
+
8
+ import { postMessageChannel } from "./iframe-channel";
9
+ import { installCanvasImageRetry, renderResolvedDocument } from "./iframe-render";
10
+ import { measureHits, startInteraction } from "./iframe-interaction";
11
+ import {
12
+ AUTO_SCROLL_STEP,
13
+ clearIframeDrag,
14
+ computeDropInstruction,
15
+ resolveDropTarget,
16
+ scrollDirection,
17
+ startGrabDetector,
18
+ } from "./iframe-drop";
19
+ import { startIframeInlineEdit } from "./iframe-inline-edit";
20
+ import { startIframeSlashBridge } from "./iframe-slash";
21
+ import { startKeyForwarding } from "./iframe-keys";
22
+ import { applyIframePatch } from "./iframe-patch";
23
+ import { disposeAllSubtrees } from "./iframe-subtree";
24
+ import { serializeDataScope } from "./serialize-scope";
25
+ import { getActivePath, isEditing, stopEditing } from "../editor/inline-edit";
26
+ import { isAncestor } from "../state";
27
+ import type { JxDocOp } from "../tabs/patch-ops";
28
+ import type { JxPath } from "../state";
29
+ // ObserveScope MUST come from the runtime: the $defs refs are created by the runtime's copy of
30
+ // @vue/reactivity, and dep tracking is per module instance — an effect from the studio's own copy
31
+ // Would never re-run when a dev-proxy data source settles.
32
+ import { observeScope, reapplyStyle, setResolveToken } from "@jxsuite/runtime";
33
+ import type { IframeChannel } from "./iframe-channel";
34
+ import type { CanvasMode, DragSrcKind, IframeToParent, ParentToIframe } from "./iframe-protocol";
35
+ import type { JxDocument, JxMutableNode, JxStyle } from "@jxsuite/schema/types";
36
+ import type { IframeRenderCtx, RenderHandle } from "./iframe-render";
37
+
38
+ /**
39
+ * Resolve the drop placement for a forwarded cursor: point hit-test → nearest `[data-jx-path]` →
40
+ * pure {@link computeDropInstruction}. Returns null when the cursor resolves to no droppable target.
41
+ * Shared by the `dragMove` (display-only preview) and `drop` (fresh, authoritative) handlers.
42
+ */
43
+ function previewAt(
44
+ cursor: { x: number; y: number },
45
+ src: DragSrcKind,
46
+ shadowDoc: JxMutableNode,
47
+ doc: Document,
48
+ ) {
49
+ const targetEl = resolveDropTarget(cursor.x, cursor.y, doc);
50
+ if (!targetEl) {
51
+ return null;
52
+ }
53
+ return computeDropInstruction(targetEl, cursor.y, shadowDoc, src);
54
+ }
55
+
56
+ /** Set-key keys the patcher applies IN PLACE (never a subtree re-render) — safe under a live edit. */
57
+ const IN_PLACE_KEYS = new Set(["style", "textContent"]);
58
+
59
+ /**
60
+ * Whether applying `forwardOps` would re-render/detach the element of the live edit session: any op
61
+ * that is NOT an in-place set-key (style/text/event) whose affected node is an ancestor-or-self of
62
+ * the active edit path. Structural ops affect the PARENT's children (any sibling churn under an
63
+ * ancestor can reflow/replace the edited element's position), so their parent path is compared.
64
+ */
65
+ export function patchDisturbsActiveEdit(forwardOps: JxDocOp[]): boolean {
66
+ const activePath = getActivePath();
67
+ if (!activePath) {
68
+ return false;
69
+ }
70
+ for (const op of forwardOps) {
71
+ let affected: JxPath;
72
+ if (op.op === "set-key") {
73
+ if (IN_PLACE_KEYS.has(op.key) || op.key.startsWith("on")) {
74
+ continue;
75
+ }
76
+ affected = op.path;
77
+ } else if (op.op === "move-child") {
78
+ affected = op.fromParentPath;
79
+ if (isAncestor(affected, activePath) || isAncestor(op.toParentPath, activePath)) {
80
+ return true;
81
+ }
82
+ continue;
83
+ } else {
84
+ affected = op.parentPath;
85
+ }
86
+ if (isAncestor(affected, activePath)) {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Drive a channel: render each `render` message into `container`, dropping stale generations, and
95
+ * acknowledge with `renderComplete`/`renderError`. Exposed (rather than inlined in {@link boot}) so
96
+ * tests can exercise it with a fake channel. Returns a teardown function.
97
+ */
98
+ export function startCanvasIframe(opts: {
99
+ channel: IframeChannel<IframeToParent, ParentToIframe>;
100
+ container: HTMLElement;
101
+ }): () => void {
102
+ const { channel, container } = opts;
103
+ let handle: RenderHandle | null = null;
104
+ // Disposer for the live render's dataScope re-post effect (see the render handler); stopped
105
+ // Alongside the handle so a superseded render's refs can't keep posting.
106
+ let stopDataScopeWatch: (() => void) | null = null;
107
+ let latestGen = -1;
108
+ // The raw page doc the current DOM was rendered from — the patch source-of-truth. `renderedGen`
109
+ // Tracks which generation it (and the DOM) reflect, so patches for an in-flight/superseded render
110
+ // Are handled correctly rather than applied against the wrong tree.
111
+ let shadowDoc: JxMutableNode | null = null;
112
+ let renderedGen = -1;
113
+ // The mode of the LIVE render — gates the interactive surfaces (inline editing, insert zones,
114
+ // Grab-drags) that only design/edit modes own. Adopted alongside shadowDoc.
115
+ let currentMode: CanvasMode = "design";
116
+ // The current render's retained context (scope/mapping), used to render subtrees for structural
117
+ // Patches. Set together with `shadowDoc`, so it's non-null whenever a patch is applied.
118
+ let renderCtx: IframeRenderCtx | null = null;
119
+
120
+ // Cross-frame drag session (Phase 4c). `dragStart` records the source kind + the gen the session
121
+ // Began against; dragMove/drop tag every reply with both so the parent can stale-gate them.
122
+ let dragSrc: DragSrcKind | null = null;
123
+ let dragGen = -1;
124
+ // The parent-session id (from dragStart), so NATIVE drag events routed into this frame can post
125
+ // DragOver/dropResult tagged for the parent's seq gate.
126
+ let sessionSeq = -1;
127
+
128
+ // ─── Auto-scroll (commit 6) — a SELF-SUSTAINING rAF loop. When a dragMove lands in an edge band,
129
+ // The loop each frame scrolls the viewport AND re-hit-tests the CACHED cursor (elementFromPoint is
130
+ // Viewport-relative, so the same x,y now resolves to a different node after the scroll) and re-posts
131
+ // The over-preview. It is NOT driven by dragMove (the pointer is stationary at an edge-hold). Stops
132
+ // On: band exit (next dragMove, dir 0), drop/dragEnd/dragCancel, or scroll extent reached (scrollY
133
+ // Unchanged). The win/rAF body is the only uncovered line; scrollDirection is PURE + unit-tested.
134
+ let autoScrollFrame = 0;
135
+ // The cached edge-hold cursor. For a flow-3 (iframe-driven) drag it also carries the drag `src`
136
+ // (the parent never posts a dragStart, so `dragSrc`/`dragGen` are null) and `withCursor`, so the
137
+ // Loop re-posts dragOver WITH the cursor to keep the parent's ghost tracking during an edge-hold
138
+ // (the parent has no pointer of its own then). Parent-driven flows (1/2/4) leave `src` undefined
139
+ // And `withCursor` false — the tick falls back to `dragSrc`/`dragGen` and the parent moves the
140
+ // Ghost from its own raw pointer.
141
+ let autoScrollCursor: {
142
+ x: number;
143
+ y: number;
144
+ dragSeq: number;
145
+ withCursor: boolean;
146
+ src?: DragSrcKind;
147
+ gen?: number;
148
+ } | null = null;
149
+ const win = container.ownerDocument.defaultView;
150
+
151
+ function stopAutoScroll(): void {
152
+ if (autoScrollFrame && win) {
153
+ win.cancelAnimationFrame(autoScrollFrame);
154
+ }
155
+ autoScrollFrame = 0;
156
+ autoScrollCursor = null;
157
+ }
158
+
159
+ function autoScrollTick(): void {
160
+ autoScrollFrame = 0;
161
+ // Flow 3 supplies its own src on the cached cursor; flows 1/2/4 use the session's dragSrc.
162
+ const src = autoScrollCursor?.src ?? dragSrc;
163
+ if (!win || !autoScrollCursor || !src || !shadowDoc) {
164
+ return;
165
+ }
166
+ const dir = scrollDirection(autoScrollCursor.y, win.innerHeight);
167
+ if (dir === 0) {
168
+ return;
169
+ }
170
+ const before = win.scrollY;
171
+ win.scrollBy(0, dir * AUTO_SCROLL_STEP);
172
+ if (win.scrollY === before) {
173
+ // Scroll extent reached — nothing more to reveal, so stop the loop.
174
+ return;
175
+ }
176
+ const preview = previewAt(autoScrollCursor, src, shadowDoc, container.ownerDocument);
177
+ channel.post({
178
+ // Flow 3 (withCursor) re-posts the cursor so the parent keeps tracking the ghost at an edge-hold.
179
+ ...(autoScrollCursor.withCursor
180
+ ? { cursor: { x: autoScrollCursor.x, y: autoScrollCursor.y } }
181
+ : {}),
182
+ dragSeq: autoScrollCursor.dragSeq,
183
+ gen: autoScrollCursor.gen ?? dragGen,
184
+ kind: "dragOver",
185
+ preview,
186
+ });
187
+ autoScrollFrame = win.requestAnimationFrame(autoScrollTick);
188
+ }
189
+
190
+ /**
191
+ * (Re)evaluate auto-scroll for a dragMove cursor: arm the loop in a band, stop it outside. The
192
+ * optional `flow3` carries the iframe-driven drag's src/gen (and forces the cursor to be
193
+ * re-posted during edge-holds); parent-driven flows pass nothing and the loop uses
194
+ * `dragSrc`/`dragGen`.
195
+ */
196
+ function updateAutoScroll(
197
+ cursor: { x: number; y: number },
198
+ dragSeq: number,
199
+ flow3?: { src: DragSrcKind; gen: number },
200
+ ): void {
201
+ if (!win || scrollDirection(cursor.y, win.innerHeight) === 0) {
202
+ stopAutoScroll();
203
+ return;
204
+ }
205
+ autoScrollCursor = {
206
+ dragSeq,
207
+ withCursor: flow3 != null,
208
+ x: cursor.x,
209
+ y: cursor.y,
210
+ ...(flow3 ? { gen: flow3.gen, src: flow3.src } : {}),
211
+ };
212
+ if (!autoScrollFrame) {
213
+ autoScrollFrame = win.requestAnimationFrame(autoScrollTick);
214
+ }
215
+ }
216
+
217
+ // Report pointer hit/hover (resolved to data-jx-path) to the parent, which owns selection +
218
+ // Overlays — the cross-origin bridge means the parent never reads our DOM directly. The shadow-doc
219
+ // Accessor feeds the insertion "+" zone computation hung off the same pointermove (the parent
220
+ // Draws the clickable "+" and runs the slash-menu → mutateInsertNode flow on click).
221
+ const stopInteraction = startInteraction(channel, container.ownerDocument, {
222
+ getMode: () => currentMode,
223
+ getShadowDoc: () => shadowDoc,
224
+ });
225
+ // Forward global-shortcut keystrokes to the parent — its shortcut handler is bound to the editor
226
+ // Document, so without this they'd be swallowed whenever focus is inside the canvas iframe.
227
+ const stopKeyForwarding = startKeyForwarding(channel, container.ownerDocument);
228
+ // Run inline editing (contenteditable) here, posting committed/split/insert results to the parent.
229
+ const stopInlineEdit = startIframeInlineEdit(channel, container, {
230
+ getMode: () => currentMode,
231
+ });
232
+ // Bridge the engine's slash menu to the parent's Spectrum menu (show/nav/select over the channel).
233
+ const stopSlashBridge = startIframeSlashBridge(channel, container.ownerDocument);
234
+ // Flow 3 (grab-anywhere): detect an element-body drag and DRIVE it locally. A drag that begins in
235
+ // The iframe gets its held-button moves in the IFRAME document (not the parent), so the iframe
236
+ // Computes the preview/drop from its own cursor and posts dragOver/dropResult directly; the parent
237
+ // Only adopts the seq, draws the indicator, and positions the ghost from the posted cursor. The
238
+ // Detector reuses the SAME previewAt + auto-scroll loop the dragMove/drop handlers use.
239
+ const stopGrabDetector = startGrabDetector(channel, container.ownerDocument, {
240
+ armAutoScroll: (cursor, dragSeq, src) =>
241
+ updateAutoScroll(cursor, dragSeq, { gen: renderedGen, src }),
242
+ gen: () => renderedGen,
243
+ getMode: () => currentMode,
244
+ previewAt: (cursor, src) =>
245
+ shadowDoc ? previewAt(cursor, src, shadowDoc, container.ownerDocument) : null,
246
+ stopAutoScroll,
247
+ });
248
+ // Auto-recover canvas images that 404 on a cold first render (component <img>s created in
249
+ // ConnectedCallback fire late, before the loopback server is warm). Re-fires the failed request a
250
+ // Few times — what the manual data-sidebar "Refresh" does, but without a full re-render.
251
+ const stopImageRetry = installCanvasImageRetry(container);
252
+
253
+ // ─── Content-height auto-sizing ─────────────────────────────────────────────
254
+ // Measure the content height and post it so the host sizes the iframe to fit — the canvas then never
255
+ // Scrolls internally (the parent overlay can't follow an internal scroll, and every node stays inside
256
+ // The iframe box so it's hit-testable). The runtime transposes viewport units to container units, so
257
+ // This converges instead of feeding back; `MAX` is a backstop if some unit slips through. `container`
258
+ // Is `#jx-canvas-root`, which overflows the fixed-size query container freely, so its scrollHeight is
259
+ // The true content height.
260
+ const MAX_CANVAS_HEIGHT = 30_000;
261
+ let lastPostedHeight = -1;
262
+ function postContentHeight(): void {
263
+ const measured = Math.min(container.scrollHeight, MAX_CANVAS_HEIGHT);
264
+ if (measured > 0 && Math.abs(measured - lastPostedHeight) >= 1) {
265
+ lastPostedHeight = measured;
266
+ // A component-definition root (marked by makeStamper) is a fragment, not a page — tell the host
267
+ // So it can drop its 480px iframe floor and let a short component hug its content.
268
+ const root = container.firstElementChild;
269
+ const fragment = root instanceof HTMLElement && root.dataset.jxDefinitionRoot !== undefined;
270
+ channel.post({ fragment, height: measured, kind: "contentHeight" });
271
+ }
272
+ }
273
+ const ResizeObs = win?.ResizeObserver;
274
+ const heightObserver = ResizeObs ? new ResizeObs(() => postContentHeight()) : null;
275
+ heightObserver?.observe(container);
276
+
277
+ // ─── Wheel forwarding (canvas zoom/pan) ─────────────────────────────────────
278
+ // The iframe is sized to its content (never scrolls itself), so wheel events over it are meant for
279
+ // The parent canvas: ctrl/cmd+wheel = zoom, plain = pan. A cross-origin OOPIF doesn't bubble wheel to
280
+ // The parent, so forward the deltas + modifiers + cursor; the host redispatches to its wheel handler.
281
+ const onWheel = (e: WheelEvent) => {
282
+ e.preventDefault();
283
+ channel.post({
284
+ ctrlKey: e.ctrlKey,
285
+ deltaX: e.deltaX,
286
+ deltaY: e.deltaY,
287
+ kind: "forwardWheel",
288
+ metaKey: e.metaKey,
289
+ shiftKey: e.shiftKey,
290
+ x: e.clientX,
291
+ y: e.clientY,
292
+ });
293
+ };
294
+ container.ownerDocument.addEventListener("wheel", onWheel, { passive: false });
295
+
296
+ // ─── Native drag routing (flows 1/2/4 over the canvas) ─────────────────────
297
+ // Chromium delivers native dragover/drop to the frame UNDER THE CURSOR, so once a
298
+ // Parent-originated drag (palette / layers / ⠿ handle) crosses onto the canvas the parent stops
299
+ // Seeing dragover — it can't forward dragMove, and cross-origin nothing preventDefaults here,
300
+ // Giving the "not allowed" cursor and a dead drop. So handle the native stream directly: while a
301
+ // Parent session is live (dragSrc set), accept the drag, post dragOver previews from OUR
302
+ // ClientX/y (already iframe-local — no rect conversion), and on drop post the authoritative
303
+ // DropResult. The cursor rides on dragOver so the parent can keep its ghost tracking (it has no
304
+ // Pointer stream of its own while the drag is over this frame).
305
+ //
306
+ // A native stream arriving with NO session yet (dragSrc null) is a parent drag that crossed onto
307
+ // The canvas before the parent could bind a host (it never sees a cursor inside the iframe rect) —
308
+ // Post nativeDragEnter so the bridge binds the session here. Throttled: dragover fires
309
+ // Continuously (~350ms even stationary), and an unclaimable stream (e.g. an OS file drag) would
310
+ // Otherwise spam the channel forever.
311
+ const NATIVE_ENTER_REPOST_MS = 300;
312
+ let lastNativeEnterPost = 0;
313
+ const onNativeDragOver = (e: DragEvent) => {
314
+ if (!dragSrc) {
315
+ const now = Date.now();
316
+ if (now - lastNativeEnterPost >= NATIVE_ENTER_REPOST_MS) {
317
+ lastNativeEnterPost = now;
318
+ channel.post({ kind: "nativeDragEnter" });
319
+ }
320
+ return;
321
+ }
322
+ e.preventDefault();
323
+ if (e.dataTransfer) {
324
+ e.dataTransfer.dropEffect = "move";
325
+ }
326
+ const cursor = { x: e.clientX, y: e.clientY };
327
+ const preview = shadowDoc
328
+ ? previewAt(cursor, dragSrc, shadowDoc, container.ownerDocument)
329
+ : null;
330
+ channel.post({ cursor, dragSeq: sessionSeq, gen: dragGen, kind: "dragOver", preview });
331
+ updateAutoScroll(cursor, sessionSeq);
332
+ };
333
+ const onNativeDrop = (e: DragEvent) => {
334
+ if (!dragSrc) {
335
+ return;
336
+ }
337
+ e.preventDefault();
338
+ const cursor = { x: e.clientX, y: e.clientY };
339
+ const preview = shadowDoc
340
+ ? previewAt(cursor, dragSrc, shadowDoc, container.ownerDocument)
341
+ : null;
342
+ channel.post({
343
+ dragSeq: sessionSeq,
344
+ gen: dragGen,
345
+ instruction: preview?.instruction ?? null,
346
+ kind: "dropResult",
347
+ targetPath: preview?.targetPath ?? null,
348
+ });
349
+ dragSrc = null;
350
+ dragGen = -1;
351
+ sessionSeq = -1;
352
+ stopAutoScroll();
353
+ clearIframeDrag();
354
+ };
355
+ container.ownerDocument.addEventListener("dragenter", onNativeDragOver, true);
356
+ container.ownerDocument.addEventListener("dragover", onNativeDragOver, true);
357
+ container.ownerDocument.addEventListener("drop", onNativeDrop, true);
358
+
359
+ const off = channel.onMessage((msg) => {
360
+ if (msg.kind === "measure") {
361
+ channel.post({
362
+ hits: measureHits(msg.paths, container.ownerDocument),
363
+ kind: "geometry",
364
+ reqId: msg.reqId,
365
+ });
366
+ return;
367
+ }
368
+ if (msg.kind === "patch") {
369
+ const { gen } = msg;
370
+ if (gen < renderedGen) {
371
+ // A newer full render already supersedes this edit — drop it.
372
+ return;
373
+ }
374
+ if (gen > renderedGen || !shadowDoc || !renderCtx) {
375
+ // The render this patch targets hasn't landed yet; let the parent escalate to a full render.
376
+ channel.post({ gen, kind: "patchError", message: "patch-ahead-of-render" });
377
+ return;
378
+ }
379
+ // A structural/subtree op that re-renders the active editable (or an ancestor) would detach
380
+ // The session's element mid-edit — commit and end the session first (in-place style/text
381
+ // Patches elsewhere leave it alone).
382
+ if (isEditing() && patchDisturbsActiveEdit(msg.forwardOps)) {
383
+ stopEditing();
384
+ }
385
+ try {
386
+ applyIframePatch(shadowDoc, msg.forwardOps, container, renderCtx);
387
+ channel.post({ gen, kind: "patchComplete" });
388
+ } catch (error) {
389
+ channel.post({
390
+ gen,
391
+ kind: "patchError",
392
+ message: String((error as Error)?.message ?? error),
393
+ });
394
+ }
395
+ return;
396
+ }
397
+ if (msg.kind === "styleUpdate") {
398
+ // Stylebook live style edit: swap the ROOT's style and re-run the runtime's style applier —
399
+ // One reapply regenerates the whole scoped-CSS cascade (real @media included) without a
400
+ // Re-render. A stale gen is dropped; the superseding render carries the same style.
401
+ if (msg.gen !== renderedGen || !shadowDoc) {
402
+ return;
403
+ }
404
+ (shadowDoc as { style?: JxStyle }).style = msg.style as JxStyle;
405
+ const rootEl = container.firstElementChild;
406
+ if (rootEl instanceof HTMLElement) {
407
+ reapplyStyle(
408
+ rootEl,
409
+ msg.style as JxStyle,
410
+ (shadowDoc as { $media?: Record<string, string> }).$media ?? {},
411
+ );
412
+ }
413
+ return;
414
+ }
415
+ if (msg.kind === "dragStart") {
416
+ // Begin a drag session: retain the source kind + the gen it targets. dragMove/drop replies are
417
+ // Tagged with this gen so the parent drops any that arrive after a re-render superseded it.
418
+ dragSrc = msg.src;
419
+ dragGen = msg.gen;
420
+ sessionSeq = msg.dragSeq;
421
+ return;
422
+ }
423
+ if (msg.kind === "dragEnd" || msg.kind === "dragCancel") {
424
+ // The pointer left the canvas / the session was cancelled: forget the session so a late move
425
+ // Or drop is a no-op, and clear any flow-3 (iframe-originated) drag state + auto-scroll.
426
+ dragSrc = null;
427
+ dragGen = -1;
428
+ sessionSeq = -1;
429
+ stopAutoScroll();
430
+ clearIframeDrag();
431
+ return;
432
+ }
433
+ if (msg.kind === "dragMove") {
434
+ // Display-only preview: hit-test the forwarded cursor, compute the placement, post dragOver.
435
+ // Null target/instruction → post a null preview so the parent clears any stale indicator.
436
+ const preview =
437
+ dragSrc && shadowDoc
438
+ ? previewAt(msg.cursor, dragSrc, shadowDoc, container.ownerDocument)
439
+ : null;
440
+ channel.post({ dragSeq: msg.dragSeq, gen: dragGen, kind: "dragOver", preview });
441
+ // Arm/stop the self-sustaining auto-scroll for this cursor (an edge-hold keeps scrolling).
442
+ updateAutoScroll(msg.cursor, msg.dragSeq);
443
+ return;
444
+ }
445
+ if (msg.kind === "drop") {
446
+ // Compute the drop FRESH from the live DOM (never from a cached preview) and post the result.
447
+ const preview =
448
+ dragSrc && shadowDoc
449
+ ? previewAt(msg.cursor, dragSrc, shadowDoc, container.ownerDocument)
450
+ : null;
451
+ channel.post({
452
+ dragSeq: msg.dragSeq,
453
+ gen: dragGen,
454
+ instruction: preview?.instruction ?? null,
455
+ kind: "dropResult",
456
+ targetPath: preview?.targetPath ?? null,
457
+ });
458
+ dragSrc = null;
459
+ dragGen = -1;
460
+ sessionSeq = -1;
461
+ stopAutoScroll();
462
+ clearIframeDrag();
463
+ return;
464
+ }
465
+ if (msg.kind !== "render" || msg.gen < latestGen) {
466
+ return;
467
+ }
468
+ // A render replaces the DOM under a live edit session — COMMIT it first (never discard). The
469
+ // Resulting editCommit/editEnd post synchronously here, so on the FIFO channel they precede
470
+ // This render's renderComplete and the parent routes them by the host's not-yet-flipped tab
471
+ // Identity — a commit racing a tab switch still lands in the document it belonged to.
472
+ if (isEditing()) {
473
+ stopEditing();
474
+ }
475
+ latestGen = msg.gen;
476
+ const { gen, mapperCtx } = msg;
477
+ const rawDoc = msg.shadowDoc as JxMutableNode;
478
+ void (async () => {
479
+ try {
480
+ // Drop the previous render's reactive scopes (root + any surgically-rendered subtrees).
481
+ disposeAllSubtrees();
482
+ stopDataScopeWatch?.();
483
+ stopDataScopeWatch = null;
484
+ handle?.dispose();
485
+ handle = await renderResolvedDocument({
486
+ container,
487
+ doc: msg.doc as JxDocument,
488
+ docBase: msg.docBase,
489
+ mapperCtx: {
490
+ arrayPaths: new Set(mapperCtx.arrayPaths),
491
+ canvasMode: mapperCtx.canvasMode,
492
+ layoutWrapped: mapperCtx.layoutWrapped,
493
+ pageContentOffset: mapperCtx.pageContentOffset,
494
+ pageContentPrefix: mapperCtx.pageContentPrefix,
495
+ },
496
+ mode: msg.mode,
497
+ siteStyle: msg.siteStyle,
498
+ });
499
+ if (gen === latestGen) {
500
+ // Adopt this generation's shadow doc + render context only once it's the live render.
501
+ shadowDoc = rawDoc;
502
+ renderCtx = handle.ctx;
503
+ renderedGen = gen;
504
+ currentMode = msg.mode;
505
+ channel.post({ gen, kind: "renderComplete" });
506
+ // Thread a serializable snapshot of the resolved $defs to the parent so the data-explorer
507
+ // Panel shows live data (the iframe, not the parent, now resolves the scope). Inside a
508
+ // Reactive effect: dev-proxy data sources ($prototype/$src) return a ref that fills AFTER
509
+ // BuildScope returns, and serializeDataScope reads defs[key], so the effect tracks those
510
+ // Refs and RE-POSTS an updated snapshot when the data settles (the host gen-gates stale
511
+ // Ones). Isolated in try/catch: a serialization failure never breaks the render ack above.
512
+ const defs = handle.ctx.defs as Record<string, unknown>;
513
+ stopDataScopeWatch = observeScope(() => {
514
+ try {
515
+ channel.post({ gen, kind: "dataScope", scope: serializeDataScope(defs) });
516
+ } catch {
517
+ // A pathological scope can't be serialized — skip; the render itself succeeded.
518
+ }
519
+ });
520
+ // Size the iframe to the freshly-rendered content (the ResizeObserver tracks later reflows).
521
+ postContentHeight();
522
+ }
523
+ } catch (error) {
524
+ channel.post({
525
+ gen,
526
+ kind: "renderError",
527
+ message: String((error as Error)?.message ?? error),
528
+ });
529
+ }
530
+ })();
531
+ });
532
+
533
+ channel.post({ kind: "ready" });
534
+ return () => {
535
+ off();
536
+ stopInteraction();
537
+ stopKeyForwarding();
538
+ stopInlineEdit();
539
+ stopSlashBridge();
540
+ stopGrabDetector();
541
+ stopImageRetry();
542
+ stopAutoScroll();
543
+ heightObserver?.disconnect();
544
+ container.ownerDocument.removeEventListener("wheel", onWheel);
545
+ container.ownerDocument.removeEventListener("dragenter", onNativeDragOver, true);
546
+ container.ownerDocument.removeEventListener("dragover", onNativeDragOver, true);
547
+ container.ownerDocument.removeEventListener("drop", onNativeDrop, true);
548
+ stopDataScopeWatch?.();
549
+ handle?.dispose();
550
+ };
551
+ }
552
+
553
+ /** The window surface {@link bootCanvasIframe} needs — injected so it's testable without a frame. */
554
+ interface BootWindow {
555
+ location: { search: string };
556
+ document: { querySelector: (selectors: string) => Element | null; body: HTMLElement };
557
+ parent: { postMessage: (message: unknown, targetOrigin: string) => void };
558
+ addEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
559
+ removeEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
560
+ }
561
+
562
+ /**
563
+ * Boot the entry against a window: open a token+origin-authenticated channel to the parent (origin
564
+ * and token are passed in via the iframe URL) and render into `#jx-canvas-root` (or `<body>`).
565
+ */
566
+ export function bootCanvasIframe(win: BootWindow): () => void {
567
+ const params = new URLSearchParams(win.location.search);
568
+ // Authenticate the runtime dev-proxy resolve/server fetches to the token-gated loopback server.
569
+ // The server rpcToken rides the iframe URL as `rpcToken`; the `token` param is the separate
570
+ // PostMessage channel secret (set by the host). Absent on dev/chromium, where setResolveToken no-ops.
571
+ setResolveToken(params.get("rpcToken"));
572
+ // ParentOrigin authenticates the parent peer. The host (iframe-host.ts) passes it ONLY for an
573
+ // Http(s) parent (dev / chromium — same-origin, the origin round-trips). It OMITS it for a
574
+ // Non-http(s) parent (electrobun views://), whose custom scheme may not surface as a postMessage
575
+ // Origin: a missing value here means fall back to "*" — token-gated, NOT a silent omission — and
576
+ // Log it loudly so the looser origin check is visible.
577
+ const explicitParentOrigin = params.get("parentOrigin");
578
+ const parentOrigin = explicitParentOrigin || "*";
579
+ if (!explicitParentOrigin) {
580
+ console.warn(
581
+ "[jx-canvas] no parentOrigin in iframe URL — accepting messages from any origin (token-gated). " +
582
+ "The parent origin did not round-trip; the channel relies on the shared token alone.",
583
+ );
584
+ }
585
+ const container = (win.document.querySelector("#jx-canvas-root") ??
586
+ win.document.body) as HTMLElement;
587
+ const channel = postMessageChannel<IframeToParent, ParentToIframe>({
588
+ acceptOrigin: parentOrigin,
589
+ source: win,
590
+ target: win.parent,
591
+ targetOrigin: parentOrigin,
592
+ token: params.get("token") || "",
593
+ });
594
+ return startCanvasIframe({ channel, container });
595
+ }
596
+
597
+ // Boot only when actually loaded as the iframe document (has a real parent frame), never in tests.
598
+ if (typeof window !== "undefined" && window.parent !== window) {
599
+ bootCanvasIframe(window as unknown as BootWindow);
600
+ }