@jxsuite/studio 0.32.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.
- package/dist/iframe-entry.js +6230 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +22791 -13709
- package/dist/studio.js.map +181 -74
- package/package.json +12 -7
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +2 -1
- package/src/page-params.ts +383 -0
- package/src/panels/ai-panel.ts +386 -328
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +0 -2
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +18 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/state.ts +29 -0
- package/src/store.ts +4 -62
- package/src/studio.ts +90 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +33 -69
- package/src/types.ts +16 -24
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +5 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- 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
|
+
}
|