@jxsuite/studio 0.28.5 → 0.29.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 (81) hide show
  1. package/dist/studio.js +6769 -5399
  2. package/dist/studio.js.map +86 -79
  3. package/package.json +5 -4
  4. package/src/browse/browse-modal.ts +2 -2
  5. package/src/browse/browse.ts +20 -19
  6. package/src/canvas/canvas-diff.ts +3 -3
  7. package/src/canvas/canvas-helpers.ts +15 -2
  8. package/src/canvas/canvas-live-render.ts +168 -86
  9. package/src/canvas/canvas-patcher.ts +656 -0
  10. package/src/canvas/canvas-perf.ts +68 -0
  11. package/src/canvas/canvas-render.ts +135 -23
  12. package/src/canvas/canvas-subtree-render.ts +113 -0
  13. package/src/canvas/canvas-utils.ts +4 -0
  14. package/src/editor/component-inline-edit.ts +4 -35
  15. package/src/editor/context-menu.ts +92 -74
  16. package/src/editor/convert-to-component.ts +11 -2
  17. package/src/editor/convert-to-repeater.ts +4 -5
  18. package/src/editor/inline-edit.ts +9 -9
  19. package/src/editor/inline-format.ts +11 -11
  20. package/src/editor/merge-tags.ts +120 -0
  21. package/src/editor/shortcuts.ts +4 -4
  22. package/src/files/components.ts +37 -0
  23. package/src/files/file-ops.ts +28 -7
  24. package/src/files/files.ts +25 -21
  25. package/src/github/github-auth.ts +14 -2
  26. package/src/github/github-publish.ts +12 -2
  27. package/src/panels/activity-bar.ts +1 -1
  28. package/src/panels/ai-panel.ts +67 -39
  29. package/src/panels/block-action-bar.ts +72 -1
  30. package/src/panels/canvas-dnd.ts +55 -26
  31. package/src/panels/data-explorer.ts +5 -3
  32. package/src/panels/dnd.ts +12 -15
  33. package/src/panels/editors.ts +5 -23
  34. package/src/panels/elements-panel.ts +2 -12
  35. package/src/panels/events-panel.ts +1 -1
  36. package/src/panels/git-panel.ts +6 -6
  37. package/src/panels/head-panel.ts +1 -1
  38. package/src/panels/layers-panel.ts +177 -147
  39. package/src/panels/preview-render.ts +15 -0
  40. package/src/panels/properties-panel.ts +16 -27
  41. package/src/panels/quick-search.ts +2 -2
  42. package/src/panels/right-panel.ts +2 -6
  43. package/src/panels/shared.ts +3 -0
  44. package/src/panels/signals-panel.ts +24 -22
  45. package/src/panels/statusbar.ts +15 -6
  46. package/src/panels/style-panel.ts +3 -3
  47. package/src/panels/style-utils.ts +3 -3
  48. package/src/panels/stylebook-panel.ts +4 -4
  49. package/src/panels/tab-bar.ts +198 -0
  50. package/src/panels/tab-strip.ts +2 -2
  51. package/src/panels/toolbar.ts +6 -75
  52. package/src/platforms/devserver.ts +60 -34
  53. package/src/reactivity.ts +2 -0
  54. package/src/services/cem-export.ts +23 -2
  55. package/src/services/code-services.ts +16 -2
  56. package/src/services/monaco-setup.ts +2 -1
  57. package/src/settings/content-types-editor.ts +16 -16
  58. package/src/settings/css-vars-editor.ts +2 -2
  59. package/src/settings/defs-editor.ts +14 -14
  60. package/src/settings/general-settings.ts +6 -6
  61. package/src/settings/head-editor.ts +2 -2
  62. package/src/site-context.ts +1 -1
  63. package/src/state.ts +66 -12
  64. package/src/store.ts +15 -3
  65. package/src/studio.ts +73 -33
  66. package/src/tabs/patch-ops.ts +143 -0
  67. package/src/tabs/tab.ts +15 -1
  68. package/src/tabs/transact.ts +454 -27
  69. package/src/types.ts +41 -0
  70. package/src/ui/color-selector.ts +7 -7
  71. package/src/ui/icons.ts +0 -30
  72. package/src/ui/media-picker.ts +2 -2
  73. package/src/ui/panel-resize.ts +6 -1
  74. package/src/ui/unit-selector.ts +2 -2
  75. package/src/ui/value-selector.ts +3 -3
  76. package/src/utils/canvas-media.ts +6 -6
  77. package/src/utils/edit-display.ts +125 -83
  78. package/src/utils/google-fonts.ts +1 -1
  79. package/src/utils/inherited-style.ts +3 -2
  80. package/src/utils/studio-utils.ts +1 -1
  81. package/src/view.ts +4 -1
