@jxsuite/studio 0.10.2 → 0.13.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 (53) hide show
  1. package/README.md +82 -0
  2. package/dist/studio.js +5114 -3424
  3. package/dist/studio.js.map +65 -49
  4. package/package.json +6 -3
  5. package/src/browse/browse.js +27 -3
  6. package/src/canvas/canvas-diff.js +184 -0
  7. package/src/canvas/canvas-helpers.js +10 -14
  8. package/src/canvas/canvas-live-render.js +136 -17
  9. package/src/canvas/canvas-render.js +154 -21
  10. package/src/canvas/canvas-utils.js +21 -23
  11. package/src/editor/component-inline-edit.js +54 -41
  12. package/src/editor/content-inline-edit.js +46 -47
  13. package/src/editor/context-menu.js +63 -39
  14. package/src/editor/convert-to-component.js +11 -14
  15. package/src/editor/insertion-helper.js +8 -10
  16. package/src/editor/shortcuts.js +69 -39
  17. package/src/files/components.js +15 -4
  18. package/src/files/files.js +72 -24
  19. package/src/panels/activity-bar.js +29 -7
  20. package/src/panels/block-action-bar.js +104 -80
  21. package/src/panels/canvas-dnd.js +132 -50
  22. package/src/panels/dnd.js +32 -28
  23. package/src/panels/editors.js +7 -14
  24. package/src/panels/elements-panel.js +9 -3
  25. package/src/panels/events-panel.js +44 -39
  26. package/src/panels/git-panel.js +45 -3
  27. package/src/panels/layers-panel.js +16 -11
  28. package/src/panels/left-panel.js +108 -43
  29. package/src/panels/overlays.js +97 -35
  30. package/src/panels/panel-events.js +18 -11
  31. package/src/panels/properties-panel.js +467 -98
  32. package/src/panels/right-panel.js +85 -37
  33. package/src/panels/shared.js +0 -22
  34. package/src/panels/signals-panel.js +125 -54
  35. package/src/panels/statusbar.js +23 -8
  36. package/src/panels/style-inputs.js +4 -2
  37. package/src/panels/style-panel.js +128 -105
  38. package/src/panels/stylebook-panel.js +42 -17
  39. package/src/panels/tab-strip.js +124 -0
  40. package/src/panels/toolbar.js +39 -9
  41. package/src/reactivity.js +28 -0
  42. package/src/settings/content-types-editor.js +78 -8
  43. package/src/settings/defs-editor.js +56 -7
  44. package/src/settings/schema-field-ui.js +99 -11
  45. package/src/site-context.js +105 -0
  46. package/src/state.js +0 -456
  47. package/src/store.js +105 -124
  48. package/src/studio.js +112 -121
  49. package/src/tabs/tab.js +153 -0
  50. package/src/tabs/transact.js +406 -0
  51. package/src/ui/spectrum.js +2 -0
  52. package/src/view.js +3 -0
  53. package/src/workspace/workspace.js +89 -0
@@ -5,16 +5,14 @@
5
5
 
6
6
  import {
7
7
  getState,
8
- update,
9
8
  renderOnly,
10
- selectNode,
11
- insertNode,
12
- updateProperty,
13
9
  getNodeAtPath,
14
10
  parentElementPath,
15
11
  childIndex,
16
12
  canvasPanels,
17
13
  } from "../store.js";
14
+ import { activeTab } from "../workspace/workspace.js";
15
+ import { transactDoc, mutateInsertNode, mutateUpdateProperty } from "../tabs/transact.js";
18
16
  import { view } from "../view.js";
19
17
  import { startEditing, isEditableBlock } from "./inline-edit.js";
20
18
  import { restoreTemplateExpressions } from "../utils/edit-display.js";
