@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.
- package/dist/iframe-entry.js +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +53069 -31746
- package/dist/studio.js.map +109 -74
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +131 -5
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +83 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- 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.
|
|
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.
|
|
43
|
-
"@jxsuite/create": "^1.
|
|
44
|
-
"@jxsuite/
|
|
45
|
-
"@jxsuite/
|
|
46
|
-
"@jxsuite/
|
|
47
|
-
"@jxsuite/
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
+
}
|