@jxsuite/studio 0.28.5 → 0.30.1

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 (86) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +9368 -6586
  3. package/dist/studio.js.map +109 -83
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +11 -10
  8. package/src/browse/browse-modal.ts +2 -2
  9. package/src/browse/browse.ts +20 -19
  10. package/src/canvas/canvas-diff.ts +3 -3
  11. package/src/canvas/canvas-helpers.ts +15 -2
  12. package/src/canvas/canvas-live-render.ts +168 -86
  13. package/src/canvas/canvas-patcher.ts +656 -0
  14. package/src/canvas/canvas-perf.ts +68 -0
  15. package/src/canvas/canvas-render.ts +135 -23
  16. package/src/canvas/canvas-subtree-render.ts +113 -0
  17. package/src/canvas/canvas-utils.ts +4 -0
  18. package/src/editor/component-inline-edit.ts +4 -35
  19. package/src/editor/context-menu.ts +96 -76
  20. package/src/editor/convert-to-component.ts +11 -2
  21. package/src/editor/convert-to-repeater.ts +4 -5
  22. package/src/editor/inline-edit.ts +9 -9
  23. package/src/editor/inline-format.ts +11 -11
  24. package/src/editor/merge-tags.ts +120 -0
  25. package/src/editor/shortcuts.ts +4 -4
  26. package/src/files/components.ts +37 -0
  27. package/src/files/file-ops.ts +28 -7
  28. package/src/files/files.ts +68 -24
  29. package/src/files/fs-events.ts +162 -0
  30. package/src/github/github-auth.ts +14 -2
  31. package/src/github/github-publish.ts +12 -2
  32. package/src/panels/activity-bar.ts +1 -1
  33. package/src/panels/ai-panel.ts +67 -39
  34. package/src/panels/block-action-bar.ts +72 -1
  35. package/src/panels/canvas-dnd.ts +55 -26
  36. package/src/panels/data-explorer.ts +5 -3
  37. package/src/panels/dnd.ts +14 -17
  38. package/src/panels/editors.ts +5 -23
  39. package/src/panels/elements-panel.ts +2 -12
  40. package/src/panels/events-panel.ts +1 -1
  41. package/src/panels/git-panel.ts +6 -6
  42. package/src/panels/head-panel.ts +1 -1
  43. package/src/panels/layers-panel.ts +177 -147
  44. package/src/panels/preview-render.ts +15 -0
  45. package/src/panels/properties-panel.ts +16 -27
  46. package/src/panels/quick-search.ts +2 -2
  47. package/src/panels/right-panel.ts +2 -6
  48. package/src/panels/shared.ts +3 -0
  49. package/src/panels/signals-panel.ts +43 -33
  50. package/src/panels/statusbar.ts +15 -6
  51. package/src/panels/style-panel.ts +3 -3
  52. package/src/panels/style-utils.ts +3 -3
  53. package/src/panels/stylebook-panel.ts +4 -4
  54. package/src/panels/tab-bar.ts +198 -0
  55. package/src/panels/tab-strip.ts +2 -2
  56. package/src/panels/toolbar.ts +6 -75
  57. package/src/platforms/devserver.ts +113 -35
  58. package/src/reactivity.ts +2 -0
  59. package/src/services/cem-export.ts +23 -2
  60. package/src/services/code-services.ts +16 -2
  61. package/src/services/monaco-setup.ts +2 -1
  62. package/src/settings/content-types-editor.ts +16 -16
  63. package/src/settings/css-vars-editor.ts +2 -2
  64. package/src/settings/defs-editor.ts +14 -14
  65. package/src/settings/general-settings.ts +6 -6
  66. package/src/settings/head-editor.ts +2 -2
  67. package/src/site-context.ts +1 -1
  68. package/src/state.ts +66 -12
  69. package/src/store.ts +15 -3
  70. package/src/studio.ts +88 -35
  71. package/src/tabs/patch-ops.ts +143 -0
  72. package/src/tabs/tab.ts +15 -1
  73. package/src/tabs/transact.ts +454 -27
  74. package/src/types.ts +71 -1
  75. package/src/ui/color-selector.ts +7 -7
  76. package/src/ui/icons.ts +0 -30
  77. package/src/ui/media-picker.ts +2 -2
  78. package/src/ui/panel-resize.ts +14 -8
  79. package/src/ui/unit-selector.ts +5 -2
  80. package/src/ui/value-selector.ts +3 -3
  81. package/src/utils/canvas-media.ts +6 -6
  82. package/src/utils/edit-display.ts +125 -83
  83. package/src/utils/google-fonts.ts +1 -1
  84. package/src/utils/inherited-style.ts +3 -2
  85. package/src/utils/studio-utils.ts +1 -1
  86. package/src/view.ts +4 -1
@@ -2,16 +2,77 @@
2
2
  import { toRaw } from "../reactivity";
3
3
  import { jsonClone } from "../utils/studio-utils";
4
4
  import { childIndex, getNodeAtPath, isAncestor, parentElementPath, pathsEqual } from "../state";
5
+ import {
6
+ beginRecording,
7
+ endRecording,
8
+ getPatchConsumer,
9
+ recordDocOp,
10
+ recordPatch,
11
+ } from "./patch-ops";
12
+
13
+ import type { JxDocOp, JxDocOpPair, TransactionRecord } from "./patch-ops";
5
14
 
