@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
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,
@@ -51,8 +59,10 @@ import {
51
59
  openFileInTab,
52
60
  openHomePage,
53
61
  registerFileTreeDnD,
62
+ reloadCleanTab,
54
63
  setupTreeKeyboard,
55
64
  } from "./files/files";
65
+ import { startFsSync } from "./files/fs-events";
56
66
  import { renderImportsTemplate } from "./panels/imports-panel";
57
67
  import { renderHeadTemplate } from "./panels/head-panel";
58
68
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
@@ -84,6 +94,7 @@ import * as overlaysPanel from "./panels/overlays";
84
94
  import * as rightPanelMod from "./panels/right-panel";
85
95
  import * as leftPanelMod from "./panels/left-panel";
86
96
  import * as tabStrip from "./panels/tab-strip";
97
+ import * as tabBar from "./panels/tab-bar";
87
98
  import { renderStylebookOverlays } from "./panels/stylebook-panel";
88
99
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd";
89
100
  import { defaultDef } from "./panels/shared";
@@ -98,7 +109,7 @@ import { initWelcome } from "./panels/welcome-screen";
98
109
  import { openNewProjectModal } from "./new-project/new-project-modal";
99
110
  import type { DocumentStackEntry, GitDiffState } from "./types";
100
111
  import type { JxPath } from "./state";
101
- import type { JxMutableNode } from "@jxsuite/schema/types";
112
+ import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
102
113
 
103
114
  void _swc;
104
115
 
