@jxsuite/studio 0.11.0 → 0.14.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 (55) hide show
  1. package/dist/studio.js +6517 -5870
  2. package/dist/studio.js.map +57 -51
  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 +28 -2
  7. package/src/canvas/canvas-render.js +170 -20
  8. package/src/canvas/canvas-utils.js +22 -25
  9. package/src/editor/component-inline-edit.js +55 -44
  10. package/src/editor/content-inline-edit.js +47 -50
  11. package/src/editor/context-menu.js +78 -53
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -11
  14. package/src/editor/shortcuts.js +88 -64
  15. package/src/files/components.js +15 -4
  16. package/src/files/file-ops.js +57 -108
  17. package/src/files/files.js +81 -28
  18. package/src/panels/activity-bar.js +31 -7
  19. package/src/panels/block-action-bar.js +104 -80
  20. package/src/panels/canvas-dnd.js +4 -10
  21. package/src/panels/dnd.js +77 -35
  22. package/src/panels/editors.js +17 -26
  23. package/src/panels/elements-panel.js +17 -11
  24. package/src/panels/events-panel.js +44 -39
  25. package/src/panels/git-panel.js +47 -4
  26. package/src/panels/layers-panel.js +25 -21
  27. package/src/panels/left-panel.js +109 -44
  28. package/src/panels/overlays.js +91 -41
  29. package/src/panels/panel-events.js +28 -37
  30. package/src/panels/preview-render.js +7 -3
  31. package/src/panels/properties-panel.js +179 -104
  32. package/src/panels/pseudo-preview.js +9 -8
  33. package/src/panels/right-panel.js +85 -37
  34. package/src/panels/shared.js +0 -22
  35. package/src/panels/signals-panel.js +125 -54
  36. package/src/panels/statusbar.js +26 -19
  37. package/src/panels/style-inputs.js +5 -4
  38. package/src/panels/style-panel.js +128 -105
  39. package/src/panels/style-utils.js +8 -6
  40. package/src/panels/stylebook-layers-panel.js +5 -5
  41. package/src/panels/stylebook-panel.js +27 -31
  42. package/src/panels/tab-strip.js +124 -0
  43. package/src/panels/toolbar.js +40 -10
  44. package/src/reactivity.js +28 -0
  45. package/src/settings/content-types-editor.js +56 -7
  46. package/src/settings/defs-editor.js +56 -7
  47. package/src/settings/schema-field-ui.js +60 -25
  48. package/src/state.js +2 -459
  49. package/src/store.js +61 -219
  50. package/src/studio.js +163 -300
  51. package/src/tabs/tab.js +168 -0
  52. package/src/tabs/transact.js +406 -0
  53. package/src/ui/color-selector.js +4 -5
  54. package/src/view.js +3 -0
  55. package/src/workspace/workspace.js +90 -0