6
15
  import type { Tab } from "../tabs/tab";
7
16
  import type { JxPath } from "../state";
8
17
 
9
18
  import type { JsonValue } from "../types";
10
19
  import type { JxEventBinding, JxMutableNode, JxStateObject, JxStyle } from "@jxsuite/schema/types";
11
- import { ensureNestedStyle, getNestedStyle } from "@jxsuite/schema/guards";
20
+ import { ensureNestedStyle, getNestedStyle, isEventBinding } from "@jxsuite/schema/guards";
12
21
 
13
22
  const HISTORY_LIMIT = 100;
14
23
 
24
+ /** Every Nth history entry stores a full snapshot so states can be materialized by replay. */
25
+ const CHECKPOINT_INTERVAL = 20;
26
+
27
+ /** Opt-out flag for patch-based history (forces full-snapshot-per-edit legacy behavior). */
28
+ function patchHistoryEnabled() {
29
+ try {
30
+ return typeof localStorage === "undefined" || !localStorage.getItem("jx-legacy-history");
31
+ } catch {
32
+ return true;
33
+ }
34
+ }
35
+
36
+ /** Deep-clone a recorded value (undefined passes through; reactive proxies are read through). */
37
+ function cloneValue<T>(v: T): T {
38
+ return v === undefined || v === null ? v : (jsonClone(v as object) as T);
39
+ }
40
+
41
+ /** Forward/inverse pair for a single-key change on the node at path. */
42
+ function setKeyPair(path: JxPath, key: string, before: unknown, after: unknown): JxDocOpPair {
43
+ return {
44
+ forward: { key, op: "set-key", path, value: after },
45
+ inverse: { key, op: "set-key", path, value: before },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Translate `path` into post-splice coordinates when it descends through `parentPath`'s children at
51
+ * or beyond the spliced index: a removal (delta -1) at fromIndex shifts deeper paths down; an
52
+ * insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice doesn't
53
+ * affect it. Used to express a move's inverse in post-mutation coordinates.
54
+ */
55
+ function shiftPrefixedIndex(
56
+ parentPath: JxPath,
57
+ path: JxPath,
58
+ spliceIndex: number,
59
+ delta: number,
60
+ inclusive: boolean,
61
+ ): JxPath {
62
+ const d = parentPath.length;
63
+ const isProperPrefix = d < path.length && parentPath.every((seg, i) => seg === path[i]);
64
+ if (!isProperPrefix || path[d] !== "children" || typeof path[d + 1] !== "number") {
65
+ return path;
66
+ }
67
+ const idx = path[d + 1] as number;
68
+ if (inclusive ? idx < spliceIndex : idx <= spliceIndex) {
69
+ return path;
70
+ }
71
+ const shifted = [...path];
72
+ shifted[d + 1] = idx + delta;
73
+ return shifted;
74
+ }
75
+
15
76
  /** Any value that can sit at a document node key (undefined/null/"" deletes it). */
16
77
  export type JxNodeValue =
17
78
  | JsonValue
@@ -52,30 +113,121 @@ export function transactDoc(
52
113
  if (!tab) {
53
114
  return;
54
115
  }
55
- mutationFn(tab);
116
+ const selectionBefore = tab.session.selection ? [...tab.session.selection] : null;
117
+ beginRecording();
118
+ let record: TransactionRecord;
119
+ try {
120
+ mutationFn(tab);
121
+ } finally {
122
+ record = endRecording();
123
+ }
56
124
 
57
125
  // Replace the document root reference so effects tracking tab.doc.document re-trigger.
58
126
  // Nested objects are shared — the mutation already modified them in place.
59
127
  const raw = toRaw(tab.doc.document);
60
- tab.doc.document = { ...raw };
128
+ const newRef = { ...raw };
61
129
 
62
- if (!skipHistory) {
63
- const snapshot = {
64
- document: jsonClone(raw),
65
- selection: tab.session.selection ? [...tab.session.selection] : null,
66
- };
67
- const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
68
- truncated.push(snapshot);
69
- if (truncated.length > HISTORY_LIMIT) {
70
- truncated.shift();
130
+ // When the canvas patcher can apply this change surgically, mark the new root reference as
131
+ // Consumed BEFORE assigning it — the canvas doc-effect runs synchronously on assignment and
132
+ // Skips the full render for consumed references. Panels' own effects still fire as before.
133
+ // Anything unrecorded or unclassifiable falls through to today's full-render path.
134
+ const consumer = getPatchConsumer();
135
+ const verdict =
136
+ consumer === null
137
+ ? { patchable: false, reason: "no-patch-consumer" }
138
+ : record.ops.length === 0
139
+ ? { patchable: false, reason: "unrecorded-mutation" }
140
+ : consumer.classify(tab, record.ops);
141
+ if (verdict.patchable) {
142
+ consumer!.markConsumed(newRef);
143
+ }
144
+
145
+ tab.doc.document = newRef;
146
+
147
+ if (verdict.patchable) {
148
+ try {
149
+ consumer!.apply(tab, record.ops);
150
+ } catch (error) {
151
+ consumer!.escalate(
152
+ `patch-apply-failed: ${error instanceof Error ? error.message : String(error)}`,
153
+ );
71
154
  }
72
- tab.history.snapshots = truncated;
73
- tab.history.index = truncated.length - 1;
155
+ }
156
+
157
+ if (!skipHistory) {
158
+ pushHistoryEntry(tab, raw, record, selectionBefore);
74
159
  }
75
160
 
76
161
  tab.doc.dirty = true;
77
162
  }
78
163
 
164
+ /**
165
+ * Append a history entry for a completed transaction. With patch-based history, entries carry
166
+ * replayable forward/inverse ops and only periodic checkpoints store full document snapshots —
167
+ * eliminating the whole-document clone per edit. Non-invertible or un-instrumented transactions
168
+ * store a snapshot, which always keeps every state recoverable.
169
+ *
170
+ * @param {Tab} tab
171
+ * @param {JxMutableNode} raw Post-mutation raw document
172
+ * @param {TransactionRecord} record
173
+ * @param {JxPath | null} selectionBefore
174
+ */
175
+ function pushHistoryEntry(
176
+ tab: Tab,
177
+ raw: JxMutableNode,
178
+ record: TransactionRecord,
179
+ selectionBefore: JxPath | null,
180
+ ) {
181
+ const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
182
+ const useOps = patchHistoryEnabled() && record.invertible && record.docOps.length > 0;
183
+ const needCheckpoint = !useOps || truncated.length % CHECKPOINT_INTERVAL === 0;
184
+ truncated.push({
185
+ document: needCheckpoint ? jsonClone(raw) : null,
186
+ forwardOps: useOps ? record.docOps.map((p) => p.forward) : null,
187
+ inverseOps: useOps ? record.docOps.map((p) => p.inverse) : null,
188
+ selection: tab.session.selection ? [...tab.session.selection] : null,
189
+ selectionBefore,
190
+ });
191
+ if (truncated.length > HISTORY_LIMIT) {
192
+ // The new base entry must be a self-contained checkpoint before the old base is dropped.
193
+ if (!truncated[1]!.document) {
194
+ truncated[1]!.document = materializeState(truncated, 1);
195
+ }
196
+ truncated.shift();
197
+ }
198
+ tab.history.snapshots = truncated;
199
+ tab.history.index = truncated.length - 1;
200
+ }
201
+
202
+ /**
203
+ * Reconstruct the document at history state `target`: copy the nearest checkpoint at or before it,
204
+ * then replay forward ops. Every entry holds either a snapshot or forward ops, so this always
205
+ * succeeds.
206
+ *
207
+ * @param {HistorySnapshot[]} snapshots
208
+ * @param {number} target
209
+ * @returns {JxMutableNode}
210
+ */
211
+ function materializeState(
212
+ snapshots: { document: JxMutableNode | null; forwardOps?: JxDocOp[] | null }[],
213
+ target: number,
214
+ ): JxMutableNode {
215
+ let base = target;
216
+ while (base >= 0 && !snapshots[base]!.document) {
217
+ base -= 1;
218
+ }
219
+ if (base < 0) {
220
+ throw new Error("history-missing-checkpoint");
221
+ }
222
+ const doc = jsonClone(snapshots[base]!.document) as JxMutableNode;
223
+ for (let i = base + 1; i <= target; i++) {
224
+ for (const op of snapshots[i]!.forwardOps ?? []) {
225
+ applyDocOpToDoc(doc, op);
226
+ }
227
+ }
228
+ return doc;
229
+ }
230
+
79
231
  /**
80
232
  * Convenience: transact with a mutation fn that receives the document directly.
81
233
  *
@@ -91,17 +243,168 @@ export function transact(
91
243
  transactDoc(tab, (t) => fn(t.doc.document), opts);
92
244
  }
93
245
 
246
+ // ─── Document-op application ─────────────────────────────────────────────────
247
+
248
+ /** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
249
+ const DOC_META_KEYS = new Set(["state", "$media", "$head", "$elements", "imports", "$layout"]);
250
+
251
+ /**
252
+ * Apply a replayable doc op to a bare document tree (history replay — no canvas recording).
253
+ *
254
+ * @param {JxMutableNode} doc
255
+ * @param {JxDocOp} op
256
+ */
257
+ function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp) {
258
+ switch (op.op) {
259
+ case "set-key": {
260
+ const node = getNodeAtPath(doc, op.path);
261
+ if (!node) {
262
+ throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
263
+ }
264
+ if (op.value === undefined) {
265
+ delete node[op.key];
266
+ } else {
267
+ node[op.key] = cloneValue(op.value) as JxNodeValue;
268
+ }
269
+ return;
270
+ }
271
+ case "insert-child": {
272
+ const parent = getNodeAtPath(doc, op.parentPath);
273
+ childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
274
+ return;
275
+ }
276
+ case "remove-child": {
277
+ const parent = getNodeAtPath(doc, op.parentPath);
278
+ childArray(parent).splice(op.index, 1);
279
+ return;
280
+ }
281
+ case "set-child": {
282
+ const parent = getNodeAtPath(doc, op.parentPath);
283
+ childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
284
+ return;
285
+ }
286
+ case "move-child": {
287
+ const fromParent = getNodeAtPath(doc, op.fromParentPath);
288
+ const toParent = getNodeAtPath(doc, op.toParentPath);
289
+ const [node] = childArray(fromParent).splice(op.fromIndex, 1);
290
+ childArray(toParent).splice(op.toIndex, 0, node!);
291
+ return;
292
+ }
293
+ default: {
294
+ throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Apply a doc op to the live document AND record the matching canvas patch op, so undo/redo
301
+ * transactions patch the canvas surgically like any other edit.
302
+ *
303
+ * @param {Tab} tab
304
+ * @param {JxDocOp} op
305
+ */
306
+ function applyDocOp(tab: Tab, op: JxDocOp) {
307
+ const prev =
308
+ op.op === "set-key" ? (getNodeAtPath(tab.doc.document, op.path)?.[op.key] as unknown) : null;
309
+ applyDocOpToDoc(tab.doc.document, op);
310
+ switch (op.op) {
311
+ case "set-key": {
312
+ if (DOC_META_KEYS.has(op.key)) {
313
+ recordPatch({ key: op.key, op: "doc-meta" });
314
+ } else if (op.key === "style") {
315
+ recordPatch({ op: "set-style", path: op.path });
316
+ } else if (op.key === "textContent") {
317
+ recordPatch({ op: "set-text", path: op.path });
318
+ } else if (op.key === "attributes" || op.key === "cases" || op.key === "$props") {
319
+ recordPatch({ op: "replace", path: op.path });
320
+ } else {
321
+ const isEvent =
322
+ op.key.startsWith("on") && (isEventBinding(op.value) || isEventBinding(prev));
323
+ recordPatch({ isEvent, key: op.key, op: "set-prop", path: op.path });
324
+ }
325
+ return;
326
+ }
327
+ case "insert-child": {
328
+ recordPatch({ index: op.index, op: "insert", parentPath: op.parentPath });
329
+ return;
330
+ }
331
+ case "remove-child": {
332
+ recordPatch({ op: "remove", path: [...op.parentPath, "children", op.index] });
333
+ return;
334
+ }
335
+ case "set-child": {
336
+ recordPatch({ op: "replace", path: [...op.parentPath, "children", op.index] });
337
+ return;
338
+ }
339
+ case "move-child": {
340
+ recordPatch({
341
+ fromPath: [...op.fromParentPath, "children", op.fromIndex],
342
+ op: "move",
343
+ toIndex: op.toIndex,
344
+ toParentPath: op.toParentPath,
345
+ });
346
+ return;
347
+ }
348
+ default: {
349
+ break;
350
+ }
351
+ }
352
+ }
353
+
94
354
  // ─── Undo / Redo ─────────────────────────────────────────────────────────────
95
355
 
356
+ /** Restore a materialized snapshot state (full-render path). */
357
+ function restoreState(tab: Tab, index: number) {
358
+ const { snapshots } = tab.history;
359
+ tab.doc.document = materializeState(snapshots, index);
360
+ const snap = snapshots[index]!;
361
+ tab.session.selection = snap.selection ? [...toRaw(snap.selection)] : null;
362
+ }
363
+
364
+ /** Debug-flag assertion: ops-based undo/redo must land on the same state replay produces. */
365
+ function assertHistoryConsistency(tab: Tab, index: number) {
366
+ try {
367
+ if (typeof localStorage === "undefined" || !localStorage.getItem("jx-canvas-debug")) {
368
+ return;
369
+ }
370
+ } catch {
371
+ return;
372
+ }
373
+ const expected = JSON.stringify(materializeState(tab.history.snapshots, index));
374
+ const actual = JSON.stringify(jsonClone(toRaw(tab.doc.document)));
375
+ if (expected !== actual) {
376
+ console.error("jx history: ops-based undo/redo diverged from checkpoint replay", {
377
+ index,
378
+ });
379
+ }
380
+ }
381
+
96
382
  /** @param {Tab} tab */
97
383
  export function undo(tab: Tab) {
98
384
  if (tab.history.index <= 0) {
99
385
  return;
100
386
  }
101
- tab.history.index -= 1;
102
- const snap = tab.history.snapshots[tab.history.index];
103
- tab.doc.document = structuredClone(toRaw(snap.document));
104
- tab.session.selection = snap.selection ? [...toRaw(snap.selection)] : null;
387
+ const entry = tab.history.snapshots[tab.history.index]!;
388
+ const inverseOps = patchHistoryEnabled() ? entry.inverseOps : null;
389
+ if (inverseOps && inverseOps.length > 0) {
390
+ // Surgical path: apply inverse ops through the normal transaction pipeline (the canvas
391
+ // Patches in place when it can), in reverse recording order.
392
+ transactDoc(
393
+ tab,
394
+ (t) => {
395
+ for (let i = inverseOps.length - 1; i >= 0; i--) {
396
+ applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
397
+ }
398
+ },
399
+ { skipHistory: true },
400
+ );
401
+ tab.session.selection = entry.selectionBefore ? [...toRaw(entry.selectionBefore)] : null;
402
+ tab.history.index -= 1;
403
+ assertHistoryConsistency(tab, tab.history.index);
404
+ } else {
405
+ tab.history.index -= 1;
406
+ restoreState(tab, tab.history.index);
407
+ }
105
408
  tab.doc.dirty = true;
106
409
  }
107
410
 
@@ -110,10 +413,25 @@ export function redo(tab: Tab) {
110
413
  if (tab.history.index >= tab.history.snapshots.length - 1) {
111
414
  return;
112
415
  }
113
- tab.history.index += 1;
114
- const snap = tab.history.snapshots[tab.history.index];
115
- tab.doc.document = structuredClone(toRaw(snap.document));
116
- tab.session.selection = snap.selection ? [...toRaw(snap.selection)] : null;
416
+ const entry = tab.history.snapshots[tab.history.index + 1]!;
417
+ const forwardOps = patchHistoryEnabled() ? entry.forwardOps : null;
418
+ if (forwardOps && forwardOps.length > 0) {
419
+ transactDoc(
420
+ tab,
421
+ (t) => {
422
+ for (const op of forwardOps) {
423
+ applyDocOp(t, toRaw(op) as JxDocOp);
424
+ }
425
+ },
426
+ { skipHistory: true },
427
+ );
428
+ tab.session.selection = entry.selection ? [...toRaw(entry.selection)] : null;
429
+ tab.history.index += 1;
430
+ assertHistoryConsistency(tab, tab.history.index);
431
+ } else {
432
+ tab.history.index += 1;
433
+ restoreState(tab, tab.history.index);
434
+ }
117
435
  tab.doc.dirty = true;
118
436
  }
119
437
 
@@ -136,6 +454,11 @@ export function mutateInsertNode(
136
454
  return;
137
455
  }
138
456
  childArray(parent).splice(index, 0, structuredClone(nodeDef));
457
+ recordPatch({ index, op: "insert", parentPath });
458
+ recordDocOp({
459
+ forward: { index, node: cloneValue(nodeDef), op: "insert-child", parentPath },
460
+ inverse: { index, op: "remove-child", parentPath },
461
+ });
139
462
  }
140
463
 
141
464
  /**
@@ -148,7 +471,15 @@ export function mutateRemoveNode(tab: Tab, path: JxPath) {
148
471
  }
149
472
  const elemPath = parentElementPath(path) as JxPath;
150
473
  const idx = childIndex(path) as number;
151
- (getNodeAtPath(tab.doc.document, elemPath).children as JxMutableNode[]).splice(idx, 1);
474
+ const [removed] = (getNodeAtPath(tab.doc.document, elemPath).children as JxMutableNode[]).splice(
475
+ idx,
476
+ 1,
477
+ );
478
+ recordPatch({ op: "remove", path });
479
+ recordDocOp({
480
+ forward: { index: idx, op: "remove-child", parentPath: elemPath },
481
+ inverse: { index: idx, node: cloneValue(removed), op: "insert-child", parentPath: elemPath },
482
+ });
152
483
  if (tab.session.selection && isAncestor(path, tab.session.selection)) {
153
484
  tab.session.selection = null;
154
485
  }
@@ -170,6 +501,11 @@ export function mutateDuplicateNode(tab: Tab, path: JxPath) {
170
501
  const idx = childIndex(path) as number;
171
502
  const clone = structuredClone(toRaw(node));
172
503
  (getNodeAtPath(tab.doc.document, elemPath).children as JxMutableNode[]).splice(idx + 1, 0, clone);
504
+ recordPatch({ index: idx + 1, op: "insert", parentPath: elemPath });
505
+ recordDocOp({
506
+ forward: { index: idx + 1, node: cloneValue(clone), op: "insert-child", parentPath: elemPath },
507
+ inverse: { index: idx + 1, op: "remove-child", parentPath: elemPath },
508
+ });
173
509
  tab.session.selection = [...elemPath, "children", idx + 1];
174
510
  }
175
511
 
@@ -192,7 +528,13 @@ export function mutateWrapNode(tab: Tab, path: JxPath, wrapperTag = "div") {
192
528
  children: [structuredClone(toRaw(node))],
193
529
  tagName: wrapperTag,
194
530
  };
531
+ const original = cloneValue(node);
195
532
  (getNodeAtPath(tab.doc.document, elemPath).children as JxMutableNode[]).splice(idx, 1, wrapper);
533
+ recordPatch({ op: "replace", path });
534
+ recordDocOp({
535
+ forward: { index: idx, node: cloneValue(wrapper), op: "set-child", parentPath: elemPath },
536
+ inverse: { index: idx, node: original, op: "set-child", parentPath: elemPath },
537
+ });
196
538
  tab.session.selection = [...elemPath, "children", idx];
197
539
  }
198
540
 
@@ -223,6 +565,26 @@ export function mutateMoveNode(tab: Tab, fromPath: JxPath, toParentPath: JxPath,
223
565
  adjustedIndex -= 1;
224
566
  }
225
567
  childArray(toParent).splice(adjustedIndex, 0, node);
568
+ recordPatch({ fromPath, op: "move", toIndex: adjustedIndex, toParentPath });
569
+ recordDocOp({
570
+ forward: {
571
+ fromIndex: fromIdx,
572
+ fromParentPath,
573
+ op: "move-child",
574
+ toIndex: adjustedIndex,
575
+ toParentPath,
576
+ },
577
+ inverse: {
578
+ fromIndex: adjustedIndex,
579
+ // The inverse runs against the post-move document, so both parent paths are translated
580
+ // Into post-move coordinates: the removal shifted paths under fromParent, the insertion
581
+ // Shifted paths under toParent.
582
+ fromParentPath: shiftPrefixedIndex(fromParentPath, toParentPath, fromIdx, -1, false),
583
+ op: "move-child",
584
+ toIndex: fromIdx,
585
+ toParentPath: shiftPrefixedIndex(toParentPath, fromParentPath, adjustedIndex, 1, true),
586
+ },
587
+ });
226
588
 
227
589
  if (pathsEqual(tab.session.selection, fromPath)) {
228
590
  let idx = toIndex;
@@ -245,11 +607,25 @@ export function mutateMoveNode(tab: Tab, fromPath: JxPath, toParentPath: JxPath,
245
607
  */
246
608
  export function mutateUpdateProperty(tab: Tab, path: JxPath, key: string, value?: JxNodeValue) {
247
609
  const node = getNodeAtPath(tab.doc.document, path);
610
+ const prev = node[key];
611
+ const before = cloneValue(prev);
248
612
  if (value === undefined || value === null || value === "") {
249
613
  delete node[key];
250
614
  } else {
251
615
  node[key] = value;
252
616
  }
617
+ recordDocOp(setKeyPair(path, key, before, cloneValue(node[key])));
618
+ if (key === "textContent") {
619
+ recordPatch({ op: "set-text", path });
620
+ } else if (key === "style") {
621
+ recordPatch({ op: "set-style", path });
622
+ } else {
623
+ // Event-binding edits are invisible on the canvas (stripped in design/edit renders); the
624
+ // Deleted-value case checks the previous value since the new one is gone.
625
+ const bindingValue = value === undefined || value === null || value === "" ? prev : value;
626
+ const isEvent = key.startsWith("on") && isEventBinding(bindingValue);
627
+ recordPatch({ isEvent, key, op: "set-prop", path });
628
+ }
253
629
  }
254
630
 
255
631
  /**
@@ -260,6 +636,7 @@ export function mutateUpdateProperty(tab: Tab, path: JxPath, key: string, value?
260
636
  */
261
637
  export function mutateUpdateStyle(tab: Tab, path: JxPath, prop: string, value: string | undefined) {
262
638
  const node = getNodeAtPath(tab.doc.document, path);
639
+ const styleBefore = cloneValue(node.style);
263
640
  if (!node.style) {
264
641
  node.style = {};
265
642
  }
@@ -271,6 +648,8 @@ export function mutateUpdateStyle(tab: Tab, path: JxPath, prop: string, value: s
271
648
  if (Object.keys(node.style).length === 0) {
272
649
  delete node.style;
273
650
  }
651
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
652
+ recordPatch({ op: "set-style", path });
274
653
  }
275
654
 
276
655
  /**
@@ -286,6 +665,7 @@ export function mutateUpdateAttribute(
286
665
  value?: string | undefined,
287
666
  ) {
288
667
  const node = getNodeAtPath(tab.doc.document, path);
668
+ const attrsBefore = cloneValue(node.attributes);
289
669
  if (!node.attributes) {
290
670
  node.attributes = {};
291
671
  }
@@ -297,6 +677,8 @@ export function mutateUpdateAttribute(
297
677
  if (Object.keys(node.attributes).length === 0) {
298
678
  delete node.attributes;
299
679
  }
680
+ recordDocOp(setKeyPair(path, "attributes", attrsBefore, cloneValue(node.attributes)));
681
+ recordPatch({ attr, op: "set-attr", path });
300
682
  }
301
683
 
302
684
  /**
@@ -317,6 +699,7 @@ export function mutateUpdateMediaStyle(
317
699
  return mutateUpdateStyle(tab, path, prop, value);
318
700
  }
319
701
  const node = getNodeAtPath(tab.doc.document, path);
702
+ const styleBefore = cloneValue(node.style);
320
703
  if (!node.style) {
321
704
  node.style = {};
322
705
  }
@@ -333,6 +716,8 @@ export function mutateUpdateMediaStyle(
333
716
  if (Object.keys(node.style).length === 0) {
334
717
  delete node.style;
335
718
  }
719
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
720
+ recordPatch({ op: "set-style", path });
336
721
  }
337
722
 
338
723
  /**
@@ -350,6 +735,7 @@ export function mutateUpdateNestedStyle(
350
735
  value: string | undefined,
351
736
  ) {
352
737
  const node = getNodeAtPath(tab.doc.document, path);
738
+ const styleBefore = cloneValue(node.style);
353
739
  if (!node.style) {
354
740
  node.style = {};
355
741
  }
@@ -365,6 +751,8 @@ export function mutateUpdateNestedStyle(
365
751
  if (Object.keys(node.style).length === 0) {
366
752
  delete node.style;
367
753
  }
754
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
755
+ recordPatch({ op: "set-style", path });
368
756
  }
369
757
 
370
758
  /**
@@ -384,6 +772,7 @@ export function mutateUpdateMediaNestedStyle(
384
772
  value: string | undefined,
385
773
  ) {
386
774
  const node = getNodeAtPath(tab.doc.document, path);
775
+ const styleBefore = cloneValue(node.style);
387
776
  if (!node.style) {
388
777
  node.style = {};
389
778
  }
@@ -404,6 +793,8 @@ export function mutateUpdateMediaNestedStyle(
404
793
  if (Object.keys(node.style).length === 0) {
405
794
  delete node.style;
406
795
  }
796
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
797
+ recordPatch({ op: "set-style", path });
407
798
  }
408
799
 
409
800
  /**
@@ -424,6 +815,7 @@ export function mutateUpdateNestedStylePath(
424
815
  value: string | undefined,
425
816
  ) {
426
817
  const node = getNodeAtPath(tab.doc.document, path);
818
+ const styleBefore = cloneValue(node.style);
427
819
  if (!node.style) {
428
820
  node.style = {};
429
821
  }
@@ -436,9 +828,9 @@ export function mutateUpdateNestedStylePath(
436
828
  // Clean up empty parent objects
437
829
  let cur: JxStyle | undefined = node.style;
438
830
  for (let i = 0; i < stylePath.length && cur; i++) {
439
- const child = getNestedStyle(cur, stylePath[i]);
831
+ const child = getNestedStyle(cur, stylePath[i]!);
440
832
  if (child && Object.keys(child).length === 0) {
441
- delete cur[stylePath[i]];
833
+ delete cur[stylePath[i]!];
442
834
  break;
443
835
  }
444
836
  cur = child;
@@ -449,6 +841,8 @@ export function mutateUpdateNestedStylePath(
449
841
  if (Object.keys(node.style).length === 0) {
450
842
  delete node.style;
451
843
  }
844
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
845
+ recordPatch({ op: "set-style", path });
452
846
  }
453
847
 
454
848
  /**
@@ -470,6 +864,7 @@ export function mutateUpdateMediaNestedStylePath(
470
864
  value: string | undefined,
471
865
  ) {
472
866
  const node = getNodeAtPath(tab.doc.document, path);
867
+ const styleBefore = cloneValue(node.style);
473
868
  if (!node.style) {
474
869
  node.style = {};
475
870
  }
@@ -483,9 +878,9 @@ export function mutateUpdateMediaNestedStylePath(
483
878
  delete obj[prop];
484
879
  let cur: JxStyle | undefined = media;
485
880
  for (let i = 0; i < stylePath.length && cur; i++) {
486
- const child = getNestedStyle(cur, stylePath[i]);
881
+ const child = getNestedStyle(cur, stylePath[i]!);
487
882
  if (child && Object.keys(child).length === 0) {
488
- delete cur[stylePath[i]];
883
+ delete cur[stylePath[i]!];
489
884
  break;
490
885
  }
491
886
  cur = child;
@@ -499,6 +894,8 @@ export function mutateUpdateMediaNestedStylePath(
499
894
  if (Object.keys(node.style).length === 0) {
500
895
  delete node.style;
501
896
  }
897
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
898
+ recordPatch({ op: "set-style", path });
502
899
  }
503
900
 
504
901
  /**
@@ -508,11 +905,14 @@ export function mutateUpdateMediaNestedStylePath(
508
905
  */
509
906
  export function mutateReplaceStyle(tab: Tab, path: JxPath, style: JxStyle | undefined) {
510
907
  const node = getNodeAtPath(tab.doc.document, path);
908
+ const styleBefore = cloneValue(node.style);
511
909
  if (style && Object.keys(style).length > 0) {
512
910
  node.style = style;
513
911
  } else {
514
912
  delete node.style;
515
913
  }
914
+ recordDocOp(setKeyPair(path, "style", styleBefore, cloneValue(node.style)));
915
+ recordPatch({ op: "set-style", path });
516
916
  }
517
917
 
518
918
  /**
@@ -522,10 +922,13 @@ export function mutateReplaceStyle(tab: Tab, path: JxPath, style: JxStyle | unde
522
922
  */
523
923
  export function mutateAddDef(tab: Tab, name: string, def: Record<string, JsonValue>) {
524
924
  const doc = tab.doc.document;
925
+ const before = cloneValue(doc.state);
525
926
  if (!doc.state) {
526
927
  doc.state = {};
527
928
  }
528
929
  doc.state[name] = def;
930
+ recordDocOp(setKeyPair([], "state", before, cloneValue(doc.state)));
931
+ recordPatch({ key: "state", op: "doc-meta" });
529
932
  }
530
933
 
531
934
  /**
@@ -535,10 +938,13 @@ export function mutateAddDef(tab: Tab, name: string, def: Record<string, JsonVal
535
938
  export function mutateRemoveDef(tab: Tab, name: string) {
536
939
  const doc = tab.doc.document;
537
940
  if (doc.state) {
941
+ const before = cloneValue(doc.state);
538
942
  delete doc.state[name];
539
943
  if (Object.keys(doc.state).length === 0) {
540
944
  delete doc.state;
541
945
  }
946
+ recordDocOp(setKeyPair([], "state", before, cloneValue(doc.state)));
947
+ recordPatch({ key: "state", op: "doc-meta" });
542
948
  }
543
949
  }
544
950
 
@@ -549,6 +955,7 @@ export function mutateRemoveDef(tab: Tab, name: string) {
549
955
  */
550
956
  export function mutateUpdateDef(tab: Tab, name: string, updates: Record<string, unknown>) {
551
957
  const doc = tab.doc.document;
958
+ const stateBefore = cloneValue(doc.state);
552
959
  if (!doc.state) {
553
960
  doc.state = {};
554
961
  }
@@ -566,6 +973,8 @@ export function mutateUpdateDef(tab: Tab, name: string, updates: Record<string,
566
973
  delete entry[k];
567
974
  }
568
975
  }
976
+ recordDocOp(setKeyPair([], "state", stateBefore, cloneValue(doc.state)));
977
+ recordPatch({ key: "state", op: "doc-meta" });
569
978
  }
570
979
 
571
980
  /**
@@ -578,8 +987,11 @@ export function mutateRenameDef(tab: Tab, oldName: string, newName: string) {
578
987
  if (!doc.state || !doc.state[oldName]) {
579
988
  return;
580
989
  }
990
+ const before = cloneValue(doc.state);
581
991
  doc.state[newName] = doc.state[oldName];
582
992
  delete doc.state[oldName];
993
+ recordDocOp(setKeyPair([], "state", before, cloneValue(doc.state)));
994
+ recordPatch({ key: "state", op: "doc-meta" });
583
995
  }
584
996
 
585
997
  /**
@@ -589,6 +1001,7 @@ export function mutateRenameDef(tab: Tab, oldName: string, newName: string) {
589
1001
  */
590
1002
  export function mutateUpdateMedia(tab: Tab, name: string, query?: string | undefined) {
591
1003
  const doc = tab.doc.document;
1004
+ const mediaBefore = cloneValue(doc.$media);
592
1005
  if (!doc.$media) {
593
1006
  doc.$media = {};
594
1007
  }
@@ -600,6 +1013,8 @@ export function mutateUpdateMedia(tab: Tab, name: string, query?: string | undef
600
1013
  } else {
601
1014
  doc.$media[name] = query;
602
1015
  }
1016
+ recordDocOp(setKeyPair([], "$media", mediaBefore, cloneValue(doc.$media)));
1017
+ recordPatch({ key: "$media", op: "doc-meta" });
603
1018
  }
604
1019
 
605
1020
  /**
@@ -610,6 +1025,7 @@ export function mutateUpdateMedia(tab: Tab, name: string, query?: string | undef
610
1025
  */
611
1026
  export function mutateUpdateProp(tab: Tab, path: JxPath, propName: string, value: JsonValue) {
612
1027
  const node = getNodeAtPath(tab.doc.document, path);
1028
+ const propsBefore = cloneValue(node.$props);
613
1029
  if (!node.$props) {
614
1030
  node.$props = {};
615
1031
  }
@@ -621,6 +1037,8 @@ export function mutateUpdateProp(tab: Tab, path: JxPath, propName: string, value
621
1037
  if (Object.keys(node.$props).length === 0) {
622
1038
  delete node.$props;
623
1039
  }
1040
+ recordDocOp(setKeyPair(path, "$props", propsBefore, cloneValue(node.$props)));
1041
+ recordPatch({ op: "replace", path });
624
1042
  }
625
1043
 
626
1044
  /**
@@ -636,10 +1054,13 @@ export function mutateAddSwitchCase(
636
1054
  caseDef?: JxMutableNode,
637
1055
  ) {
638
1056
  const node = getNodeAtPath(tab.doc.document, path);
1057
+ const before = cloneValue(node.cases);
639
1058
  if (!node.cases) {
640
1059
  node.cases = {};
641
1060
  }
642
1061
  node.cases[caseName] = caseDef || { tagName: "div", textContent: caseName };
1062
+ recordDocOp(setKeyPair(path, "cases", before, cloneValue(node.cases)));
1063
+ recordPatch({ op: "replace", path });
643
1064
  }
644
1065
 
645
1066
  /**
@@ -650,7 +1071,10 @@ export function mutateAddSwitchCase(
650
1071
  export function mutateRemoveSwitchCase(tab: Tab, path: JxPath, caseName: string) {
651
1072
  const node = getNodeAtPath(tab.doc.document, path);
652
1073
  if (node.cases) {
1074
+ const before = cloneValue(node.cases);
653
1075
  delete node.cases[caseName];
1076
+ recordDocOp(setKeyPair(path, "cases", before, cloneValue(node.cases)));
1077
+ recordPatch({ op: "replace", path });
654
1078
  }
655
1079
  }
656
1080
 
@@ -665,8 +1089,11 @@ export function mutateRenameSwitchCase(tab: Tab, path: JxPath, oldName: string,
665
1089
  if (!node.cases || !node.cases[oldName]) {
666
1090
  return;
667
1091
  }
1092
+ const before = cloneValue(node.cases);
668
1093
  node.cases[newName] = node.cases[oldName];
669
1094
  delete node.cases[oldName];
1095
+ recordDocOp(setKeyPair(path, "cases", before, cloneValue(node.cases)));
1096
+ recordPatch({ op: "replace", path });
670
1097
  }
671
1098
 
672
1099
  // ─── Frontmatter ─────────────────────────────────────────────────────────────