@@ -50,28 +48,21 @@ export function enterInlineEdit(el, path) {
50
48
  const node = getNodeAtPath(S.document, commitPath);
51
49
  if (children) {
52
50
  if (node && JSON.stringify(node.children) === JSON.stringify(children)) return;
53
- let s = updateProperty(S, commitPath, "textContent", undefined);
54
- s = updateProperty(s, commitPath, "children", children);
55
- update(s);
51
+ transactDoc(activeTab.value, (t) => {
52
+ mutateUpdateProperty(t, commitPath, "textContent", undefined);
53
+ mutateUpdateProperty(t, commitPath, "children", children);
54
+ });
56
55
  } else if (textContent != null) {
57
56
  if (node && node.textContent === textContent && !node.children) return;
58
- let s = updateProperty(S, commitPath, "children", undefined);
59
- s = updateProperty(s, commitPath, "textContent", textContent);
60
- update(s);
57
+ transactDoc(activeTab.value, (t) => {
58
+ mutateUpdateProperty(t, commitPath, "children", undefined);
59
+ mutateUpdateProperty(t, commitPath, "textContent", textContent);
60
+ });
61
61
  }
62
62
  },
63
63
 
64
64
  onSplit(/** @type {any} */ splitPath, /** @type {any} */ before, /** @type {any} */ after) {
65
65
  const tag = "p";
66
- let s = getState();
67
-
68
- if (before.textContent != null) {
69
- s = updateProperty(s, splitPath, "children", undefined);
70
- s = updateProperty(s, splitPath, "textContent", before.textContent);
71
- } else if (before.children) {
72
- s = updateProperty(s, splitPath, "textContent", undefined);
73
- s = updateProperty(s, splitPath, "children", before.children);
74
- }
75
66
 
76
67
  // Insert new element after with "after" content
77
68
  const parentPath = /** @type {any} */ (parentElementPath(splitPath));
@@ -86,10 +77,19 @@ export function enterInlineEdit(el, path) {
86
77
  newNode.textContent = "";
87
78
  }
88
79
 
89
- s = insertNode(s, parentPath, idx + 1, newNode);
90
80
  const newPath = [...parentPath, "children", idx + 1];
91
- s = selectNode(s, newPath);
92
- update(s);
81
+
82
+ transactDoc(activeTab.value, (t) => {
83
+ if (before.textContent != null) {
84
+ mutateUpdateProperty(t, splitPath, "children", undefined);
85
+ mutateUpdateProperty(t, splitPath, "textContent", before.textContent);
86
+ } else if (before.children) {
87
+ mutateUpdateProperty(t, splitPath, "textContent", undefined);
88
+ mutateUpdateProperty(t, splitPath, "children", before.children);
89
+ }
90
+ mutateInsertNode(t, parentPath, idx + 1, newNode);
91
+ t.session.selection = newPath;
92
+ });
93
93
 
94
94
  // Re-enter editing on the new element after render
95
95
  requestAnimationFrame(() => {
@@ -125,17 +125,17 @@ export function enterInlineEdit(el, path) {
125
125
 
126
126
  // If the element is empty, swap its tagName instead of inserting after
127
127
  if (isEmpty) {
128
- let s = getState();
129
- s = updateProperty(s, afterPath, "tagName", cmd.tag);
130
- s = updateProperty(s, afterPath, "children", undefined);
131
- const def = defaultDef(cmd.tag);
132
- if (def.textContent && def.textContent !== "Paragraph text") {
133
- s = updateProperty(s, afterPath, "textContent", def.textContent);
134
- } else {
135
- s = updateProperty(s, afterPath, "textContent", undefined);
136
- }
137
- s = selectNode(s, afterPath);
138
- update(s);
128
+ transactDoc(activeTab.value, (t) => {
129
+ mutateUpdateProperty(t, afterPath, "tagName", cmd.tag);
130
+ mutateUpdateProperty(t, afterPath, "children", undefined);
131
+ const def = defaultDef(cmd.tag);
132
+ if (def.textContent && def.textContent !== "Paragraph text") {
133
+ mutateUpdateProperty(t, afterPath, "textContent", def.textContent);
134
+ } else {
135
+ mutateUpdateProperty(t, afterPath, "textContent", undefined);
136
+ }
137
+ t.session.selection = afterPath;
138
+ });
139
139
 
140
140
  requestAnimationFrame(() => {
141
141
  const activePanel = getActivePanel();
@@ -152,23 +152,22 @@ export function enterInlineEdit(el, path) {
152
152
  const elementDef = defaultDef(cmd.tag);
153
153
  const parentPath = /** @type {any} */ (parentElementPath(afterPath));
154
154
  const idx = /** @type {number} */ (childIndex(afterPath));
155
+ const newPath = [...parentPath, "children", idx + 1];
155
156
 
156
157
  // Apply pending commit from inline edit first (batched to avoid double render)
157
- let s = getState();
158
- if (commitData) {
159
- if (commitData.children) {
160
- s = updateProperty(s, afterPath, "textContent", undefined);
161
- s = updateProperty(s, afterPath, "children", commitData.children);
162
- } else if (commitData.textContent != null) {
163
- s = updateProperty(s, afterPath, "children", undefined);
164
- s = updateProperty(s, afterPath, "textContent", commitData.textContent);
158
+ transactDoc(activeTab.value, (t) => {
159
+ if (commitData) {
160
+ if (commitData.children) {
161
+ mutateUpdateProperty(t, afterPath, "textContent", undefined);
162
+ mutateUpdateProperty(t, afterPath, "children", commitData.children);
163
+ } else if (commitData.textContent != null) {
164
+ mutateUpdateProperty(t, afterPath, "children", undefined);
165
+ mutateUpdateProperty(t, afterPath, "textContent", commitData.textContent);
166
+ }
165
167
  }
166
- }
167
-
168
- s = insertNode(s, parentPath, idx + 1, structuredClone(elementDef));
169
- const newPath = [...parentPath, "children", idx + 1];
170
- s = selectNode(s, newPath);
171
- update(s);
168
+ mutateInsertNode(t, parentPath, idx + 1, structuredClone(elementDef));
169
+ t.session.selection = newPath;
170
+ });
172
171
 
173
172
  // If the inserted element is editable, enter editing
174
173
  requestAnimationFrame(() => {
@@ -1,26 +1,32 @@
1
1
  // ─── Clipboard & Context Menu ─────────────────────────────────────────────────
2
2
  import { html, render as litRender } from "lit-html";
3
+ import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
4
+ import { activeTab } from "../workspace/workspace.js";
3
5
  import {
4
- update,
5
- selectNode,
6
- insertNode,
7
- removeNode,
8
- duplicateNode,
9
- wrapNode,
10
- getNodeAtPath,
11
- parentElementPath,
12
- childIndex,
13
- } from "../store.js";
6
+ transactDoc,
7
+ mutateInsertNode,
8
+ mutateRemoveNode,
9
+ mutateDuplicateNode,
10
+ mutateWrapNode,
11
+ } from "../tabs/transact.js";
14
12
  import { statusMessage } from "../panels/statusbar.js";
15
13
  import { convertToComponent } from "./convert-to-component.js";
16
14
  import { componentRegistry } from "../files/components.js";
17
15
 
18
- /** @type {any} */
16
+ /**
17
+ * @typedef {import("../state.js").StudioState} StudioState
18
+ *
19
+ * @typedef {import("../state.js").JxPath} JxPath
20
+ *
21
+ * @typedef {import("../state.js").JxNode} JxNode
22
+ */
23
+
24
+ /** @type {JxNode | null} */
19
25
  let clipboard = null;
20
26
 
21
27
  // ─── Clipboard ────────────────────────────────────────────────────────────────
22
28
 
23
- /** @param {any} S */
29
+ /** @param {StudioState} S */
24
30
  export function copyNode(S) {
25
31
  if (!S.selection) return;
26
32
  const node = getNodeAtPath(S.document, S.selection);
@@ -29,32 +35,32 @@ export function copyNode(S) {
29
35
  statusMessage("Copied");
30
36
  }
31
37
 
32
- /** @param {any} S */
38
+ /** @param {StudioState} S */
33
39
  export function cutNode(S) {
34
40
  if (!S.selection || S.selection.length < 2) return;
35
- const node = getNodeAtPath(S.document, S.selection);
41
+ const sel = S.selection;
42
+ const node = getNodeAtPath(S.document, sel);
36
43
  if (!node) return;
37
44
  clipboard = structuredClone(node);
38
- update(removeNode(S, S.selection));
45
+ transactDoc(activeTab.value, (t) => mutateRemoveNode(t, sel));
39
46
  statusMessage("Cut");
40
47
  }
41
48
 
42
- /** @param {any} S */
49
+ /** @param {StudioState} S */
43
50
  export function pasteNode(S) {
44
51
  if (!clipboard) return;
52
+ const clip = clipboard;
45
53
  const pPath = S.selection || [];
46
54
  const parent = getNodeAtPath(S.document, pPath);
47
55
  if (!parent) return;
48
56
 
49
57
  if (S.selection && S.selection.length >= 2) {
50
- // Paste as sibling after selection
51
- const pp = /** @type {any} */ (parentElementPath(S.selection));
58
+ const pp = /** @type {JxPath} */ (parentElementPath(S.selection));
52
59
  const idx = /** @type {number} */ (childIndex(S.selection));
53
- update(insertNode(S, pp, idx + 1, structuredClone(clipboard)));
60
+ transactDoc(activeTab.value, (t) => mutateInsertNode(t, pp, idx + 1, structuredClone(clip)));
54
61
  } else {
55
- // Paste as last child of root/selected
56
62
  const idx = parent.children ? parent.children.length : 0;
57
- update(insertNode(S, pPath, idx, structuredClone(clipboard)));
63
+ transactDoc(activeTab.value, (t) => mutateInsertNode(t, pPath, idx, structuredClone(clip)));
58
64
  }
59
65
  statusMessage("Pasted");
60
66
  }
@@ -77,9 +83,9 @@ export function dismissContextMenu() {
77
83
  }
78
84
 
79
85
  /**
80
- * @param {any} e
81
- * @param {any} path
82
- * @param {any} S
86
+ * @param {MouseEvent} e
87
+ * @param {JxPath} path
88
+ * @param {StudioState} S
83
89
  * @param {{ onEditComponent?: (path: string) => void }} [opts]
84
90
  */
85
91
  export function showContextMenu(e, path, S, opts = {}) {
@@ -90,7 +96,7 @@ export function showContextMenu(e, path, S, opts = {}) {
90
96
  if (!node) return;
91
97
 
92
98
  // Select the node
93
- update(selectNode(S, path));
99
+ activeTab.value.session.selection = path;
94
100
 
95
101
  /** @type {{ label: string; action?: () => void; danger?: boolean }[]} */
96
102
  const items = [];
@@ -98,64 +104,82 @@ export function showContextMenu(e, path, S, opts = {}) {
98
104
  items.push({ label: "Copy", action: () => copyNode(S) });
99
105
  if (path.length >= 2) {
100
106
  items.push({ label: "Cut", action: () => cutNode(S) });
101
- items.push({ label: "Duplicate", action: () => update(duplicateNode(S, S.selection)) });
107
+ items.push({
108
+ label: "Duplicate",
109
+ action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
110
+ });
102
111
  items.push({ label: "—" }); // separator
103
112
  items.push({
104
113
  label: "Insert before",
105
114
  action: () => {
106
- const pp = /** @type {any} */ (parentElementPath(path));
115
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
107
116
  const idx = /** @type {number} */ (childIndex(path));
108
- update(insertNode(S, pp, idx, { tagName: "p", children: [] }));
117
+ transactDoc(activeTab.value, (t) =>
118
+ mutateInsertNode(t, pp, idx, { tagName: "p", children: [] }),
119
+ );
109
120
  },
110
121
  });
111
122
  items.push({
112
123
  label: "Insert after",
113
124
  action: () => {
114
- const pp = /** @type {any} */ (parentElementPath(path));
125
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
115
126
  const idx = /** @type {number} */ (childIndex(path));
116
- update(insertNode(S, pp, idx + 1, { tagName: "p", children: [] }));
127
+ transactDoc(activeTab.value, (t) =>
128
+ mutateInsertNode(t, pp, idx + 1, { tagName: "p", children: [] }),
129
+ );
117
130
  },
118
131
  });
119
132
  items.push({
120
133
  label: "Wrap in Div",
121
- action: () => update(wrapNode(S, S.selection)),
134
+ action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
122
135
  });
123
136
  if (node.tagName) {
124
137
  const isComponent =
125
138
  node.tagName.includes("-") &&
126
- componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
139
+ componentRegistry.some(
140
+ (/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName,
141
+ );
127
142
  if (isComponent && opts.onEditComponent) {
128
- const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === node.tagName);
143
+ const comp = componentRegistry.find(
144
+ (/** @type {{ tagName: string; path: string }} */ c) => c.tagName === node.tagName,
145
+ );
129
146
  items.push({
130
147
  label: "Edit Component",
131
- action: () => opts.onEditComponent?.(comp.path),
148
+ action: () => opts.onEditComponent?.(/** @type {string} */ (comp?.path)),
132
149
  });
133
150
  } else if (!isComponent) {
134
151
  items.push({
135
152
  label: "Convert to Component",
136
- action: () => convertToComponent(S),
153
+ action: () => convertToComponent(),
137
154
  });
138
155
  }
139
156
  }
140
157
  items.push({ label: "—" }); // separator
141
- items.push({ label: "Delete", action: () => update(removeNode(S, S.selection)), danger: true });
158
+ items.push({
159
+ label: "Delete",
160
+ action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
161
+ danger: true,
162
+ });
142
163
  }
143
164
  if (clipboard) {
165
+ const clip = clipboard;
144
166
  items.push({ label: "—" });
145
167
  items.push({
146
168
  label: "Paste inside",
147
169
  action: () => {
148
170
  const idx = node.children ? node.children.length : 0;
149
- update(insertNode(S, path, idx, structuredClone(clipboard)));
171
+ transactDoc(activeTab.value, (t) => mutateInsertNode(t, path, idx, structuredClone(clip)));
150
172
  },
151
173
  });
152
174
  if (path.length >= 2) {
153
175
  items.push({
154
176
  label: "Paste after",
155
177
  action: () => {
156
- const pp = /** @type {any} */ (parentElementPath(path));
178
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
157
179
  const idx = /** @type {number} */ (childIndex(path));
158
- update(insertNode(S, pp, idx + 1, structuredClone(clipboard)));
180
+ transactDoc(activeTab.value, (t) =>
181
+ mutateInsertNode(t, pp, idx + 1, structuredClone(clip)),
182
+ );
159
183
  },
160
184
  });
161
185
  }
@@ -1,6 +1,8 @@
1
1
  // ─── Convert to Component ─────────────────────────────────────────────────────
2
2
  import { html, render as litRender } from "lit-html";
3
- import { update, getNodeAtPath, applyMutation, parentElementPath, childIndex } from "../store.js";
3
+ import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
4
+ import { activeTab } from "../workspace/workspace.js";
5
+ import { transact } from "../tabs/transact.js";
4
6
  import {
5
7
  computeRelativePath,
6
8
  loadComponentRegistry,
@@ -11,15 +13,12 @@ import { statusMessage } from "../panels/statusbar.js";
11
13
 
12
14
  const VALID_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
13
15
 
14
- /**
15
- * Convert the currently selected element into a reusable component.
16
- *
17
- * @param {any} S - Current studio state
18
- */
19
- export async function convertToComponent(S) {
20
- if (!S.selection || S.selection.length < 2) return;
16
+ /** Convert the currently selected element into a reusable component. */
17
+ export async function convertToComponent() {
18
+ const tab = activeTab.value;
19
+ if (!tab?.session.selection || tab.session.selection.length < 2) return;
21
20
 
22
- const node = getNodeAtPath(S.document, S.selection);
21
+ const node = getNodeAtPath(tab.doc.document, tab.session.selection);
23
22
  if (!node || !node.tagName) return;
24
23
 
25
24
  const defaultName = deriveDefaultName(node);
@@ -32,11 +31,11 @@ export async function convertToComponent(S) {
32
31
 
33
32
  // Compute paths
34
33
  const componentFile = "components/" + name + ".json";
35
- const refPath = computeRelativePath(S.documentPath, componentFile);
34
+ const refPath = computeRelativePath(tab.documentPath, componentFile);
36
35
 
37
36
  // Single atomic mutation: replace node + add $elements ref
38
- const selectionPath = S.selection;
39
- const newState = applyMutation(S, (doc) => {
37
+ const selectionPath = tab.session.selection;
38
+ transact(tab, (doc) => {
40
39
  // Navigate to parent's children array and replace the node
41
40
  const pp = parentElementPath(selectionPath) ?? [];
42
41
  const idx = childIndex(selectionPath);
@@ -54,8 +53,6 @@ export async function convertToComponent(S) {
54
53
  }
55
54
  });
56
55
 
57
- update(newState);
58
-
59
56
  // Write component file and refresh registry
60
57
  try {
61
58
  const platform = getPlatform();
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import { showSlashMenu } from "./slash-menu.js";
9
+ import { activeTab } from "../workspace/workspace.js";
10
+ import { transactDoc, mutateInsertNode } from "../tabs/transact.js";
9
11
 
10
12
  // ─── Types ────────────────────────────────────────────────────────────────────
11
13
 
@@ -32,15 +34,11 @@ import { showSlashMenu } from "./slash-menu.js";
32
34
  /**
33
35
  * @typedef {Object} InsertionHelperContext
34
36
  * @property {() => any} getState - Returns the current editor state.
35
- * @property {(state: any) => void} update - Commits a new state.
36
37
  * @property {() => string} getCanvasMode - Returns the active canvas mode.
37
38
  * @property {(fn: Function) => any} withPanelPointerEvents - Executes fn with pointer-events
38
39
  * temporarily enabled on the canvas.
39
40
  * @property {() => number} effectiveZoom - Returns the current zoom scale factor.
40
41
  * @property {(tag: string) => Object} defaultDef - Creates a default element definition for a tag.
41
- * @property {(s: any, path: any[], idx: number, def: Object) => any} insertNode - Inserts a node
42
- * into the document tree.
43
- * @property {(s: any, path: any[]) => any} selectNode - Sets the selection to the given path.
44
42
  * @property {(path: any[]) => any[] | null} parentElementPath - Returns the parent element path, or
45
43
  * null for root.
46
44
  * @property {(path: any[]) => string | number} childIndex - Returns the child index within the
@@ -284,18 +282,18 @@ function onHelperClick(/** @type {MouseEvent} */ e) {
284
282
  function onSlashSelect(cmd, point) {
285
283
  if (!_ctx) return;
286
284
 
287
- const { getState, update, defaultDef, insertNode, selectNode } = _ctx;
288
- const S = getState();
285
+ const { defaultDef } = _ctx;
289
286
  const { parentPath, idx, edge } = point;
290
287
 
291
288
  const newDef = defaultDef(cmd.tag);
292
289
  const insertPath = edge === "center" ? point.path : parentPath;
293
290
  const insertIdx = edge === "center" ? 0 : idx;
294
-
295
- let s = insertNode(S, insertPath, insertIdx, newDef);
296
291
  const newPath = [...insertPath, "children", insertIdx];
297
- s = selectNode(s, newPath);
298
- update(s);
292
+
293
+ transactDoc(activeTab.value, (t) => {
294
+ mutateInsertNode(t, insertPath, insertIdx, newDef);
295
+ t.session.selection = newPath;
296
+ });
299
297
 
300
298
  hide();
301
299
  }