@jxsuite/studio 0.9.0 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
32
32
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
33
- "@jxsuite/runtime": "^0.7.0",
33
+ "@jxsuite/runtime": "^0.10.0",
34
34
  "@spectrum-web-components/accordion": "^1.12.0",
35
35
  "@spectrum-web-components/action-bar": "1.12.0",
36
36
  "@spectrum-web-components/action-button": "^1.12.0",
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Canvas helpers — extracted from studio.js (Phase 4n). Shared query/utility functions used by
3
+ * multiple canvas-related modules: element lookup, zoom, panel resolution, inline bubbling.
4
+ */
5
+
6
+ import {
7
+ getState,
8
+ canvasPanels,
9
+ elToPath,
10
+ pathsEqual,
11
+ getNodeAtPath,
12
+ parentElementPath,
13
+ } from "../store.js";
14
+ import { isInlineInContext } from "../editor/inline-edit.js";
15
+
16
+ /** @type {any} */
17
+ let _ctx = null;
18
+
19
+ /**
20
+ * Initialize the canvas helpers module.
21
+ *
22
+ * @param {{ getCanvasMode: () => string }} ctx
23
+ */
24
+ export function initCanvasHelpers(ctx) {
25
+ _ctx = ctx;
26
+ }
27
+
28
+ /** Effective zoom scale — always 1 in edit (content) mode, S.ui.zoom otherwise. */
29
+ export function effectiveZoom() {
30
+ return _ctx.getCanvasMode() === "edit" ? 1 : getState().ui.zoom;
31
+ }
32
+
33
+ /** Return the active canvas panel based on the current activeMedia setting. */
34
+ export function getActivePanel() {
35
+ if (canvasPanels.length === 0) return null;
36
+ if (canvasPanels.length === 1) return canvasPanels[0];
37
+ const S = getState();
38
+ for (const p of canvasPanels) {
39
+ if (S.ui.activeMedia === null && (p.mediaName === "base" || p.mediaName === null)) return p;
40
+ if (p.mediaName === S.ui.activeMedia) return p;
41
+ }
42
+ return canvasPanels[0];
43
+ }
44
+
45
+ /**
46
+ * Walk up the tree from a path, bubbling past inline elements until we find the nearest non-inline
47
+ * ancestor. Returns the original path if already non-inline.
48
+ *
49
+ * @param {any} doc
50
+ * @param {any} path
51
+ */
52
+ export function bubbleInlinePath(doc, path) {
53
+ let currentPath = path;
54
+ while (currentPath.length >= 2) {
55
+ const node = getNodeAtPath(doc, currentPath);
56
+ const pPath = parentElementPath(currentPath);
57
+ const parentNode = pPath ? getNodeAtPath(doc, pPath) : null;
58
+ if (!node || !parentNode) break;
59
+ const childTag = (node.tagName ?? "div").toLowerCase();
60
+ const parentTag = (parentNode.tagName ?? "div").toLowerCase();
61
+ if (!isInlineInContext(childTag, parentTag)) break;
62
+ currentPath = pPath;
63
+ }
64
+ return currentPath;
65
+ }
66
+
67
+ /**
68
+ * Find a canvas DOM element by its document path.
69
+ *
70
+ * @param {any} path
71
+ * @param {any} canvasEl
72
+ */
73
+ export function findCanvasElement(path, canvasEl) {
74
+ let el = canvasEl.firstElementChild;
75
+ if (!el) return null;
76
+ if (path.length === 0) return el;
77
+
78
+ for (let i = 0; i < path.length; i += 2) {
79
+ if (path[i] !== "children" && path[i] !== "cases") return null;
80
+ const idx = path[i + 1];
81
+ if (idx === undefined) {
82
+ el = el.children[0];
83
+ } else if (idx === "map") {
84
+ el = el.children[0]?.children[0];
85
+ } else {
86
+ el = el.children[idx];
87
+ }
88
+ if (!el) break;
89
+ }
90
+
91
+ if (el) {
92
+ const elPath = elToPath.get(el);
93
+ if (elPath && pathsEqual(elPath, path)) return el;
94
+ }
95
+
96
+ for (const candidate of canvasEl.querySelectorAll("*")) {
97
+ const p = elToPath.get(candidate);
98
+ if (p && pathsEqual(p, path)) return candidate;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Build an overlay box descriptor (no DOM creation).
105
+ *
106
+ * @param {any} el
107
+ * @param {any} type
108
+ * @param {any} panel
109
+ */
110
+ export function overlayBoxDescriptor(el, type, panel) {
111
+ const vpRect = panel.viewport.getBoundingClientRect();
112
+ const elRect = el.getBoundingClientRect();
113
+ const scale = effectiveZoom();
114
+ return {
115
+ cls: `overlay-box overlay-${type}`,
116
+ top: `${(elRect.top - vpRect.top + panel.viewport.scrollTop) / scale}px`,
117
+ left: `${(elRect.left - vpRect.left + panel.viewport.scrollLeft) / scale}px`,
118
+ width: `${elRect.width / scale}px`,
119
+ height: `${elRect.height / scale}px`,
120
+ };
121
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Canvas live render — extracted from studio.js (Phase 4p). Async runtime rendering pipeline that
3
+ * builds live canvas DOM using @jxsuite/runtime. Handles element registration, scope building, path
4
+ * mapping ($map remapping), site-level style injection, and $head element injection.
5
+ */
6
+
7
+ import { elToPath, stripEventHandlers, projectState } from "../store.js";
8
+ import { view } from "../view.js";
9
+ import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxsuite/runtime";
10
+ import {
11
+ getEffectiveElements,
12
+ getEffectiveImports,
13
+ getEffectiveMedia,
14
+ getEffectiveHead,
15
+ } from "../site-context.js";
16
+ import { componentRegistry, computeRelativePath } from "../files/components.js";
17
+ import { prepareForEditMode } from "../utils/edit-display.js";
18
+ import { getActiveElement } from "../editor/inline-edit.js";
19
+
20
+ /** @type {any} */
21
+ let _ctx = null;
22
+
23
+ /**
24
+ * Initialize the canvas live render module.
25
+ *
26
+ * @param {{
27
+ * getState: () => any;
28
+ * getCanvasMode: () => string;
29
+ * }} ctx
30
+ */
31
+ export function initCanvasLiveRender(ctx) {
32
+ _ctx = ctx;
33
+ }
34
+
35
+ /**
36
+ * Render a Jx document into a canvas element using the real runtime. Populates elToPath for each
37
+ * created element via onNodeCreated callback. Returns the live state scope on success, null on
38
+ * failure.
39
+ *
40
+ * @param {number} gen - Render generation for staleness detection
41
+ * @param {any} doc
42
+ * @param {any} canvasEl
43
+ */
44
+ export async function renderCanvasLive(gen, doc, canvasEl) {
45
+ const S = _ctx.getState();
46
+ const canvasMode = _ctx.getCanvasMode();
47
+
48
+ const renderDoc =
49
+ canvasMode === "preview" ? structuredClone(doc) : prepareForEditMode(stripEventHandlers(doc));
50
+
51
+ // In edit mode, collect paths where $map templates were inlined as children[0]
52
+ // so we can remap runtime paths (children,0,...) → (children,map,...)
53
+ const mapParentPaths = new Set();
54
+ if (canvasMode === "design" || canvasMode === "edit") {
55
+ (function findMapParents(/** @type {any} */ node, /** @type {any[]} */ path) {
56
+ if (!node || typeof node !== "object") return;
57
+ if (
58
+ node.children &&
59
+ typeof node.children === "object" &&
60
+ node.children.$prototype === "Array"
61
+ ) {
62
+ mapParentPaths.add(path.join("/"));
63
+ }
64
+ if (Array.isArray(node.children)) {
65
+ for (let i = 0; i < node.children.length; i++) {
66
+ findMapParents(node.children[i], [...path, "children", i]);
67
+ }
68
+ }
69
+ if (node.$switch && node.cases) {
70
+ for (const [k, v] of Object.entries(node.cases)) {
71
+ findMapParents(v, [...path, "cases", k]);
72
+ }
73
+ }
74
+ })(doc, []);
75
+ }
76
+
77
+ try {
78
+ const root = projectState?.projectRoot || "";
79
+ const docPrefix = root ? `${root}/` : "";
80
+ const docBase = S.documentPath ? `${location.origin}/${docPrefix}${S.documentPath}` : undefined;
81
+
82
+ // Register custom elements so the runtime can render them
83
+ let effectiveElements = getEffectiveElements(renderDoc.$elements);
84
+
85
+ // In content mode (markdown), auto-discover components for directive-based
86
+ // custom elements that have no explicit $elements registration.
87
+ if (S.mode === "content" && componentRegistry.length > 0) {
88
+ const existingRefs = new Set(
89
+ effectiveElements.map((/** @type {any} */ e) => (typeof e === "string" ? e : e?.$ref)),
90
+ );
91
+ /** @param {any} node */
92
+ const collectTags = (node) => {
93
+ /** @type {Set<string>} */
94
+ const tags = new Set();
95
+ if (!node || typeof node !== "object") return tags;
96
+ if (node.tagName) tags.add(node.tagName);
97
+ if (Array.isArray(node.children)) {
98
+ for (const child of node.children) {
99
+ for (const t of collectTags(child)) tags.add(t);
100
+ }
101
+ }
102
+ return tags;
103
+ };
104
+ for (const tag of collectTags(renderDoc)) {
105
+ const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === tag);
106
+ if (comp && comp.source !== "npm") {
107
+ const relPath = computeRelativePath(S.documentPath, comp.path);
108
+ if (!existingRefs.has(relPath)) {
109
+ effectiveElements.push({ $ref: relPath });
110
+ existingRefs.add(relPath);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ if (effectiveElements.length) {
117
+ renderDoc.$elements = effectiveElements;
118
+ for (const entry of effectiveElements) {
119
+ if (typeof entry === "string") {
120
+ try {
121
+ const specifier =
122
+ entry.startsWith("/") || entry.startsWith(".")
123
+ ? entry
124
+ : `/${projectState?.projectRoot || ""}/node_modules/${entry}`.replace(/\/+/g, "/");
125
+ await import(specifier);
126
+ } catch (/** @type {any} */ e) {
127
+ console.warn("Studio: failed to import package", entry, e);
128
+ }
129
+ } else if (entry?.$ref) {
130
+ const href = new URL(entry.$ref, docBase).href;
131
+ try {
132
+ await defineElement(href);
133
+ } catch (/** @type {any} */ e) {
134
+ console.warn("Studio: failed to register element", entry.$ref, e);
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ // Bail out if a newer render started while we were importing elements
141
+ if (gen !== view.renderGeneration) return null;
142
+
143
+ // Inject site-level imports so buildScope can resolve $prototype names
144
+ renderDoc.imports = getEffectiveImports(renderDoc.imports);
145
+
146
+ // Apply project-level styles mirroring the compiler convention:
147
+ // viewport ≈ :root → CSS custom properties (they inherit down)
148
+ // canvasEl ≈ body → regular CSS properties (inline beats CSS defaults)
149
+ // This ensures project font-family, color, etc. override the
150
+ // content-mode fallback typography rules in the stylesheet.
151
+ // In edit mode, propagate to the .content-edit-canvas wrapper for seamless appearance.
152
+ const viewport = canvasEl.closest(".canvas-panel-viewport");
153
+ const editSurface = canvasMode === "edit" ? canvasEl.closest(".content-edit-canvas") : null;
154
+ const siteStyle = projectState?.projectConfig?.style;
155
+ if (viewport) {
156
+ viewport.style.cssText = "";
157
+ if (siteStyle && typeof siteStyle === "object") {
158
+ for (const [k, v] of Object.entries(siteStyle)) {
159
+ if (k.startsWith("--")) {
160
+ viewport.style.setProperty(k, String(v));
161
+ } else {
162
+ /** @type {any} */ (viewport.style)[k] = v;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ if (editSurface) {
168
+ if (siteStyle && typeof siteStyle === "object") {
169
+ for (const [k, v] of Object.entries(siteStyle)) {
170
+ if (k.startsWith("--")) {
171
+ /** @type {any} */ (editSurface).style.setProperty(k, String(v));
172
+ } else {
173
+ /** @type {any} */ (editSurface.style)[k] = v;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ if (siteStyle && typeof siteStyle === "object") {
179
+ for (const [k, v] of Object.entries(siteStyle)) {
180
+ if (!k.startsWith("--")) {
181
+ /** @type {any} */ (canvasEl.style)[k] = v;
182
+ }
183
+ }
184
+ }
185
+
186
+ // Inject site-level $media so runtime can resolve media queries in styles
187
+ renderDoc.$media = getEffectiveMedia(renderDoc.$media);
188
+
189
+ // Inject $head elements (link/meta/script) into document.head
190
+ const effectiveHead = getEffectiveHead(renderDoc.$head);
191
+ if (effectiveHead.length) {
192
+ for (const entry of effectiveHead) {
193
+ if (!entry?.tagName) continue;
194
+ const tag = entry.tagName.toLowerCase();
195
+ const attrs = { ...entry.attributes };
196
+ const headRoot = projectState?.projectRoot || "";
197
+ for (const key of ["href", "src"]) {
198
+ if (
199
+ attrs[key] &&
200
+ !attrs[key].startsWith("/") &&
201
+ !attrs[key].startsWith(".") &&
202
+ !attrs[key].startsWith("http")
203
+ ) {
204
+ attrs[key] = `/${headRoot}/node_modules/${attrs[key]}`.replace(/\/+/g, "/");
205
+ }
206
+ }
207
+ const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
208
+ if (selector !== tag && document.head.querySelector(selector)) continue;
209
+ const el = document.createElement(tag);
210
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, /** @type {string} */ (v));
211
+ if (entry.textContent) el.textContent = entry.textContent;
212
+ document.head.appendChild(el);
213
+ }
214
+ }
215
+
216
+ const $defs = await buildScope(renderDoc, {}, docBase);
217
+ // Bail out if a newer render started while buildScope was running
218
+ if (gen !== view.renderGeneration) return null;
219
+ const el = /** @type {HTMLElement} */ (
220
+ runtimeRenderNode(renderDoc, $defs, {
221
+ onNodeCreated(/** @type {any} */ el, /** @type {any} */ path) {
222
+ // Remap $map paths: wrapper and template children → real document paths
223
+ // prepareForEditMode wraps $map template in: children[0] (wrapper) > children[0] (template)
224
+ // Real paths: wrapper → ['children'] ($map container), template → ['children', 'map']
225
+ let mappedPath = path;
226
+ if ((canvasMode === "design" || canvasMode === "edit") && mapParentPaths.size > 0) {
227
+ for (let i = 0; i < path.length - 1; i++) {
228
+ if (path[i] === "children" && path[i + 1] === 0) {
229
+ const parentKey = path.slice(0, i).join("/");
230
+ if (mapParentPaths.has(parentKey)) {
231
+ if (path.length === i + 2) {
232
+ // Wrapper div itself → $map container path
233
+ mappedPath = path.slice(0, i + 1);
234
+ } else if (
235
+ path.length >= i + 4 &&
236
+ path[i + 2] === "children" &&
237
+ path[i + 3] === 0
238
+ ) {
239
+ // Template or its descendants → children/map/...rest
240
+ mappedPath = [...path.slice(0, i), "children", "map", ...path.slice(i + 4)];
241
+ }
242
+ break;
243
+ }
244
+ }
245
+ }
246
+ }
247
+ elToPath.set(el, mappedPath);
248
+ },
249
+ _path: [],
250
+ })
251
+ );
252
+ if (canvasMode === "design" || canvasMode === "edit") {
253
+ // Disable pointer events on all rendered elements for edit mode
254
+ el.style.pointerEvents = "none";
255
+ for (const child of el.querySelectorAll("*")) {
256
+ /** @type {any} */ (child).style.pointerEvents = "none";
257
+ }
258
+ }
259
+ // Clear and append atomically — ensures the canvas is never left empty if a
260
+ // newer render starts and this one would have bailed after clearing.
261
+ canvasEl.innerHTML = "";
262
+ if (S.mode === "content") {
263
+ canvasEl.setAttribute("data-content-mode", "");
264
+ } else {
265
+ canvasEl.removeAttribute("data-content-mode");
266
+ }
267
+ canvasEl.appendChild(el);
268
+ if (canvasMode === "design" || canvasMode === "edit") {
269
+ // Custom element connectedCallbacks render children asynchronously —
270
+ // sweep again after they've had a chance to run
271
+ requestAnimationFrame(() => {
272
+ const editingEl = getActiveElement();
273
+ for (const child of canvasEl.querySelectorAll("*")) {
274
+ // Preserve pointer-events on the actively-edited element
275
+ if (view.componentInlineEdit && child === view.componentInlineEdit.el) continue;
276
+ if (editingEl && child === editingEl) continue;
277
+ /** @type {any} */ (child).style.pointerEvents = "none";
278
+ }
279
+ });
280
+ }
281
+ return $defs;
282
+ } catch (/** @type {any} */ err) {
283
+ console.warn("renderCanvasLive failed:", err.message, err);
284
+ return null;
285
+ }
286
+ }