@jxsuite/studio 0.21.4 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.21.4",
3
+ "version": "0.22.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "dependencies": {
32
32
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
33
33
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
34
- "@jxsuite/parser": "^0.21.4",
35
- "@jxsuite/runtime": "^0.21.4",
36
- "@jxsuite/schema": "^0.21.4",
34
+ "@jxsuite/parser": "^0.22.0",
35
+ "@jxsuite/runtime": "^0.22.0",
36
+ "@jxsuite/schema": "^0.22.0",
37
37
  "@spectrum-web-components/accordion": "^1.12.1",
38
38
  "@spectrum-web-components/action-bar": "1.12.1",
39
39
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -13,7 +13,6 @@ import {
13
13
  buildScope,
14
14
  defineElement,
15
15
  setSkipServerFunctions,
16
- setSkipContentResolution,
17
16
  } from "@jxsuite/runtime";
18
17
  import {
19
18
  getEffectiveElements,
@@ -139,7 +138,6 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
139
138
  // failed proxy calls and infinite reactive retries (also covers
140
139
  // async custom element connectedCallbacks that run after this function returns)
141
140
  setSkipServerFunctions(canvasMode !== "preview");
142
- setSkipContentResolution(canvasMode !== "preview");
143
141
 
144
142
  let renderDoc =
145
143
  canvasMode === "preview"
@@ -219,9 +217,9 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
219
217
  /** @type {(JxElement | string)[]} */ (renderDoc.$elements),
220
218
  );
221
219
 
