@jxsuite/studio 0.11.0 → 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 (47) hide show
  1. package/dist/studio.js +4248 -3412
  2. package/dist/studio.js.map +49 -43
  3. package/package.json +5 -3
  4. package/src/canvas/canvas-diff.js +184 -0
  5. package/src/canvas/canvas-helpers.js +10 -14
  6. package/src/canvas/canvas-live-render.js +3 -2
  7. package/src/canvas/canvas-render.js +152 -20
  8. package/src/canvas/canvas-utils.js +21 -23
  9. package/src/editor/component-inline-edit.js +54 -41
  10. package/src/editor/content-inline-edit.js +46 -47
  11. package/src/editor/context-menu.js +63 -39
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -10
  14. package/src/editor/shortcuts.js +69 -39
  15. package/src/files/components.js +15 -4
  16. package/src/files/files.js +72 -24
  17. package/src/panels/activity-bar.js +29 -7
  18. package/src/panels/block-action-bar.js +104 -80
  19. package/src/panels/dnd.js +32 -28
  20. package/src/panels/editors.js +7 -14
  21. package/src/panels/elements-panel.js +9 -3
  22. package/src/panels/events-panel.js +44 -39
  23. package/src/panels/git-panel.js +45 -3
  24. package/src/panels/layers-panel.js +16 -11
  25. package/src/panels/left-panel.js +108 -43
  26. package/src/panels/overlays.js +91 -41
  27. package/src/panels/panel-events.js +9 -12
  28. package/src/panels/properties-panel.js +179 -104
  29. package/src/panels/right-panel.js +85 -37
  30. package/src/panels/shared.js +0 -22
  31. package/src/panels/signals-panel.js +125 -54
  32. package/src/panels/statusbar.js +23 -8
  33. package/src/panels/style-inputs.js +4 -2
  34. package/src/panels/style-panel.js +128 -105
  35. package/src/panels/stylebook-panel.js +11 -15
  36. package/src/panels/tab-strip.js +124 -0
  37. package/src/panels/toolbar.js +39 -9
  38. package/src/reactivity.js +28 -0
  39. package/src/settings/content-types-editor.js +56 -7
  40. package/src/settings/defs-editor.js +56 -7
  41. package/src/settings/schema-field-ui.js +60 -25
  42. package/src/state.js +0 -456
  43. package/src/store.js +97 -124
  44. package/src/studio.js +112 -121
  45. package/src/tabs/tab.js +153 -0
  46. package/src/tabs/transact.js +406 -0
  47. package/src/workspace/workspace.js +89 -0
@@ -5,19 +5,21 @@
5
5
 
6
6
  import {
7
7
  getState,
8
- update,
9
8
  updateUi,
10
9
  renderOnly,
11
10
  getNodeAtPath,
12
- selectNode,
13
- removeNode,
14
- insertNode,
15
- updateProperty,
16
11
  parentElementPath,
17
12
  childIndex,
18
13
  canvasPanels,
19
14
  elToPath,
20
15
  } from "../store.js";
16
+ import { activeTab } from "../workspace/workspace.js";
17
+ import {
18
+ transactDoc,
19
+ mutateRemoveNode,
20
+ mutateInsertNode,
21
+ mutateUpdateProperty,
22
+ } from "../tabs/transact.js";
21
23
  import { view } from "../view.js";
22
24
  import { isSlashMenuOpen, showSlashMenu, dismissSlashMenu } from "./slash-menu.js";
23
25
  import { renderBlockActionBar } from "../panels/block-action-bar.js";
