@jxsuite/studio 1.0.0 → 1.2.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 (89) hide show
  1. package/dist/iframe-entry.js +760 -65
  2. package/dist/iframe-entry.js.map +16 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +71573 -50074
  5. package/dist/studio.js.map +123 -88
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +37 -1
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +193 -6
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +56 -3
  15. package/src/canvas/iframe-render.ts +53 -14
  16. package/src/canvas/site-style-css.ts +121 -0
  17. package/src/collab/collab-session.ts +23 -8
  18. package/src/collab/collab-state.ts +6 -3
  19. package/src/component-props.ts +104 -0
  20. package/src/editor/inline-edit-apply.ts +36 -1
  21. package/src/editor/inline-edit.ts +58 -1
  22. package/src/editor/shortcuts.ts +41 -12
  23. package/src/files/file-ops.ts +14 -0
  24. package/src/files/files.ts +53 -1
  25. package/src/grid/cell-editors.ts +288 -0
  26. package/src/grid/cell-popovers.ts +108 -0
  27. package/src/grid/csv-codec.ts +122 -0
  28. package/src/grid/edit-buffer.ts +490 -0
  29. package/src/grid/grid-controller.ts +417 -0
  30. package/src/grid/grid-layout.ts +83 -0
  31. package/src/grid/grid-open.ts +167 -0
  32. package/src/grid/grid-panel.ts +302 -0
  33. package/src/grid/grid-source.ts +210 -0
  34. package/src/grid/grid-view.ts +367 -0
  35. package/src/grid/schema-columns.ts +261 -0
  36. package/src/grid/sources/connector-source.ts +217 -0
  37. package/src/grid/sources/content-source.ts +542 -0
  38. package/src/grid/sources/csv-file-source.ts +247 -0
  39. package/src/grid/tabulator-tables.d.ts +120 -0
  40. package/src/panels/block-action-bar.ts +8 -3
  41. package/src/panels/chat-panel.ts +98 -0
  42. package/src/panels/data-grid.ts +9 -387
  43. package/src/panels/editors.ts +43 -17
  44. package/src/panels/events-panel.ts +137 -44
  45. package/src/panels/formula-workspace.ts +379 -0
  46. package/src/panels/frontmatter-fields.ts +231 -0
  47. package/src/panels/frontmatter-panel.ts +132 -0
  48. package/src/panels/git-panel.ts +1 -1
  49. package/src/panels/head-panel.ts +11 -175
  50. package/src/panels/properties-panel.ts +154 -144
  51. package/src/panels/right-panel.ts +9 -47
  52. package/src/panels/signals-panel.ts +115 -23
  53. package/src/panels/statement-editor.ts +710 -0
  54. package/src/panels/style-panel.ts +76 -22
  55. package/src/panels/stylebook-panel.ts +0 -2
  56. package/src/panels/tab-bar.ts +214 -10
  57. package/src/panels/tab-strip.ts +62 -6
  58. package/src/panels/toolbar.ts +37 -3
  59. package/src/services/ai-project-tools.ts +541 -0
  60. package/src/services/ai-session-store.ts +28 -0
  61. package/src/services/ai-system-prompt.ts +194 -24
  62. package/src/services/ai-tools.ts +88 -21
  63. package/src/services/automation.ts +16 -0
  64. package/src/services/document-assistant.ts +81 -11
  65. package/src/services/gated-registry.ts +72 -0
  66. package/src/services/live-preview.ts +0 -0
  67. package/src/services/preview-eval.ts +68 -0
  68. package/src/services/project-adoption.ts +31 -0
  69. package/src/settings/css-vars-editor.ts +122 -4
  70. package/src/site-context.ts +2 -0
  71. package/src/store.ts +4 -0
  72. package/src/studio.ts +90 -52
  73. package/src/tabs/patch-ops.ts +25 -0
  74. package/src/tabs/tab.ts +46 -1
  75. package/src/tabs/transact.ts +60 -13
  76. package/src/types.ts +12 -0
  77. package/src/ui/dynamic-slot.ts +272 -0
  78. package/src/ui/expression-editor.ts +423 -125
  79. package/src/ui/field-row.ts +5 -0
  80. package/src/ui/formula-catalog.ts +557 -0
  81. package/src/ui/formula-chips.ts +216 -0
  82. package/src/ui/formula-palette.ts +211 -0
  83. package/src/ui/layers.ts +40 -0
  84. package/src/ui/media-picker.ts +1 -1
  85. package/src/ui/panel-resize.ts +15 -1
  86. package/src/utils/canvas-media.ts +23 -0
  87. package/src/utils/preview-format.ts +26 -0
  88. package/src/view.ts +4 -4
  89. package/src/workspace/workspace.ts +14 -0
