@jxsuite/studio 1.0.0 → 1.1.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 (86) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +53069 -31746
  5. package/dist/studio.js.map +109 -74
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +20 -0
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +131 -5
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +43 -3
  15. package/src/canvas/iframe-render.ts +18 -0
  16. package/src/collab/collab-session.ts +23 -8
  17. package/src/collab/collab-state.ts +6 -3
  18. package/src/component-props.ts +104 -0
  19. package/src/editor/inline-edit-apply.ts +36 -1
  20. package/src/editor/inline-edit.ts +58 -1
  21. package/src/editor/shortcuts.ts +41 -12
  22. package/src/files/file-ops.ts +14 -0
  23. package/src/files/files.ts +53 -1
  24. package/src/grid/cell-editors.ts +288 -0
  25. package/src/grid/cell-popovers.ts +108 -0
  26. package/src/grid/csv-codec.ts +122 -0
  27. package/src/grid/edit-buffer.ts +490 -0
  28. package/src/grid/grid-controller.ts +417 -0
  29. package/src/grid/grid-layout.ts +83 -0
  30. package/src/grid/grid-open.ts +167 -0
  31. package/src/grid/grid-panel.ts +302 -0
  32. package/src/grid/grid-source.ts +210 -0
  33. package/src/grid/grid-view.ts +367 -0
  34. package/src/grid/schema-columns.ts +261 -0
  35. package/src/grid/sources/connector-source.ts +217 -0
  36. package/src/grid/sources/content-source.ts +542 -0
  37. package/src/grid/sources/csv-file-source.ts +247 -0
  38. package/src/grid/tabulator-tables.d.ts +120 -0
  39. package/src/panels/block-action-bar.ts +8 -3
  40. package/src/panels/chat-panel.ts +98 -0
  41. package/src/panels/data-grid.ts +9 -387
  42. package/src/panels/editors.ts +43 -17
  43. package/src/panels/events-panel.ts +137 -44
  44. package/src/panels/formula-workspace.ts +379 -0
  45. package/src/panels/frontmatter-fields.ts +231 -0
  46. package/src/panels/frontmatter-panel.ts +132 -0
  47. package/src/panels/git-panel.ts +1 -1
  48. package/src/panels/head-panel.ts +11 -175
  49. package/src/panels/properties-panel.ts +154 -144
  50. package/src/panels/right-panel.ts +9 -47
  51. package/src/panels/signals-panel.ts +115 -23
  52. package/src/panels/statement-editor.ts +710 -0
  53. package/src/panels/style-panel.ts +25 -1
  54. package/src/panels/stylebook-panel.ts +0 -2
  55. package/src/panels/tab-bar.ts +175 -6
  56. package/src/panels/tab-strip.ts +62 -6
  57. package/src/panels/toolbar.ts +37 -3
  58. package/src/services/ai-project-tools.ts +541 -0
  59. package/src/services/ai-session-store.ts +28 -0
  60. package/src/services/ai-system-prompt.ts +194 -24
  61. package/src/services/ai-tools.ts +88 -21
  62. package/src/services/automation.ts +16 -0
  63. package/src/services/document-assistant.ts +81 -11
  64. package/src/services/gated-registry.ts +72 -0
  65. package/src/services/live-preview.ts +0 -0
  66. package/src/services/preview-eval.ts +68 -0
  67. package/src/services/project-adoption.ts +31 -0
  68. package/src/site-context.ts +2 -0
  69. package/src/store.ts +4 -0
  70. package/src/studio.ts +83 -52
  71. package/src/tabs/patch-ops.ts +25 -0
  72. package/src/tabs/tab.ts +39 -1
  73. package/src/tabs/transact.ts +60 -13
  74. package/src/types.ts +12 -0
  75. package/src/ui/dynamic-slot.ts +272 -0
  76. package/src/ui/expression-editor.ts +423 -125
  77. package/src/ui/field-row.ts +5 -0
  78. package/src/ui/formula-catalog.ts +557 -0
  79. package/src/ui/formula-chips.ts +216 -0
  80. package/src/ui/formula-palette.ts +211 -0
  81. package/src/ui/layers.ts +40 -0
  82. package/src/ui/media-picker.ts +1 -1
  83. package/src/ui/panel-resize.ts +15 -1
  84. package/src/utils/preview-format.ts +26 -0
  85. package/src/view.ts +4 -4
  86. package/src/workspace/workspace.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,12 +39,13 @@
39
39
  "@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
40
40
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
41
41
  "@jxsuite/ai": "^0.33.0",
