@jxsuite/studio 0.14.0 → 0.16.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.14.0",
3
+ "version": "0.16.0",
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.12.0",
33
+ "@jxsuite/runtime": "^0.15.0",
34
34
  "@spectrum-web-components/accordion": "^1.12.1",
35
35
  "@spectrum-web-components/action-bar": "1.12.1",
36
36
  "@spectrum-web-components/action-button": "^1.12.1",
@@ -10,6 +10,7 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
10
10
  import { canvasWrap, canvasPanels, updateCanvas } from "../store.js";
11
11
  import { activeTab } from "../workspace/workspace.js";
12
12
  import { view } from "../view.js";
13
+ import { loadMarkdown } from "../files/file-ops.js";
13
14
  import {
14
15
  canvasPanelTemplate,
15
16
  applyTransform,
@@ -144,6 +145,7 @@ export function renderCanvas() {
144
145
  // Reset inline style overrides from other modes
145
146
  canvasWrap.style.padding = "";
146
147
  canvasWrap.style.alignItems = "";
148
+ canvasWrap.style.flexDirection = "";
147
149
  canvasWrap.style.display = "";
148
150
  canvasWrap.style.overflow = "";
149
151
  canvasWrap.style.overflow = "";
@@ -247,72 +249,79 @@ export function renderCanvas() {
247
249
  return;
248
250
  }
249
251
 
250
- // Git diff mode — render original (left) and current (right) side-by-side
252
+ // Git diff mode — render original (left) and current (right) side-by-side on panzoom surface
251
253
  if (canvasMode === "git-diff") {
252
254
  if (!_ctx.gitDiffState) {
253
- // Fallback to design mode if diff state is missing
254
255
  _ctx.setCanvasMode("design");
255
256
  renderCanvas();
256
257
  return;
257
258
  }
258
259
 
259
- canvasWrap.style.padding = "0";
260
- canvasWrap.style.overflow = "hidden";
260
+ if (modeChanged) {
261
+ canvasWrap.style.padding = "0";
262
+ canvasWrap.style.overflow = "hidden";
263
+ }
261
264
 
262
265
  const gitDiffState = _ctx.gitDiffState;
263
- const { tpl: origPanelTpl, panel: origPanel } = canvasPanelTemplate(
266
+ const panelWidth = 800;
267
+
268
+ const { tpl: origTpl, panel: origPanel } = canvasPanelTemplate(
264
269
  "git-diff-original",
265
270
  "Original",
266
271
  false,
267
- "50%",
272
+ panelWidth,
268
273
  );
269
- const { tpl: currPanelTpl, panel: currPanel } = canvasPanelTemplate(
274
+ const { tpl: currTpl, panel: currPanel } = canvasPanelTemplate(
270
275
  "git-diff-current",
271
276
  "Current",
272
277
  false,
273
- "50%",
278
+ panelWidth,
274
279
  );
275
280
 
276
- const diffTpl = html`
277
- <div style="display: flex; height: 100%; gap: 0;">
278
- <div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
279
- ${origPanelTpl}
280
- </div>
281
- <div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
282
- ${currPanelTpl}
281
+ litRender(
282
+ html`
283
+ <div
284
+ class="panzoom-wrap"
285
+ style="transform-origin:0 0"
286
+ ${ref((el) => {
287
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
288
+ })}
289
+ >
290
+ ${origTpl} ${currTpl}
283
291
  </div>
284
- </div>
285
- `;
292
+ `,
293
+ canvasWrap,
294
+ );
286
295
 
287
- litRender(diffTpl, canvasWrap);
288
296
  canvasPanels.push(/** @type {any} */ (origPanel));
289
297
  canvasPanels.push(/** @type {any} */ (currPanel));
290
298
 
291
- // Parse original document from git content
292
- let originalDoc = null;
293
- try {
299
+ /** @param {string} content */
300
+ const parseContent = (content) => {
294
301
  if (gitDiffState.isMarkdown) {
295
- // For markdown, we need to convert it to Jx format
296
- // For now, use a simple placeholder until we have markdown parsing
297
- originalDoc = {
298
- tagName: "div",
299
- children: [{ tagName: "p", textContent: "Original (markdown)" }],
300
- };
301
- } else {
302
- // Parse as JSON
303
- originalDoc = JSON.parse(gitDiffState.originalContent);
302
+ return loadMarkdown(content).then((r) => r.document);
304
303
  }
305
- } catch {
306
- originalDoc = {
307
- tagName: "div",
308
- children: [{ tagName: "p", textContent: "Failed to parse original" }],
309
- };
310
- }
311
-
312
- const currDoc = S.document;
304
+ return Promise.resolve().then(() => {
305
+ try {
306
+ return JSON.parse(content);
307
+ } catch {
308
+ return { tagName: "div", children: [{ tagName: "p", textContent: "Failed to parse" }] };
309
+ }
310
+ });
311
+ };
312
+
313
+ const featureToggles = S.ui.featureToggles;
314
+ Promise.all([
315
+ parseContent(gitDiffState.originalContent || ""),
316
+ parseContent(gitDiffState.currentContent || ""),
317
+ ]).then(([originalDoc, currentDoc]) => {
318
+ renderCanvasIntoPanel(origPanel, new Set(), featureToggles, originalDoc, gitDiffState);
319
+ renderCanvasIntoPanel(currPanel, new Set(), featureToggles, currentDoc, gitDiffState);
320
+ });
313
321
 
314
- renderCanvasIntoPanel(origPanel, new Set(), S.ui.featureToggles, originalDoc, gitDiffState);
315
- renderCanvasIntoPanel(currPanel, new Set(), S.ui.featureToggles, currDoc, gitDiffState);
322
+ applyTransform();
323
+ if (modeChanged) observeCenterUntilStable();
324
+ renderZoomIndicator();
316
325
  return;
317
326
  }
318
327
 
@@ -498,6 +507,7 @@ function renderCanvasIntoPanel(
498
507
  } else {
499
508
  // Fallback to structural preview
500
509
  updateCanvas({ status: "ready", scope: null, error: null });
510
+ panel.canvas.innerHTML = "";
501
511
  renderCanvasNode(docToRender, [], panel.canvas, activeBreakpoints, featureToggles);
502
512
  }
503
513
  registerPanelDnD(panel);
@@ -1,13 +1,14 @@
1
1
  // ─── Clipboard & Context Menu ─────────────────────────────────────────────────
2
2
  import { html, render as litRender } from "lit-html";
3
3
  import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
4
- import { activeTab } from "../workspace/workspace.js";
4
+ import { activeTab, workspace } from "../workspace/workspace.js";
5
5
  import {
6
6
  transactDoc,
7
7
  mutateInsertNode,
8
8
  mutateRemoveNode,
9
9
  mutateDuplicateNode,
10
10
  mutateWrapNode,
11
+ mutateReplaceStyle,
11
12
  } from "../tabs/transact.js";
12
13
  import { statusMessage } from "../panels/statusbar.js";
13
14
  import { convertToComponent } from "./convert-to-component.js";
@@ -21,9 +22,6 @@ import { componentRegistry } from "../files/components.js";
21
22
  * @typedef {import("../state.js").JxNode} JxNode
22
23
  */
23
24
 
24
- /** @type {JxNode | null} */
25
- let clipboard = null;
26
-
27
25
  // ─── Clipboard ────────────────────────────────────────────────────────────────
28
26
 
29
27
  export function copyNode() {
@@ -31,7 +29,7 @@ export function copyNode() {
31
29
  if (!tab?.session.selection) return;
32
30
  const node = getNodeAtPath(tab.doc.document, tab.session.selection);
33
31
  if (!node) return;
34
- clipboard = structuredClone(node);
32
+ workspace.clipboard = structuredClone(node);
35
33
  statusMessage("Copied");
36
34
  }
37
35
 
@@ -41,16 +39,16 @@ export function cutNode() {
41
39
  const sel = tab.session.selection;
42
40
  const node = getNodeAtPath(tab.doc.document, sel);
43
41
  if (!node) return;
44
- clipboard = structuredClone(node);
42
+ workspace.clipboard = structuredClone(node);
45
43
  transactDoc(tab, (t) => mutateRemoveNode(t, sel));
46
44
  statusMessage("Cut");
47
45
  }
48
46
 
49
47
  export function pasteNode() {
50
- if (!clipboard) return;
48
+ if (!workspace.clipboard) return;
51
49
  const tab = activeTab.value;
52
50
  if (!tab) return;
53
- const clip = clipboard;
51
+ const clip = workspace.clipboard;
54
52
  const pPath = tab.session.selection || [];
55
53
  const parent = getNodeAtPath(tab.doc.document, pPath);
56
54
  if (!parent) return;
@@ -66,6 +64,25 @@ export function pasteNode() {
66
64
  statusMessage("Pasted");
67
65
  }
68
66
 
67
+ export function copyStyles() {
68
+ const tab = activeTab.value;
69
+ if (!tab?.session.selection) return;
70
+ const node = getNodeAtPath(tab.doc.document, tab.session.selection);
71
+ if (!node?.style) return;
72
+ workspace.styleClipboard = JSON.parse(JSON.stringify(node.style));
73
+ statusMessage("Styles copied");
74
+ }
75
+
76
+ export function pasteStyles() {
77
+ if (!workspace.styleClipboard) return;
78
+ const tab = activeTab.value;
79
+ if (!tab?.session.selection) return;
80
+ const style = JSON.parse(JSON.stringify(workspace.styleClipboard));
81
+ const sel = /** @type {JxPath} */ (tab.session.selection);
82
+ transactDoc(tab, (t) => mutateReplaceStyle(t, sel, style));
83
+ statusMessage("Styles pasted");
84
+ }
85
+
69
86
  // ─── Context menu ─────────────────────────────────────────────────────────────
70
87
 
71
88
  const ctxMenu = document.createElement("sp-popover");
@@ -109,6 +126,26 @@ export function showContextMenu(e, path, opts = {}) {
109
126
  label: "Duplicate",
110
127
  action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
111
128
  });
129
+ if (node.style) {
130
+ items.push({
131
+ label: "Copy styles",
132
+ action: () => {
133
+ workspace.styleClipboard = JSON.parse(JSON.stringify(node.style));
134
+ statusMessage("Styles copied");
135
+ },
136
+ });
137
+ }
138
+ if (workspace.styleClipboard) {
139
+ items.push({
140
+ label: "Paste styles",
141
+ action: () => {
142
+ if (!workspace.styleClipboard) return;
143
+ const style = JSON.parse(JSON.stringify(workspace.styleClipboard));
144
+ transactDoc(activeTab.value, (t) => mutateReplaceStyle(t, path, style));
145
+ statusMessage("Styles pasted");
146
+ },
147
+ });
148
+ }
112
149
  items.push({ label: "—" }); // separator
113
150
  items.push({
114
151
  label: "Insert before",
@@ -162,8 +199,8 @@ export function showContextMenu(e, path, opts = {}) {
162
199
  danger: true,
163
200
  });
164
201
  }
165
- if (clipboard) {
166
- const clip = clipboard;
202
+ if (workspace.clipboard) {
203
+ const clip = workspace.clipboard;
167
204
  items.push({ label: "—" });
168
205
  items.push({
169
206
  label: "Paste inside",
@@ -174,9 +174,11 @@ export async function exportFile() {
174
174
  * @param {import("../tabs/tab.js").Tab} tab
175
175
  * @returns {string}
176
176
  */
177
- function serializeDocument(tab) {
177
+ export function serializeDocument(tab) {
178
178
  if (tab.doc.sourceFormat === "md") {
179
- return jxDocToMd(tab.doc.document);
179
+ const fm = tab.doc.content?.frontmatter || {};
180
+ const fullDoc = { ...fm, children: tab.doc.document.children ?? [] };
181
+ return jxDocToMd(fullDoc);
180
182
  }
181
183
  if (tab.doc.mode === "content") {
182
184
  const mdast = jxToMd(tab.doc.document);
@@ -383,83 +383,83 @@ export function setupTreeKeyboard(/** @type {HTMLElement} */ tree) {
383
383
  // ─── Context menu ─────────────────────────────────────────────────────────────
384
384
 
385
385
  /** @type {HTMLElement | null} */
386
- let fileContextPopover = null;
386
+ let _fileCtxHost = null;
387
+
388
+ function getFileCtxHost() {
389
+ if (!_fileCtxHost) {
390
+ _fileCtxHost = document.createElement("div");
391
+ _fileCtxHost.style.display = "contents";
392
+ (document.querySelector("sp-theme") || document.body).appendChild(_fileCtxHost);
393
+ document.addEventListener("click", dismissFileContextMenu);
394
+ }
395
+ return _fileCtxHost;
396
+ }
397
+
398
+ function dismissFileContextMenu() {
399
+ const host = _fileCtxHost;
400
+ if (!host) return;
401
+ const popover = host.querySelector("sp-popover");
402
+ if (popover) popover.removeAttribute("open");
403
+ }
387
404
 
388
405
  function showFileContextMenu(
389
406
  /** @type {MouseEvent} */ e,
390
407
  /** @type {{ name: string; path: string; type: string }} */ entry,
391
408
  /** @type {{ openFileFn: (path: string) => void; renderLeftPanel: () => void }} */ ctx,
392
409
  ) {
393
- if (fileContextPopover) {
394
- fileContextPopover.remove();
395
- fileContextPopover = null;
396
- }
397
-
410
+ e.preventDefault();
411
+ const host = getFileCtxHost();
398
412
  const isDir = entry.type === "directory";
399
- fileContextPopover = document.createElement("div");
400
413
 
401
- const tpl = html`
402
- <sp-popover
403
- placement="right-start"
404
- open
405
- style="position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:9999"
406
- >
407
- <sp-menu style="min-width:160px">
408
- ${!isDir
409
- ? html`<sp-menu-item
410
- @click=${() => {
411
- closeFileContextMenu();
412
- ctx.openFileFn(entry.path);
413
- }}
414
- >Open</sp-menu-item
415
- >`
416
- : nothing}
417
- ${isDir
418
- ? html`<sp-menu-item
419
- @click=${() => {
420
- closeFileContextMenu();
421
- createNewFile(entry.path, ctx.renderLeftPanel);
422
- }}
423
- >New File…</sp-menu-item
424
- >`
425
- : nothing}
426
- <sp-menu-divider></sp-menu-divider>
427
- <sp-menu-item
428
- @click=${() => {
429
- closeFileContextMenu();
430
- renameFile(entry, ctx.renderLeftPanel);
431
- }}
432
- >Rename…</sp-menu-item
433
- >
434
- <sp-menu-item
435
- style="color:var(--danger)"
436
- @click=${() => {
437
- closeFileContextMenu();
438
- deleteFile(entry, ctx.renderLeftPanel);
439
- }}
440
- >Delete</sp-menu-item
441
- >
442
- </sp-menu>
443
- </sp-popover>
444
- `;
445
-
446
- litRender(tpl, fileContextPopover);
447
- document.body.appendChild(fileContextPopover);
448
-
449
- const closeHandler = (/** @type {MouseEvent} */ ev) => {
450
- if (!fileContextPopover?.contains(/** @type {Node} */ (ev.target))) {
451
- closeFileContextMenu();
452
- document.removeEventListener("mousedown", closeHandler, true);
453
- }
454
- };
455
- setTimeout(() => document.addEventListener("mousedown", closeHandler, true), 0);
456
- }
414
+ /** @type {{ label: string; action?: () => void; danger?: boolean }[]} */
415
+ const items = [];
457
416
 
458
- function closeFileContextMenu() {
459
- if (fileContextPopover) {
460
- fileContextPopover.remove();
461
- fileContextPopover = null;
417
+ if (!isDir) {
418
+ items.push({ label: "Open", action: () => ctx.openFileFn(entry.path) });
462
419
  }
420
+ if (isDir) {
421
+ items.push({
422
+ label: "New File\u2026",
423
+ action: () => createNewFile(entry.path, ctx.renderLeftPanel),
424
+ });
425
+ }
426
+ items.push({ label: "\u2014" });
427
+ items.push({ label: "Rename\u2026", action: () => renameFile(entry, ctx.renderLeftPanel) });
428
+ items.push({
429
+ label: "Delete",
430
+ action: () => deleteFile(entry, ctx.renderLeftPanel),
431
+ danger: true,
432
+ });
433
+
434
+ litRender(
435
+ html`<sp-popover style="position:fixed;z-index:10000">
436
+ <sp-menu>
437
+ ${items.map((item) =>
438
+ item.label === "\u2014"
439
+ ? html`<sp-menu-divider></sp-menu-divider>`
440
+ : html`<sp-menu-item
441
+ style=${item.danger ? "color: var(--danger)" : ""}
442
+ @click=${() => {
443
+ dismissFileContextMenu();
444
+ item.action?.();
445
+ }}
446
+ >${item.label}</sp-menu-item
447
+ >`,
448
+ )}
449
+ </sp-menu>
450
+ </sp-popover>`,
451
+ host,
452
+ );
453
+
454
+ const popover = /** @type {HTMLElement} */ (host.querySelector("sp-popover"));
455
+ popover.setAttribute("open", "");
456
+ const menuRect = popover.getBoundingClientRect();
457
+ let x = e.clientX,
458
+ y = e.clientY;
459
+ if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
460
+ if (y + menuRect.height > window.innerHeight) y = window.innerHeight - menuRect.height - 4;
461
+ popover.style.left = `${x}px`;
462
+ popover.style.top = `${y}px`;
463
463
  }
464
464
 
465
465
  // ─── File CRUD ────────────────────────────────────────────────────────────────
@@ -57,6 +57,8 @@ export function renderFunctionEditor() {
57
57
 
58
58
  litRender(nothing, canvasWrap);
59
59
  canvasWrap.style.padding = "0";
60
+ canvasWrap.style.flexDirection = "column";
61
+ canvasWrap.style.alignItems = "stretch";
60
62
 
61
63
  // Toolbar breadcrumb handles context display — re-render it
62
64
  renderOnly("toolbar");
@@ -65,12 +67,14 @@ export function renderFunctionEditor() {
65
67
  /** @type {HTMLDivElement | null} */
66
68
  let editorContainer = null;
67
69
  litRender(
68
- html`<div
69
- class="source-editor"
70
- ${ref((el) => {
71
- if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
72
- })}
73
- ></div>`,
70
+ html`<div class="source-wrap">
71
+ <div
72
+ class="source-editor"
73
+ ${ref((el) => {
74
+ if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
75
+ })}
76
+ ></div>
77
+ </div>`,
74
78
  canvasWrap,
75
79
  );
76
80
 
@@ -7,6 +7,7 @@ import { html, nothing } from "lit-html";
7
7
  import { live } from "lit-html/directives/live.js";
8
8
  import { getPlatform } from "../platform.js";
9
9
  import { updateUi, renderOnly } from "../store.js";
10
+ import { activeTab } from "../workspace/workspace.js";
10
11
  import { view } from "../view.js";
11
12
 
12
13
  async function refreshGitStatus() {
@@ -70,7 +71,8 @@ export function renderGitPanel(S, ctx) {
70
71
  const totalChanges = status?.files?.length || 0;
71
72
 
72
73
  const doCommit = async () => {
73
- const msg = S.ui.gitCommitMessage?.trim();
74
+ const tab = activeTab.value;
75
+ const msg = tab?.session.ui.gitCommitMessage?.trim();
74
76
  if (!msg) return;
75
77
  updateUi("gitCommitMessage", "");
76
78
  await gitAction("gitCommit", msg);
@@ -145,11 +147,7 @@ export function renderGitPanel(S, ctx) {
145
147
  }
146
148
  }}
147
149
  ></sp-textfield>
148
- <sp-action-button
149
- class="git-commit-btn"
150
- @click=${doCommit}
151
- ?disabled=${!S.ui.gitCommitMessage?.trim() || loading}
152
- >
150
+ <sp-action-button class="git-commit-btn" @click=${doCommit} ?disabled=${loading}>
153
151
  <sp-icon-checkmark slot="icon" size="xs"></sp-icon-checkmark>
154
152
  Commit
155
153
  </sp-action-button>
@@ -162,29 +160,33 @@ export function renderGitPanel(S, ctx) {
162
160
  const dir = parts.join("/");
163
161
 
164
162
  const onFileClick = async () => {
165
- // Only support diff for modified/added files
166
163
  if (file.status !== "M" && file.status !== "A") return;
167
164
  if (!file.path.endsWith(".md") && !file.path.endsWith(".json")) return;
168
165
 
169
166
  try {
170
167
  const plat = getPlatform();
171
168
  updateUi("gitLoading", true);
172
- const originalContent = await plat.gitShow({ path: file.path, ref: "HEAD" });
173
169
 
174
- // Determine if it's markdown or JSON
175
- const isMarkdown = file.path.endsWith(".md");
170
+ const [originalContent, currentContent] = await Promise.all([
171
+ file.status === "A"
172
+ ? Promise.resolve("")
173
+ : plat.gitShow({ path: file.path, ref: "HEAD" }),
174
+ plat.readFile(file.path),
175
+ ]);
176
176
 
177
- // For now, we'll need to pass the original content to the canvas
178
- // This requires state management which we'll add to studio.js
179
- updateUi("gitDiffState", {
177
+ const isMarkdown = file.path.endsWith(".md");
178
+ const diffState = {
180
179
  filePath: file.path,
181
180
  originalContent,
181
+ currentContent,
182
182
  isMarkdown,
183
183
  fileStatus: file.status,
184
- });
184
+ };
185
+
186
+ updateUi("gitDiffState", diffState);
185
187
 
186
- // Trigger diff mode via context
187
188
  if (ctx?.setCanvasMode) {
189
+ if (ctx.setGitDiffState) ctx.setGitDiffState(diffState);
188
190
  ctx.setCanvasMode("git-diff");
189
191
  }
190
192
  } catch (/** @type {any} */ e) {
@@ -40,6 +40,7 @@ import { selectStylebookTag, stylebookMeta } from "./stylebook-panel.js";
40
40
  * registerElementsDnD: () => void;
41
41
  * registerComponentsDnD: () => void;
42
42
  * setupTreeKeyboard: (tree: HTMLElement) => void;
43
+ * setGitDiffState: (state: any) => void;
43
44
  * }} LeftPanelCtx
44
45
  */
45
46
 
@@ -69,6 +70,9 @@ export function mount(ctx) {
69
70
  void tab.doc.mode;
70
71
  void tab.session.selection;
71
72
  void tab.session.ui.settingsTab;
73
+ void tab.session.ui.gitStatus;
74
+ void tab.session.ui.gitLoading;
75
+ void tab.session.ui.gitError;
72
76
  render();
73
77
  });
74
78
  });