@@ -59,7 +61,9 @@ export function enterComponentInlineEdit(el, path) {
59
61
  if (voids.has(node.tagName)) return;
60
62
 
61
63
  for (const p of canvasPanels) {
62
- const boxes = p.overlay.querySelectorAll(".overlay-box");
64
+ const boxes = /** @type {NodeListOf<HTMLElement>} */ (
65
+ p.overlay.querySelectorAll(".overlay-box")
66
+ );
63
67
  for (const box of boxes) {
64
68
  box.style.border = "none";
65
69
  }
@@ -104,10 +108,11 @@ export function enterComponentInlineEdit(el, path) {
104
108
  if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target)) return;
105
109
  document.removeEventListener("mousedown", outsideHandler, true);
106
110
 
111
+ /** @type {(string | number)[] | null} */
107
112
  let hitPath = null,
108
113
  hitMedia = null;
109
114
  for (const p of canvasPanels) {
110
- const els = p.canvas.querySelectorAll("*");
115
+ const els = /** @type {NodeListOf<HTMLElement>} */ (p.canvas.querySelectorAll("*"));
111
116
  for (const el of els) el.style.pointerEvents = "auto";
112
117
  p.overlayClk.style.display = "none";
113
118
  const found = document.elementsFromPoint(evt.clientX, evt.clientY);
@@ -132,37 +137,44 @@ export function enterComponentInlineEdit(el, path) {
132
137
 
133
138
  const isEmpty = !newText;
134
139
  const pPath = parentElementPath(editPath);
135
- const S = getState();
136
140
 
137
141
  if (hitPath) {
142
+ let hp = hitPath;
138
143
  const media = hitMedia === "base" ? null : (hitMedia ?? null);
139
- updateUi("pendingInlineEdit", { path: hitPath, mediaName: hitMedia });
140
- const withMedia = { ...S, ui: { ...S.ui, activeMedia: media } };
144
+ updateUi("pendingInlineEdit", { path: hp, mediaName: hitMedia });
145
+ activeTab.value.session.ui.activeMedia = media;
141
146
  if (isEmpty && pPath) {
142
- let s = removeNode(withMedia, editPath);
143
- const removedIdx = /** @type {number} */ (childIndex(editPath));
144
- const hitIdx = /** @type {number} */ (childIndex(hitPath));
145
- const hitParent = parentElementPath(hitPath);
146
- if (hitParent && pPath && hitParent.join("/") === pPath.join("/") && hitIdx > removedIdx) {
147
- hitPath = [...pPath, "children", hitIdx - 1];
148
- updateUi("pendingInlineEdit", { path: hitPath, mediaName: hitMedia });
149
- }
150
- update(selectNode(s, hitPath));
147
+ transactDoc(activeTab.value, (t) => {
148
+ mutateRemoveNode(t, editPath);
149
+ const removedIdx = /** @type {number} */ (childIndex(editPath));
150
+ const hitIdx = /** @type {number} */ (childIndex(hp));
151
+ const hitParent = parentElementPath(hp);
152
+ if (
153
+ hitParent &&
154
+ pPath &&
155
+ hitParent.join("/") === pPath.join("/") &&
156
+ hitIdx > removedIdx
157
+ ) {
158
+ hp = [...pPath, "children", hitIdx - 1];
159
+ updateUi("pendingInlineEdit", { path: hp, mediaName: hitMedia });
160
+ }
161
+ t.session.selection = hp;
162
+ });
151
163
  } else if (newText !== originalText) {
152
- update(
153
- selectNode(
154
- updateProperty(withMedia, editPath, "textContent", newText || undefined),
155
- hitPath,
156
- ),
157
- );
164
+ transactDoc(activeTab.value, (t) => {
165
+ mutateUpdateProperty(t, editPath, "textContent", newText || undefined);
166
+ t.session.selection = hp;
167
+ });
158
168
  } else {
159
- update(selectNode(withMedia, hitPath));
169
+ activeTab.value.session.selection = hp;
160
170
  }
161
171
  } else {
162
172
  if (isEmpty && pPath) {
163
- update(removeNode(S, editPath));
173
+ transactDoc(activeTab.value, (t) => mutateRemoveNode(t, editPath));
164
174
  } else if (newText !== originalText) {
165
- update(updateProperty(S, editPath, "textContent", newText || undefined));
175
+ transactDoc(activeTab.value, (t) =>
176
+ mutateUpdateProperty(t, editPath, "textContent", newText || undefined),
177
+ );
166
178
  } else {
167
179
  renderOnly("canvas");
168
180
  renderOnly("overlays");
@@ -219,13 +231,13 @@ function splitParagraph() {
219
231
 
220
232
  cleanupComponentInlineEdit(el);
221
233
 
222
- const S = getState();
223
- let s = updateProperty(S, path, "textContent", textBefore || undefined);
224
- s = insertNode(s, pPath, idx + 1, newDef);
225
- s = selectNode(s, newPath);
234
+ transactDoc(activeTab.value, (t) => {
235
+ mutateUpdateProperty(t, path, "textContent", textBefore || undefined);
236
+ mutateInsertNode(t, pPath, idx + 1, newDef);
237
+ t.session.selection = newPath;
238
+ });
226
239
 
227
240
  updateUi("pendingInlineEdit", { path: newPath, mediaName });
228
- update(s);
229
241
  }
230
242
 
231
243
  function _commitComponentInlineEdit() {
@@ -235,12 +247,13 @@ function _commitComponentInlineEdit() {
235
247
 
236
248
  cleanupComponentInlineEdit(el);
237
249
 
238
- const S = getState();
239
250
  const pPath = parentElementPath(path);
240
251
  if (!newText && pPath) {
241
- update(removeNode(S, path));
252
+ transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path));
242
253
  } else if (newText !== originalText) {
243
- update(updateProperty(S, path, "textContent", newText || undefined));
254
+ transactDoc(activeTab.value, (t) =>
255
+ mutateUpdateProperty(t, path, "textContent", newText || undefined),
256
+ );
244
257
  } else {
245
258
  renderOnly("canvas");
246
259
  renderOnly("overlays");
@@ -303,15 +316,15 @@ function handleComponentSlashSelect(cmd) {
303
316
 
304
317
  cleanupComponentInlineEdit(el);
305
318
 
306
- const S = getState();
307
319
  const newDef = defaultDef(cmd.tag);
308
320
  const newPath = [...pPath, "children", idx];
309
321
 
310
- let s = removeNode(S, path);
311
- s = insertNode(s, pPath, idx, newDef);
312
- s = selectNode(s, newPath);
322
+ transactDoc(activeTab.value, (t) => {
323
+ mutateRemoveNode(t, path);
324
+ mutateInsertNode(t, pPath, idx, newDef);
325
+ t.session.selection = newPath;
326
+ });
313
327
 
314
328
  const hasText = newDef.textContent != null;
315
329
  if (hasText) updateUi("pendingInlineEdit", { path: newPath, mediaName });
316
- update(s);
317
330
  }
@@ -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
  }