42
- "@jxsuite/collab": "^0.3.0",
43
- "@jxsuite/create": "^1.0.0",
44
- "@jxsuite/markup": "^0.2.0",
45
- "@jxsuite/protocol": "^0.4.0",
46
- "@jxsuite/runtime": "^1.0.0",
47
- "@jxsuite/schema": "^1.0.0",
42
+ "@jxsuite/collab": "^0.4.0",
43
+ "@jxsuite/create": "^1.1.0",
44
+ "@jxsuite/formulas": "^0.0.2",
45
+ "@jxsuite/markup": "^0.3.0",
46
+ "@jxsuite/protocol": "^0.5.0",
47
+ "@jxsuite/runtime": "^1.1.0",
48
+ "@jxsuite/schema": "^1.1.0",
48
49
  "@spectrum-web-components/accordion": "^1.12.2",
49
50
  "@spectrum-web-components/action-bar": "1.12.2",
50
51
  "@spectrum-web-components/action-button": "^1.12.2",
@@ -81,12 +82,13 @@
81
82
  "@spectrum-web-components/toast": "^1.12.2",
82
83
  "@spectrum-web-components/tooltip": "^1.12.2",
83
84
  "@spectrum-web-components/underlay": "^1.12.2",
84
- "@vue/reactivity": "3.5.39",
85
+ "@vue/reactivity": "3.5.40",
85
86
  "ajv": "^8.20.0",
86
87
  "ajv-formats": "^3.0.1",
87
88
  "lit": "^3.3.3",
88
89
  "lit-html": "^3.3.3",
89
90
  "monaco-editor": "^0.55.1",
91
+ "tabulator-tables": "^6.5",
90
92
  "y-monaco": "^0.1.6",
91
93
  "yaml": "^2.9.0"
92
94
  },
@@ -95,7 +97,7 @@
95
97
  "@types/mdast": "^4.0.4",
96
98
  "@webref/css": "^8.7.0",
97
99
  "@webref/elements": "^2.7.1",
98
- "@webref/idl": "^3.81.3"
100
+ "@webref/idl": "^3.82.0"
99
101
  },
100
102
  "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
101
103
  }
@@ -26,6 +26,7 @@ import {
26
26
  resolveParamBoundState,
27
27
  substitutePreviewParams,
28
28
  } from "../page-params";
29
+ import { isComponentDoc, substitutePreviewProps } from "../component-props";
29
30
 
30
31
  import type { JxElement, JxMutableNode } from "@jxsuite/schema/types";
31
32
  import type { ComponentEntry } from "../files/components.js";
@@ -182,6 +183,17 @@ export async function resolveCanvasDocument(doc: JxMutableNode): Promise<{
182
183
  }
183
184
  }
184
185
 
186
+ // Component definition docs (non-page): seed chosen test-prop values into the render doc's state
187
+ // So a non-instantiated component previews with real data — templates, dataScope snapshots, and
188
+ // Live/snapshot expression previews all see the values (M6, the previewParams mirror). Pure
189
+ // Rebuild for the same reason as substitutePreviewParams above.
190
+ if (!isPage && tab && isComponentDoc(renderDoc)) {
191
+ const { previewProps } = tab.session.ui;
192
+ if (previewProps && Object.keys(previewProps).length > 0) {
193
+ renderDoc = substitutePreviewProps(renderDoc, previewProps);
194
+ }
195
+ }
196
+
185
197
  const arrayPaths = new Set<string>();