222
- // In content mode (markdown), auto-discover components for directive-based
223
- // custom elements that have no explicit $elements registration.
224
- if (S.mode === "content" && componentRegistry.length > 0) {
220
+ // In content mode (markdown) or when a layout is applied, auto-discover components
221
+ // for custom elements that have no explicit $elements registration.
222
+ if ((S.mode === "content" || layoutWrapped) && componentRegistry.length > 0) {
225
223
  const existingRefs = new Set(
226
224
  effectiveElements.map((/** @type {JxElement | string} */ e) =>
227
225
  typeof e === "string" ? e : e?.$ref,
@@ -367,6 +365,9 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
367
365
  for (const entry of effectiveHead) {
368
366
  if (!entry?.tagName) continue;
369
367
  const tag = entry.tagName.toLowerCase();
368
+ // Skip inline scripts — they can contain arbitrary JS/HTML that throws
369
+ // when the browser tries to execute it in the studio context
370
+ if (tag === "script" && !entry.attributes?.src) continue;
370
371
  const attrs = { ...entry.attributes };
371
372
  for (const key of ["href", "src"]) {
372
373
  const val = attrs[key];
@@ -664,11 +664,12 @@ function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
664
664
  const tab = activeTab.value;
665
665
  if (!tab) return;
666
666
  const docMedia = getEffectiveMedia(tab.doc.document.$media || {});
667
- const validBreakpoints = new Set();
667
+ // Build a set of CSS condition texts that match active breakpoints
668
+ const activeConditions = new Set();
668
669
  for (const name of activeBreakpoints) {
669
- if (docMedia[name]) validBreakpoints.add(name);
670
+ if (docMedia[name]) activeConditions.add(docMedia[name]);
670
671
  }
671
- const overrides = collectMediaOverrides(document.styleSheets, validBreakpoints);
672
+ const overrides = collectMediaOverrides(document.styleSheets, activeConditions);
672
673
  applyOverridesToCanvas(canvasEl, overrides);
673
674
  }
674
675
 
@@ -1,6 +1,7 @@
1
1
  // ─── Clipboard & Context Menu ─────────────────────────────────────────────────
2
2
  import { html } from "lit-html";
3
3
  import { ref } from "lit-html/directives/ref.js";
4
+ import { htmlToJx } from "@jxsuite/parser/html-to-jx";
4
5
  import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
5
6
  import { activeTab, workspace } from "../workspace/workspace.js";
6
7
  import {
@@ -26,33 +27,135 @@ import { startLayerTitleEdit } from "../panels/layers-panel.js";
26
27
  * @typedef {JxMutableNode} JxNode
27
28
  */
28
29
 
29
- // ─── Clipboard ────────────────────────────────────────────────────────────────
30
+ // ─── Clipboard helpers ───────────────────────────────────────────────────────
30
31
 
31
- export function copyNode() {
32
+ const JX_MIME = "web application/jx+json";
33
+
34
+ /** @param {JxNode | string} node */
35
+ function nodeToHtml(node) {
36
+ if (typeof node === "string") return node;
37
+ const tag = node.tagName || "div";
38
+ let attrs = "";
39
+ if (node.attributes) {
40
+ for (const [k, v] of Object.entries(node.attributes)) {
41
+ attrs += v === "" ? ` ${k}` : ` ${k}="${v.replace(/"/g, """)}"`;
42
+ }
43
+ }
44
+ if (node.style) {
45
+ const css = Object.entries(node.style)
46
+ .map(([k, v]) => `${k}:${v}`)
47
+ .join(";");
48
+ if (css) attrs += ` style="${css.replace(/"/g, """)}"`;
49
+ }
50
+ let inner = "";
51
+ if (node.textContent) {
52
+ inner = node.textContent;
53
+ } else if (node.children) {
54
+ inner = node.children.map((c) => nodeToHtml(c)).join("");
55
+ }
56
+ return `<${tag}${attrs}>${inner}</${tag}>`;
57
+ }
58
+
59
+ /**
60
+ * Write a Jx node to the system clipboard with both jx+json and text/html types.
61
+ *
62
+ * @param {object} json
63
+ */
64
+ async function writeToClipboard(json) {
65
+ workspace.clipboard = json;
66
+ try {
67
+ await navigator.clipboard.write([
68
+ new ClipboardItem({
69
+ [JX_MIME]: new Blob([JSON.stringify(json)], { type: JX_MIME }),
70
+ "text/html": new Blob([nodeToHtml(json)], { type: "text/html" }),
71
+ }),
72
+ ]);
73
+ } catch {
74
+ // Fallback: write as plain text if custom MIME not supported
75
+ try {
76
+ await navigator.clipboard.writeText(JSON.stringify(json));
77
+ } catch {
78
+ // clipboard API unavailable — workspace.clipboard is the fallback
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Read from the system clipboard. Returns Jx node(s) or null.
85
+ *
86
+ * @returns {Promise<JxNode[] | null>}
87
+ */
88
+ async function readFromClipboard() {
89
+ try {
90
+ const items = await navigator.clipboard.read();
91
+ for (const item of items) {
92
+ if (item.types.includes(JX_MIME)) {
93
+ const blob = await item.getType(JX_MIME);
94
+ const json = JSON.parse(await blob.text());
95
+ return [json];
96
+ }
97
+ if (item.types.includes("text/html")) {
98
+ const blob = await item.getType("text/html");
99
+ const htmlStr = await blob.text();
100
+ const nodes = htmlToJx(htmlStr);
101
+ const jxNodes = /** @type {JxNode[]} */ (
102
+ nodes.map((n) => (typeof n === "string" ? { tagName: "p", textContent: n } : n))
103
+ );
104
+ if (jxNodes.length > 0) return jxNodes;
105
+ }
106
+ if (item.types.includes("text/plain")) {
107
+ const blob = await item.getType("text/plain");
108
+ const text = await blob.text();
109
+ // Try parsing as Jx JSON
110
+ try {
111
+ const parsed = JSON.parse(text);
112
+ if (parsed && parsed.tagName) return [parsed];
113
+ } catch {
114
+ // plain text → paragraph node
115
+ }
116
+ if (text.trim()) return [{ tagName: "p", textContent: text.trim() }];
117
+ }
118
+ }
119
+ } catch {
120
+ // clipboard API unavailable — use workspace fallback
121
+ if (workspace.clipboard) {
122
+ return [JSON.parse(JSON.stringify(workspace.clipboard))];
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // ─── Clipboard actions ───────────────────────────────────────────────────────
129
+
130
+ export async function copyNode() {
32
131
  const tab = activeTab.value;
33
132
  if (!tab?.session.selection) return;
34
133
  const node = getNodeAtPath(tab.doc.document, tab.session.selection);
35
134
  if (!node) return;
36
- workspace.clipboard = structuredClone(node);
135
+ const json = JSON.parse(JSON.stringify(node));
136
+ await writeToClipboard(json);
37
137
  statusMessage("Copied");
38
138
  }
39
139
 
40
- export function cutNode() {
140
+ export async function cutNode() {
41
141
  const tab = activeTab.value;
42
142
  if (!tab?.session.selection || tab.session.selection.length < 2) return;
43
143
  const sel = tab.session.selection;
44
144
  const node = getNodeAtPath(tab.doc.document, sel);
45
145
  if (!node) return;
46
- workspace.clipboard = structuredClone(node);
146
+ const json = JSON.parse(JSON.stringify(node));
147
+ await writeToClipboard(json);
47
148
  transactDoc(tab, (t) => mutateRemoveNode(t, sel));
48
149
  statusMessage("Cut");
49
150
  }
50
151
 
51
- export function pasteNode() {
52
- if (!workspace.clipboard) return;
152
+ export async function pasteNode() {
53
153
  const tab = activeTab.value;
54
154
  if (!tab) return;
55
- const clip = workspace.clipboard;
155
+
156
+ const nodes = await readFromClipboard();
157
+ if (!nodes || nodes.length === 0) return;
158
+
56
159
  const pPath = tab.session.selection || [];
57
160
  const parent = getNodeAtPath(tab.doc.document, pPath);
58
161
  if (!parent) return;
@@ -60,10 +163,18 @@ export function pasteNode() {
60
163
  if (tab.session.selection && tab.session.selection.length >= 2) {
61
164
  const pp = /** @type {JxPath} */ (parentElementPath(tab.session.selection));
62
165
  const idx = /** @type {number} */ (childIndex(tab.session.selection));
63
- transactDoc(tab, (t) => mutateInsertNode(t, pp, idx + 1, structuredClone(clip)));
166
+ transactDoc(tab, (t) => {
167
+ for (let i = 0; i < nodes.length; i++) {
168
+ mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
169
+ }
170
+ });
64
171
  } else {
65
172
  const idx = parent.children ? parent.children.length : 0;
66
- transactDoc(tab, (t) => mutateInsertNode(t, pPath, idx, structuredClone(clip)));
173
+ transactDoc(tab, (t) => {
174
+ for (let i = 0; i < nodes.length; i++) {
175
+ mutateInsertNode(t, pPath, idx + i, nodes[i]);
176
+ }
177
+ });
67
178
  }
68
179
  statusMessage("Pasted");
69
180
  }
@@ -214,28 +325,37 @@ export function showContextMenu(e, path, opts = {}) {
214
325
  danger: true,
215
326
  });
216
327
  }
217
- if (workspace.clipboard) {
218
- const clip = workspace.clipboard;
328
+ if (path.length >= 2) {
219
329
  items.push({ label: "—" });
220
330
  items.push({
221
331
  label: "Paste inside",
222
- action: () => {
332
+ action: async () => {
333
+ const nodes = await readFromClipboard();
334
+ if (!nodes || nodes.length === 0) return;
223
335
  const idx = node.children ? node.children.length : 0;
224
- transactDoc(activeTab.value, (t) => mutateInsertNode(t, path, idx, structuredClone(clip)));
336
+ transactDoc(activeTab.value, (t) => {
337
+ for (let i = 0; i < nodes.length; i++) {
338
+ mutateInsertNode(t, path, idx + i, nodes[i]);
339
+ }
340
+ });
341
+ statusMessage("Pasted");
342
+ },
343
+ });
344
+ items.push({
345
+ label: "Paste after",
346
+ action: async () => {
347
+ const nodes = await readFromClipboard();
348
+ if (!nodes || nodes.length === 0) return;
349
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
350
+ const idx = /** @type {number} */ (childIndex(path));
351
+ transactDoc(activeTab.value, (t) => {
352
+ for (let i = 0; i < nodes.length; i++) {
353
+ mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
354
+ }
355
+ });
356
+ statusMessage("Pasted");
225
357
  },
226
358
  });
227
- if (path.length >= 2) {
228
- items.push({
229
- label: "Paste after",
230
- action: () => {
231
- const pp = /** @type {JxPath} */ (parentElementPath(path));
232
- const idx = /** @type {number} */ (childIndex(path));
233
- transactDoc(activeTab.value, (t) =>
234
- mutateInsertNode(t, pp, idx + 1, structuredClone(clip)),
235
- );
236
- },
237
- });
238
- }
239
359
  }
240
360
 
241
361
  let x = e.clientX,
@@ -129,7 +129,8 @@ function onMouseMove(e) {
129
129
  if (!_ctx || !_helper) return;
130
130
 
131
131
  const { getCanvasMode } = _ctx;
132
- if (getCanvasMode() !== "design") {
132
+ const mode = getCanvasMode();
133
+ if (mode !== "design" && mode !== "edit") {
133
134
  hide();
134
135
  return;
135
136
  }
@@ -222,7 +223,7 @@ function showAt(el, edge, path, parentPath, idx) {
222
223
  // Set CSS anchor on target element
223
224
  if (_currentAnchor !== el) {
224
225
  clearAnchor();
225
- el.style.anchorName = "--jx-insert";
226
+ /** @type {any} */ (el.style).anchorName = "--jx-insert";
226
227
  _currentAnchor = el;
227
228
  }
228
229
 
@@ -258,7 +259,7 @@ function hideNow() {
258
259
 
259
260
  function clearAnchor() {
260
261
  if (_currentAnchor) {
261
- _currentAnchor.style.anchorName = "";
262
+ /** @type {any} */ (_currentAnchor.style).anchorName = "";
262
263
  _currentAnchor = null;
263
264
  }
264
265
  }
@@ -843,6 +843,13 @@ function collapsePropsToAttrMap(propsObj) {
843
843
  mdAttrKey = key.slice(1);
844
844
  }
845
845
  // Strip @ prefix for media queries (inside style.* paths)
846
+ if (key === "@") {
847
+ // Bare "@" (no media name) — treat contents as base-level style props
848
+ if (value && typeof value === "object" && !Array.isArray(value)) {
849
+ walk(/** @type {Record<string, unknown>} */ (value), prefix);
850
+ }
851
+ continue;
852
+ }
846
853
  if (key.startsWith("@--")) {
847
854
  mdAttrKey = key.slice(1);
848
855
  }
@@ -9,11 +9,44 @@ import { html, nothing } from "lit-html";
9
9
  import { live } from "lit-html/directives/live.js";
10
10
  import { renderFieldRow } from "../ui/field-row.js";
11
11
  import { renderMediaPicker } from "../ui/media-picker.js";
12
- import { debouncedStyleCommit, projectState } from "../store.js";
12
+ import { debouncedStyleCommit, renderOnly, projectState } from "../store.js";
13
13
  import { activeTab } from "../workspace/workspace.js";
14
14
  import { transactDoc, mutateUpdateFrontmatter } from "../tabs/transact.js";
15
15
  import { findContentTypeSchema } from "../utils/studio-utils.js";
16
16
  import { isGoogleFontEntry, isGoogleFontPreconnect } from "../utils/google-fonts.js";
17
+ import { invalidateLayoutCache } from "../site-context.js";
18
+ import { getPlatform } from "../platform.js";
19
+
20
+ // ─── Layout picker ──────────────────────────────────────────────────────────
21
+
22
+ /** @type {{ name: string; path: string }[] | null} */
23
+ let layoutEntries = null;
24
+
25
+ async function loadLayoutEntries() {
26
+ try {
27
+ const platform = getPlatform();
28
+ const listing = await platform.listDirectory("layouts");
29
+ layoutEntries = listing
30
+ .filter(
31
+ (/** @type {{ type: string; name: string }} */ f) =>
32
+ f.type === "file" && f.name.endsWith(".json"),
33
+ )
34
+ .map((/** @type {{ type: string; name: string }} */ f) => ({
35
+ name: f.name
36
+ .replace(/\.json$/, "")
37
+ .replace(/[-_]+/g, " ")
38
+ .replace(/\b\w/g, (c) => c.toUpperCase()),
39
+ path: `./layouts/${f.name}`,
40
+ }));
41
+ } catch {
42
+ layoutEntries = [];
43
+ }
44
+ renderOnly("leftPanel");
45
+ }
46
+
47
+ export function invalidateLayoutPickerCache() {
48
+ layoutEntries = null;
49
+ }
17
50
 
18
51
  // ─── Field definitions ───────────────────────────────────────────────────
19
52
 
@@ -282,9 +315,79 @@ export function renderHeadTemplate({ document: doc, applyMutation, renderLeftPan
282
315
  const isContent = tab?.doc.mode === "content";
283
316
  const frontmatterSection = isContent ? renderFrontmatterSection() : nothing;
284
317
 
318
+ // Layout field
319
+ const isPage =
320
+ tab?.documentPath &&
321
+ projectState?.isSiteProject &&
322
+ (tab.documentPath.startsWith("pages/") || tab.documentPath.startsWith("./pages/"));
323
+
324
+ /** @type {import("lit-html").TemplateResult | symbol} */
325
+ let layoutSection = nothing;
326
+ if (isPage) {
327
+ if (layoutEntries === null) {
328
+ loadLayoutEntries();
329
+ } else {
330
+ const currentLayout = doc.$layout;
331
+ const defaultLayout = projectState?.projectConfig?.defaults?.layout;
332
+ const displayValue =
333
+ currentLayout === false ? "__none__" : currentLayout ? currentLayout : "__default__";
334
+ const defaultLabel = defaultLayout
335
+ ? defaultLayout
336
+ .replace(/^\.\/layouts\//, "")
337
+ .replace(/\.json$/, "")
338
+ .replace(/[-_]+/g, " ")
339
+ .replace(/\b\w/g, (/** @type {string} */ c) => c.toUpperCase())
340
+ : "";
341
+
342
+ layoutSection = html`
343
+ <div class="imports-section">
344
+ <div class="imports-section-header">
345
+ <span class="imports-section-title">Layout</span>
346
+ </div>
347
+ <div class="head-section-body">
348
+ ${renderFieldRow({
349
+ prop: "layout",
350
+ label: "Layout",
351
+ hasValue: currentLayout !== undefined,
352
+ onClear: () =>
353
+ applyMutation((/** @type {JxMutableNode} */ d) => {
354
+ delete d.$layout;
355
+ }),
356
+ widget: html`
357
+ <sp-picker
358
+ size="s"
359
+ value=${displayValue}
360
+ @change=${(/** @type {Event} */ e) => {
361
+ const val = /** @type {HTMLInputElement} */ (e.target).value;
362
+ applyMutation((/** @type {JxMutableNode} */ d) => {
363
+ if (val === "__default__") delete d.$layout;
364
+ else if (val === "__none__") d.$layout = false;
365
+ else d.$layout = val;
366
+ });
367
+ invalidateLayoutCache();
368
+ }}
369
+ >
370
+ <sp-menu-item value="__default__"
371
+ >Default${defaultLabel ? ` (${defaultLabel})` : ""}</sp-menu-item
372
+ >
373
+ <sp-menu-item value="__none__">None</sp-menu-item>
374
+ <sp-menu-divider></sp-menu-divider>
375
+ ${layoutEntries.map(
376
+ (/** @type {{ name: string; path: string }} */ l) =>
377
+ html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
378
+ )}
379
+ </sp-picker>
380
+ `,
381
+ })}
382
+ </div>
383
+ </div>
384
+ `;
385
+ }
386
+ }
387
+
285
388
  return html`
286
389
  <div class="imports-panel">
287
- ${frontmatterSection}
390
+ ${frontmatterSection} ${layoutSection}
288
391
 
289
392
  <!-- Page section -->
290
393
  <div class="imports-section">