package/src/studio.ts CHANGED
@@ -11,6 +11,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
11
11
 
12
12
  import {
13
13
  canvasWrap,
14
+ chatPanelEl,
14
15
  getNodeAtPath,
15
16
  initShellRefs,
16
17
  projectState,
@@ -23,14 +24,20 @@ import {
23
24
  updateUi,
24
25
  } from "./store";
25
26
 
26
- import { activeTab, closeAllTabs, openTab } from "./workspace/workspace";
27
+ import {
28
+ activeTab,
29
+ closeAllTabs,
30
+ openTab,
31
+ setWorkspaceProject,
32
+ workspace,
33
+ } from "./workspace/workspace";
27
34
  import { mutateUpdateDef, mutateUpdateProperty, transactDoc } from "./tabs/transact";
28
35
  import { effect } from "./reactivity";
29
36
 
30
37
  import { view } from "./view";
31
38
 
32
39
  import { isEditing } from "./editor/inline-edit";
33
- import { applyTransform, initCanvasUtils, positionZoomIndicator } from "./canvas/canvas-utils";
40
+ import { applyTransform, initCanvasUtils } from "./canvas/canvas-utils";
34
41
  import {
35
42
  initCanvasRender,
36
43
  renderCanvas,
@@ -41,6 +48,7 @@ import { consumePatchedDocument, initCanvasPatcher } from "./canvas/canvas-patch
41
48
  import {
42
49
  commitActiveEditSession,
43
50
  getEditSnapshot,
51
+ postColorSchemeToLiveHosts,
44
52
  setCanvasContextMenuHandler,
45
53
  setCanvasSlashHandler,
46
54
  setIframePatchEscalation,
@@ -113,12 +121,15 @@ import { components as _swc } from "./ui/spectrum";
113
121
  import "./ui/panel-resize.js";
114
122
  // Built-in schema-form controls (schema-builder, secret) register on import
115
123
  import "./ui/form-controls.js";
116
- import { initLayers } from "./ui/layers";
124
+ import { initLayers, showSaveDiscardDialog } from "./ui/layers";
117
125
  import { initShortcuts } from "./editor/shortcuts";
118
126
  import { renderActivityBar, mount as mountActivityBar } from "./panels/activity-bar";
119
127
  import * as toolbarPanel from "./panels/toolbar";
120
128
  import * as overlaysPanel from "./panels/overlays";
129
+ import * as frontmatterPanelMod from "./panels/frontmatter-panel";
121
130
  import * as rightPanelMod from "./panels/right-panel";
131
+ import * as chatPanelMod from "./panels/chat-panel";
132
+ import { setProjectAdopter } from "./services/project-adoption";
122
133
  import * as leftPanelMod from "./panels/left-panel";
123
134
  import * as tabStrip from "./panels/tab-strip";
124
135
  import * as tabBar from "./panels/tab-bar";
@@ -127,6 +138,7 @@ import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from ".
127
138
  import { registerCanvasDndBridge } from "./panels/canvas-dnd-bridge";
128
139
  import { defaultDef } from "./panels/shared";
129
140
  import { registerFunctionCompletions } from "./panels/editors";
141
+ import { closeFormulaWorkspace } from "./panels/formula-workspace";
130
142
  import {
131
143
  initBlockActionBar,
132
144
  isEditChromeTarget,
@@ -142,6 +154,7 @@ import { initWelcome } from "./panels/welcome-screen";
142
154
  import { openAddRepoModal } from "./new-project/add-repo-modal";
143
155
  import { openNewProjectModal } from "./new-project/new-project-modal";
144
156
  import type { DocumentStackEntry, GitDiffState } from "./types";
157
+ import type { Tab } from "./tabs/tab";
145
158
  import type { JxPath } from "./state";
146
159
  import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
147
160
 
@@ -223,19 +236,41 @@ async function navigateToComponent(componentPath: string) {
223
236
  }
224
237
  }
225
238
 
239
+ /**
240
+ * Leaving a drilled-in component discards its edits (the pop restores the parent). Because saving
241
+ * is explicit, prompt when the child is dirty: Save writes it, Discard drops it, Cancel stays.
242
+ * Returns false to abort navigation.
243
+ *
244
+ * @param {Tab} tab
245
+ */
246
+ async function confirmLeaveDirtyChild(tab: Tab): Promise<boolean> {
247
+ if (!tab.doc.dirty || !tab.documentPath) {
248
+ return true;
249
+ }
250
+ const name = tab.documentPath.split("/").pop() || "component";
251
+ const choice = await showSaveDiscardDialog("Unsaved Changes", `"${name}" has unsaved changes.`);
252
+ if (choice === "cancel") {
253
+ return false;
254
+ }
255
+ if (choice === "save") {
256
+ try {
257
+ await getPlatform().writeFile(tab.documentPath, await serializeDocument(tab));
258
+ } catch (error) {
259
+ statusMessage(`Save error: ${(error as Error).message}`);
260
+ return false;
261
+ }
262
+ }
263
+ // "discard": leave without writing — the child's edits are dropped with the popped frame.
264
+ return true;
265
+ }
266
+
226
267
  async function navigateBack() {
227
268
  const tab = activeTab.value;
228
269
  if (!tab?.session.documentStack || tab.session.documentStack.length === 0) {
229
270
  return;
230
271
  }
231
- if (tab.doc.dirty && tab.documentPath) {
232
- try {
233
- const platform = getPlatform();
234
- await platform.writeFile(tab.documentPath, await serializeDocument(tab));
235
- } catch (error) {
236
- const err = error as Error;
237
- statusMessage(`Save error: ${err.message}`);
238
- }
272
+ if (!(await confirmLeaveDirtyChild(tab))) {
273
+ return;
239
274
  }
240
275
 
241
276
  // Pop the stack
@@ -262,14 +297,8 @@ async function navigateToLevel(targetIndex: number) {
262
297
  if (!stack || targetIndex < 0 || targetIndex >= stack.length) {
263
298
  return;
264
299
  }
265
- if (tab.doc.dirty && tab.documentPath) {
266
- try {
267
- const platform = getPlatform();
268
- await platform.writeFile(tab.documentPath, await serializeDocument(tab));
269
- } catch (error) {
270
- const err = error as Error;
271
- statusMessage(`Save error: ${err.message}`);
272
- }
300
+ if (!(await confirmLeaveDirtyChild(tab))) {
301
+ return;
273
302
  }
274
303
 
275
304
  const frame = stack[targetIndex] as DocumentStackEntry;
@@ -393,6 +422,7 @@ initQuickSearch({ openRecentProject: (root: string) => openRecentProject(root) }
393
422
  tabStrip.mount(document.querySelector("#tab-strip") as HTMLElement);
394
423
 
395
424
  tabBar.mount(document.querySelector("#tab-bar") as HTMLElement, {
425
+ closeFormulaWorkspace: () => closeFormulaWorkspace(),
396
426
  closeFunctionEditor: () => closeFunctionEditor(),
397
427
  exportFile,
398
428
  getCanvasMode,
@@ -442,6 +472,26 @@ document.addEventListener(
442
472
  true,
443
473
  );
444
474
 
475
+ // Unsaved-changes guard: saving is explicit (no idle autosave), so warn before the window unloads
476
+ // While any open tab has unsaved edits. For collab tabs, `dirty` reflects the room-level unsaved
477
+ // State; closing loses in-memory edits that were never flushed to disk.
478
+ export function hasUnsavedTabs(): boolean {
479
+ for (const tab of workspace.tabs.values()) {
480
+ if (tab.doc.dirty) {
481
+ return true;
482
+ }
483
+ }
484
+ return false;
485
+ }
486
+
487
+ window.addEventListener("beforeunload", (e: BeforeUnloadEvent) => {
488
+ if (hasUnsavedTabs()) {
489
+ e.preventDefault();
490
+ // Legacy browsers require a truthy returnValue to trigger the native confirm prompt.
491
+ e.returnValue = "";
492
+ }
493
+ });
494
+
445
495
  initCanvasUtils({
446
496
  getCanvasMode,
447
497
  getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
@@ -518,10 +568,12 @@ effect(() => {
518
568
  if (tab) {
519
569
  void tab.doc.mode;
520
570
  void tab.session.ui.canvasMode;
571
+ void tab.session.ui.editingFormula;
521
572
  void tab.session.ui.editingFunction;
522
573
  void tab.session.ui.featureToggles;
523
574
  void tab.session.ui.preview;
524
575
  void tab.session.ui.previewParams;
576
+ void tab.session.ui.previewProps;
525
577
  void tab.session.ui.settingsTab;
526
578
  void tab.session.ui.showLayout;
527
579
  void tab.session.ui.stylebookTab;
@@ -530,6 +582,12 @@ effect(() => {
530
582
  }
531
583
  scheduleCanvasRender();
532
584
  });
585
+ // Color-scheme preview is a document-level attribute flip inside the iframe — deliberately its
586
+ // Own effect, never part of the ui-effect above: flipping the scheme must not re-render.
587
+ effect(() => {
588
+ const s = activeTab.value?.session.ui.previewColorScheme;
589
+ postColorSchemeToLiveHosts(s === "light" || s === "dark" ? s : null);
590
+ });
533
591
 
534
592
  rightPanelMod.mount({
535
593
  getCanvasMode,
@@ -537,6 +595,15 @@ rightPanelMod.mount({
537
595
  renderCanvas: () => renderCanvas(),
538
596
  });
539
597
 
598
+ // Above-canvas frontmatter Properties panel (content-collection docs, edit mode).
599
+ frontmatterPanelMod.mount({ getCanvasMode });
600
+
601
+ // The persistent AI chat sidebar — mounts once, available with or without a project/document.
602
+ chatPanelMod.mount(chatPanelEl);
603
+ // The assistant's create_project tool adopts freshly scaffolded projects through the same
604
+ // Flow as the recent-projects list.
605
+ setProjectAdopter(openRecentProject);
606
+
540
607
  leftPanelMod.mount({
541
608
  cloneRepository: () => cloneRepository({ openRecentProject }),
542
609
  defBadgeLabel,
@@ -568,6 +635,8 @@ leftPanelMod.mount({
568
635
  registerRenderer("leftPanel", () => leftPanelMod.render());
569
636
  registerRenderer("canvas", () => renderCanvas());
570
637
  registerRenderer("rightPanel", () => rightPanelMod.render());
638
+ registerRenderer("frontmatterPanel", () => frontmatterPanelMod.render());
639
+ registerRenderer("chatPanel", () => chatPanelMod.render());
571
640
  registerRenderer("overlays", () => overlaysPanel.render());
572
641
  setStatusbarRenderer(() => renderStatusbar());
573
642
  mountStatusbar();
@@ -667,6 +736,7 @@ if (_projectParam) {
667
736
  searchQuery: "",
668
737
  selectedPath: siteCtx.fileRelPath || null,
669
738
  });
739
+ setWorkspaceProject(siteCtx.sitePath, siteCtx.projectConfig || null);
670
740
 
671
741
  refreshExtensionUi(platform);
672
742
  await autoSyncProjectOnOpen();
@@ -859,6 +929,7 @@ async function openRecentProject(root: string) {
859
929
  searchQuery: "",
860
930
  selectedPath: null,
861
931
  });
932
+ setWorkspaceProject(root, config);
862
933
 
863
934
  await autoSyncProjectOnOpen();
864
935
  await ensureDependenciesInstalled();
@@ -918,7 +989,6 @@ initShortcuts(() => ({
918
989
  openProject,
919
990
  panX: view.panX,
920
991
  panY: view.panY,
921
- positionZoomIndicator,
922
992
  saveFile,
923
993
  setPan: (x, y) => {
924
994
  view.panX = x;
@@ -926,35 +996,3 @@ initShortcuts(() => ({
926
996
  view.needsCenter = false;
927
997
  },
928
998
  }));
929
-
930
- // ─── Autosave (registered as update middleware) ──────────────────────────────
931
-
932
- const AUTO_SAVE_DELAY = 2000;
933
-
934
- function scheduleAutosave() {
935
- const tab = activeTab.value;
936
- if (!tab?.fileHandle || !tab.doc.dirty) {
937
- return;
938
- }
939
- if (view.autosaveTimer) {
940
- clearTimeout(view.autosaveTimer);
941
- }
942
- view.autosaveTimer = setTimeout(async () => {
943
- const t = activeTab.value;
944
- if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
945
- try {
946
- const writable = await t.fileHandle.createWritable();
947
- await writable.write(await serializeDocument(t));
948
- await writable.close();
949
- t.doc.dirty = false;
950
- statusMessage("Auto-saved");
951
- } catch {}
952
- }
953
- }, AUTO_SAVE_DELAY);
954
- }
955
-
956
- effect(() => {
957
- if (activeTab.value?.doc.dirty) {
958
- scheduleAutosave();
959
- }
960
- });
@@ -40,12 +40,26 @@ export type JxPatchOp =
40
40
  export type { JxDocOp } from "@jxsuite/collab/ops";
41
41
  export type { JxDocOpPair };
42
42
 
43
+ /**
44
+ * A frontmatter key change, replayable in either direction. Frontmatter lives outside the document
45
+ * tree (`tab.doc.content.frontmatter`, split off by the format host), so these are studio-local and
46
+ * never enter the shared `JxDocOp` vocabulary the collab bridge and canvas iframe mirror.
47
+ * `undefined` means the key was/becomes absent.
48
+ */
49
+ export interface JxFmOp {
50
+ field: string;
51
+ before?: unknown;
52
+ after?: unknown;
53
+ }
54
+
43
55
  /** Everything recorded during one transaction. */
44
56
  export interface TransactionRecord {
45
57
  /** Canvas patch ops (path-only) for surgical DOM updates. */
46
58
  ops: JxPatchOp[];
47
59
  /** Replayable forward/inverse document ops for patch-based history. */
48
60
  docOps: JxDocOpPair[];
61
+ /** Replayable frontmatter key changes (content-mode docs) for history undo/redo. */
62
+ fmOps: JxFmOp[];
49
63
  /**
50
64
  * False when some mutation in the transaction could not produce a guaranteed-correct inverse
51
65
  * (e.g. a move whose parents' paths interact) — history falls back to a checkpoint snapshot.
@@ -86,12 +100,14 @@ export function getPatchConsumer(): PatchConsumer | null {
86
100
 
87
101
  let _recording: JxPatchOp[] | null = null;
88
102
  let _docOps: JxDocOpPair[] | null = null;
103
+ let _fmOps: JxFmOp[] | null = null;
89
104
  let _invertible = true;
90
105
 
91
106
  /** Begin recording patch ops for a transaction. */
92
107
  export function beginRecording() {
93
108
  _recording = [];
94
109
  _docOps = [];
110
+ _fmOps = [];
95
111
  _invertible = true;
96
112
  }
97
113
 
@@ -109,6 +125,13 @@ export function recordDocOp(pair: JxDocOpPair) {
109
125
  }
110
126
  }
111
127
 
128
+ /** Record a replayable frontmatter key change. No-op outside a transaction. */
129
+ export function recordFmOp(op: JxFmOp) {
130
+ if (_fmOps) {
131
+ _fmOps.push(op);
132
+ }
133
+ }
134
+
112
135
  /** Mark the current transaction as non-invertible — history stores a checkpoint instead. */
113
136
  export function markNonInvertible() {
114
137
  _invertible = false;
@@ -121,11 +144,13 @@ export function markNonInvertible() {
121
144
  export function endRecording(): TransactionRecord {
122
145
  const record: TransactionRecord = {
123
146
  docOps: _docOps ?? [],
147
+ fmOps: _fmOps ?? [],
124
148
  invertible: _invertible,
125
149
  ops: _recording ?? [],
126
150
  };
127
151
  _recording = null;
128
152
  _docOps = null;
153
+ _fmOps = null;
129
154
  _invertible = true;
130
155
  return record;
131
156
  }
package/src/tabs/tab.ts CHANGED
@@ -4,14 +4,16 @@ import { formatByName, formatForPath } from "../format/format-host";
4
4
  import { normalizeArrayChildren } from "../state";
5
5
  import type {
6
6
  DocumentStackEntry,
7
+ FormulaEditDef,
7
8
  FunctionEditDef,
8
9
  GitBranchesResult,
9
10
  GitDiffState,
10
11
  GitStatusResult,
11
12
  InlineEditDef,
13
+ JsonValue,
12
14
  } from "../types";
13
15
  import type { JxMutableNode } from "@jxsuite/schema/types";
14
- import type { JxDocOp } from "./patch-ops";
16
+ import type { JxDocOp, JxFmOp } from "./patch-ops";
15
17
 
16
18
  export interface TabUi {
17
19
  rightTab: string;
@@ -20,13 +22,33 @@ export interface TabUi {
20
22
  preview: boolean;
21
23
  /** Show elements inherited from the page's layout (pages with an effective layout only). */
22
24
  showLayout: boolean;
25
+ /** Above-canvas frontmatter Properties accordion expanded (content-collection docs, edit mode). */
26
+ frontmatterOpen: boolean;
23
27
  /** Chosen literal values for dynamic route params (e.g. { sku: "mini-trencher" }). */
24
28
  previewParams: Record<string, string>;
29
+ /**
30
+ * Chosen test values for a component doc's props (state entries), seeded into the canvas render
31
+ * so a non-instantiated component previews with real data (M6) — the previewParams mirror.
32
+ */
33
+ previewProps: Record<string, JsonValue> | null;
25
34
  zoom: number;
35
+ /**
36
+ * Edit-mode content zoom — browser-page-zoom semantics (content reflows at the zoomed effective
37
+ * width while the canvas footprint stays fixed), unlike `zoom` which pan/zooms the whole canvas.
38
+ */
39
+ editZoom: number;
26
40
  activeMedia: string | null;
27
41
  activeSelector: string | null;
28
42
  editingFunction: FunctionEditDef | null;
43
+ /** Full-screen formula workspace target ($expression editing); editingFunction wins if both set. */
44
+ editingFormula: FormulaEditDef | null;
29
45
  featureToggles: Record<string, boolean>;
46
+ /**
47
+ * Canvas color-scheme preview: follow the OS ("auto") or force a scheme. Drives the
48
+ * data-color-scheme attribute on the canvas iframe root (spec §9.5) and, in the style sidebar,
49
+ * which scheme layer edits target.
50
+ */
51
+ previewColorScheme: "auto" | "light" | "dark";
30
52
  styleSections: Record<string, boolean>;
31
53
  inspectorSections: Record<string, boolean>;
32
54
  styleShorthands: Record<string, boolean>;
@@ -46,6 +68,18 @@ export interface TabUi {
46
68
  pendingInlineEdit: InlineEditDef | null;
47
69
  }
48
70
 
71
+ /**
72
+ * A live (iframe-evaluated) expression preview retained per editing target (M6). Stored on
73
+ * `session.canvas` beside `scope` — plain wire data (path-key → display-string pairs), rebuilt into
74
+ * an ExpressionPreview by services/live-preview.ts on read.
75
+ */
76
+ export interface StoredLivePreview {
77
+ /** Serialized expression + context the values were computed for (staleness check). */
78
+ key: string;
79
+ values: [string, string][];
80
+ error: string | null;
81
+ }
82
+
49
83
  interface HistorySnapshot {
50
84
  /** Full document at this state (checkpoint), or null when the ops describe the transition. */
51
85
  document: Record<string, unknown> | null;
@@ -57,6 +91,8 @@ interface HistorySnapshot {
57
91
  forwardOps?: JxDocOp[] | null;
58
92
  /** Replayable ops transforming this state back into the previous one. */
59
93
  inverseOps?: JxDocOp[] | null;
94
+ /** Frontmatter key changes in this transaction (before/after values, replayed both ways). */
95
+ fmOps?: JxFmOp[] | null;
60
96
  }
61
97
 
62
98
  export interface Tab {
@@ -89,6 +125,9 @@ export interface Tab {
89
125
  // The bridge as a `dataScope` message and read by the data-explorer panel. Plain data now —
90
126
  // The old live `EffectScope` (with `.stop()`) moved into the iframe realm with buildScope.
91
127
  scope: Record<string, unknown> | null;
128
+ // Latest live (iframe-evaluated) expression previews keyed by editing-target id, stored
129
+ // Beside `scope` the same way (services/live-preview.ts owns reads/writes).
130
+ livePreviews: Record<string, StoredLivePreview> | null;
92
131
  error: string | null;
93
132
  pendingInlineEdit: InlineEditDef | null;
94
133
  };
@@ -109,8 +148,11 @@ function createDefaultUi(canvasMode: string, preview = false) {
109
148
  activeMedia: null,
110
149
  activeSelector: null,
111
150
  canvasMode,
151
+ editZoom: 1,
152
+ editingFormula: null,
112
153
  editingFunction: null,
113
154
  featureToggles: {},
155
+ frontmatterOpen: true,
114
156
  gitBranches: null,
115
157
  gitCommitMessage: "",
116
158
  gitDiffState: null,
@@ -120,7 +162,9 @@ function createDefaultUi(canvasMode: string, preview = false) {
120
162
  inspectorSections: {},
121
163
  pendingInlineEdit: null,
122
164
  preview,
165
+ previewColorScheme: "auto" as const,
123
166
  previewParams: {},
167
+ previewProps: null,
124
168
  rightTab: "properties",
125
169
  settingsTab: "stylebook",
126
170
  showLayout: true,
@@ -204,6 +248,7 @@ export function createTab({
204
248
  session: reactive({
205
249
  canvas: {
206
250
  error: null,
251
+ livePreviews: null,
207
252
  pendingInlineEdit: null,
208
253
  scope: null,
209
254
  status: "idle",
@@ -8,16 +8,23 @@ import {
8
8
  endRecording,
9
9
  getPatchConsumer,
10
10
  recordDocOp,
11
+ recordFmOp,
11
12
  recordPatch,
12
13
  } from "./patch-ops";
13
14
 
14
- import type { JxDocOp, JxDocOpPair, TransactionRecord } from "./patch-ops";
15
+ import type { JxDocOp, JxDocOpPair, JxFmOp, TransactionRecord } from "./patch-ops";
15
16
 
16
17
  import type { Tab } from "../tabs/tab";
17
18
  import type { JxPath } from "../state";
18
19
 
19
20
  import type { JsonValue } from "../types";
20
- import type { JxEventBinding, JxMutableNode, JxStateObject, JxStyle } from "@jxsuite/schema/types";
21
+ import type {
22
+ JxAttributeValue,
23
+ JxEventBinding,
24
+ JxMutableNode,
25
+ JxStateObject,
26
+ JxStyle,
27
+ } from "@jxsuite/schema/types";
21
28
  import { ensureNestedStyle, getNestedStyle, isEventBinding } from "@jxsuite/schema/guards";
22
29
 
23
30
  const HISTORY_LIMIT = 100;
@@ -205,10 +212,14 @@ function pushHistoryEntry(
205
212
  selectionBefore: JxPath | null,
206
213
  ) {
207
214
  const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
208
- const useOps = patchHistoryEnabled() && record.invertible && record.docOps.length > 0;
215
+ const useOps =
216
+ patchHistoryEnabled() &&
217
+ record.invertible &&
218
+ (record.docOps.length > 0 || record.fmOps.length > 0);
209
219
  const needCheckpoint = !useOps || truncated.length % CHECKPOINT_INTERVAL === 0;
210
220
  truncated.push({
211
221
  document: needCheckpoint ? jsonClone(raw) : null,
222
+ fmOps: useOps && record.fmOps.length > 0 ? record.fmOps : null,
212
223
  forwardOps: useOps ? record.docOps.map((p) => p.forward) : null,
213
224
  inverseOps: useOps ? record.docOps.map((p) => p.inverse) : null,
214
225
  selection: tab.session.selection ? [...tab.session.selection] : null,
@@ -471,14 +482,23 @@ export function undo(tab: Tab) {
471
482
  }
472
483
  const entry = tab.history.snapshots[tab.history.index]!;
473
484
  const inverseOps = patchHistoryEnabled() ? entry.inverseOps : null;
474
- if (inverseOps && inverseOps.length > 0) {
485
+ const fmOps = patchHistoryEnabled() ? entry.fmOps : null;
486
+ if ((inverseOps && inverseOps.length > 0) || (fmOps && fmOps.length > 0)) {
475
487
  // Surgical path: apply inverse ops through the normal transaction pipeline (the canvas
476
488
  // Patches in place when it can), in reverse recording order.
477
489
  transactDoc(
478
490
  tab,
479
491
  (t) => {
480
- for (let i = inverseOps.length - 1; i >= 0; i--) {
481
- applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
492
+ if (inverseOps) {
493
+ for (let i = inverseOps.length - 1; i >= 0; i--) {
494
+ applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
495
+ }
496
+ }
497
+ if (fmOps) {
498
+ for (let i = fmOps.length - 1; i >= 0; i--) {
499
+ const op = toRaw(fmOps[i]) as JxFmOp;
500
+ applyFmState(t, op.field, op.before);
501
+ }
482
502
  }
483
503
  },
484
504
  { origin: "history", skipHistory: true },
@@ -505,12 +525,21 @@ export function redo(tab: Tab) {
505
525
  }
506
526
  const entry = tab.history.snapshots[tab.history.index + 1]!;
507
527
  const forwardOps = patchHistoryEnabled() ? entry.forwardOps : null;
508
- if (forwardOps && forwardOps.length > 0) {
528
+ const fmOps = patchHistoryEnabled() ? entry.fmOps : null;
529
+ if ((forwardOps && forwardOps.length > 0) || (fmOps && fmOps.length > 0)) {
509
530
  transactDoc(
510
531
  tab,
511
532
  (t) => {
512
- for (const op of forwardOps) {
513
- applyDocOp(t, toRaw(op) as JxDocOp);
533
+ if (forwardOps) {
534
+ for (const op of forwardOps) {
535
+ applyDocOp(t, toRaw(op) as JxDocOp);
536
+ }
537
+ }
538
+ if (fmOps) {
539
+ for (const op of fmOps) {
540
+ const raw = toRaw(op) as JxFmOp;
541
+ applyFmState(t, raw.field, raw.after);
542
+ }
514
543
  }
515
544
  },
516
545
  { origin: "history", skipHistory: true },
@@ -746,13 +775,13 @@ export function mutateUpdateStyle(tab: Tab, path: JxPath, prop: string, value: s
746
775
  * @param {Tab} tab
747
776
  * @param {JxPath} path
748
777
  * @param {string} attr
749
- * @param {string | undefined} value
778
+ * @param {JxAttributeValue | undefined} value — a literal, `$ref` binding, or `${}` template.
750
779
  */
751
780
  export function mutateUpdateAttribute(
752
781
  tab: Tab,
753
782
  path: JxPath,
754
783
  attr: string,
755
- value?: string | undefined,
784
+ value?: JxAttributeValue | undefined,
756
785
  ) {
757
786
  const node = getNodeAtPath(tab.doc.document, path);
758
787
  const attrsBefore = cloneValue(node.attributes);
@@ -1196,10 +1225,28 @@ export function mutateRenameSwitchCase(tab: Tab, path: JxPath, oldName: string,
1196
1225
  * @param {JsonValue} value
1197
1226
  */
1198
1227
  export function mutateUpdateFrontmatter(tab: Tab, field: string, value?: JsonValue) {
1199
- if (value === undefined || value === null || value === "") {
1228
+ const fm = tab.doc.content.frontmatter;
1229
+ const before = Object.hasOwn(fm, field) ? cloneValue(fm[field]) : undefined;
1230
+ const deletes = value === undefined || value === null || value === "";
1231
+ if (deletes) {
1232
+ delete fm[field];
1233
+ } else {
1234
+ fm[field] = value;
1235
+ }
1236
+ const after = deletes ? undefined : cloneValue(value);
1237
+ recordFmOp({ after, before, field });
1238
+ tab.doc.dirty = true;
1239
+ }
1240
+
1241
+ /**
1242
+ * Restore a frontmatter key to a recorded history state: `undefined` means the key was absent.
1243
+ * Unlike `mutateUpdateFrontmatter`, values like `""`/`false`/`null` are written verbatim (source
1244
+ * round-trips can put them there) and no fm op is recorded — history replay must not re-record.
1245
+ */
1246
+ function applyFmState(tab: Tab, field: string, value: unknown) {
1247
+ if (value === undefined) {
1200
1248
  delete tab.doc.content.frontmatter[field];
1201
1249
  } else {
1202
1250
  tab.doc.content.frontmatter[field] = value;
1203
1251
  }
1204
- tab.doc.dirty = true;
1205
1252
  }
package/src/types.ts CHANGED
@@ -427,6 +427,18 @@ export interface FunctionEditDef {
427
427
  parameters?: string[];
428
428
  }
429
429
 
430
+ /**
431
+ * Identifies which document position's `$expression` the full-screen formula workspace is editing:
432
+ * a state entry (`type: "def"` + defName) or an element event binding (`type: "event"` + path +
433
+ * eventKey). Mirrors FunctionEditDef, the Monaco function editor's target shape.
434
+ */
435
+ export interface FormulaEditDef {
436
+ type: "def" | "event";
437
+ defName?: string;
438
+ path?: JxPath;
439
+ eventKey?: string;
440
+ }
441
+
430
442
  export interface GitDiffState {
431
443
  filePath: string;
432
444
  originalContent: string;