@@ -31,7 +31,7 @@ export function renderGeneralSettings(container: HTMLElement) {
31
31
  };
32
32
 
33
33
  const onAdapterChange = (e: Event) => {
34
- updateSiteConfig({
34
+ void updateSiteConfig({
35
35
  build: { ...config.build, adapter: (e.target as HTMLInputElement).value },
36
36
  });
37
37
  };
@@ -40,7 +40,7 @@ export function renderGeneralSettings(container: HTMLElement) {
40
40
  // Lazy import breaks the general-settings ↔ settings-modal module cycle
41
41
  const { closeSettingsModal } = await import("./settings-modal");
42
42
  closeSettingsModal();
43
- openFileInTab("project.json");
43
+ void openFileInTab("project.json");
44
44
  };
45
45
 
46
46
  // ─── Breakpoints ($media) ───────────────────────────────────────────────────
@@ -50,7 +50,7 @@ export function renderGeneralSettings(container: HTMLElement) {
50
50
 
51
51
  const onMediaValueChange = (key: string) => (e: Event) => {
52
52
  const updated = { ...media, [key]: (e.target as HTMLInputElement).value };
53
- updateSiteConfig({ $media: updated });
53
+ void updateSiteConfig({ $media: updated });
54
54
  };
55
55
 
56
56
  const onMediaNameChange = (oldKey: string) => (e: Event) => {
@@ -63,20 +63,20 @@ export function renderGeneralSettings(container: HTMLElement) {
63
63
  for (const [k, v] of Object.entries(media)) {
64
64
  updated[k === oldKey ? newKey : k] = v;
65
65
  }
66
- updateSiteConfig({ $media: updated });
66
+ void updateSiteConfig({ $media: updated });
67
67
  renderGeneralSettings(container);
68
68
  };
69
69
 
70
70
  const onRemoveBreakpoint = (key: string) => () => {
71
71
  const updated = { ...media };
72
72
  delete updated[key];
73
- updateSiteConfig({ $media: updated });
73
+ void updateSiteConfig({ $media: updated });
74
74
  renderGeneralSettings(container);
75
75
  };
76
76
 
77
77
  const onAddBreakpoint = () => {
78
78
  const updated = { ...media, "--new": "(max-width: 480px)" };
79
- updateSiteConfig({ $media: updated });
79
+ void updateSiteConfig({ $media: updated });
80
80
  renderGeneralSettings(container);
81
81
  };
82
82
 
@@ -23,7 +23,7 @@ export function renderHeadEditor(container: HTMLElement) {
23
23
  const headEntries = config.$head || ([] as JxHeadEntry[]);
24
24
 
25
25
  const save = () => {
26
- updateSiteConfig({ $head: headEntries });
26
+ void updateSiteConfig({ $head: headEntries });
27
27
  };
28
28
 
29
29
  const addEntry = (tag: string) => {
@@ -52,7 +52,7 @@ export function renderHeadEditor(container: HTMLElement) {
52
52
  };
53
53
 
54
54
  const updateEntry = (idx: number, key: string, val: string) => {
55
- const entry = headEntries[idx];
55
+ const entry = headEntries[idx]!;
56
56
  if (key === "content" && (entry.tagName === "script" || entry.tagName === "style")) {
57
57
  entry.textContent = val;
58
58
  } else {
@@ -170,7 +170,7 @@ export async function resolveLayoutDoc(layoutPath: string) {
170
170
  try {
171
171
  const platform = getPlatform();
172
172
  const content = await platform.readFile(normalized);
173
- const doc = JSON.parse(content);
173
+ const doc = JSON.parse(content) as JxMutableNode;
174
174
  layoutCache.set(normalized, doc);
175
175
  return structuredClone(doc);
176
176
  } catch {
package/src/state.ts CHANGED
@@ -84,6 +84,52 @@ export function childList(node: JxMutableNode | null | undefined): (JxMutableNod
84
84
  return Array.isArray(node?.children) ? node.children : [];
85
85
  }
86
86
 
87
+ /**
88
+ * Normalize a document in place to the canonical array-member form: a legacy whole-children
89
+ * repeater (`children: { $prototype: "Array", … }`) becomes a single member of a children array
90
+ * (`children: [{ $prototype: "Array", … }]`). Recurses through children, repeater templates, and
91
+ * `$switch` cases. Runs once when a document is loaded into a tab so every in-studio doc — and its
92
+ * history checkpoints — uses the member form before any mutation.
93
+ *
94
+ * @param {unknown} node
95
+ * @returns {unknown} The same node (mutated)
96
+ */
97
+ export function normalizeArrayChildren(node: unknown): unknown {
98
+ if (!node || typeof node !== "object") {
99
+ return node;
100
+ }
101
+ if (Array.isArray(node)) {
102
+ for (const child of node) {
103
+ normalizeArrayChildren(child);
104
+ }
105
+ return node;
106
+ }
107
+ const n = node as JxMutableNode;
108
+ const { children } = n;
109
+ if (
110
+ children &&
111
+ typeof children === "object" &&
112
+ !Array.isArray(children) &&
113
+ (children as JxMutableNode).$prototype === "Array"
114
+ ) {
115
+ n.children = [children as JxMutableNode];
116
+ }
117
+ if (Array.isArray(n.children)) {
118
+ for (const child of n.children) {
119
+ normalizeArrayChildren(child);
120
+ }
121
+ }
122
+ if (n.$prototype === "Array" && n.map && typeof n.map === "object") {
123
+ normalizeArrayChildren(n.map);
124
+ }
125
+ if (n.cases && typeof n.cases === "object") {
126
+ for (const caseDef of Object.values(n.cases)) {
127
+ normalizeArrayChildren(caseDef);
128
+ }
129
+ }
130
+ return node;
131
+ }
132
+
87
133
  /**
88
134
  * Return the path to the parent element (strips trailing 'children' + index).
89
135
  *
@@ -180,6 +226,19 @@ export function flattenTree(
180
226
  return [{ depth, node: doc, nodeType: "text", path }];
181
227
  }
182
228
 
229
+ // Array pseudo-element (repeater): a first-class node at its own path. Emit the "map" row, then
230
+ // Recurse into its single template at `[...path, "map"]`. This is reached both when the array is
231
+ // A member of a children array (path `[…, "children", i]`) and the legacy whole-children form
232
+ // (path `[…, "children"]`).
233
+ if ((doc as JxMutableNode).$prototype === "Array") {
234
+ const rows: FlatRow[] = [{ depth, node: doc, nodeType: "map", path }];
235
+ const mapDef = (doc as JxMutableNode).map;
236
+ if (mapDef && typeof mapDef === "object") {
237
+ rows.push(...flattenTree(mapDef as JxMutableNode, [...path, "map"], depth + 1));
238
+ }
239
+ return rows;
240
+ }
241
+
183
242
  const rows: FlatRow[] = [{ depth, node: doc, nodeType: "element", path }];
184
243
 
185
244
  // Custom component instances without user-authored children are atomic in the layer tree
@@ -192,24 +251,15 @@ export function flattenTree(
192
251
  if (Array.isArray(children)) {
193
252
  for (let i = 0; i < children.length; i++) {
194
253
  const childPath = [...path, "children", i];
195
- rows.push(...flattenTree(children[i], childPath, depth + 1));
254
+ rows.push(...flattenTree(children[i]!, childPath, depth + 1));
196
255
  }
197
256
  } else if (
198
257
  children &&
199
258
  typeof children === "object" &&
200
259
  (children as JxMutableNode).$prototype === "Array"
201
260
  ) {
202
- // $map emit the map container, then recurse into the template
203
- rows.push({
204
- depth: depth + 1,
205
- node: children as JxMutableNode,
206
- nodeType: "map",
207
- path: [...path, "children"],
208
- });
209
- const mapDef = (children as JxMutableNode).map;
210
- if (mapDef && typeof mapDef === "object") {
211
- rows.push(...flattenTree(mapDef as JxMutableNode, [...path, "children", "map"], depth + 2));
212
- }
261
+ // Legacy whole-children repeater: the array occupies the children slot itself.
262
+ rows.push(...flattenTree(children as JxMutableNode, [...path, "children"], depth + 1));
213
263
  }
214
264
 
215
265
  // $switch — emit each case as a virtual child
@@ -262,6 +312,10 @@ export function nodeLabel(node: JxMutableNode | null) {
262
312
  if (node.$id) {
263
313
  return node.$id;
264
314
  }
315
+ if (node.tagName === "slot") {
316
+ const name = node.attributes?.name;
317
+ return typeof name === "string" && name.trim() ? `slot: ${name.trim()}` : "slot";
318
+ }
265
319
  const tag = node.tagName ?? "div";
266
320
  const suffix = node.$switch ? " ⇆" : "";
267
321
  if (typeof node.textContent === "string" && node.textContent.length > 0) {
package/src/store.ts CHANGED
@@ -54,6 +54,18 @@ export function initShellRefs() {
54
54
 
55
55
  export const elToPath = new WeakMap<Element, JxPath>();
56
56
 
57
+ /**
58
+ * Canvas element → the runtime scope its children render with. Captured during live renders so the
59
+ * canvas patcher can re-render a subtree in isolation with the same prototype-chained scope.
60
+ */
61
+ export const elToScope = new WeakMap<Element, Record<string, unknown>>();
62
+
63
+ /**
64
+ * Subtree root element → the effect scope created for its surgical render. Stopped when the subtree
65
+ * is replaced or removed so render-time reactive effects don't accumulate.
66
+ */
67
+ export const elToRenderScope = new WeakMap<Element, { stop: () => void }>();
68
+
57
69
  export const canvasPanels: CanvasPanel[] = [];
58
70
 
59
71
  // ─── Shared constants ────────────────────────────────────────────────────────
@@ -97,7 +109,7 @@ export function isNestedSelector(k: string) {
97
109
 
98
110
  // ─── Shared utilities ────────────────────────────────────────────────────────
99
111
 
100
- const _styleDebounceTimers = new Map();
112
+ const _styleDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
101
113
 
102
114
  /**
103
115
  * @param {string} prop
@@ -139,7 +151,7 @@ export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
139
151
  }
140
152
  if (Array.isArray(node)) {
141
153
  // Arrays of nodes round-trip element-wise; the array itself is not a node.
142
- return node.map((n) => stripEventHandlers(n)) as unknown as JxMutableNode;
154
+ return node.map((n: JxMutableNode) => stripEventHandlers(n)) as unknown as JxMutableNode;
143
155
  }
144
156
  const out: Record<string, unknown> = {};
145
157
  for (const [k, v] of Object.entries(node)) {
@@ -148,7 +160,7 @@ export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
148
160
  }
149
161
  if (k === "children") {
150
162
  out.children = Array.isArray(v)
151
- ? v.map((c) => stripEventHandlers(c))
163
+ ? v.map((c: JxMutableNode) => stripEventHandlers(c))
152
164
  : stripEventHandlers(v as JxMutableNode);
153
165
  } else if (k === "cases" && typeof v === "object") {
154
166
  const cases: Record<string, unknown> = {};
package/src/studio.ts CHANGED
@@ -29,11 +29,19 @@ import { effect } from "./reactivity";
29
29
  import { view } from "./view";
30
30
 
31
31
  import { isEditableBlock, isEditing } from "./editor/inline-edit";
32
- import { initComponentInlineEdit } from "./editor/component-inline-edit";
32
+ import { enterComponentInlineEdit, initComponentInlineEdit } from "./editor/component-inline-edit";
33
33
  import { enterInlineEdit } from "./editor/content-inline-edit";
34
34
  import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
35
35
  import { findCanvasElement, getActivePanel, initCanvasHelpers } from "./canvas/canvas-helpers";
36
- import { initCanvasRender, renderCanvas } from "./canvas/canvas-render";
36
+ import {
37
+ applyCanvasMediaOverrides,
38
+ initCanvasRender,
39
+ renderCanvas,
40
+ renderOverlays,
41
+ scheduleCanvasRender,
42
+ } from "./canvas/canvas-render";
43
+ import { consumePatchedDocument, initCanvasPatcher } from "./canvas/canvas-patcher";
44
+ import { registerSubtreeDnD } from "./panels/canvas-dnd";
37
45
  import { initCanvasLiveRender } from "./canvas/canvas-live-render";
38
46
  import {
39
47
  mountStatusbar,
@@ -84,6 +92,7 @@ import * as overlaysPanel from "./panels/overlays";
84
92
  import * as rightPanelMod from "./panels/right-panel";
85
93
  import * as leftPanelMod from "./panels/left-panel";
86
94
  import * as tabStrip from "./panels/tab-strip";
95
+ import * as tabBar from "./panels/tab-bar";
87
96
  import { renderStylebookOverlays } from "./panels/stylebook-panel";
88
97
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd";
89
98
  import { defaultDef } from "./panels/shared";
@@ -98,7 +107,7 @@ import { initWelcome } from "./panels/welcome-screen";
98
107
  import { openNewProjectModal } from "./new-project/new-project-modal";
99
108
  import type { DocumentStackEntry, GitDiffState } from "./types";
100
109
  import type { JxPath } from "./state";
101
- import type { JxMutableNode } from "@jxsuite/schema/types";
110
+ import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
102
111
 
103
112
  void _swc;
104
113
 
@@ -133,7 +142,7 @@ async function navigateToComponent(componentPath: string) {
133
142
  if (!content) {
134
143
  return;
135
144
  }
136
- const parsed = JSON.parse(content);
145
+ const parsed = JSON.parse(content) as JxMutableNode;
137
146
  const tab = activeTab.value;
138
147
  if (!tab) {
139
148
  return;
@@ -289,7 +298,7 @@ requestIdleCallback(() => {
289
298
  const frag = document.createDocumentFragment();
290
299
  for (const [name] of webdata.cssProps) {
291
300
  const opt = document.createElement("option");
292
- opt.value = name;
301
+ opt.value = name!;
293
302
  frag.append(opt);
294
303
  }
295
304
  dl.append(frag);
@@ -316,11 +325,8 @@ initShellRefs();
316
325
  toolbarPanel.mount(toolbarEl, {
317
326
  closeFunctionEditor: () => closeFunctionEditor(),
318
327
  getCanvasMode,
319
- navigateBack: () => navigateBack(),
320
- navigateToLevel: (i: number) => navigateToLevel(i),
321
328
  openProject: () => openProject(),
322
329
  openRecentProject: (root: string) => openRecentProject(root),
323
- parseMediaEntries,
324
330
  renderCanvas: () => renderCanvas(),
325
331
  safeRenderRightPanel: () => safeRenderRightPanel(),
326
332
  saveFile: () => saveFile(),
@@ -332,6 +338,15 @@ initQuickSearch();
332
338
 
333
339
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
334
340
 
341
+ tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
342
+ closeFunctionEditor: () => closeFunctionEditor(),
343
+ exportFile,
344
+ getCanvasMode,
345
+ navigateBack: () => navigateBack(),
346
+ navigateToLevel: (i: number) => navigateToLevel(i),
347
+ parseMediaEntries,
348
+ });
349
+
335
350
  overlaysPanel.mount({
336
351
  getCanvasMode,
337
352
  isEditing,
@@ -366,9 +381,16 @@ initPanelEvents({
366
381
  initCanvasLiveRender({
367
382
  getCanvasMode,
368
383
  });
384
+ initCanvasPatcher({
385
+ applyCanvasMediaOverrides,
386
+ enterComponentInlineEdit,
387
+ getCanvasMode,
388
+ registerSubtreeDnD,
389
+ renderOverlays,
390
+ scheduleCanvasRender,
391
+ updateForcedPseudoPreview,
392
+ });
369
393
  initCanvasRender({
370
- closeFunctionEditor: () => closeFunctionEditor(),
371
- exportFile,
372
394
  getCanvasMode,
373
395
  get gitDiffState() {
374
396
  return gitDiffState;
@@ -385,20 +407,32 @@ initWelcome({
385
407
  openNewProject: async () => {
386
408
  const result = await openNewProjectModal();
387
409
  if (result) {
388
- openRecentProject(result.root);
410
+ void openRecentProject(result.root);
389
411
  }
390
412
  },
391
413
  openProject: () => openProject(),
392
414
  openRecentProject: (root: string) => openRecentProject(root),
393
415
  });
394
416
 
395
- // Effect-driven canvas rendering: auto-triggers renderCanvas when reactive deps change.
396
- // Uses double-RAF so the canvas render yields to higher-priority panel paints first.
397
- let _canvasRafId = 0;
417
+ // Effect-driven canvas rendering, split into two triggers so document changes can be
418
+ // Distinguished from mode/UI changes:
419
+ // - doc-effect: tracks only the document root reference. Document mutations that were
420
+ // Consumed surgically by the canvas patcher skip the full render here.
421
+ // - ui-effect: tracks canvas mode and UI flags; always schedules a full render.
422
+ // Scheduling is deduped inside scheduleCanvasRender (double-RAF).
423
+ effect(() => {
424
+ const tab = activeTab.value;
425
+ if (tab) {
426
+ const doc = tab.doc.document;
427
+ if (doc && consumePatchedDocument(doc)) {
428
+ return;
429
+ }
430
+ }
431
+ scheduleCanvasRender();
432
+ });
398
433
  effect(() => {
399
434
  const tab = activeTab.value;
400
435
  if (tab) {
401
- void tab.doc.document;
402
436
  void tab.doc.mode;
403
437
  void tab.session.ui.canvasMode;
404
438
  void tab.session.ui.editingFunction;
@@ -408,18 +442,7 @@ effect(() => {
408
442
  void tab.session.ui.stylebookFilter;
409
443
  void tab.session.ui.stylebookCustomizedOnly;
410
444
  }
411
- if (!_canvasRafId) {
412
- _canvasRafId = requestAnimationFrame(() => {
413
- _canvasRafId = requestAnimationFrame(() => {
414
- _canvasRafId = 0;
415
- try {
416
- renderCanvas();
417
- } catch (error) {
418
- console.error("renderCanvas error:", error);
419
- }
420
- });
421
- });
422
- }
445
+ scheduleCanvasRender();
423
446
  });
424
447
 
425
448
  rightPanelMod.mount({
@@ -499,7 +522,7 @@ if (_projectParam) {
499
522
  render();
500
523
  const platform = getPlatform();
501
524
  // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: project probing must not block the initial render
502
- (async () => {
525
+ void (async () => {
503
526
  try {
504
527
  const siteCtx = platform.resolveSiteContext
505
528
  ? await platform.resolveSiteContext(_projectParam)
@@ -592,14 +615,14 @@ if (_projectParam) {
592
615
  ({ frontmatter } = result);
593
616
  parsedMode = result.mode;
594
617
  } else {
595
- parsedDoc = JSON.parse(content);
618
+ parsedDoc = JSON.parse(content) as JxMutableNode;
596
619
  }
597
620
 
598
621
  // Open in a tab
599
622
  openTab({
600
623
  id: fileRelPath,
601
624
  documentPath: fileRelPath,
602
- document: parsedDoc,
625
+ document: parsedDoc as JxMutableNode,
603
626
  ...(frontmatter != null && { frontmatter }),
604
627
  sourceFormat: fileFormat?.name ?? null,
605
628
  });
@@ -621,7 +644,7 @@ if (_projectParam) {
621
644
  }
622
645
  } else {
623
646
  // Normal mode: probe for project at server root
624
- loadProject();
647
+ void loadProject();
625
648
  render();
626
649
  }
627
650
 
@@ -643,9 +666,26 @@ function openProject() {
643
666
  async function openRecentProject(root: string) {
644
667
  try {
645
668
  const platform = getPlatform();
669
+
670
+ // Multi-window (desktop): if this window already holds a project, open the chosen one in a new
671
+ // Window (focusing an existing window if it's already open) rather than replacing this project.
672
+ if (projectState && platform.openProjectInNewWindow) {
673
+ await platform.openProjectInNewWindow(root);
674
+ return;
675
+ }
676
+
677
+ // Multi-window (desktop): bind THIS window's backend to the project before reading from it. If
678
+ // The project is already open in another window, that window is focused and we bail here.
679
+ if (platform.setWindowProject) {
680
+ const res = await platform.setWindowProject(root);
681
+ if (res.deduped) {
682
+ return;
683
+ }
684
+ }
685
+
646
686
  platform.projectRoot = root;
647
687
  const content = await platform.readFile("project.json");
648
- const config = JSON.parse(content);
688
+ const config = JSON.parse(content) as ProjectConfig;
649
689
 
650
690
  closeAllTabs();
651
691
 
@@ -654,7 +694,7 @@ async function openRecentProject(root: string) {
654
694
  dirs: new Map(),
655
695
  expanded: new Set(),
656
696
  isSiteProject: true,
657
- name: config.name || root.split("/").pop(),
697
+ name: config.name || root.split("/").pop()!,
658
698
  projectConfig: config,
659
699
  projectRoot: root,
660
700
  searchQuery: "",
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Patch ops — typed descriptors of document mutations, recorded by the mutators in transact.ts
3
+ * during a transaction. The canvas patcher consumes them to update the live canvas DOM surgically
4
+ * instead of re-rendering everything. Ops carry paths, not values: consumers read the post-mutation
5
+ * document as the single source of truth.
6
+ */
7
+
8
+ import type { JxPath } from "../state";
9
+ import type { Tab } from "./tab.js";
10
+
11
+ export type JxPatchOp =
12
+ /** The node's style changed (any depth: base prop, @media block, nested selector). */
13
+ | { op: "set-style"; path: JxPath }
14
+ /** The node's textContent changed. */
15
+ | { op: "set-text"; path: JxPath }
16
+ /** A non-style, non-text node property changed (key = property name). */
17
+ | { op: "set-prop"; path: JxPath; key: string; isEvent?: boolean }
18
+ /** The node's attributes[attr] changed. */
19
+ | { op: "set-attr"; path: JxPath; attr: string }
20
+ /** A node was inserted at parentPath.children[index]. */
21
+ | { op: "insert"; parentPath: JxPath; index: number }
22
+ /** The node at path was removed. */
23
+ | { op: "remove"; path: JxPath }
24
+ /** The node moved between/within children arrays (indices are post-mutation document truth). */
25
+ | { op: "move"; fromPath: JxPath; toParentPath: JxPath; toIndex: number }
26
+ /** The node at path was structurally replaced (wrap, tagName change, $props edit). */
27
+ | { op: "replace"; path: JxPath }
28
+ /** A document-level key changed (state/$media/$head/$elements/imports/$layout) — escalates. */
29
+ | { op: "doc-meta"; key: string };
30
+
31
+ /**
32
+ * Value-carrying document mutation, replayable in either direction. Mutators record a
33
+ * forward/inverse pair per change; history applies them for surgical undo/redo and for
34
+ * materializing states from checkpoints — without whole-document snapshots per edit.
35
+ */
36
+ export type JxDocOp =
37
+ /** Set (or delete, when value is undefined) a key on the node at path. */
38
+ | { op: "set-key"; path: JxPath; key: string; value?: unknown }
39
+ /** Insert a node at parentPath.children[index]. */
40
+ | { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
41
+ /** Remove parentPath.children[index]. */
42
+ | { op: "remove-child"; parentPath: JxPath; index: number }
43
+ /** Replace parentPath.children[index] with node. */
44
+ | { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
45
+ /** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
46
+ | {
47
+ op: "move-child";
48
+ fromParentPath: JxPath;
49
+ fromIndex: number;
50
+ toParentPath: JxPath;
51
+ toIndex: number;
52
+ };
53
+
54
+ export interface JxDocOpPair {
55
+ forward: JxDocOp;
56
+ inverse: JxDocOp;
57
+ }
58
+
59
+ /** Everything recorded during one transaction. */
60
+ export interface TransactionRecord {
61
+ /** Canvas patch ops (path-only) for surgical DOM updates. */
62
+ ops: JxPatchOp[];
63
+ /** Replayable forward/inverse document ops for patch-based history. */
64
+ docOps: JxDocOpPair[];
65
+ /**
66
+ * False when some mutation in the transaction could not produce a guaranteed-correct inverse
67
+ * (e.g. a move whose parents' paths interact) — history falls back to a checkpoint snapshot.
68
+ */
69
+ invertible: boolean;
70
+ }
71
+
72
+ /**
73
+ * The canvas-side consumer of patch batches. Registered by the canvas patcher at studio init;
74
+ * absent in headless tests, where every transaction falls back to the full-render path.
75
+ */
76
+ export interface PatchConsumer {
77
+ /** Decide whether this batch can be applied surgically in the current canvas state. */
78
+ classify: (tab: Tab, ops: JxPatchOp[]) => { patchable: boolean; reason: string };
79
+ /** Mark a document root reference as surgically consumed (checked by the canvas doc-effect). */
80
+ markConsumed: (docRef: object) => void;
81
+ /** Apply the batch to all ready canvas panels. Throws on any failure (caller escalates). */
82
+ apply: (tab: Tab, ops: JxPatchOp[]) => void;
83
+ /** Schedule a full canvas render as the fallback path, recording the reason. */
84
+ escalate: (reason: string) => void;
85
+ }
86
+
87
+ let _consumer: PatchConsumer | null = null;
88
+
89
+ export function setPatchConsumer(consumer: PatchConsumer | null) {
90
+ _consumer = consumer;
91
+ }
92
+
93
+ export function getPatchConsumer(): PatchConsumer | null {
94
+ return _consumer;
95
+ }
96
+
97
+ // ─── Recording ───────────────────────────────────────────────────────────────
98
+
99
+ let _recording: JxPatchOp[] | null = null;
100
+ let _docOps: JxDocOpPair[] | null = null;
101
+ let _invertible = true;
102
+
103
+ /** Begin recording patch ops for a transaction. */
104
+ export function beginRecording() {
105
+ _recording = [];
106
+ _docOps = [];
107
+ _invertible = true;
108
+ }
109
+
110
+ /** Record a patch op describing an in-place mutation. No-op outside a transaction. */
111
+ export function recordPatch(op: JxPatchOp) {
112
+ if (_recording) {
113
+ _recording.push(op);
114
+ }
115
+ }
116
+
117
+ /** Record a replayable forward/inverse document-op pair. No-op outside a transaction. */
118
+ export function recordDocOp(pair: JxDocOpPair) {
119
+ if (_docOps) {
120
+ _docOps.push(pair);
121
+ }
122
+ }
123
+
124
+ /** Mark the current transaction as non-invertible — history stores a checkpoint instead. */
125
+ export function markNonInvertible() {
126
+ _invertible = false;
127
+ }
128
+
129
+ /**
130
+ * End recording and return everything recorded. An empty ops array means the mutation ran without
131
+ * instrumentation (unknown change shape) — callers must treat that as not patchable.
132
+ */
133
+ export function endRecording(): TransactionRecord {
134
+ const record: TransactionRecord = {
135
+ docOps: _docOps ?? [],
136
+ invertible: _invertible,
137
+ ops: _recording ?? [],
138
+ };
139
+ _recording = null;
140
+ _docOps = null;
141
+ _invertible = true;
142
+ return record;
143
+ }
package/src/tabs/tab.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference lib="dom" />
2
2
  import { effectScope, reactive } from "../reactivity";
3
3
  import { formatByName, formatForPath } from "../format/format-host";
4
+ import { normalizeArrayChildren } from "../state";
4
5
  import type {
5
6
  DocumentStackEntry,
6
7
  FunctionEditDef,
@@ -10,6 +11,7 @@ import type {
10
11
  InlineEditDef,
11
12
  } from "../types";
12
13
  import type { JxMutableNode } from "@jxsuite/schema/types";
14
+ import type { JxDocOp } from "./patch-ops";
13
15
 
14
16
  export interface TabUi {
15
17
  rightTab: string;
@@ -39,8 +41,16 @@ export interface TabUi {
39
41
  }
40
42
 
41
43
  interface HistorySnapshot {
42
- document: Record<string, unknown>;
44
+ /** Full document at this state (checkpoint), or null when the ops describe the transition. */
45
+ document: Record<string, unknown> | null;
46
+ /** Selection after this state's transaction. */
43
47
  selection: (string | number)[] | null;
48
+ /** Selection before this state's transaction (restored on surgical undo). */
49
+ selectionBefore?: (string | number)[] | null;
50
+ /** Replayable ops transforming the previous state into this one. */
51
+ forwardOps?: JxDocOp[] | null;
52
+ /** Replayable ops transforming this state back into the previous one. */
53
+ inverseOps?: JxDocOp[] | null;
44
54
  }
45
55
 
46
56
  export interface Tab {
@@ -145,6 +155,10 @@ export function createTab({
145
155
  }) {
146
156
  const scope = effectScope();
147
157
 
158
+ // Normalize legacy whole-children repeaters to the canonical array-member form before the doc
159
+ // (and its first history checkpoint) are stored.
160
+ normalizeArrayChildren(document);
161
+
148
162
  const resolvedModes = capabilities?.modes ?? inferModes(documentPath, sourceFormat);
149
163
 
150
164
  const tab = scope.run(() => ({