186
198
  if (canvasMode === "design" || canvasMode === "edit") {
187
199
  (function findArrayPaths(node: JxMutableNode, path: (string | number)[]) {
@@ -219,9 +231,16 @@ export async function resolveCanvasDocument(doc: JxMutableNode): Promise<{
219
231
  })(doc, []);
220
232
  }
221
233
 
222
- // Component auto-discovery (content mode or layout) mirrors the legacy render path.
234
+ // Component auto-discovery (content mode, wrapped layout, or a layout opened on its own)
235
+ // Mirrors the legacy render path. A directly-opened layout is neither a page (so it never
236
+ // Sets layoutWrapped) nor content mode (a plain .json file skips the mode-setting parse),
237
+ // So it needs its own gate; scoping to layouts/ avoids the single-component-edit path, where
238
+ // The doc's own root tag would otherwise inject a $ref to itself.
239
+ const isLayoutDoc =
240
+ S.documentPath != null &&
241
+ (S.documentPath.startsWith("layouts/") || S.documentPath.startsWith("./layouts/"));
223
242
  const effectiveElements = getEffectiveElements(renderDoc.$elements as (JxElement | string)[]);
224
- if ((S.mode === "content" || layoutWrapped) && componentRegistry.length > 0) {
243
+ if ((S.mode === "content" || layoutWrapped || isLayoutDoc) && componentRegistry.length > 0) {
225
244
  const existingRefs = new Set(
226
245
  effectiveElements.map((e: JxElement | string) => (typeof e === "string" ? e : e?.$ref)),
227
246
  );
@@ -15,15 +15,15 @@ import { attachCursorStyles } from "../collab/monaco-cursors";
15
15
  import type { AwarenessLike } from "../collab/monaco-cursors";
16
16
  import { view } from "../view";
17
17
  import { parseSourceForPath, serializeDocument } from "../files/file-ops";
18
+ import { detachGridPanel, gridPanelMounted, renderGridMode } from "../grid/grid-panel";
18
19
  import { formatByName, formatForPath } from "../format/format-host";
19
20
  import { renderWelcome } from "../panels/welcome-screen";
20
21
  import { projectState } from "../state";
21
22
  import {
23
+ applyEditZoom,
22
24
  applyTransform,
23
25
  canvasPanelTemplate,
24
26
  observeCenterUntilStable,
25
- renderZoomIndicator,
26
- resetZoomIndicator,
27
27
  updateActivePanelHeaders,
28
28
  } from "./canvas-utils";
29
29
  import { parseMediaEntries } from "../utils/canvas-media";
@@ -40,6 +40,7 @@ import { dismissBlockActionBar, dismissLinkPopover } from "../panels/block-actio
40
40
  import { dismissContextMenu } from "../editor/context-menu";
41
41
  import { dismissSlashMenu } from "../editor/slash-menu";
42
42
  import { renderFunctionEditor } from "../panels/editors";
43
+ import { renderFormulaWorkspace } from "../panels/formula-workspace";
43
44
  import { mediaDisplayName } from "../panels/shared";
44
45
  import { statusMessage } from "../panels/statusbar";
45
46
  import * as overlaysPanel from "../panels/overlays";
@@ -174,6 +175,7 @@ function resetCanvasView() {
174
175
  view.functionEditor.dispose();
175
176
  view.functionEditor = null;
176
177
  }
178
+ detachGridPanel();
177
179
  disposeSourceCollab();
178
180
  if (view.monacoEditor) {
179
181
  view.monacoEditor.getModel()?.dispose();
@@ -206,7 +208,6 @@ function resetCanvasView() {
206
208
  canvasWrap.style.flexDirection = "";
207
209
  canvasWrap.style.display = "";
208
210
  canvasWrap.style.overflow = "";
209
- resetZoomIndicator();
210
211
  dismissBlockActionBar();
211
212
  dismissLinkPopover();
212
213
  dismissContextMenu();
@@ -266,6 +267,13 @@ export function renderCanvas() {
266
267
  view.functionEditor = null;
267
268
  }
268
269
 
270
+ // Formula workspace mode: full-screen structured editing of an $expression (the function editor
271
+ // Takes precedence when both targets are set).
272
+ if (S.ui.editingFormula) {
273
+ renderFormulaWorkspace();
274
+ return;
275
+ }
276
+
269
277
  // Source mode: update existing Monaco editor without recreating. Don't replace the buffer while
270
278
  // The user is actively typing in it — that would reformat under the cursor (the source view is
271
279
  // The editing surface here, mirroring the panel draft-state behaviour).
@@ -287,6 +295,12 @@ export function renderCanvas() {
287
295
  return;
288
296
  }
289
297
 
298
+ // Grid fast-path: the grid panel runs its own effect scope (toolbar + engine stay live), so a
299
+ // Same-tab re-render while the panel is mounted needs nothing from the canvas pipeline.
300
+ if (canvasMode === "grid" && gridPanelMounted(tab)) {
301
+ return;
302
+ }
303
+
290
304
  // Stylebook fast-path: a style edit re-applies IN PLACE via the bridge (the iframe re-runs the
291
305
  // Runtime's style applier on the specimen root — real @media, no re-render, no iframe reload).
292
306
  // Filter/Customized changes fall through to the full rebuild (they change which specimens exist),
@@ -344,6 +358,9 @@ export function renderCanvas() {
344
358
  view.centerObserver = null;
345
359
  }
346
360
 
361
+ // Destroy the grid panel if switching away from grid mode
362
+ detachGridPanel();
363
+
347
364
  // Dispose Monaco editor if switching away from source mode
348
365
  disposeSourceCollab();
349
366
  if (view.monacoEditor) {
@@ -362,9 +379,6 @@ export function renderCanvas() {
362
379
  canvasWrap.style.overflow = "";
363
380
  canvasWrap.style.overflow = "";
364
381
 
365
- // Clear zoom indicator (only re-rendered by design/stylebook)
366
- resetZoomIndicator();
367
-
368
382
  // Dismiss open popovers/toolbars that are no longer relevant
369
383
  dismissBlockActionBar();
370
384
  dismissLinkPopover();
@@ -380,12 +394,20 @@ export function renderCanvas() {
380
394
  applyTransform,
381
395
  canvasPanelTemplate,
382
396
  observeCenterUntilStable,
383
- renderZoomIndicator,
384
397
  updateActivePanelHeaders,
385
398
  });
386
399
  return;
387
400
  }
388
401
 
402
+ // Grid mode: spreadsheet editor over the tab's grid source (mirrors the Monaco/stylebook
403
+ // Non-iframe-editor pattern; the panel owns its own reactivity from here).
404
+ if (canvasMode === "grid") {
405
+ canvasWrap.style.padding = "0";
406
+ canvasWrap.style.display = "block";
407
+ renderGridMode(canvasWrap, tab);
408
+ return;
409
+ }
410
+
389
411
  // Source mode: create Monaco editor instead of canvas
390
412
  if (canvasMode === "source") {
391
413
  canvasWrap.style.padding = "0";
@@ -599,18 +621,15 @@ export function renderCanvas() {
599
621
  if (modeChanged) {
600
622
  observeCenterUntilStable();
601
623
  }
602
- renderZoomIndicator();
603
624
  return;
604
625
  }
605
626
 
606
- // Edit (content) mode — centered column, no panzoom, always 100%
627
+ // Edit (content) mode — centered column, no panzoom; `ui.editZoom` reflows content at the zoomed
628
+ // Effective width while the on-screen footprint stays fixed (browser-page-zoom semantics).
607
629
  if (canvasMode === "edit") {
608
630
  if (modeChanged) {
609
631
  canvasWrap.style.padding = "0";
610
632
  canvasWrap.style.overflow = "hidden";
611
-
612
- // Remove zoom indicator left over from design mode
613
- resetZoomIndicator();
614
633
  }
615
634
 
616
635
  const { baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
@@ -633,6 +652,10 @@ export function renderCanvas() {
633
652
  litRender(editTpl, canvasWrap);
634
653
  canvasPanels.push(panel as unknown as CanvasPanel);
635
654
  renderCanvasIntoPanel(panel as unknown as CanvasPanel, S.ui.featureToggles);
655
+ // The column must exist in the DOM before the zoom's live width measurement — so the zoom is
656
+ // Applied after the render rather than baked into the template (the panel mounts fluid and is
657
+ // Immediately re-fitted; the iframe hasn't painted yet, so nothing visibly jumps).
658
+ applyEditZoom();
636
659
  return;
637
660
  }
638
661
 
@@ -684,7 +707,6 @@ export function renderCanvas() {
684
707
  if (modeChanged) {
685
708
  observeCenterUntilStable();
686
709
  }
687
- renderZoomIndicator();
688
710
  return;
689
711
  }
690
712
 
@@ -747,9 +769,6 @@ export function renderCanvas() {
747
769
  if (modeChanged) {
748
770
  observeCenterUntilStable();
749
771
  }
750
-
751
- // Floating zoom indicator
752
- renderZoomIndicator();
753
772
  }
754
773
 
755
774
  /**
@@ -4,7 +4,7 @@
4
4
  * template creation, centering, transform application, zoom indicator, and fit-to-screen.
5
5
  */
6
6
 
7
- import { html, render as litRender, nothing } from "lit-html";
7
+ import { html, nothing } from "lit-html";
8
8
  import type { CanvasPanel } from "../types";
9
9
  import { ref } from "lit-html/directives/ref.js";
10
10
  import { classMap } from "lit-html/directives/class-map.js";
@@ -14,7 +14,6 @@ import { ifDefined } from "lit-html/directives/if-defined.js";
14
14
  import { canvasPanels, canvasWrap, renderOnly, updateUi } from "../store";
15
15
  import { activeTab } from "../workspace/workspace";
16
16
  import { view } from "../view";
17
- import { getLayerSlot } from "../ui/layers";
18
17
  import { findCanvasElement, getActivePanel, panelMediaToActiveMedia } from "./canvas-helpers";
19
18
  import { rectOf } from "../utils/geometry";
20
19
  import type { TemplateResult } from "lit-html";
@@ -25,8 +24,6 @@ let _ctx: {
25
24
  setZoomDirect: (zoom: number) => void;
26
25
  };
27
26
 
28
- let _zoomIndicatorEl: HTMLElement | null = null;
29
-
30
27
  /**
31
28
  * Initialize the canvas utils module.
32
29
  *
@@ -162,12 +159,127 @@ export function applyTransform() {
162
159
  }
163
160
  const zoom = _ctx.getZoom();
164
161
  view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`;
165
- renderZoomIndicator();
166
162
  // Overlays live INSIDE the scaled panzoom-wrap (iframe hosts draw there), so no per-mode redraw
167
- // Is needed here — the flush only re-anchors the fixed block-action-bar.
163
+ // Is needed here — the flush only re-anchors the fixed block-action-bar. The tab-bar zoom widget
164
+ // Tracks `ui.zoom` reactively, so no explicit indicator refresh is needed either.
165
+ renderOnly("overlays");
166
+ }
167
+
168
+ // ─── Edit-mode content zoom ──────────────────────────────────────────────────
169
+ // Browser-page-zoom semantics: the canvas footprint stays fixed while the CONTENT reflows at the
170
+ // Zoomed effective width — unlike design mode's panzoom, which visually scales a fixed layout.
171
+
172
+ export const EDIT_ZOOM_MIN = 0.25;
173
+
174
+ export const EDIT_ZOOM_MAX = 3;
175
+
176
+ /** Clamp an edit-zoom value to the supported range. */
177
+ export function clampEditZoom(zoom: number): number {
178
+ return Math.min(EDIT_ZOOM_MAX, Math.max(EDIT_ZOOM_MIN, zoom));
179
+ }
180
+
181
+ /**
182
+ * Apply the active tab's edit-mode content zoom to the single edit panel.
183
+ *
184
+ * Mechanism: a parent-side transform on the iframe never reflows the iframe's internal document (it
185
+ * is its own layout viewport), so genuine reflow requires resizing the iframe's REAL CSS width to
186
+ * `renderWidth / editZoom` — and a compensating `scale(editZoom)` on the canvas element brings the
187
+ * rendered footprint back to exactly `renderWidth`. The overlay layer (the iframe's DOM sibling
188
+ * inside the canvas element) inherits the transform, so overlay boxes stay drawn at scale 1 (D-2),
189
+ * and `hostDragGeometry`'s empirical `rect.width / clientWidth` evaluates to exactly `editZoom`
190
+ * with no bridge changes.
191
+ *
192
+ * `renderWidth` is measured live from `.content-edit-column` (already `min(baseWidth, available)`
193
+ * via normal block layout) — the nominal `$media["--"]` width would overflow a narrow studio
194
+ * window.
195
+ *
196
+ * HARD INVARIANT: never trigger a canvas re-render from here (`renderCanvas`/`mountIframeCanvas`) —
197
+ * a re-render rebuilds the iframe DOM and would destroy a live inline-edit session. Live zoom is
198
+ * bare style writes only; the iframe's own ResizeObserver re-posts `contentHeight` after the
199
+ * reflow, which also finalizes the viewport height.
200
+ */
201
+ export function applyEditZoom() {
202
+ if (_ctx.getCanvasMode() !== "edit") {
203
+ return;
204
+ }
205
+ const [panel] = canvasPanels;
206
+ if (!panel?.canvas || !panel.viewport) {
207
+ return;
208
+ }
209
+ const canvasEl = panel.canvas;
210
+ const iframe = canvasEl.querySelector("iframe");
211
+ const editZoom = activeTab.value?.session.ui.editZoom ?? 1;
212
+ if (editZoom === 1) {
213
+ // Exactly today's fluid behavior — no inline width, no transform, auto viewport height.
214
+ panel._width = null;
215
+ canvasEl.style.width = "";
216
+ canvasEl.style.transform = "";
217
+ canvasEl.style.transformOrigin = "";
218
+ if (iframe) {
219
+ iframe.style.width = "100%";
220
+ }
221
+ panel.viewport.style.height = "";
222
+ } else {
223
+ const column = canvasWrap.querySelector<HTMLElement>(".content-edit-column");
224
+ if (!column) {
225
+ return;
226
+ }
227
+ // The column width is parent-driven (width:100%, max-width:baseWidth), so it is independent of
228
+ // The canvas content — measuring it mid-zoom is stable. No rounding: layoutWidth * editZoom
229
+ // Must equal renderWidth exactly.
230
+ const renderWidth = rectOf(column).width;
231
+ if (renderWidth <= 0) {
232
+ return;
233
+ }
234
+ const layoutWidth = renderWidth / editZoom;
235
+ panel._width = layoutWidth;
236
+ canvasEl.style.width = `${layoutWidth}px`;
237
+ canvasEl.style.transform = `scale(${editZoom})`;
238
+ canvasEl.style.transformOrigin = "top left";
239
+ if (iframe) {
240
+ iframe.style.width = `${layoutWidth}px`;
241
+ // Transforms don't affect an ancestor's auto-height, so the viewport (the white "page"
242
+ // Surface) needs its height pinned to the SCALED content height. offsetHeight is the
243
+ // Pre-measurement approximation; the next contentHeight message writes the exact value.
244
+ panel.viewport.style.height = `${iframe.offsetHeight * editZoom}px`;
245
+ }
246
+ }
247
+ // Re-anchor the fixed block-action-bar over the rescaled canvas.
168
248
  renderOnly("overlays");
169
249
  }
170
250
 
251
+ /** Set the active tab's edit zoom (clamped) and apply it synchronously. */
252
+ export function setEditZoom(zoom: number) {
253
+ const tab = activeTab.value;
254
+ if (!tab) {
255
+ return;
256
+ }
257
+ tab.session.ui.editZoom = clampEditZoom(zoom);
258
+ applyEditZoom();
259
+ }
260
+
261
+ let _editZoomRaf = 0;
262
+
263
+ /**
264
+ * Wheel-rate edit-zoom setter: the reactive `editZoom` write lands immediately (the tab-bar label
265
+ * tracks it), but the DOM work — an iframe width resize, i.e. a real reflow — is coalesced to one
266
+ * `applyEditZoom()` per animation frame so a fast ctrl+scroll burst doesn't thrash layout.
267
+ */
268
+ export function requestEditZoom(zoom: number) {
269
+ const tab = activeTab.value;
270
+ if (!tab) {
271
+ return;
272
+ }
273
+ tab.session.ui.editZoom = clampEditZoom(zoom);
274
+ if (_editZoomRaf) {
275
+ return;
276
+ }
277
+ _editZoomRaf = requestAnimationFrame(() => {
278
+ _editZoomRaf = 0;
279
+ applyEditZoom();
280
+ });
281
+ }
282
+
171
283
  /** Calculate zoom + pan to fit all panels within the viewport. */
172
284
  export function fitToScreen() {
173
285
  if (!view.panzoomWrap) {
@@ -209,12 +321,6 @@ export function resetZoom() {
209
321
  _ctx.setZoomDirect(1);
210
322
  centerCanvas();
211
323
  applyTransform();
212
- renderZoomIndicator();
213
- }
214
-
215
- /** Reset the zoom indicator (clear its content). Called when switching to non-panzoom modes. */
216
- export function resetZoomIndicator() {
217
- litRender(nothing, getLayerSlot("popover", "zoom-indicator"));
218
324
  }
219
325
 
220
326
  /**
@@ -286,66 +392,6 @@ export function panToElement(path: (string | number)[]) {
286
392
  _panToEl(el, panel);
287
393
  }
288
394
 
289
- /**
290
- * Render the floating zoom indicator at the bottom center of canvas-wrap. Uses position: fixed,
291
- * computed from canvas-wrap bounds.
292
- */
293
- export function renderZoomIndicator() {
294
- const zoom = _ctx.getZoom();
295
- const host = getLayerSlot("popover", "zoom-indicator");
296
- litRender(
297
- html`
298
- <div
299
- class="zoom-indicator"
300
- ${ref((el: Element | undefined) => {
301
- _zoomIndicatorEl = (el as HTMLElement) || null;
302
- })}
303
- >
304
- <span class="zoom-indicator-action" title="Reset to 100%" @click=${resetZoom}>
305
- <svg
306
- width="14"
307
- height="14"
308
- viewBox="0 0 16 16"
309
- fill="none"
310
- stroke="currentColor"
311
- stroke-width="1.5"
312
- >
313
- <circle cx="8" cy="8" r="5.5" />
314
- <path d="M8 5.5v5M5.5 8h5" />
315
- </svg>
316
- </span>
317
- <span class="zoom-indicator-label">${Math.round(zoom * 100)}%</span>
318
- <span class="zoom-indicator-action" title="Fit to screen" @click=${fitToScreen}>
319
- <svg
320
- width="14"
321
- height="14"
322
- viewBox="0 0 16 16"
323
- fill="none"
324
- stroke="currentColor"
325
- stroke-width="1.5"
326
- >
327
- <rect x="2" y="2" width="12" height="12" rx="1" />
328
- <path d="M2 6h12M6 2v12" />
329
- </svg>
330
- </span>
331
- </div>
332
- `,
333
- host,
334
- );
335
- positionZoomIndicator();
336
- }
337
-
338
- /** Position the zoom indicator relative to canvas-wrap bounds. */
339
- export function positionZoomIndicator() {
340
- if (!_zoomIndicatorEl) {
341
- return;
342
- }
343
- const rect = rectOf(canvasWrap);
344
- _zoomIndicatorEl.style.left = `${rect.left + rect.width / 2}px`;
345
- _zoomIndicatorEl.style.top = `${rect.bottom - 32}px`;
346
- _zoomIndicatorEl.style.transform = "translateX(-50%)";
347
- }
348
-
349
395
  /** Toggle "active" class on canvas panel headers based on activeMedia. */
350
396
  export function updateActivePanelHeaders() {
351
397
  const activeMedia = activeTab.value?.session.ui.activeMedia ?? null;
@@ -21,6 +21,7 @@ import { startIframeSlashBridge } from "./iframe-slash";
21
21
  import { startKeyForwarding } from "./iframe-keys";
22
22
  import { applyIframePatch } from "./iframe-patch";
23
23
  import { disposeAllSubtrees } from "./iframe-subtree";
24
+ import { evaluateLiveExprs } from "./iframe-eval";
24
25
  import { serializeDataScope } from "./serialize-scope";
25
26
  import { getActivePath, isEditing, stopEditing } from "../editor/inline-edit";
26
27
  import { isAncestor } from "../state";
@@ -226,8 +227,11 @@ export function startCanvasIframe(opts: {
226
227
  // Document, so without this they'd be swallowed whenever focus is inside the canvas iframe.
227
228
  const stopKeyForwarding = startKeyForwarding(channel, container.ownerDocument);
228
229
  // Run inline editing (contenteditable) here, posting committed/split/insert results to the parent.
230
+ // The shadow-doc accessor gates prop-bound sessions on the RAW instance prop value (template/$ref
231
+ // Valued props render display sugar and must not be plain-text edited).
229
232
  const stopInlineEdit = startIframeInlineEdit(channel, container, {
230
233
  getMode: () => currentMode,
234
+ getShadowDoc: () => shadowDoc,
231
235
  });
232
236
  // Bridge the engine's slash menu to the parent's Spectrum menu (show/nav/select over the channel).
233
237
  const stopSlashBridge = startIframeSlashBridge(channel, container.ownerDocument);
@@ -365,6 +369,22 @@ export function startCanvasIframe(opts: {
365
369
  });
366
370
  return;
367
371
  }
372
+ if (msg.kind === "evalExpr") {
373
+ // Live expression preview (M6): evaluate against the LIVE render's resolved scope — but only
374
+ // The render the request targeted. A stale/ahead gen gets an empty reply (never values from
375
+ // The wrong scope); the parent additionally gen-gates the reply against its own state.
376
+ const results =
377
+ msg.gen === renderedGen && renderCtx && shadowDoc
378
+ ? evaluateLiveExprs(
379
+ msg.exprs,
380
+ renderCtx.defs as Record<string, unknown>,
381
+ shadowDoc,
382
+ msg.contextPath,
383
+ )
384
+ : [];
385
+ channel.post({ gen: msg.gen, kind: "evalResult", reqId: msg.reqId, results });
386
+ return;
387
+ }
368
388
  if (msg.kind === "patch") {
369
389
  const { gen } = msg;
370
390
  if (gen < renderedGen) {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * In-iframe live expression evaluation (M6) — answers the parent's `evalExpr` requests against the
3
+ * LIVE resolved scope of the current render (buildScope's `$defs`), so previews see real repeater
4
+ * items, real `window#/` globals, and uncapped data values instead of the parent's serialized
5
+ * snapshot.
6
+ *
7
+ * Safety: every expression evaluates against a fresh plain-data clone of the scope (a per-key JSON
8
+ * round-trip, the serialize-scope idiom — it reads through the runtime's reactive proxies), so
9
+ * mutating operators write into the clone and never touch the live canvas. Named-formula callables
10
+ * (pure `$expression` entries with parameters) are kept BY REFERENCE so `call` nodes work; every
11
+ * other function (side-effecting handlers, server fns) is dropped so a preview can never run one.
12
+ */
13
+
14
+ import { evaluateExpression } from "@jxsuite/runtime/expression";
15
+ import { resolveRef } from "@jxsuite/runtime";
16
+ import { formatPreviewValue } from "../utils/preview-format";
17
+
18
+ import type { ExpressionNode } from "@jxsuite/runtime/expression";
19
+ import type { EvalExprResult } from "./iframe-protocol";
20
+
21
+ type Scope = Record<string, unknown>;
22
+
23
+ /** Whether the raw (document) state entry is a pure named-formula (`$expression` + parameters). */
24
+ function isNamedFormulaEntry(entry: unknown): boolean {
25
+ return (
26
+ typeof entry === "object" &&
27
+ entry !== null &&
28
+ !Array.isArray(entry) &&
29
+ "$expression" in entry &&
30
+ Array.isArray((entry as { parameters?: unknown }).parameters)
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Clone the live `$defs` scope into a plain object safe to evaluate (and mutate) against. Data
36
+ * values are JSON round-tripped per key (reads through reactive proxies, unwraps top-level refs);
37
+ * functions survive only when their raw state entry is a pure named formula — anything else
38
+ * callable (event handlers, structured-body functions, server fns) is dropped so a stray `call`
39
+ * can't run a side effect against the live canvas.
40
+ */
41
+ export function cloneLiveScope(defs: Scope, rawState: Record<string, unknown> | null): Scope {
42
+ const out: Scope = {};
43
+ for (const key of Object.keys(defs)) {
44
+ try {
45
+ // Reading through the reactive proxy auto-unwraps a top-level ref/computed — and can throw (a
46
+ // Computed getter that throws), which maps the key to null instead of aborting the clone.
47
+ const value = defs[key];
48
+ if (typeof value === "function") {
49
+ if (isNamedFormulaEntry(rawState?.[key])) {
50
+ out[key] = value; // A pure formula callable — safe (and needed) for `call` nodes.
51
+ }
52
+ continue;
53
+ }
54
+ const json = JSON.stringify(value);
55
+ out[key] = json === undefined ? null : (JSON.parse(json) as unknown);
56
+ } catch {
57
+ out[key] = null;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Bind repeater `$map` contexts for a node at `contextPath`: walk the shadow doc along the path
65
+ * and, at every repeater crossing (a `$prototype: "Array"` node entered through its `map`
66
+ * template), resolve the repeater's `items` against the scope built so far and layer the FIRST
67
+ * item's `$map` context (item + index 0) over it — the same shape `renderMappedArrayInto` gives
68
+ * rendered items, so `$map/item` / `$map/index` refs preview against real data. Nested repeaters
69
+ * chain naturally (an inner `items` ref may read the outer `$map/item`).
70
+ */
71
+ export function bindRepeaterContext(
72
+ scope: Scope,
73
+ shadowDoc: unknown,
74
+ contextPath: (string | number)[] | null,
75
+ ): Scope {
76
+ if (!contextPath || contextPath.length === 0) {
77
+ return scope;
78
+ }
79
+ let node: unknown = shadowDoc;
80
+ let bound = scope;
81
+ for (const seg of contextPath) {
82
+ if (!node || typeof node !== "object") {
83
+ break;
84
+ }
85
+ const rec = node as Record<string, unknown>;
86
+ if (rec.$prototype === "Array" && seg === "map") {
87
+ // Entering the repeater's template — bind the first item's context before descending.
88
+ const itemsDef = rec.items;
89
+ const items: unknown =
90
+ itemsDef && typeof itemsDef === "object" && "$ref" in itemsDef
91
+ ? resolveRef((itemsDef as { $ref: string }).$ref, bound)
92
+ : itemsDef;
93
+ if (Array.isArray(items) && items.length > 0) {
94
+ let item: unknown;
95
+ try {
96
+ // Items resolved from the shadow doc (a literal array) must not be mutable through the
97
+ // Preview scope — clone; scope-resolved items are already clone data, so this is free.
98
+ item = structuredClone(items[0]);
99
+ } catch {
100
+ item = null;
101
+ }
102
+ const child = Object.create(bound) as Scope;
103
+ child.$map = { index: 0, item };
104
+ child["$map/item"] = item;
105
+ child["$map/index"] = 0;
106
+ bound = child;
107
+ }
108
+ }
109
+ node = rec[seg as keyof typeof rec];
110
+ }
111
+ return bound;
112
+ }
113
+
114
+ /**
115
+ * Evaluate each requested expression against a fresh clone of the live scope (with the repeater
116
+ * context for `contextPath` bound), reporting every sub-node's value through the engine's trace
117
+ * hook as pre-formatted display strings. Exceptions are guarded per expression into `error`.
118
+ */
119
+ export function evaluateLiveExprs(
120
+ exprs: { id: string; node: unknown }[],
121
+ defs: Scope,
122
+ shadowDoc: unknown,
123
+ contextPath: (string | number)[] | null,
124
+ ): EvalExprResult[] {
125
+ const rawState =
126
+ shadowDoc && typeof shadowDoc === "object"
127
+ ? ((shadowDoc as { state?: Record<string, unknown> }).state ?? null)
128
+ : null;
129
+ return exprs.map(({ id, node }) => {
130
+ const values = new Map<string, string>();
131
+ // A fresh scope per expression: a mutating operator writes into this clone only, and can never
132
+ // Leak into the next expression's evaluation (or the live canvas).
133
+ let scope: Scope;
134
+ try {
135
+ scope = bindRepeaterContext(cloneLiveScope(defs, rawState), shadowDoc, contextPath);
136
+ } catch (error) {
137
+ return { error: error instanceof Error ? error.message : String(error), id, values: [] };
138
+ }
139
+ try {
140
+ if (!node || typeof node !== "object" || !("operator" in node)) {
141
+ throw new Error("not an expression node");
142
+ }
143
+ evaluateExpression(node as ExpressionNode, scope, null, undefined, {
144
+ report: (path, value) => values.set(path.join("/"), formatPreviewValue(value)),
145
+ });
146
+ return { id, values: [...values.entries()] };
147
+ } catch (error) {
148
+ return {
149
+ error: error instanceof Error ? error.message : String(error),
150
+ id,
151
+ values: [...values.entries()],
152
+ };
153
+ }
154
+ });
155
+ }