@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,363 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * In-iframe surgical patcher — applies value-carrying forward ops to the iframe's shadow doc and
4
+ * the live DOM, so an edit updates in place instead of re-rendering the whole document. This is the
5
+ * cross-frame analog of `canvas-patcher.ts`: the parent classifies + records ops and posts the
6
+ * forward (value-carrying) half; the iframe folds them into its non-reactive shadow doc and mutates
7
+ * the DOM it owns.
8
+ *
9
+ * It applies the full op set the legacy patcher does: in-place `set-key` writes (`style`/
10
+ * `textContent`, and inert event bindings); structural relocation (`remove-child`/`move-child`) as
11
+ * pure DOM moves with `data-jx-path` remapping; and subtree re-renders (`insert-child`/`set-child`,
12
+ * and any other `set-key` that changes rendered output) via {@link renderSubtreeIframe}. Any op it
13
+ * can't apply throws, which the caller reports as `patchError` so the parent escalates to a full
14
+ * render.
15
+ */
16
+
17
+ import { reapplyStyle } from "@jxsuite/runtime";
18
+ import { getNodeAtPath } from "../state";
19
+ import { applyDocOpToDoc } from "../tabs/doc-op-apply";
20
+ import { parseJxPath, serializeJxPath } from "./path-mapping";
21
+ import { disposeSubtree, renderSubtreeIframe } from "./iframe-subtree";
22
+ import {
23
+ computeEmptyPlaceholderClass,
24
+ EMPTY_PLACEHOLDER_CLASSES,
25
+ templateToEditDisplay,
26
+ } from "../utils/edit-display";
27
+ import type { IframeRenderCtx } from "./iframe-render";
28
+ import type { WireDocOp } from "./iframe-protocol";
29
+ import type { JxPath } from "../state";
30
+ import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
31
+
32
+ /**
33
+ * Apply a batch of forward ops. Every op is folded into the shadow doc first (so DOM reads see
34
+ * post-mutation truth, mirroring the parent patcher reading its already-mutated reactive doc), then
35
+ * each op patches the DOM. Throws on the first op it cannot apply surgically.
36
+ */
37
+ export function applyIframePatch(
38
+ shadowDoc: JxMutableNode,
39
+ forwardOps: WireDocOp[],
40
+ container: HTMLElement,
41
+ ctx?: IframeRenderCtx,
42
+ ): void {
43
+ for (const op of forwardOps) {
44
+ applyDocOpToDoc(shadowDoc, op);
45
+ }
46
+ for (const op of forwardOps) {
47
+ applyOpToDom(shadowDoc, op, container, ctx);
48
+ }
49
+ }
50
+
51
+ /** The render context, asserted present for ops that re-render a subtree (the caller always has it). */
52
+ function requireCtx(ctx: IframeRenderCtx | undefined): IframeRenderCtx {
53
+ if (!ctx) {
54
+ throw new Error("iframe-patch-no-render-ctx");
55
+ }
56
+ return ctx;
57
+ }
58
+
59
+ function applyOpToDom(
60
+ doc: JxMutableNode,
61
+ op: WireDocOp,
62
+ container: HTMLElement,
63
+ ctx: IframeRenderCtx | undefined,
64
+ ): void {
65
+ switch (op.op) {
66
+ case "set-key": {
67
+ if (op.key === "style") {
68
+ patchStyle(doc, op.path, container, ctx);
69
+ return;
70
+ }
71
+ if (op.key === "textContent") {
72
+ patchText(doc, op.path, container);
73
+ return;
74
+ }
75
+ // Event bindings are stripped from the design/edit render — nothing to patch.
76
+ if (op.key.startsWith("on")) {
77
+ return;
78
+ }
79
+ // Any other key (attributes / $props / cases / tagName / a non-event prop) changes rendered
80
+ // Output, so re-render the node's subtree in place — mirrors the legacy patcher's replace.
81
+ replaceSubtree(doc, op.path, container, requireCtx(ctx));
82
+ return;
83
+ }
84
+ case "insert-child": {
85
+ insertChild(doc, op.parentPath, op.index, container, requireCtx(ctx));
86
+ return;
87
+ }
88
+ case "set-child": {
89
+ replaceSubtree(doc, [...op.parentPath, "children", op.index], container, requireCtx(ctx));
90
+ return;
91
+ }
92
+ case "remove-child": {
93
+ removeChild(doc, op.parentPath, op.index, container);
94
+ return;
95
+ }
96
+ case "move-child": {
97
+ moveChild(doc, op.fromParentPath, op.fromIndex, op.toParentPath, op.toIndex, container);
98
+ return;
99
+ }
100
+ default: {
101
+ // The WireDocOp union is exhaustively handled above; this guards a future op kind.
102
+ throw new Error(`iframe-patch-unsupported-op:${JSON.stringify(op)}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ // ─── Structural ops needing a subtree render (Phase 3b-2) ───────────────────────
108
+
109
+ /** Render the inserted node and splice it into the parent's DOM, shifting later siblings up. */
110
+ function insertChild(
111
+ doc: JxMutableNode,
112
+ parentPath: JxPath,
113
+ index: number,
114
+ container: HTMLElement,
115
+ ctx: IframeRenderCtx,
116
+ ): void {
117
+ const parentEl = requireElement(container, parentPath);
118
+ const parentNode = getNodeAtPath(doc, parentPath);
119
+ const newEl = renderSubtreeIframe(doc, [...parentPath, "children", index], ctx);
120
+ // Remap existing siblings BEFORE attaching the new subtree so it isn't itself remapped.
121
+ remapChildPaths(container, parentPath, index, 1);
122
+ insertAt(parentEl, newEl, domChildReference(parentEl, parentNode, index));
123
+ syncContainerPlaceholder(parentEl, parentNode);
124
+ }
125
+
126
+ /** Re-render the node at `docPath` and swap it into the DOM in place (disposing the old subtree). */
127
+ function replaceSubtree(
128
+ doc: JxMutableNode,
129
+ docPath: JxPath,
130
+ container: HTMLElement,
131
+ ctx: IframeRenderCtx,
132
+ ): void {
133
+ const oldEl = requireElement(container, docPath);
134
+ const newEl = renderSubtreeIframe(doc, docPath, ctx);
135
+ disposeSubtree(oldEl);
136
+ oldEl.replaceWith(newEl);
137
+ }
138
+
139
+ // ─── Structural ops (no subtree render: pure DOM move + data-jx-path remap) ──────
140
+
141
+ /** Remove the element at `parentPath/children/index`, shift later siblings down, dispose its scope. */
142
+ function removeChild(
143
+ doc: JxMutableNode,
144
+ parentPath: JxPath,
145
+ index: number,
146
+ container: HTMLElement,
147
+ ): void {
148
+ const el = requireElement(container, [...parentPath, "children", index]);
149
+ const parentEl = el.parentElement;
150
+ disposeSubtree(el);
151
+ el.remove();
152
+ remapChildPaths(container, parentPath, index + 1, -1);
153
+ if (parentEl) {
154
+ syncContainerPlaceholder(parentEl, getNodeAtPath(doc, parentPath));
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Move the element from `fromParentPath/children/fromIndex` to `toParentPath/children/toIndex`.
160
+ * Detach first, shift the from-parent's later siblings down, then re-read the to-parent's (possibly
161
+ * shifted) path, shift its siblings up, rewrite the moved subtree's path prefix, and reinsert. The
162
+ * shadow doc is already folded, so `toIndex` and the to-parent's children reflect post-move truth.
163
+ */
164
+ function moveChild(
165
+ doc: JxMutableNode,
166
+ fromParentPath: JxPath,
167
+ fromIndex: number,
168
+ toParentPath: JxPath,
169
+ toIndex: number,
170
+ container: HTMLElement,
171
+ ): void {
172
+ const fromPath = [...fromParentPath, "children", fromIndex];
173
+ const el = requireElement(container, fromPath);
174
+ const fromParentEl = el.parentElement;
175
+ // Resolve the destination element before any remap renames it out from under us.
176
+ const toParentEl = requireElement(container, toParentPath);
177
+
178
+ el.remove();
179
+ remapChildPaths(container, fromParentPath, fromIndex + 1, -1);
180
+ // The detach remap may have shifted the to-parent's own path — read it fresh.
181
+ const toPrefix = elementPath(toParentEl) ?? toParentPath;
182
+ remapChildPaths(container, toPrefix, toIndex, 1);
183
+ rewriteSubtreePathPrefix(el, fromPath, [...toPrefix, "children", toIndex]);
184
+
185
+ const toParentNode = getNodeAtPath(doc, toPrefix);
186
+ insertAt(toParentEl, el, domChildReference(toParentEl, toParentNode, toIndex));
187
+
188
+ // Emptiness may have changed at either end (e.g. moved the last/only child out, or into an empty
189
+ // Container) — keep both parents' placeholder classes in sync with the folded doc.
190
+ if (fromParentEl) {
191
+ syncContainerPlaceholder(
192
+ fromParentEl,
193
+ getNodeAtPath(doc, elementPath(fromParentEl) ?? fromParentPath),
194
+ );
195
+ }
196
+ syncContainerPlaceholder(toParentEl, toParentNode);
197
+ }
198
+
199
+ /**
200
+ * Keep a container's empty-placeholder class in sync with its (folded) doc node after a child
201
+ * change.
202
+ */
203
+ function syncContainerPlaceholder(parentEl: Element, parentNode: JxMutableNode | undefined): void {
204
+ for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
205
+ parentEl.classList.remove(cls);
206
+ }
207
+ const placeholder = parentNode ? computeEmptyPlaceholderClass(parentNode) : null;
208
+ if (placeholder) {
209
+ parentEl.classList.add(placeholder);
210
+ }
211
+ }
212
+
213
+ /** Insert `node` before `ref`, or append when `ref` is null (inserting at the end). */
214
+ function insertAt(parentEl: Element, node: Node, ref: ChildNode | null): void {
215
+ if (ref) {
216
+ ref.before(node);
217
+ } else {
218
+ parentEl.append(node);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The DOM node currently occupying `children[index]` of `parentEl`, or null to append. The runtime
224
+ * renders an optional leading text node (when the parent has its own textContent) followed by one
225
+ * node per child entry, in order — so the index is offset by that text node.
226
+ */
227
+ function domChildReference(
228
+ parentEl: Element,
229
+ parentNode: JxMutableNode | undefined,
230
+ index: number,
231
+ ): ChildNode | null {
232
+ const text = parentNode?.textContent;
233
+ const textOffset = text != null && String(text) !== "" ? 1 : 0;
234
+ return parentEl.childNodes[textOffset + index] ?? null;
235
+ }
236
+
237
+ /**
238
+ * Shift the child-index segment of every `data-jx-path` under `container` whose path is
239
+ * `[...parentPath, "children", i, ...]` with `i >= fromIndex`, by `delta`. Mirrors the legacy
240
+ * patcher's `elToPath` remap, but the iframe stores paths as `data-jx-path` attributes.
241
+ */
242
+ function remapChildPaths(
243
+ container: HTMLElement,
244
+ parentPath: JxPath,
245
+ fromIndex: number,
246
+ delta: number,
247
+ ): void {
248
+ const depth = parentPath.length;
249
+ for (const el of container.querySelectorAll<HTMLElement>("[data-jx-path]")) {
250
+ const p = elementPath(el);
251
+ if (!p || p.length < depth + 2 || p[depth] !== "children") {
252
+ continue;
253
+ }
254
+ const idx = p[depth + 1];
255
+ if (typeof idx !== "number" || idx < fromIndex) {
256
+ continue;
257
+ }
258
+ if (!parentPath.every((seg, i) => p[i] === seg)) {
259
+ continue;
260
+ }
261
+ const np = [...p];
262
+ np[depth + 1] = idx + delta;
263
+ el.dataset.jxPath = serializeJxPath(np);
264
+ }
265
+ }
266
+
267
+ /** Rewrite the `data-jx-path` of `el` and its descendants, replacing `oldPrefix` with `newPrefix`. */
268
+ function rewriteSubtreePathPrefix(el: HTMLElement, oldPrefix: JxPath, newPrefix: JxPath): void {
269
+ const targets: HTMLElement[] = [el, ...el.querySelectorAll<HTMLElement>("[data-jx-path]")];
270
+ for (const t of targets) {
271
+ const p = elementPath(t);
272
+ if (!p || p.length < oldPrefix.length || !oldPrefix.every((seg, i) => p[i] === seg)) {
273
+ continue;
274
+ }
275
+ t.dataset.jxPath = serializeJxPath([...newPrefix, ...p.slice(oldPrefix.length)]);
276
+ }
277
+ }
278
+
279
+ /** Read an element's stamped document path, or null when it carries no `data-jx-path`. */
280
+ function elementPath(el: HTMLElement): JxPath | null {
281
+ const raw = el.dataset.jxPath;
282
+ return raw === undefined ? null : parseJxPath(raw);
283
+ }
284
+
285
+ /**
286
+ * Re-emit the node's style onto its element, matching the full render's edit-mode style transform.
287
+ * Pass the document's merged `$media` map (the same one resolveCanvasDocument put on the render
288
+ * doc, carried on the render ctx's built scope) through to reapplyStyle so an `@--name` block
289
+ * resolves to its real `@media (min-width:…)` query — otherwise a surgical edit on a media-bearing
290
+ * node would re-emit an invalid `@media name` and silently drop the responsive rule until the next
291
+ * full render.
292
+ */
293
+ function patchStyle(
294
+ doc: JxMutableNode,
295
+ path: (string | number)[],
296
+ container: HTMLElement,
297
+ ctx: IframeRenderCtx | undefined,
298
+ ): void {
299
+ const el = requireElement(container, path);
300
+ const node = getNodeAtPath(doc, path);
301
+ const mediaQueries = (ctx?.defs?.["$media"] as Record<string, string> | undefined) ?? {};
302
+ reapplyStyle(el, editModeStyle(node?.style), mediaQueries);
303
+ }
304
+
305
+ /** Write the node's display text, syncing the empty-placeholder class like the full render. */
306
+ function patchText(doc: JxMutableNode, path: (string | number)[], container: HTMLElement): void {
307
+ const el = requireElement(container, path);
308
+ const node = getNodeAtPath(doc, path);
309
+ el.textContent = textDisplayValue(node);
310
+ for (const cls of EMPTY_PLACEHOLDER_CLASSES) {
311
+ el.classList.remove(cls);
312
+ }
313
+ const placeholder = node ? computeEmptyPlaceholderClass(node) : null;
314
+ if (placeholder) {
315
+ el.classList.add(placeholder);
316
+ }
317
+ }
318
+
319
+ /** Locate the rendered element for a document path via its stamped `data-jx-path` attribute. */
320
+ function requireElement(container: HTMLElement, path: (string | number)[]): HTMLElement {
321
+ const serialized = serializeJxPath(path);
322
+ const esc = serialized.replaceAll("\\", String.raw`\\`).replaceAll("'", String.raw`\'`);
323
+ const el = container.querySelector(`[data-jx-path='${esc}']`);
324
+ if (!(el instanceof HTMLElement)) {
325
+ throw new Error(`iframe-patch-element-not-found:${serialized}`);
326
+ }
327
+ return el;
328
+ }
329
+
330
+ /**
331
+ * Edit-mode style transform — mirrors prepareForEditMode (and the legacy patcher's editModeStyle):
332
+ * top-level template-string values are blanked so no reactive bindings are created for them.
333
+ */
334
+ function editModeStyle(style: JxStyle | undefined): JxStyle | undefined {
335
+ if (!style || typeof style !== "object") {
336
+ return undefined;
337
+ }
338
+ const out: Record<string, unknown> = {};
339
+ for (const [k, v] of Object.entries(style)) {
340
+ out[k] = typeof v === "string" && v.includes("${") ? "" : v;
341
+ }
342
+ return out as JxStyle;
343
+ }
344
+
345
+ /**
346
+ * Display text for a node's textContent in design/edit mode — mirrors prepareForEditMode's
347
+ * template-string and $ref display rules (and the legacy patcher's textDisplayValue).
348
+ */
349
+ function textDisplayValue(node: JxMutableNode | undefined): string {
350
+ const v = (node as { textContent?: unknown } | undefined)?.textContent;
351
+ if (v == null) {
352
+ return "";
353
+ }
354
+ if (typeof v === "string") {
355
+ return v.includes("${") ? templateToEditDisplay(v) : v;
356
+ }
357
+ if (typeof v === "object" && (v as Record<string, unknown>).$ref) {
358
+ const ref = String((v as Record<string, unknown>).$ref);
359
+ const label = ref.startsWith("#/state/") ? ref.slice(8) : ref;
360
+ return `{${label}}`;
361
+ }
362
+ return String(v);
363
+ }