@@ -1,60 +1,67 @@
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 */
24
- export function copyNode(S) {
25
- if (!S.selection) return;
26
- const node = getNodeAtPath(S.document, S.selection);
29
+ export function copyNode() {
30
+ const tab = activeTab.value;
31
+ if (!tab?.session.selection) return;
32
+ const node = getNodeAtPath(tab.doc.document, tab.session.selection);
27
33
  if (!node) return;
28
34
  clipboard = structuredClone(node);
29
35
  statusMessage("Copied");
30
36
  }
31
37
 
32
- /** @param {any} S */
33
- export function cutNode(S) {
34
- if (!S.selection || S.selection.length < 2) return;
35
- const node = getNodeAtPath(S.document, S.selection);
38
+ export function cutNode() {
39
+ const tab = activeTab.value;
40
+ if (!tab?.session.selection || tab.session.selection.length < 2) return;
41
+ const sel = tab.session.selection;
42
+ const node = getNodeAtPath(tab.doc.document, sel);
36
43
  if (!node) return;
37
44
  clipboard = structuredClone(node);
38
- update(removeNode(S, S.selection));
45
+ transactDoc(tab, (t) => mutateRemoveNode(t, sel));
39
46
  statusMessage("Cut");
40
47
  }
41
48
 
42
- /** @param {any} S */
43
- export function pasteNode(S) {
49
+ export function pasteNode() {
44
50
  if (!clipboard) return;
45
- const pPath = S.selection || [];
46
- const parent = getNodeAtPath(S.document, pPath);
51
+ const tab = activeTab.value;
52
+ if (!tab) return;
53
+ const clip = clipboard;
54
+ const pPath = tab.session.selection || [];
55
+ const parent = getNodeAtPath(tab.doc.document, pPath);
47
56
  if (!parent) return;
48
57
 
49
- if (S.selection && S.selection.length >= 2) {
50
- // Paste as sibling after selection
51
- const pp = /** @type {any} */ (parentElementPath(S.selection));
52
- const idx = /** @type {number} */ (childIndex(S.selection));
53
- update(insertNode(S, pp, idx + 1, structuredClone(clipboard)));
58
+ if (tab.session.selection && tab.session.selection.length >= 2) {
59
+ const pp = /** @type {JxPath} */ (parentElementPath(tab.session.selection));
60
+ const idx = /** @type {number} */ (childIndex(tab.session.selection));
61
+ transactDoc(tab, (t) => mutateInsertNode(t, pp, idx + 1, structuredClone(clip)));
54
62
  } else {
55
- // Paste as last child of root/selected
56
63
  const idx = parent.children ? parent.children.length : 0;
57
- update(insertNode(S, pPath, idx, structuredClone(clipboard)));
64
+ transactDoc(tab, (t) => mutateInsertNode(t, pPath, idx, structuredClone(clip)));
58
65
  }
59
66
  statusMessage("Pasted");
60
67
  }
@@ -77,85 +84,103 @@ export function dismissContextMenu() {
77
84
  }
78
85
 
79
86
  /**
80
- * @param {any} e
81
- * @param {any} path
82
- * @param {any} S
87
+ * @param {MouseEvent} e
88
+ * @param {JxPath} path
83
89
  * @param {{ onEditComponent?: (path: string) => void }} [opts]
84
90
  */
85
- export function showContextMenu(e, path, S, opts = {}) {
91
+ export function showContextMenu(e, path, opts = {}) {
86
92
  e.preventDefault();
87
93
  ctxMenu.removeAttribute("open");
88
94
 
89
- const node = getNodeAtPath(S.document, path);
95
+ const tab = activeTab.value;
96
+ const node = getNodeAtPath(tab?.doc.document, path);
90
97
  if (!node) return;
91
98
 
92
99
  // Select the node
93
- update(selectNode(S, path));
100
+ tab.session.selection = path;
94
101
 
95
102
  /** @type {{ label: string; action?: () => void; danger?: boolean }[]} */
96
103
  const items = [];
97
104
 
98
- items.push({ label: "Copy", action: () => copyNode(S) });
105
+ items.push({ label: "Copy", action: () => copyNode() });
99
106
  if (path.length >= 2) {
100
- items.push({ label: "Cut", action: () => cutNode(S) });
101
- items.push({ label: "Duplicate", action: () => update(duplicateNode(S, S.selection)) });
107
+ items.push({ label: "Cut", action: () => cutNode() });
108
+ items.push({
109
+ label: "Duplicate",
110
+ action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
111
+ });
102
112
  items.push({ label: "—" }); // separator
103
113
  items.push({
104
114
  label: "Insert before",
105
115
  action: () => {
106
- const pp = /** @type {any} */ (parentElementPath(path));
116
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
107
117
  const idx = /** @type {number} */ (childIndex(path));
108
- update(insertNode(S, pp, idx, { tagName: "p", children: [] }));
118
+ transactDoc(activeTab.value, (t) =>
119
+ mutateInsertNode(t, pp, idx, { tagName: "p", children: [] }),
120
+ );
109
121
  },
110
122
  });
111
123
  items.push({
112
124
  label: "Insert after",
113
125
  action: () => {
114
- const pp = /** @type {any} */ (parentElementPath(path));
126
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
115
127
  const idx = /** @type {number} */ (childIndex(path));
116
- update(insertNode(S, pp, idx + 1, { tagName: "p", children: [] }));
128
+ transactDoc(activeTab.value, (t) =>
129
+ mutateInsertNode(t, pp, idx + 1, { tagName: "p", children: [] }),
130
+ );
117
131
  },
118
132
  });
119
133
  items.push({
120
134
  label: "Wrap in Div",
121
- action: () => update(wrapNode(S, S.selection)),
135
+ action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
122
136
  });
123
137
  if (node.tagName) {
124
138
  const isComponent =
125
139
  node.tagName.includes("-") &&
126
- componentRegistry.some((/** @type {any} */ c) => c.tagName === node.tagName);
140
+ componentRegistry.some(
141
+ (/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName,
142
+ );
127
143
  if (isComponent && opts.onEditComponent) {
128
- const comp = componentRegistry.find((/** @type {any} */ c) => c.tagName === node.tagName);
144
+ const comp = componentRegistry.find(
145
+ (/** @type {{ tagName: string; path: string }} */ c) => c.tagName === node.tagName,
146
+ );
129
147
  items.push({
130
148
  label: "Edit Component",
131
- action: () => opts.onEditComponent?.(comp.path),
149
+ action: () => opts.onEditComponent?.(/** @type {string} */ (comp?.path)),
132
150
  });
133
151
  } else if (!isComponent) {
134
152
  items.push({
135
153
  label: "Convert to Component",
136
- action: () => convertToComponent(S),
154
+ action: () => convertToComponent(),
137
155
  });
138
156
  }
139
157
  }
140
158
  items.push({ label: "—" }); // separator
141
- items.push({ label: "Delete", action: () => update(removeNode(S, S.selection)), danger: true });
159
+ items.push({
160
+ label: "Delete",
161
+ action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
162
+ danger: true,
163
+ });
142
164
  }
143
165
  if (clipboard) {
166
+ const clip = clipboard;
144
167
  items.push({ label: "—" });
145
168
  items.push({
146
169
  label: "Paste inside",
147
170
  action: () => {
148
171
  const idx = node.children ? node.children.length : 0;
149
- update(insertNode(S, path, idx, structuredClone(clipboard)));
172
+ transactDoc(activeTab.value, (t) => mutateInsertNode(t, path, idx, structuredClone(clip)));
150
173
  },
151
174
  });
152
175
  if (path.length >= 2) {
153
176
  items.push({
154
177
  label: "Paste after",
155
178
  action: () => {
156
- const pp = /** @type {any} */ (parentElementPath(path));
179
+ const pp = /** @type {JxPath} */ (parentElementPath(path));
157
180
  const idx = /** @type {number} */ (childIndex(path));
158
- update(insertNode(S, pp, idx + 1, structuredClone(clipboard)));
181
+ transactDoc(activeTab.value, (t) =>
182
+ mutateInsertNode(t, pp, idx + 1, structuredClone(clip)),
183
+ );
159
184
  },
160
185
  });
161
186
  }
@@ -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
 
@@ -31,16 +33,11 @@ import { showSlashMenu } from "./slash-menu.js";
31
33
 
32
34
  /**
33
35
  * @typedef {Object} InsertionHelperContext
34
- * @property {() => any} getState - Returns the current editor state.
35
- * @property {(state: any) => void} update - Commits a new state.
36
36
  * @property {() => string} getCanvasMode - Returns the active canvas mode.
37
37
  * @property {(fn: Function) => any} withPanelPointerEvents - Executes fn with pointer-events
38
38
  * temporarily enabled on the canvas.
39
39
  * @property {() => number} effectiveZoom - Returns the current zoom scale factor.
40
40
  * @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
41
  * @property {(path: any[]) => any[] | null} parentElementPath - Returns the parent element path, or
45
42
  * null for root.
46
43
  * @property {(path: any[]) => string | number} childIndex - Returns the child index within the
@@ -284,18 +281,18 @@ function onHelperClick(/** @type {MouseEvent} */ e) {
284
281
  function onSlashSelect(cmd, point) {
285
282
  if (!_ctx) return;
286
283
 
287
- const { getState, update, defaultDef, insertNode, selectNode } = _ctx;
288
- const S = getState();
284
+ const { defaultDef } = _ctx;
289
285
  const { parentPath, idx, edge } = point;
290
286
 
291
287
  const newDef = defaultDef(cmd.tag);
292
288
  const insertPath = edge === "center" ? point.path : parentPath;
293
289
  const insertIdx = edge === "center" ? 0 : idx;
294
-
295
- let s = insertNode(S, insertPath, insertIdx, newDef);
296
290
  const newPath = [...insertPath, "children", insertIdx];
297
- s = selectNode(s, newPath);
298
- update(s);
291
+
292
+ transactDoc(activeTab.value, (t) => {
293
+ mutateInsertNode(t, insertPath, insertIdx, newDef);
294
+ t.session.selection = newPath;
295
+ });
299
296
 
300
297
  hide();
301
298
  }
@@ -5,46 +5,43 @@
5
5
  * shortcuts on the canvas / document.
6
6
  */
7
7
 
8
+ import { getNodeAtPath, parentElementPath, childIndex, canvasWrap } from "../store.js";
9
+ import { activeTab, workspace, closeTab } from "../workspace/workspace.js";
8
10
  import {
9
- selectNode,
10
- undo,
11
- redo,
12
- removeNode,
13
- insertNode,
14
- duplicateNode,
15
- getNodeAtPath,
16
- parentElementPath,
17
- childIndex,
18
- canvasWrap,
19
- update,
20
- } from "../store.js";
11
+ transactDoc,
12
+ mutateInsertNode,
13
+ mutateRemoveNode,
14
+ mutateDuplicateNode,
15
+ undo as tabUndo,
16
+ redo as tabRedo,
17
+ } from "../tabs/transact.js";
21
18
  import { isEditing } from "./inline-edit.js";
22
19
  import { copyNode, cutNode, pasteNode } from "./context-menu.js";
23
20
 
21
+ /** @typedef {import("../state.js").JxPath} JxPath */
22
+
24
23
  /**
25
24
  * Initialise all keyboard (and wheel/pointer) shortcuts.
26
25
  *
27
26
  * @param {() => {
28
- * S: any;
29
- * setS: (s: any) => void;
30
27
  * canvasMode: string;
31
28
  * panX: number;
32
29
  * panY: number;
33
30
  * setPan: (x: number, y: number) => void;
34
31
  * applyTransform: () => void;
35
32
  * positionZoomIndicator: () => void;
36
- * componentInlineEdit: any;
33
+ * componentInlineEdit: object | null;
37
34
  * saveFile: () => void;
38
35
  * openProject: () => void;
39
- * enterEditOnPath: (path: any) => void;
36
+ * enterEditOnPath: (path: JxPath) => void;
40
37
  * }} getContext
41
38
  */
42
39
  export function initShortcuts(getContext) {
43
40
  // Wheel handler: Ctrl+Scroll = zoom (cursor-centered), plain scroll = pan
44
41
  canvasWrap.addEventListener(
45
42
  "wheel",
46
- (/** @type {any} */ e) => {
47
- const { S, setS, canvasMode, panX, panY, setPan, applyTransform } = getContext();
43
+ (/** @type {WheelEvent} */ e) => {
44
+ const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
48
45
  // Edit (content) mode: let the scroll container handle scrolling natively
49
46
  if (canvasMode === "edit") return;
50
47
  e.preventDefault();
@@ -53,13 +50,13 @@ export function initShortcuts(getContext) {
53
50
  const rect = canvasWrap.getBoundingClientRect();
54
51
  const cursorX = e.clientX - rect.left;
55
52
  const cursorY = e.clientY - rect.top;
56
- const oldZoom = S.ui.zoom;
53
+ const oldZoom = activeTab.value?.session.ui.zoom ?? 1;
57
54
  const delta = -e.deltaY * 0.005;
58
55
  const newZoom = Math.min(5.0, Math.max(0.05, oldZoom * (1 + delta)));
59
56
  const ratio = newZoom / oldZoom;
60
57
  // Adjust pan so the point under cursor stays stationary
61
58
  setPan(cursorX - (cursorX - panX) * ratio, cursorY - (cursorY - panY) * ratio);
62
- setS({ ...S, ui: { ...S.ui, zoom: newZoom } });
59
+ activeTab.value.session.ui.zoom = newZoom;
63
60
  } else if (e.shiftKey) {
64
61
  // Shift+scroll = horizontal pan
65
62
  setPan(panX - e.deltaY, panY);
@@ -73,7 +70,7 @@ export function initShortcuts(getContext) {
73
70
  );
74
71
 
75
72
  // Middle-mouse drag panning
76
- canvasWrap.addEventListener("pointerdown", (/** @type {any} */ e) => {
73
+ canvasWrap.addEventListener("pointerdown", (/** @type {PointerEvent} */ e) => {
77
74
  const ctx = getContext();
78
75
  if (ctx.canvasMode === "edit") return; // no panning in edit mode
79
76
  if (e.button !== 1) return; // middle button only
@@ -81,7 +78,7 @@ export function initShortcuts(getContext) {
81
78
  canvasWrap.setPointerCapture(e.pointerId);
82
79
  let lastX = e.clientX,
83
80
  lastY = e.clientY;
84
- const onMove = (/** @type {any} */ ev) => {
81
+ const onMove = (/** @type {PointerEvent} */ ev) => {
85
82
  const { panX, panY, setPan, applyTransform } = getContext();
86
83
  setPan(panX + (ev.clientX - lastX), panY + (ev.clientY - lastY));
87
84
  lastX = ev.clientX;
@@ -102,8 +99,6 @@ export function initShortcuts(getContext) {
102
99
 
103
100
  document.addEventListener("keydown", (e) => {
104
101
  const {
105
- S,
106
- setS,
107
102
  canvasMode,
108
103
  setPan,
109
104
  applyTransform,
@@ -112,6 +107,7 @@ export function initShortcuts(getContext) {
112
107
  openProject,
113
108
  enterEditOnPath,
114
109
  } = getContext();
110
+ const tab = activeTab.value;
115
111
  const mod = e.ctrlKey || e.metaKey;
116
112
 
117
113
  // Don't intercept when typing in inputs or contenteditable
@@ -125,14 +121,19 @@ export function initShortcuts(getContext) {
125
121
  e.preventDefault();
126
122
  saveFile();
127
123
  }
124
+ if (mod && e.key === "w") {
125
+ e.preventDefault();
126
+ }
128
127
  return;
129
128
  }
130
129
  if (isEditing()) {
131
- // Let inline editor handle its own keyboard events; only intercept Save
132
130
  if (mod && e.key === "s") {
133
131
  e.preventDefault();
134
132
  saveFile();
135
133
  }
134
+ if (mod && e.key === "w") {
135
+ e.preventDefault();
136
+ }
136
137
  return;
137
138
  }
138
139
  if (componentInlineEdit) {
@@ -140,11 +141,25 @@ export function initShortcuts(getContext) {
140
141
  e.preventDefault();
141
142
  saveFile();
142
143
  }
144
+ if (mod && e.key === "w") {
145
+ e.preventDefault();
146
+ }
143
147
  return;
144
148
  }
145
149
 
146
150
  if (mod) {
147
151
  switch (e.key) {
152
+ case "w":
153
+ e.preventDefault();
154
+ if (workspace.activeTabId && workspace.tabOrder.length > 1) {
155
+ const tab = workspace.tabs.get(workspace.activeTabId);
156
+ if (tab?.doc.dirty) {
157
+ const name = tab.documentPath?.split("/").pop() || "Untitled";
158
+ if (!window.confirm(`"${name}" has unsaved changes. Close without saving?`)) break;
159
+ }
160
+ closeTab(workspace.activeTabId);
161
+ }
162
+ break;
148
163
  case "o":
149
164
  e.preventDefault();
150
165
  openProject();
@@ -155,28 +170,32 @@ export function initShortcuts(getContext) {
155
170
  break;
156
171
  case "z":
157
172
  e.preventDefault();
158
- update(e.shiftKey ? redo(S) : undo(S));
173
+ if (e.shiftKey) tabRedo(activeTab.value);
174
+ else tabUndo(activeTab.value);
159
175
  break;
160
176
  case "d":
161
177
  e.preventDefault();
162
- if (S.selection) update(duplicateNode(S, S.selection));
178
+ if (tab?.session.selection) {
179
+ const sel = tab.session.selection;
180
+ transactDoc(tab, (t) => mutateDuplicateNode(t, sel));
181
+ }
163
182
  break;
164
183
  case "c":
165
184
  e.preventDefault();
166
- copyNode(S);
185
+ copyNode();
167
186
  break;
168
187
  case "x":
169
188
  e.preventDefault();
170
- cutNode(S);
189
+ cutNode();
171
190
  break;
172
191
  case "v":
173
192
  e.preventDefault();
174
- pasteNode(S);
193
+ pasteNode();
175
194
  break;
176
195
  case "0":
177
196
  if (canvasMode === "edit") break;
178
197
  e.preventDefault();
179
- setS({ ...S, ui: { ...S.ui, zoom: 1 } });
198
+ activeTab.value.session.ui.zoom = 1;
180
199
  setPan(16, 16);
181
200
  applyTransform();
182
201
  break;
@@ -184,13 +203,13 @@ export function initShortcuts(getContext) {
184
203
  case "+":
185
204
  if (canvasMode === "edit") break;
186
205
  e.preventDefault();
187
- setS({ ...S, ui: { ...S.ui, zoom: Math.min(5.0, S.ui.zoom * 1.2) } });
206
+ activeTab.value.session.ui.zoom = Math.min(5.0, (tab?.session.ui.zoom ?? 1) * 1.2);
188
207
  applyTransform();
189
208
  break;
190
209
  case "-":
191
210
  if (canvasMode === "edit") break;
192
211
  e.preventDefault();
193
- setS({ ...S, ui: { ...S.ui, zoom: Math.max(0.05, S.ui.zoom / 1.2) } });
212
+ activeTab.value.session.ui.zoom = Math.max(0.05, (tab?.session.ui.zoom ?? 1) / 1.2);
194
213
  applyTransform();
195
214
  break;
196
215
  }
@@ -200,46 +219,48 @@ export function initShortcuts(getContext) {
200
219
  switch (e.key) {
201
220
  case "Delete":
202
221
  case "Backspace":
203
- if (S.selection && S.selection.length >= 2) {
222
+ if (tab?.session.selection && tab.session.selection.length >= 2) {
204
223
  e.preventDefault();
205
- update(removeNode(S, S.selection));
224
+ const sel = tab.session.selection;
225
+ transactDoc(tab, (t) => mutateRemoveNode(t, sel));
206
226
  }
207
227
  break;
208
228
  case "Escape":
209
- update(selectNode(S, null));
229
+ activeTab.value.session.selection = null;
210
230
  break;
211
231
  case "Enter":
212
- if (S.selection && S.selection.length >= 2) {
232
+ if (tab?.session.selection && tab.session.selection.length >= 2) {
213
233
  e.preventDefault();
214
- const pp = /** @type {any} */ (parentElementPath(S.selection));
215
- const idx = /** @type {number} */ (childIndex(S.selection));
216
- let s = insertNode(S, pp, idx + 1, { tagName: "p", textContent: "" });
234
+ const pp = /** @type {JxPath} */ (parentElementPath(tab.session.selection));
235
+ const idx = /** @type {number} */ (childIndex(tab.session.selection));
217
236
  const newPath = [...pp, "children", idx + 1];
218
- s = selectNode(s, newPath);
219
- update(s);
237
+ transactDoc(tab, (t) => {
238
+ mutateInsertNode(t, pp, idx + 1, { tagName: "p", textContent: "" });
239
+ t.session.selection = newPath;
240
+ });
220
241
  enterEditOnPath(newPath);
221
242
  }
222
243
  break;
223
244
  case "ArrowUp":
224
245
  e.preventDefault();
225
- navigateSelection(S);
246
+ navigateSelection(-1);
226
247
  break;
227
248
  case "ArrowDown":
228
249
  e.preventDefault();
229
- navigateSelection(S, 1);
250
+ navigateSelection(1);
230
251
  break;
231
252
  case "ArrowLeft":
232
253
  e.preventDefault();
233
- if (S.selection && S.selection.length >= 2) {
234
- update(selectNode(S, parentElementPath(S.selection)));
254
+ if (tab?.session.selection && tab.session.selection.length >= 2) {
255
+ activeTab.value.session.selection = parentElementPath(tab.session.selection);
235
256
  }
236
257
  break;
237
258
  case "ArrowRight":
238
259
  e.preventDefault();
239
- if (S.selection) {
240
- const node = getNodeAtPath(S.document, S.selection);
260
+ if (tab?.session.selection) {
261
+ const node = getNodeAtPath(tab.doc.document, tab.session.selection);
241
262
  if (node?.children?.length > 0) {
242
- update(selectNode(S, [...S.selection, "children", 0]));
263
+ activeTab.value.session.selection = [...tab.session.selection, "children", 0];
243
264
  }
244
265
  }
245
266
  break;
@@ -249,8 +270,8 @@ export function initShortcuts(getContext) {
249
270
  // Block ctrl+scroll (browser zoom) on all non-canvas areas
250
271
  document.addEventListener(
251
272
  "wheel",
252
- (/** @type {any} */ e) => {
253
- if ((e.ctrlKey || e.metaKey) && !canvasWrap.contains(e.target)) {
273
+ (/** @type {WheelEvent} */ e) => {
274
+ if ((e.ctrlKey || e.metaKey) && !canvasWrap.contains(/** @type {Node} */ (e.target))) {
254
275
  e.preventDefault();
255
276
  }
256
277
  },
@@ -258,23 +279,26 @@ export function initShortcuts(getContext) {
258
279
  );
259
280
  }
260
281
 
261
- /**
262
- * @param {any} S
263
- * @param {number} [direction]
264
- */
265
- function navigateSelection(S, direction = -1) {
266
- if (!S.selection) {
267
- update(selectNode(S, []));
282
+ /** @param {number} [direction] */
283
+ function navigateSelection(direction = -1) {
284
+ const tab = activeTab.value;
285
+ if (!tab?.session.selection) {
286
+ activeTab.value.session.selection = [];
268
287
  return;
269
288
  }
270
- if (S.selection.length < 2) return; // can't navigate from root
289
+ if (tab.session.selection.length < 2) return;
271
290
 
272
- const parent = getNodeAtPath(S.document, /** @type {any} */ (parentElementPath(S.selection)));
273
- const idx = /** @type {number} */ (childIndex(S.selection));
291
+ const parent = getNodeAtPath(
292
+ tab.doc.document,
293
+ /** @type {JxPath} */ (parentElementPath(tab.session.selection)),
294
+ );
295
+ const idx = /** @type {number} */ (childIndex(tab.session.selection));
274
296
  const newIdx = idx + direction;
275
297
  if (parent?.children && newIdx >= 0 && newIdx < parent.children.length) {
276
- update(
277
- selectNode(S, [.../** @type {any[]} */ (parentElementPath(S.selection)), "children", newIdx]),
278
- );
298
+ activeTab.value.session.selection = [
299
+ .../** @type {JxPath} */ (parentElementPath(tab.session.selection)),
300
+ "children",
301
+ newIdx,
302
+ ];
279
303
  }
280
304
  }