@@ -133,7 +144,7 @@ async function navigateToComponent(componentPath: string) {
133
144
  if (!content) {
134
145
  return;
135
146
  }
136
- const parsed = JSON.parse(content);
147
+ const parsed = JSON.parse(content) as JxMutableNode;
137
148
  const tab = activeTab.value;
138
149
  if (!tab) {
139
150
  return;
@@ -289,7 +300,7 @@ requestIdleCallback(() => {
289
300
  const frag = document.createDocumentFragment();
290
301
  for (const [name] of webdata.cssProps) {
291
302
  const opt = document.createElement("option");
292
- opt.value = name;
303
+ opt.value = name!;
293
304
  frag.append(opt);
294
305
  }
295
306
  dl.append(frag);
@@ -316,11 +327,8 @@ initShellRefs();
316
327
  toolbarPanel.mount(toolbarEl, {
317
328
  closeFunctionEditor: () => closeFunctionEditor(),
318
329
  getCanvasMode,
319
- navigateBack: () => navigateBack(),
320
- navigateToLevel: (i: number) => navigateToLevel(i),
321
330
  openProject: () => openProject(),
322
331
  openRecentProject: (root: string) => openRecentProject(root),
323
- parseMediaEntries,
324
332
  renderCanvas: () => renderCanvas(),
325
333
  safeRenderRightPanel: () => safeRenderRightPanel(),
326
334
  saveFile: () => saveFile(),
@@ -332,6 +340,15 @@ initQuickSearch();
332
340
 
333
341
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
334
342
 
343
+ tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
344
+ closeFunctionEditor: () => closeFunctionEditor(),
345
+ exportFile,
346
+ getCanvasMode,
347
+ navigateBack: () => navigateBack(),
348
+ navigateToLevel: (i: number) => navigateToLevel(i),
349
+ parseMediaEntries,
350
+ });
351
+
335
352
  overlaysPanel.mount({
336
353
  getCanvasMode,
337
354
  isEditing,
@@ -366,9 +383,16 @@ initPanelEvents({
366
383
  initCanvasLiveRender({
367
384
  getCanvasMode,
368
385
  });
386
+ initCanvasPatcher({
387
+ applyCanvasMediaOverrides,
388
+ enterComponentInlineEdit,
389
+ getCanvasMode,
390
+ registerSubtreeDnD,
391
+ renderOverlays,
392
+ scheduleCanvasRender,
393
+ updateForcedPseudoPreview,
394
+ });
369
395
  initCanvasRender({
370
- closeFunctionEditor: () => closeFunctionEditor(),
371
- exportFile,
372
396
  getCanvasMode,
373
397
  get gitDiffState() {
374
398
  return gitDiffState;
@@ -385,20 +409,32 @@ initWelcome({
385
409
  openNewProject: async () => {
386
410
  const result = await openNewProjectModal();
387
411
  if (result) {
388
- openRecentProject(result.root);
412
+ void openRecentProject(result.root);
389
413
  }
390
414
  },
391
415
  openProject: () => openProject(),
392
416
  openRecentProject: (root: string) => openRecentProject(root),
393
417
  });
394
418
 
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;
419
+ // Effect-driven canvas rendering, split into two triggers so document changes can be
420
+ // Distinguished from mode/UI changes:
421
+ // - doc-effect: tracks only the document root reference. Document mutations that were
422
+ // Consumed surgically by the canvas patcher skip the full render here.
423
+ // - ui-effect: tracks canvas mode and UI flags; always schedules a full render.
424
+ // Scheduling is deduped inside scheduleCanvasRender (double-RAF).
425
+ effect(() => {
426
+ const tab = activeTab.value;
427
+ if (tab) {
428
+ const doc = tab.doc.document;
429
+ if (doc && consumePatchedDocument(doc)) {
430
+ return;
431
+ }
432
+ }
433
+ scheduleCanvasRender();
434
+ });
398
435
  effect(() => {
399
436
  const tab = activeTab.value;
400
437
  if (tab) {
401
- void tab.doc.document;
402
438
  void tab.doc.mode;
403
439
  void tab.session.ui.canvasMode;
404
440
  void tab.session.ui.editingFunction;
@@ -408,18 +444,7 @@ effect(() => {
408
444
  void tab.session.ui.stylebookFilter;
409
445
  void tab.session.ui.stylebookCustomizedOnly;
410
446
  }
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
- }
447
+ scheduleCanvasRender();
423
448
  });
424
449
 
425
450
  rightPanelMod.mount({
@@ -483,6 +508,13 @@ function safeRenderRightPanel() {
483
508
  // Now that renderers are registered, bootstrap
484
509
  registerFunctionCompletions();
485
510
 
511
+ let fsUnsub: (() => void) | null = null;
512
+ /** (Re)subscribe the sidebar to backend filesystem events for the active project. */
513
+ function ensureFsSync() {
514
+ fsUnsub?.();
515
+ fsUnsub = startFsSync({ onContentChange: reloadCleanTab, renderLeftPanel });
516
+ }
517
+
486
518
  const _urlParams = new URLSearchParams(location.search);
487
519
  const _projectParam = _urlParams.get("project") || _urlParams.get("open");
488
520
 
@@ -499,7 +531,7 @@ if (_projectParam) {
499
531
  render();
500
532
  const platform = getPlatform();
501
533
  // oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: project probing must not block the initial render
502
- (async () => {
534
+ void (async () => {
503
535
  try {
504
536
  const siteCtx = platform.resolveSiteContext
505
537
  ? await platform.resolveSiteContext(_projectParam)
@@ -592,14 +624,14 @@ if (_projectParam) {
592
624
  ({ frontmatter } = result);
593
625
  parsedMode = result.mode;
594
626
  } else {
595
- parsedDoc = JSON.parse(content);
627
+ parsedDoc = JSON.parse(content) as JxMutableNode;
596
628
  }
597
629
 
598
630
  // Open in a tab
599
631
  openTab({
600
632
  id: fileRelPath,
601
633
  documentPath: fileRelPath,
602
- document: parsedDoc,
634
+ document: parsedDoc as JxMutableNode,
603
635
  ...(frontmatter != null && { frontmatter }),
604
636
  sourceFormat: fileFormat?.name ?? null,
605
637
  });
@@ -621,8 +653,9 @@ if (_projectParam) {
621
653
  }
622
654
  } else {
623
655
  // Normal mode: probe for project at server root
624
- loadProject();
656
+ void loadProject();
625
657
  render();
658
+ ensureFsSync();
626
659
  }
627
660
 
628
661
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
@@ -634,18 +667,37 @@ function renderLeftPanel() {
634
667
  function loadProject() {
635
668
  return _loadProject();
636
669
  }
637
- function openProject() {
638
- return _openProject({
670
+ async function openProject() {
671
+ const result = await _openProject({
639
672
  renderActivityBar: () => renderActivityBar(),
640
673
  renderLeftPanel,
641
674
  });
675
+ ensureFsSync();
676
+ return result;
642
677
  }
643
678
  async function openRecentProject(root: string) {
644
679
  try {
645
680
  const platform = getPlatform();
681
+
682
+ // Multi-window (desktop): if this window already holds a project, open the chosen one in a new
683
+ // Window (focusing an existing window if it's already open) rather than replacing this project.
684
+ if (projectState && platform.openProjectInNewWindow) {
685
+ await platform.openProjectInNewWindow(root);
686
+ return;
687
+ }
688
+
689
+ // Multi-window (desktop): bind THIS window's backend to the project before reading from it. If
690
+ // The project is already open in another window, that window is focused and we bail here.
691
+ if (platform.setWindowProject) {
692
+ const res = await platform.setWindowProject(root);
693
+ if (res.deduped) {
694
+ return;
695
+ }
696
+ }
697
+
646
698
  platform.projectRoot = root;
647
699
  const content = await platform.readFile("project.json");
648
- const config = JSON.parse(content);
700
+ const config = JSON.parse(content) as ProjectConfig;
649
701
 
650
702
  closeAllTabs();
651
703
 
@@ -654,7 +706,7 @@ async function openRecentProject(root: string) {
654
706
  dirs: new Map(),
655
707
  expanded: new Set(),
656
708
  isSiteProject: true,
657
- name: config.name || root.split("/").pop(),
709
+ name: config.name || root.split("/").pop()!,
658
710
  projectConfig: config,
659
711
  projectRoot: root,
660
712
  searchQuery: "",
@@ -688,6 +740,7 @@ async function openRecentProject(root: string) {
688
740
  statusMessage(`Opened project: ${requireProjectState().name}`);
689
741
 
690
742
  await openHomePage();
743
+ ensureFsSync();
691
744
  } catch (error) {
692
745
  statusMessage(`Error: ${errorMessage(error)}`);
693
746
  }
@@ -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(() => ({