@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,458 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * In-iframe render core — turns a fully-resolved document into live DOM via @jxsuite/runtime,
4
+ * stamping `data-jx-path` so the editor can map nodes back to document paths across the frame
5
+ * boundary. The parent does the heavy resolution (layout distribution, site-context, `$head`,
6
+ * components, edit-mode transforms) and posts the result; this core stays dependency-light (runtime
7
+ * + reactivity + the pure path-mapping helpers) so the iframe bundle is small.
8
+ *
9
+ * Because the iframe is served from the real project origin, the runtime's verbatim
10
+ * `el.setAttribute("src", "/images/foo.jpg")` resolves natively — the fix that motivated the whole
11
+ * migration, with no data: URL rewriting.
12
+ */
13
+
14
+ import {
15
+ buildScope,
16
+ defineElement,
17
+ renderNode,
18
+ runScoped,
19
+ setCanvasDelinkAnchors,
20
+ setCanvasViewportTranspose,
21
+ setRootMedia,
22
+ setSkipServerFunctions,
23
+ transposeCanvasUnits,
24
+ } from "@jxsuite/runtime";
25
+ import { classifyRenderNode, serializeJxPath } from "./path-mapping";
26
+ import type { CanvasMode } from "./iframe-protocol";
27
+ import type { JxDocument } from "@jxsuite/schema/types";
28
+ import type { PathMapCtx } from "./path-mapping";
29
+
30
+ /**
31
+ * The retained render context a full render leaves behind so the surgical patcher can re-render an
32
+ * individual subtree (insert/replace/attr edits) with the SAME scope, doc base, path mapping, and
33
+ * mode — making a patched subtree indistinguishable from a full re-render.
34
+ */
35
+ export interface IframeRenderCtx {
36
+ /** The `$defs` scope built for the document (resolves `$ref`/state/`$media` bindings). */
37
+ defs: Awaited<ReturnType<typeof buildScope>>;
38
+ docBase: string;
39
+ mapperCtx: PathMapCtx;
40
+ mode: CanvasMode;
41
+ }
42
+
43
+ export interface RenderHandle {
44
+ /** Stop the render's reactive effect scope (call before re-rendering to avoid effect leaks). */
45
+ dispose: () => void;
46
+ /** Context for surgical subtree re-renders against this generation's scope/mapping. */
47
+ ctx: IframeRenderCtx;
48
+ }
49
+
50
+ interface HeadEntry {
51
+ tagName?: string;
52
+ attributes?: Record<string, unknown>;
53
+ textContent?: string;
54
+ }
55
+
56
+ /**
57
+ * Register the document's `$elements` (components) in this iframe realm so the runtime can render
58
+ * them.
59
+ */
60
+ export async function registerElements(doc: JxDocument, docBase: string): Promise<void> {
61
+ const elements = (doc as { $elements?: unknown[] }).$elements;
62
+ if (!Array.isArray(elements)) {
63
+ return;
64
+ }
65
+ // Register in parallel, each guarded by a timeout so one slow/hanging component can't block the
66
+ // Whole render (the document still renders; the unresolved tag just stays inert).
67
+ await Promise.all(
68
+ elements.map(async (entry) => {
69
+ const task = (async () => {
70
+ if (typeof entry === "string") {
71
+ const specifier =
72
+ entry.startsWith("/") || entry.startsWith(".") ? entry : `/node_modules/${entry}`;
73
+ await import(specifier);
74
+ } else if (entry && typeof entry === "object" && "$ref" in entry) {
75
+ await defineElement(new URL(String((entry as { $ref: string }).$ref), docBase).href);
76
+ } else if (entry && typeof entry === "object") {
77
+ await defineElement(entry as JxDocument, docBase);
78
+ }
79
+ })();
80
+ try {
81
+ await Promise.race([
82
+ task,
83
+ new Promise((_, reject) => {
84
+ setTimeout(() => reject(new Error("register timeout")), 5000);
85
+ }),
86
+ ]);
87
+ } catch (error) {
88
+ console.warn("iframe canvas: failed to register element", JSON.stringify(entry), error);
89
+ }
90
+ }),
91
+ );
92
+ }
93
+
94
+ /** Id of the injected design/edit-mode canvas stylesheet (placeholder affordances). */
95
+ export const EDIT_PLACEHOLDER_STYLE_ID = "jx-canvas-edit-css";
96
+
97
+ /**
98
+ * The design/edit-mode canvas CSS, ported from the parent editor stylesheet (index.html) with
99
+ * iframe-safe fallbacks — the parent theme variables (--fg-dim/--radius/--accent) don't exist in
100
+ * the iframe document. The placeholder CLASSES are stamped by prepareForEditMode (parent-side
101
+ * resolution + surgical subtree renders), so preview mode never matches these rules — but the sheet
102
+ * is still removed there (belt and braces). The `[contenteditable="true"]:empty` hint is new: it
103
+ * marks the caret's empty paragraph (e.g. right after an Enter split) and advertises the slash
104
+ * menu.
105
+ */
106
+ export const EDIT_PLACEHOLDER_CSS = `
107
+ .empty-media-placeholder {
108
+ display: inline-block;
109
+ min-width: 120px;
110
+ min-height: 80px;
111
+ border: 1px dashed color-mix(in srgb, #808080 30%, transparent);
112
+ border-radius: 4px;
113
+ background: color-mix(in srgb, #808080 5%, transparent)
114
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' fill='none'%3E%3Crect x='4' y='8' width='32' height='24' rx='2' stroke='%23808080' stroke-opacity='.4' stroke-width='1.5'/%3E%3Ccircle cx='13' cy='16' r='3' stroke='%23808080' stroke-opacity='.4' stroke-width='1.5'/%3E%3Cpath d='M8 28l8-8 5 5 4-4 7 7' stroke='%23808080' stroke-opacity='.4' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
115
+ center / 40px no-repeat;
116
+ color: transparent;
117
+ font-size: 0;
118
+ overflow: hidden;
119
+ }
120
+ .empty-text-placeholder:not([contenteditable])::after {
121
+ content: "Click here to add text...";
122
+ color: color-mix(in srgb, #808080 40%, transparent);
123
+ font-style: italic;
124
+ font-size: 13px;
125
+ pointer-events: none;
126
+ }
127
+ .empty-container-placeholder {
128
+ border: 1px dashed color-mix(in srgb, #808080 25%, transparent);
129
+ border-radius: 4px;
130
+ min-height: 32px;
131
+ position: relative;
132
+ }
133
+ .empty-container-placeholder::after {
134
+ content: "Drag elements here...";
135
+ position: absolute;
136
+ top: 50%;
137
+ left: 50%;
138
+ transform: translate(-50%, -50%);
139
+ color: color-mix(in srgb, #808080 40%, transparent);
140
+ font-style: italic;
141
+ font-size: 11px;
142
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
143
+ pointer-events: none;
144
+ white-space: nowrap;
145
+ }
146
+ [contenteditable="true"]:empty::after {
147
+ content: "Type / for commands";
148
+ color: color-mix(in srgb, #808080 40%, transparent);
149
+ font-style: italic;
150
+ font-size: 13px;
151
+ pointer-events: none;
152
+ }
153
+ `;
154
+
155
+ /**
156
+ * Keep the design/edit canvas stylesheet in sync with the render mode: present (idempotently) for
157
+ * design/edit, removed otherwise (preview must look live; stylebook specimens must not show "Click
158
+ * here to add text..." placeholders).
159
+ */
160
+ export function syncEditModeCss(doc: Document, mode: CanvasMode): void {
161
+ const existing = doc.head.querySelector(`#${EDIT_PLACEHOLDER_STYLE_ID}`);
162
+ if (mode !== "design" && mode !== "edit") {
163
+ existing?.remove();
164
+ return;
165
+ }
166
+ if (existing) {
167
+ return;
168
+ }
169
+ const style = doc.createElement("style");
170
+ style.id = EDIT_PLACEHOLDER_STYLE_ID;
171
+ style.textContent = EDIT_PLACEHOLDER_CSS;
172
+ doc.head.append(style);
173
+ }
174
+
175
+ /** Id of the injected stylebook-mode chrome stylesheet (section/card scaffolding). */
176
+ export const STYLEBOOK_STYLE_ID = "jx-canvas-stylebook-css";
177
+
178
+ /**
179
+ * Card/section chrome for the stylebook specimen document, ported from the parent editor stylesheet
180
+ * with self-contained fallback values (the parent theme vars don't exist in the iframe).
181
+ * Deliberately OMITS the parent's `.element-card-preview { pointer-events: none }` — the iframe
182
+ * owns hit-testing and needs real hits on the specimens.
183
+ */
184
+ export const STYLEBOOK_CSS = `
185
+ .sb-root {
186
+ padding: 16px;
187
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
188
+ }
189
+ .sb-section {
190
+ margin-bottom: 24px;
191
+ }
192
+ .sb-label {
193
+ font-size: 10px;
194
+ font-weight: 700;
195
+ text-transform: uppercase;
196
+ letter-spacing: 0.08em;
197
+ color: color-mix(in srgb, #808080 70%, transparent);
198
+ padding: 8px 0 4px;
199
+ border-bottom: 1px solid color-mix(in srgb, #808080 25%, transparent);
200
+ margin-bottom: 8px;
201
+ }
202
+ .sb-body {
203
+ padding: 4px 0;
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 6px;
207
+ }
208
+ .element-card {
209
+ display: flex;
210
+ flex-direction: column;
211
+ width: 100%;
212
+ border: 1px solid color-mix(in srgb, #808080 30%, transparent);
213
+ border-radius: 4px;
214
+ overflow: hidden;
215
+ margin-bottom: 6px;
216
+ }
217
+ .element-card-preview {
218
+ background: #fff;
219
+ padding: 6px 8px;
220
+ min-height: 32px;
221
+ display: flex;
222
+ align-items: center;
223
+ overflow: hidden;
224
+ }
225
+ .element-card-preview > * {
226
+ max-width: 100%;
227
+ margin: 0;
228
+ padding: 0;
229
+ }
230
+ .element-card-preview > hr {
231
+ width: 100%;
232
+ border: none;
233
+ border-top: 1px solid color-mix(in srgb, #808080 40%, transparent);
234
+ }
235
+ .element-card-preview > input,
236
+ .element-card-preview > textarea,
237
+ .element-card-preview > select,
238
+ .element-card-preview > button,
239
+ .element-card-preview > progress,
240
+ .element-card-preview > meter {
241
+ font-size: 10px;
242
+ }
243
+ .element-card-label {
244
+ padding: 2px 6px;
245
+ font-size: 10px;
246
+ color: color-mix(in srgb, #808080 70%, transparent);
247
+ background: color-mix(in srgb, #808080 8%, transparent);
248
+ text-align: center;
249
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
250
+ }
251
+ .sb-fallback {
252
+ padding: 12px;
253
+ border: 1px dashed color-mix(in srgb, #808080 40%, transparent);
254
+ border-radius: 4px;
255
+ color: color-mix(in srgb, #808080 70%, transparent);
256
+ }
257
+ .sb-empty {
258
+ padding: 48px;
259
+ text-align: center;
260
+ color: color-mix(in srgb, #808080 70%, transparent);
261
+ font-size: 13px;
262
+ }
263
+ `;
264
+
265
+ /**
266
+ * Keep the stylebook chrome stylesheet in sync with the render mode: present (idempotently) for
267
+ * stylebook, removed otherwise.
268
+ */
269
+ export function syncStylebookCss(doc: Document, mode: CanvasMode): void {
270
+ const existing = doc.head.querySelector(`#${STYLEBOOK_STYLE_ID}`);
271
+ if (mode !== "stylebook") {
272
+ existing?.remove();
273
+ return;
274
+ }
275
+ if (existing) {
276
+ return;
277
+ }
278
+ const style = doc.createElement("style");
279
+ style.id = STYLEBOOK_STYLE_ID;
280
+ style.textContent = STYLEBOOK_CSS;
281
+ doc.head.append(style);
282
+ }
283
+
284
+ /** Apply the project's site style: custom properties on :root, plain properties on <body>. */
285
+ export function applySiteStyle(siteStyle: Record<string, unknown> | null | undefined): void {
286
+ if (!siteStyle || typeof siteStyle !== "object") {
287
+ return;
288
+ }
289
+ const rootStyle = document.documentElement.style;
290
+ const bodyStyle = document.body.style as unknown as Record<string, string>;
291
+ for (const [key, value] of Object.entries(siteStyle)) {
292
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
293
+ continue;
294
+ }
295
+ if (key.startsWith("--")) {
296
+ rootStyle.setProperty(key, transposeCanvasUnits(String(value)));
297
+ } else {
298
+ bodyStyle[key] = transposeCanvasUnits(String(value));
299
+ }
300
+ }
301
+ }
302
+
303
+ /** Inject the document's `$head` (link/meta/script) into the iframe's <head>, de-duped by href/src. */
304
+ export function injectHead(doc: JxDocument): void {
305
+ const head = (doc as { $head?: HeadEntry[] }).$head;
306
+ if (!Array.isArray(head)) {
307
+ return;
308
+ }
309
+ for (const entry of head) {
310
+ if (!entry?.tagName) {
311
+ continue;
312
+ }
313
+ const tag = String(entry.tagName).toLowerCase();
314
+ // Skip inline scripts in design/edit; they're for the live page, not the editor canvas.
315
+ if (tag === "script" && !entry.attributes?.src) {
316
+ continue;
317
+ }
318
+ const attrs = { ...entry.attributes } as Record<string, unknown>;
319
+ for (const key of ["href", "src"]) {
320
+ const val = attrs[key];
321
+ if (
322
+ typeof val === "string" &&
323
+ !val.startsWith("/") &&
324
+ !val.startsWith(".") &&
325
+ !val.startsWith("http")
326
+ ) {
327
+ attrs[key] = `/node_modules/${val}`;
328
+ }
329
+ }
330
+ const sel = `${tag}${attrs.href ? `[href="${String(attrs.href)}"]` : ""}${attrs.src ? `[src="${String(attrs.src)}"]` : ""}`;
331
+ if (sel !== tag && document.head.querySelector(sel)) {
332
+ continue;
333
+ }
334
+ const el = document.createElement(tag);
335
+ for (const [k, v] of Object.entries(attrs)) {
336
+ el.setAttribute(k, String(v));
337
+ }
338
+ if (entry.textContent) {
339
+ el.textContent = entry.textContent;
340
+ }
341
+ document.head.append(el);
342
+ }
343
+ }
344
+
345
+ /** Build the `onNodeCreated` hook that stamps `data-jx-path` / `data-jx-layout` on rendered nodes. */
346
+ export function makeStamper(ctx: PathMapCtx) {
347
+ return (created: Node, path: (string | number)[], def: unknown) => {
348
+ if (!(created instanceof HTMLElement)) {
349
+ return;
350
+ }
351
+ // The OPENED document's root can itself be a component definition (`tagName: "eer-cta"`). If
352
+ // That tag is already registered in this realm (a previously-rendered page instantiated it —
353
+ // Hosts persist across tab switches), the upgrade's connectedCallback would wipe the stamped
354
+ // Editable tree and re-render a live instance with default state. Mark the root so the
355
+ // Runtime's element class leaves it alone (see defineElement's connectedCallback guard).
356
+ if (path.length === 0 && (def as { tagName?: string } | null)?.tagName?.includes("-")) {
357
+ created.dataset.jxDefinitionRoot = "";
358
+ }
359
+ const classified = classifyRenderNode(path, def, ctx);
360
+ if (classified.kind === "layout") {
361
+ created.dataset.jxLayout = "";
362
+ return;
363
+ }
364
+ created.dataset.jxPath = serializeJxPath(classified.path);
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Recover canvas images that fail their first load. Registered components create their <img> in
370
+ * connectedCallback AFTER async registration, so on a cold first render those requests can fire
371
+ * before the loopback server is warm and 404 — which the browser then caches as a
372
+ * permanently-broken <img>. This re-fires a failed request a few times with backoff (exactly what
373
+ * the manual canvas re-render does), recovering the image without a full re-render. Bounded
374
+ * per-image so a genuinely missing file settles broken. Intentional data: placeholders never error,
375
+ * so they're untouched. Returns a teardown that removes the listener. <img> error events don't
376
+ * bubble, so listen in CAPTURE.
377
+ */
378
+ export function installCanvasImageRetry(root: HTMLElement, maxAttempts = 3): () => void {
379
+ const attempts = new WeakMap<HTMLImageElement, number>();
380
+ const onError = (event: Event): void => {
381
+ const img = event.target;
382
+ if (!(img instanceof HTMLImageElement)) {
383
+ return;
384
+ }
385
+ // A data: URL never errors (and an empty src isn't a real request) — nothing to retry.
386
+ if (!img.src || img.src.startsWith("data:")) {
387
+ return;
388
+ }
389
+ const attempt = (attempts.get(img) ?? 0) + 1;
390
+ if (attempt > maxAttempts) {
391
+ // Bounded: a genuinely missing file settles broken instead of retrying forever.
392
+ return;
393
+ }
394
+ attempts.set(img, attempt);
395
+ setTimeout(() => {
396
+ // Re-fire the request by clearing then re-assigning the same src (mirrors the manual re-render).
397
+ const s = img.src;
398
+ img.src = "";
399
+ img.src = s;
400
+ }, 150 * attempt);
401
+ };
402
+ root.addEventListener("error", onError, true);
403
+ return () => root.removeEventListener("error", onError, true);
404
+ }
405
+
406
+ /**
407
+ * Render a resolved document into `container`, replacing its current children. `mode` controls
408
+ * whether server functions run (live in `preview`; skipped in `design`/`edit`). Returns a handle
409
+ * whose `dispose()` stops the reactive scope.
410
+ */
411
+ export async function renderResolvedDocument(opts: {
412
+ container: HTMLElement;
413
+ doc: JxDocument;
414
+ docBase: string;
415
+ mode: CanvasMode;
416
+ mapperCtx: PathMapCtx;
417
+ siteStyle?: Record<string, unknown> | null;
418
+ }): Promise<RenderHandle> {
419
+ setSkipServerFunctions(opts.mode !== "preview");
420
+ // Transpose viewport units (vh/vw/…) → container units (cqh/cqw/…) so they resolve against the
421
+ // Canvas's fixed-size query container (canvas.html) instead of the iframe element. That decouples
422
+ // Them from the iframe height, letting the host size the iframe to its content without `100vh`
423
+ // Sections feeding back into an ever-growing height. Set every render (the iframe always wants it).
424
+ setCanvasViewportTranspose(true);
425
+ // De-link `<a href>` in design/edit so clicks select the anchor instead of navigating the iframe;
426
+ // Preview keeps real links live (mirrors the server-function gate above).
427
+ setCanvasDelinkAnchors(opts.mode !== "preview");
428
+ applySiteStyle(opts.siteStyle);
429
+ injectHead(opts.doc);
430
+ syncEditModeCss(opts.container.ownerDocument, opts.mode);
431
+ syncStylebookCss(opts.container.ownerDocument, opts.mode);
432
+ // Seed the runtime's root $media before buildScope so a COMPONENT with its own `@--name` blocks
433
+ // But no own `$media` resolves the breakpoint to its real query (the iframe path calls buildScope
434
+ // Directly and never the runtime's `Jx()` entry, which is the only other place _rootMedia is set).
435
+ // Set it every render (even to `{}`) so a stale map from a previous document cannot leak.
436
+ setRootMedia((opts.doc as { $media?: Record<string, string> }).$media ?? {});
437
+ // Register components BEFORE renderNode. The runtime only applies a custom element's `$props` when
438
+ // That element is ALREADY defined (renderNode gates renderCustomElementWithProps on a truthy
439
+ // `customElements.get(tagName)`); if we register after render, every component paints with its
440
+ // Props dropped — it upgrades in place to the empty default state (<img src="">) — which was the
441
+ // Empty-render regression. `registerElements` wraps each element in a per-element 5s Promise.race
442
+ // Timeout and swallows failures internally, so awaiting it can't block the render indefinitely on
443
+ // A slow/hanging component (the document still renders; an unresolved tag just stays inert).
444
+ await registerElements(opts.doc, opts.docBase);
445
+ const $defs = await buildScope(opts.doc, {}, opts.docBase);
446
+ const onNodeCreated = makeStamper(opts.mapperCtx);
447
+ // The scope MUST be the runtime's (runScoped): renderNode creates its binding effects with the
448
+ // Runtime's copy of @vue/reactivity, and scope collection is per module instance — a studio
449
+ // EffectScope here collects nothing and dispose() would leak every effect of this render.
450
+ const { result: el, stop } = runScoped(
451
+ () => renderNode(opts.doc, $defs, { _path: [], onNodeCreated }) as HTMLElement,
452
+ );
453
+ opts.container.replaceChildren(el);
454
+ return {
455
+ ctx: { defs: $defs, docBase: opts.docBase, mapperCtx: opts.mapperCtx, mode: opts.mode },
456
+ dispose: stop,
457
+ };
458
+ }
@@ -0,0 +1,114 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Iframe-realm slash-menu bridge — the inline-edit engine detects "/" and drives a DI'd
4
+ * {@link SlashController}; the real lit/Spectrum menu can't render in the slim iframe bundle, so
5
+ * this controller proxies it across the postMessage bridge. It posts `slashShow` (with the edited
6
+ * element's IFRAME-VIEWPORT rect + the typed filter) / `slashDismiss`; the host shows the parent
7
+ * menu and posts back `slashSelect` / `slashDismissed`.
8
+ *
9
+ * While the menu is open, the four navigation keys are intercepted here CAPTURE-PHASE — before the
10
+ * engine's element-level keydown — and posted as `slashNav` for the parent menu's key handler. That
11
+ * restores the legacy "shared slash menu captures Enter" contract (the engine's own Enter handler
12
+ * defers when `slash.isOpen()`), and keeps Escape from ending the edit session while the menu is
13
+ * up.
14
+ */
15
+
16
+ import { setSlashController } from "../editor/inline-edit";
17
+ import { rectOf } from "../utils/geometry";
18
+ import type { SlashCommand, SlashController } from "../editor/inline-edit";
19
+ import type { IframeChannel } from "./iframe-channel";
20
+ import type { IframeToParent, ParentToIframe } from "./iframe-protocol";
21
+
22
+ type SlashNavKey = Extract<IframeToParent, { kind: "slashNav" }>["key"];
23
+
24
+ const NAV_KEYS = new Set(["ArrowUp", "ArrowDown", "Enter", "Escape"]);
25
+
26
+ /**
27
+ * Register the bridge SlashController for this iframe realm and wire its keyboard/dismissal
28
+ * listeners. Returns a teardown function (restores a no-op controller).
29
+ */
30
+ export function startIframeSlashBridge(
31
+ channel: IframeChannel<IframeToParent, ParentToIframe>,
32
+ doc: Document,
33
+ ): () => void {
34
+ let open = false;
35
+ // Kept across `slashDismissed`: the parent's select() dismisses the menu BEFORE it fires
36
+ // OnSelect, so a slashSelect legitimately arrives after the dismissal notification.
37
+ let onSelect: ((cmd: SlashCommand) => void) | null = null;
38
+ let lastPostedFilter: string | null = null;
39
+
40
+ const controller: SlashController = {
41
+ dismiss: () => {
42
+ if (!open) {
43
+ return;
44
+ }
45
+ open = false;
46
+ lastPostedFilter = null;
47
+ channel.post({ kind: "slashDismiss" });
48
+ },
49
+ isOpen: () => open,
50
+ show: (anchorEl, filter, cbs) => {
51
+ ({ onSelect } = cbs);
52
+ if (open && filter === lastPostedFilter) {
53
+ return;
54
+ }
55
+ open = true;
56
+ lastPostedFilter = filter;
57
+ const r = rectOf(anchorEl);
58
+ channel.post({
59
+ filter,
60
+ kind: "slashShow",
61
+ rect: { height: r.height, width: r.width, x: r.left, y: r.top },
62
+ });
63
+ },
64
+ };
65
+ setSlashController(controller);
66
+
67
+ const onKeyDown = (e: KeyboardEvent) => {
68
+ if (!open || !NAV_KEYS.has(e.key)) {
69
+ return;
70
+ }
71
+ e.preventDefault();
72
+ e.stopPropagation();
73
+ channel.post({ key: e.key as SlashNavKey, kind: "slashNav" });
74
+ };
75
+ const onMouseDown = () => {
76
+ // The parent menu's outside-click listener can't see iframe clicks — any in-iframe press while
77
+ // The menu is open dismisses it (legacy parity: the popover is never inside this document). The
78
+ // Engine's click-away commit is gated on slash.isOpen(), so the dismissal wins and the edit
79
+ // Session survives the click.
80
+ if (open) {
81
+ controller.dismiss();
82
+ }
83
+ };
84
+ doc.addEventListener("keydown", onKeyDown, true);
85
+ doc.addEventListener("mousedown", onMouseDown, true);
86
+
87
+ const off = channel.onMessage((msg) => {
88
+ if (msg.kind === "slashSelect") {
89
+ open = false;
90
+ lastPostedFilter = null;
91
+ onSelect?.({ ...msg.cmd });
92
+ return;
93
+ }
94
+ if (msg.kind === "slashDismissed") {
95
+ open = false;
96
+ lastPostedFilter = null;
97
+ }
98
+ });
99
+
100
+ return () => {
101
+ doc.removeEventListener("keydown", onKeyDown, true);
102
+ doc.removeEventListener("mousedown", onMouseDown, true);
103
+ off();
104
+ setSlashController({
105
+ dismiss: () => {
106
+ // Bridge torn down — no menu in this realm.
107
+ },
108
+ isOpen: () => false,
109
+ show: () => {
110
+ // Bridge torn down — no menu in this realm.
111
+ },
112
+ });
113
+ };
114
+ }
@@ -0,0 +1,113 @@
1
+ /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
3
+ /**
4
+ * In-iframe subtree rendering for surgical structural patches (insert / replace / attribute edits).
5
+ * The cross-frame analog of `canvas-subtree-render.ts`: it re-renders a single shadow-doc node into
6
+ * fresh DOM using the SAME edit-mode transform, runtime renderer, scope (`$defs`), path mapping,
7
+ * and mode the full render used — so a patched subtree is indistinguishable from a full re-render.
8
+ *
9
+ * Dependency-light (runtime + reactivity + the pure path/edit helpers) so it stays inside the slim
10
+ * canvas-iframe bundle. Where the legacy patcher records render scopes in the `elToRenderScope`
11
+ * WeakMap, this stamps `data-jx-path` and tracks per-subtree effect scopes locally.
12
+ */
13
+
14
+ import { elementStyleTags, renderNode, runScoped } from "@jxsuite/runtime";
15
+ import { getNodeAtPath } from "../state";
16
+ import { makeStamper } from "./iframe-render";
17
+ import { prepareForEditMode } from "../utils/edit-display";
18
+ import { stripEventHandlers } from "../utils/strip-events";
19
+ import type { IframeRenderCtx } from "./iframe-render";
20
+ import type { JxPath } from "../state";
21
+ import type { JxMutableNode } from "@jxsuite/schema/types";
22
+
23
+ /**
24
+ * The scope disposer each surgically-rendered subtree root owns (for disposal on remove/replace).
25
+ * Disposers come from the RUNTIME's runScoped — renderNode's effects belong to the runtime's copy
26
+ * of vue-reactivity, so a studio-instance effectScope here would collect nothing and leak every
27
+ * binding effect of a superseded subtree (they keep firing against detached DOM).
28
+ */
29
+ const elScope = new WeakMap<HTMLElement, () => void>();
30
+ /** Every live subtree disposer, so a full re-render can stop them all (they're detached roots). */
31
+ const liveScopes = new Set<() => void>();
32
+
33
+ /**
34
+ * Render the shadow-doc node at `docPath` into a detached DOM subtree, stamped with `data-jx-path`
35
+ * and edit-mode-prepared exactly like the full render. Throws when the path resolves to nothing.
36
+ */
37
+ export function renderSubtreeIframe(
38
+ shadowDoc: JxMutableNode,
39
+ docPath: JxPath,
40
+ ctx: IframeRenderCtx,
41
+ ): HTMLElement | Text {
42
+ const node = getNodeAtPath(shadowDoc, docPath) as JxMutableNode | string | undefined;
43
+ if (node === undefined) {
44
+ throw new Error(`iframe-patch-node-not-found:${docPath.join("/")}`);
45
+ }
46
+ const def = typeof node === "string" ? node : prepareForEditMode(stripEventHandlers(node));
47
+ const { result: rendered, stop } = runScoped(() =>
48
+ renderNode(def, ctx.defs, {
49
+ _path: docPathToRenderPath(docPath, ctx),
50
+ onNodeCreated: makeStamper(ctx.mapperCtx),
51
+ }),
52
+ );
53
+ // Track the disposer for bulk dispose on full re-render; element disposers are also keyed by their
54
+ // Root element so a targeted remove/replace can stop just that subtree (a bare text node owns none).
55
+ liveScopes.add(stop);
56
+ if (rendered instanceof HTMLElement) {
57
+ elScope.set(rendered, stop);
58
+ }
59
+ return rendered;
60
+ }
61
+
62
+ /**
63
+ * Release a removed/replaced subtree: stop the effect scopes rooted inside it and drop the scoped
64
+ * `<style>` tags the runtime emitted for its elements (otherwise they orphan in the iframe head).
65
+ */
66
+ export function disposeSubtree(el: Element): void {
67
+ const targets: Element[] = [el, ...el.querySelectorAll("*")];
68
+ for (const t of targets) {
69
+ if (!(t instanceof HTMLElement)) {
70
+ continue;
71
+ }
72
+ const tag = elementStyleTags.get(t);
73
+ if (tag) {
74
+ tag.remove();
75
+ elementStyleTags.delete(t);
76
+ }
77
+ const stop = elScope.get(t);
78
+ if (stop) {
79
+ stop();
80
+ liveScopes.delete(stop);
81
+ elScope.delete(t);
82
+ }
83
+ }
84
+ }
85
+
86
+ /** Stop every live subtree scope — called before a full re-render replaces the whole document. */
87
+ export function disposeAllSubtrees(): void {
88
+ for (const stop of liveScopes) {
89
+ stop();
90
+ }
91
+ liveScopes.clear();
92
+ }
93
+
94
+ /**
95
+ * Inverse of the path mapper's layout-prefix strip: shadow-doc paths are page-relative, render
96
+ * paths are layout-merged. Re-applies the slot-container offset so the runtime renders the subtree
97
+ * at the same render path the full render used. ($map/$switch paths never reach here — they
98
+ * escalate.)
99
+ */
100
+ function docPathToRenderPath(docPath: JxPath, ctx: IframeRenderCtx): (string | number)[] {
101
+ const { mapperCtx } = ctx;
102
+ if (mapperCtx.layoutWrapped && mapperCtx.pageContentPrefix && docPath[0] === "children") {
103
+ const [, idx] = docPath;
104
+ return typeof idx === "number"
105
+ ? [
106
+ ...mapperCtx.pageContentPrefix,
107
+ idx + (mapperCtx.pageContentOffset ?? 0),
108
+ ...docPath.slice(2),
109
+ ]
110
+ : [...mapperCtx.pageContentPrefix, ...docPath.slice(1)];
111
+ }
112
+ return docPath;
113
+ }