@jxsuite/studio 0.13.0 → 0.15.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 (38) hide show
  1. package/dist/studio.js +467 -625
  2. package/dist/studio.js.map +37 -37
  3. package/package.json +1 -1
  4. package/src/canvas/canvas-live-render.js +25 -0
  5. package/src/canvas/canvas-render.js +20 -2
  6. package/src/canvas/canvas-utils.js +1 -2
  7. package/src/editor/component-inline-edit.js +1 -3
  8. package/src/editor/content-inline-edit.js +1 -3
  9. package/src/editor/context-menu.js +73 -35
  10. package/src/editor/insertion-helper.js +0 -1
  11. package/src/editor/shortcuts.js +39 -45
  12. package/src/files/file-ops.js +57 -108
  13. package/src/files/files.js +13 -8
  14. package/src/panels/activity-bar.js +6 -4
  15. package/src/panels/canvas-dnd.js +4 -10
  16. package/src/panels/dnd.js +45 -7
  17. package/src/panels/editors.js +14 -16
  18. package/src/panels/elements-panel.js +13 -13
  19. package/src/panels/git-panel.js +2 -1
  20. package/src/panels/layers-panel.js +11 -12
  21. package/src/panels/left-panel.js +2 -2
  22. package/src/panels/panel-events.js +19 -25
  23. package/src/panels/preview-render.js +7 -3
  24. package/src/panels/pseudo-preview.js +9 -8
  25. package/src/panels/statusbar.js +8 -16
  26. package/src/panels/style-inputs.js +2 -3
  27. package/src/panels/style-utils.js +8 -6
  28. package/src/panels/stylebook-layers-panel.js +5 -5
  29. package/src/panels/stylebook-panel.js +16 -16
  30. package/src/panels/toolbar.js +2 -2
  31. package/src/state.js +2 -3
  32. package/src/store.js +2 -133
  33. package/src/studio.js +111 -239
  34. package/src/tabs/tab.js +19 -4
  35. package/src/tabs/transact.js +19 -1
  36. package/src/ui/color-selector.js +4 -5
  37. package/src/view.js +3 -0
  38. package/src/workspace/workspace.js +5 -0
package/src/studio.js CHANGED
@@ -6,27 +6,14 @@
6
6
  */
7
7
 
8
8
  import {
9
- createState,
10
- pushDocument,
11
- popDocument,
12
9
  getNodeAtPath,
13
10
  canvasWrap,
14
11
  toolbarEl,
15
- canvasPanels,
16
12
  registerRenderer,
17
13
  render,
18
- setUpdateFn,
19
- setGetStateFn,
20
- addUpdateMiddleware,
21
- runUpdateMiddleware,
22
- addPostRenderHook,
23
- runPostRenderHooks,
24
14
  projectState,
25
15
  setProjectState,
26
16
  updateUi,
27
- setUpdateSessionFn,
28
- toFlat,
29
- fromFlat,
30
17
  } from "./store.js";
31
18
 
32
19
  import { activeTab, openTab } from "./workspace/workspace.js";
@@ -36,17 +23,9 @@ import { effect } from "./reactivity.js";
36
23
  import { view } from "./view.js";
37
24
 
38
25
  import { isEditing, isEditableBlock } from "./editor/inline-edit.js";
39
- import {
40
- enterComponentInlineEdit,
41
- initComponentInlineEdit,
42
- } from "./editor/component-inline-edit.js";
26
+ import { initComponentInlineEdit } from "./editor/component-inline-edit.js";
43
27
  import { enterInlineEdit } from "./editor/content-inline-edit.js";
44
- import {
45
- initCanvasUtils,
46
- applyTransform,
47
- positionZoomIndicator,
48
- updateActivePanelHeaders,
49
- } from "./canvas/canvas-utils.js";
28
+ import { initCanvasUtils, applyTransform, positionZoomIndicator } from "./canvas/canvas-utils.js";
50
29
  import { initCanvasHelpers, getActivePanel, findCanvasElement } from "./canvas/canvas-helpers.js";
51
30
  import { initCanvasRender, renderCanvas } from "./canvas/canvas-render.js";
52
31
  import { initCanvasLiveRender } from "./canvas/canvas-live-render.js";
@@ -56,17 +35,11 @@ import {
56
35
  setStatusbarRenderer,
57
36
  mountStatusbar,
58
37
  } from "./panels/statusbar.js";
59
- import {
60
- openFile as _openFile,
61
- loadMarkdown as _loadMarkdown,
62
- saveFile as _saveFile,
63
- exportFile as _exportFile,
64
- } from "./files/file-ops.js";
38
+ import { openFile, loadMarkdown, saveFile, exportFile } from "./files/file-ops.js";
65
39
  import {
66
40
  loadProject as _loadProject,
67
41
  openProject as _openProject,
68
42
  renderFilesTemplate as _renderFilesTemplate,
69
- openFileFromTree as _openFileFromTree,
70
43
  openFileInTab,
71
44
  setupTreeKeyboard,
72
45
  } from "./files/files.js";
@@ -112,13 +85,6 @@ import { initPanelEvents } from "./panels/panel-events.js";
112
85
  // These mutable variables are local to studio.js for now. As sections are extracted
113
86
  // into their own modules, they will migrate to ctx in store.js.
114
87
 
115
- /** @type {any} */
116
- let S; // current state (flat compatibility view)
117
- /** @type {any} */
118
- let doc = null; // doc slice (persisted, history, autosave)
119
- /** @type {any} */
120
- let session = null; // session slice (selection, hover, ui)
121
-
122
88
  /** Creates a display:contents container appended to sp-theme or body, for floating popovers/menus. */
123
89
  function createFloatingContainer() {
124
90
  const el = document.createElement("div");
@@ -127,7 +93,18 @@ function createFloatingContainer() {
127
93
  return el;
128
94
  }
129
95
 
130
- let canvasMode = "design";
96
+ function getCanvasMode() {
97
+ return activeTab.value?.session.ui.canvasMode ?? "design";
98
+ }
99
+
100
+ /** @param {string} mode */
101
+ function setCanvasMode(mode) {
102
+ if (getCanvasMode() === "git-diff" && mode !== "git-diff") {
103
+ gitDiffState = null;
104
+ }
105
+ const tab = activeTab.value;
106
+ if (tab) tab.session.ui.canvasMode = mode;
107
+ }
131
108
 
132
109
  /** @type {any} */
133
110
  let gitDiffState = null;
@@ -141,9 +118,30 @@ async function navigateToComponent(componentPath) {
141
118
  const content = await platform.readFile(componentPath);
142
119
  if (!content) return;
143
120
  const parsed = JSON.parse(content);
144
- S = pushDocument(S, parsed, componentPath);
145
- S.dirty = false;
146
- ({ doc, session } = fromFlat(S));
121
+ const tab = activeTab.value;
122
+ if (!tab) return;
123
+
124
+ // Push current state onto the document stack
125
+ const frame = {
126
+ document: tab.doc.document,
127
+ selection: tab.session.selection,
128
+ documentPath: tab.documentPath,
129
+ dirty: tab.doc.dirty,
130
+ mode: tab.doc.mode,
131
+ };
132
+ if (!tab.session.documentStack) tab.session.documentStack = [];
133
+ tab.session.documentStack.push(frame);
134
+
135
+ // Load the component
136
+ tab.doc.document = parsed;
137
+ tab.doc.dirty = false;
138
+ tab.doc.mode = /** @type {any} */ (null);
139
+ tab.documentPath = componentPath;
140
+ tab.session.selection = null;
141
+ view.leftTab = "layers";
142
+ tab.session.ui.activeMedia = null;
143
+ tab.session.ui.activeSelector = null;
144
+
147
145
  render();
148
146
  statusMessage(`Editing component: ${parsed.tagName || componentPath}`);
149
147
  } catch (/** @type {any} */ e) {
@@ -153,37 +151,46 @@ async function navigateToComponent(componentPath) {
153
151
  }
154
152
 
155
153
  async function navigateBack() {
156
- if (!S.documentStack || S.documentStack.length === 0) return;
157
- if (S.dirty && S.documentPath) {
154
+ const tab = activeTab.value;
155
+ if (!tab?.session.documentStack || tab.session.documentStack.length === 0) return;
156
+ if (tab.doc.dirty && tab.documentPath) {
158
157
  try {
159
158
  const platform = getPlatform();
160
- await platform.writeFile(S.documentPath, JSON.stringify(S.document, null, 2));
159
+ await platform.writeFile(tab.documentPath, JSON.stringify(tab.doc.document, null, 2));
161
160
  } catch (/** @type {any} */ e) {
162
161
  const err = /** @type {any} */ (e);
163
162
  statusMessage(`Save error: ${err.message}`);
164
163
  }
165
164
  }
166
- S = popDocument(S);
167
- ({ doc, session } = fromFlat(S));
165
+
166
+ // Pop the stack
167
+ const frame = /** @type {any} */ (tab.session.documentStack.pop());
168
+ if (!frame) return;
169
+ tab.doc.document = frame.document;
170
+ tab.doc.dirty = frame.dirty;
171
+ tab.doc.mode = frame.mode;
172
+ tab.documentPath = frame.documentPath;
173
+ tab.session.selection = frame.selection;
174
+ view.leftTab = "layers";
175
+
168
176
  render();
169
177
  statusMessage("Returned to parent document");
170
178
  }
171
179
 
172
180
  async function closeFunctionEditor() {
173
- const editing = S.ui.editingFunction;
181
+ const tab = activeTab.value;
182
+ const editing = /** @type {any} */ (tab?.session.ui.editingFunction);
174
183
  if (!editing) return;
175
184
  if (view.functionEditor) {
176
185
  const currentCode = view.functionEditor.getValue();
177
186
  const minResult = await codeService("minify", { code: currentCode });
178
187
  const bodyToStore = minResult?.code ?? currentCode;
179
188
  if (editing.type === "def") {
180
- transactDoc(activeTab.value, (t) =>
181
- mutateUpdateDef(t, editing.defName, { body: bodyToStore }),
182
- );
189
+ transactDoc(tab, (t) => mutateUpdateDef(t, editing.defName, { body: bodyToStore }));
183
190
  } else if (editing.type === "event") {
184
- const node = getNodeAtPath(S.document, editing.path);
191
+ const node = getNodeAtPath(tab.doc.document, editing.path);
185
192
  const current = node?.[editing.eventKey] || {};
186
- transactDoc(activeTab.value, (t) =>
193
+ transactDoc(tab, (t) =>
187
194
  mutateUpdateProperty(t, editing.path, editing.eventKey, {
188
195
  ...current,
189
196
  $prototype: "Function",
@@ -234,13 +241,7 @@ const EMPTY_DOC = {
234
241
  ],
235
242
  };
236
243
 
237
- S = createState(structuredClone(EMPTY_DOC));
238
- ({ doc, session } = fromFlat(S));
239
- setGetStateFn(() => S);
240
-
241
- // Create the initial reactive tab — this is the canonical state container.
242
- // The flat `S` object and `_update`/`_updateSession` are kept as a compatibility layer
243
- // while call sites are progressively migrated to direct reactive mutations.
244
+ // Create the initial reactive tab — the canonical state container.
244
245
  openTab({ id: "initial", document: structuredClone(EMPTY_DOC) });
245
246
 
246
247
  // ─── Render loop ──────────────────────────────────────────────────────────────
@@ -253,14 +254,8 @@ toolbarPanel.mount(toolbarEl, {
253
254
  openFile: () => openFile(),
254
255
  saveFile: () => saveFile(),
255
256
  parseMediaEntries,
256
- getCanvasMode: () => canvasMode,
257
- setCanvasMode: (/** @type {string} */ m) => {
258
- // Clear gitDiffState when exiting diff mode via toolbar
259
- if (canvasMode === "git-diff" && m !== "git-diff") {
260
- gitDiffState = null;
261
- }
262
- canvasMode = m;
263
- },
257
+ getCanvasMode,
258
+ setCanvasMode,
264
259
  renderCanvas: () => renderCanvas(),
265
260
  safeRenderRightPanel: () => safeRenderRightPanel(),
266
261
  });
@@ -268,51 +263,41 @@ toolbarPanel.mount(toolbarEl, {
268
263
  tabStrip.mount(/** @type {HTMLElement} */ (document.querySelector("#tab-strip")));
269
264
 
270
265
  overlaysPanel.mount({
271
- getCanvasMode: () => canvasMode,
266
+ getCanvasMode,
272
267
  isEditing,
273
268
  renderBlockActionBar,
274
269
  });
275
270
 
276
271
  initBlockActionBar({
277
- getCanvasMode: () => canvasMode,
272
+ getCanvasMode,
278
273
  navigateToComponent,
279
274
  createFloatingContainer,
280
275
  });
281
276
 
282
277
  initComponentInlineEdit({ findCanvasElement });
283
- initCanvasHelpers({ getCanvasMode: () => canvasMode, getZoom: () => S.ui.zoom });
278
+ initCanvasHelpers({
279
+ getCanvasMode,
280
+ getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
281
+ });
284
282
  initCanvasUtils({
285
- getCanvasMode: () => canvasMode,
286
- getZoom: () => S.ui.zoom,
283
+ getCanvasMode,
284
+ getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
287
285
  setZoomDirect: (zoom) => {
288
- session = { ...session, ui: { ...session.ui, zoom } };
289
- S = toFlat(doc, session);
290
286
  if (activeTab.value) activeTab.value.session.ui.zoom = zoom;
291
287
  },
292
288
  renderStylebookOverlays,
293
289
  });
294
290
  initPanelEvents({
295
- getState: () => S,
296
- setState: (s) => {
297
- S = s;
298
- ({ doc, session } = fromFlat(S));
299
- },
300
- getCanvasMode: () => canvasMode,
291
+ getCanvasMode,
301
292
  enterInlineEdit,
302
293
  navigateToComponent,
303
294
  });
304
295
  initCanvasLiveRender({
305
- getCanvasMode: () => canvasMode,
296
+ getCanvasMode,
306
297
  });
307
298
  initCanvasRender({
308
- getCanvasMode: () => canvasMode,
309
- setCanvasMode: (/** @type {string} */ mode) => {
310
- // Clear gitDiffState when exiting diff mode
311
- if (canvasMode === "git-diff" && mode !== "git-diff") {
312
- gitDiffState = null;
313
- }
314
- canvasMode = mode;
315
- },
299
+ getCanvasMode,
300
+ setCanvasMode,
316
301
  openFileFromTree,
317
302
  exportFile,
318
303
  gitDiffState,
@@ -328,6 +313,7 @@ effect(() => {
328
313
  if (!tab) return;
329
314
  void tab.doc.document;
330
315
  void tab.doc.mode;
316
+ void tab.session.ui.canvasMode;
331
317
  void tab.session.ui.editingFunction;
332
318
  void tab.session.ui.featureToggles;
333
319
  void tab.session.ui.settingsTab;
@@ -349,16 +335,14 @@ effect(() => {
349
335
 
350
336
  rightPanelMod.mount({
351
337
  navigateToComponent,
352
- getCanvasMode: () => canvasMode,
338
+ getCanvasMode,
353
339
  renderCanvas: () => renderCanvas(),
354
340
  updateForcedPseudoPreview,
355
341
  });
356
342
 
357
343
  leftPanelMod.mount({
358
- getCanvasMode: () => canvasMode,
359
- setCanvasMode: (/** @type {string} */ mode) => {
360
- canvasMode = mode;
361
- },
344
+ getCanvasMode,
345
+ setCanvasMode,
362
346
  renderImportsTemplate,
363
347
  renderFilesTemplate,
364
348
  renderSignalsTemplate,
@@ -383,14 +367,14 @@ registerRenderer("leftPanel", () => leftPanelMod.render());
383
367
  registerRenderer("canvas", () => renderCanvas());
384
368
  registerRenderer("rightPanel", () => rightPanelMod.render());
385
369
  registerRenderer("overlays", () => overlaysPanel.render());
386
- setStatusbarRenderer(() => renderStatusbar(S));
370
+ setStatusbarRenderer(() => renderStatusbar());
387
371
  mountStatusbar();
388
372
  mountActivityBar();
389
373
 
390
374
  // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
391
375
  canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
392
376
  if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
393
- if (!S.selection) return;
377
+ if (!activeTab.value?.session.selection) return;
394
378
  activeTab.value.session.selection = null;
395
379
  });
396
380
 
@@ -398,80 +382,7 @@ function safeRenderRightPanel() {
398
382
  rightPanelMod.render();
399
383
  }
400
384
 
401
- // Register the update implementation with the store
402
- setGetStateFn(() => S);
403
- setUpdateFn(function _update(/** @type {any} */ newState) {
404
- const prevDoc = S.document;
405
- const prevSel = S.selection;
406
- const prevUi = S.ui;
407
- S = newState;
408
-
409
- // Keep doc/session slices in sync with flat S
410
- ({ doc, session } = fromFlat(S));
411
-
412
- // Sync into reactive tab so effects fire
413
- const tab = activeTab.value;
414
- if (tab) {
415
- tab.doc.document = S.document;
416
- tab.doc.dirty = S.dirty;
417
- tab.doc.mode = S.mode;
418
- tab.doc.handlersSource = S.handlersSource;
419
- tab.doc.content.frontmatter = S.content?.frontmatter ?? {};
420
- tab.session.selection = S.selection;
421
- tab.session.hover = S.hover;
422
- tab.session.clipboard = S.clipboard ?? null;
423
- for (const [k, v] of Object.entries(S.ui || {})) {
424
- /** @type {any} */ (tab.session.ui)[k] = v;
425
- }
426
- tab.session.canvas.status = S.canvas?.status ?? "idle";
427
- tab.session.canvas.scope = S.canvas?.scope ?? null;
428
- tab.session.canvas.error = S.canvas?.error ?? null;
429
- }
430
-
431
- if (prevUi?.activeMedia !== S.ui?.activeMedia) {
432
- updateActivePanelHeaders();
433
- }
434
-
435
- runPostRenderHooks(prevDoc, prevSel);
436
- runUpdateMiddleware(S);
437
- });
438
-
439
- // Register session dispatch — lightweight path for selection/hover/ui changes
440
- setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
441
- const prev = session;
442
- session = { ...session, ...patch };
443
- if (patch.ui) {
444
- session.ui = { ...prev.ui, ...patch.ui };
445
- }
446
- if (patch.canvas) {
447
- session.canvas = { ...prev.canvas, ...patch.canvas };
448
- }
449
- S = toFlat(doc, session);
450
-
451
- if (prev.ui?.activeMedia !== session.ui?.activeMedia) {
452
- updateActivePanelHeaders();
453
- }
454
-
455
- // Process pending inline edit when canvas becomes ready
456
- const canvasChanged = prev.canvas !== session.canvas;
457
- if (canvasChanged && session.canvas.status === "ready" && session.ui?.pendingInlineEdit) {
458
- const { path, mediaName: mn } = session.ui.pendingInlineEdit;
459
- updateUi("pendingInlineEdit", null);
460
- const targetPanel =
461
- canvasPanels.find((/** @type {any} */ p) => p.mediaName === mn) || canvasPanels[0];
462
- if (targetPanel) {
463
- const el = findCanvasElement(path, targetPanel.canvas);
464
- if (el) enterComponentInlineEdit(el, path);
465
- }
466
- }
467
-
468
- runPostRenderHooks(doc.document, prev.selection);
469
- });
470
-
471
- // Register post-render hook for pseudo-state preview
472
- addPostRenderHook(() => updateForcedPseudoPreview());
473
-
474
- // Now that renderers and update are registered, bootstrap
385
+ // Now that renderers are registered, bootstrap
475
386
  registerFunctionCompletions();
476
387
 
477
388
  const _openParam = new URLSearchParams(location.search).get("open");
@@ -544,32 +455,30 @@ if (_openParam) {
544
455
  const fileRelPath = _fileParam || siteCtx.fileRelPath || _openParam;
545
456
  const content = await platform.readFile(fileRelPath);
546
457
  if (content) {
547
- let parsedDoc;
548
- if (fileRelPath.endsWith(".md")) {
549
- const ns = await _loadMarkdown(content, null);
550
- S = ns;
551
- S.documentPath = fileRelPath;
552
- parsedDoc = S.document;
458
+ let parsedDoc, frontmatter;
459
+ const isMd = fileRelPath.endsWith(".md");
460
+ if (isMd) {
461
+ const result = await loadMarkdown(content);
462
+ parsedDoc = result.document;
463
+ frontmatter = result.frontmatter;
553
464
  } else {
554
465
  parsedDoc = JSON.parse(content);
555
- S = createState(parsedDoc);
556
- S.documentPath = fileRelPath;
557
- }
558
- S.dirty = false;
559
- S.ui = { ...S.ui, leftTab: "files" };
560
- ({ doc, session } = fromFlat(S));
561
-
562
- // Sync into reactive tab
563
- const tab = activeTab.value;
564
- if (tab) {
565
- tab.doc.document = S.document;
566
- tab.doc.mode = S.mode;
567
- tab.doc.dirty = false;
568
- tab.doc.content = S.content || { frontmatter: {} };
569
- /** @type {any} */ (tab).documentPath = fileRelPath;
570
- tab.session.ui.leftTab = "files";
571
466
  }
572
467
 
468
+ // Open in a tab (replaces initial tab)
469
+ const { closeTab } = await import("./workspace/workspace.js");
470
+ closeTab("initial");
471
+ openTab({
472
+ id: fileRelPath,
473
+ documentPath: fileRelPath,
474
+ document: parsedDoc,
475
+ frontmatter,
476
+ sourceFormat: isMd ? "md" : null,
477
+ });
478
+
479
+ if (isMd && activeTab.value) activeTab.value.doc.mode = "content";
480
+ view.leftTab = "files";
481
+
573
482
  render();
574
483
  statusMessage(`Opened ${_openParam}`);
575
484
  }
@@ -609,35 +518,6 @@ function renderLeftPanel() {
609
518
 
610
519
  // ─── Source/Function editors: delegated to panels/editors.js ─────────────────
611
520
 
612
- // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
613
-
614
- function renderToolbar() {
615
- toolbarPanel.render();
616
- }
617
-
618
- // ─── File Operations (delegated to file-ops.js) ─────────────────────────────
619
-
620
- function fileOpsCtx() {
621
- return {
622
- S,
623
- commit: (/** @type {any} */ ns) => {
624
- S = ns;
625
- ({ doc, session } = fromFlat(S));
626
- render();
627
- },
628
- renderToolbar,
629
- };
630
- }
631
- function openFile() {
632
- return _openFile(fileOpsCtx());
633
- }
634
- function saveFile() {
635
- return _saveFile(fileOpsCtx());
636
- }
637
- function exportFile() {
638
- return _exportFile(fileOpsCtx());
639
- }
640
-
641
521
  // ─── File tree (delegated to files.js) ───────────────────────────────────────
642
522
 
643
523
  function loadProject() {
@@ -645,11 +525,6 @@ function loadProject() {
645
525
  }
646
526
  function openProject() {
647
527
  return _openProject({
648
- S,
649
- commit: (/** @type {any} */ ns) => {
650
- S = ns;
651
- ({ doc, session } = fromFlat(S));
652
- },
653
528
  renderActivityBar: () => renderActivityBar(),
654
529
  renderLeftPanel,
655
530
  });
@@ -663,12 +538,7 @@ function openFileFromTree(/** @type {any} */ path) {
663
538
 
664
539
  // ─── Keyboard shortcuts ───────────────────────────────────────────────────────
665
540
  initShortcuts(() => ({
666
- S,
667
- setS: (ns) => {
668
- S = ns;
669
- ({ doc, session } = fromFlat(S));
670
- },
671
- canvasMode,
541
+ canvasMode: getCanvasMode(),
672
542
  panX: view.panX,
673
543
  panY: view.panY,
674
544
  setPan: (x, y) => {
@@ -700,21 +570,23 @@ initShortcuts(() => ({
700
570
  const AUTO_SAVE_DELAY = 2000;
701
571
 
702
572
  function scheduleAutosave() {
703
- if (!S.fileHandle || !S.dirty) return;
573
+ const tab = activeTab.value;
574
+ if (!tab?.fileHandle || !tab.doc.dirty) return;
704
575
  clearTimeout(view.autosaveTimer);
705
576
  view.autosaveTimer = setTimeout(async () => {
706
- if (S.fileHandle && S.dirty && "createWritable" in S.fileHandle) {
577
+ const t = activeTab.value;
578
+ if (t?.fileHandle && t.doc.dirty && "createWritable" in t.fileHandle) {
707
579
  try {
708
- const writable = await S.fileHandle.createWritable();
709
- await writable.write(JSON.stringify(S.document, null, 2));
580
+ const writable = await t.fileHandle.createWritable();
581
+ await writable.write(JSON.stringify(t.doc.document, null, 2));
710
582
  await writable.close();
711
- activeTab.value.doc.dirty = false;
583
+ t.doc.dirty = false;
712
584
  statusMessage("Auto-saved");
713
585
  } catch {}
714
586
  }
715
587
  }, AUTO_SAVE_DELAY);
716
588
  }
717
589
 
718
- addUpdateMiddleware((/** @type {any} */ state) => {
719
- if (state.dirty) scheduleAutosave();
590
+ effect(() => {
591
+ if (activeTab.value?.doc.dirty) scheduleAutosave();
720
592
  });
package/src/tabs/tab.js CHANGED
@@ -2,8 +2,8 @@ import { reactive, effectScope } from "../reactivity.js";
2
2
 
3
3
  /**
4
4
  * @typedef {{
5
- * leftTab: string;
6
5
  * rightTab: string;
6
+ * canvasMode: string;
7
7
  * zoom: number;
8
8
  * activeMedia: string | null;
9
9
  * activeSelector: string | null;
@@ -45,6 +45,7 @@ import { reactive, effectScope } from "../reactivity.js";
45
45
  * document: Record<string, any>;
46
46
  * content: { frontmatter: Record<string, unknown> };
47
47
  * mode: string;
48
+ * sourceFormat: string | null;
48
49
  * handlersSource: string | null;
49
50
  * dirty: boolean;
50
51
  * };
@@ -71,8 +72,8 @@ import { reactive, effectScope } from "../reactivity.js";
71
72
  /** @returns {TabUi} */
72
73
  function createDefaultUi() {
73
74
  return {
74
- leftTab: "layers",
75
75
  rightTab: "properties",
76
+ canvasMode: "design",
76
77
  zoom: 1,
77
78
  activeMedia: null,
78
79
  activeSelector: null,
@@ -106,10 +107,18 @@ function createDefaultUi() {
106
107
  * fileHandle?: FileSystemFileHandle | null;
107
108
  * document: Record<string, any>;
108
109
  * frontmatter?: Record<string, unknown>;
110
+ * sourceFormat?: string | null;
109
111
  * }} opts
110
112
  * @returns {Tab}
111
113
  */
112
- export function createTab({ id, documentPath = null, fileHandle = null, document, frontmatter }) {
114
+ export function createTab({
115
+ id,
116
+ documentPath = null,
117
+ fileHandle = null,
118
+ document,
119
+ frontmatter,
120
+ sourceFormat = null,
121
+ }) {
113
122
  const scope = effectScope();
114
123
 
115
124
  const tab = /** @type {Tab} */ (
@@ -120,8 +129,14 @@ export function createTab({ id, documentPath = null, fileHandle = null, document
120
129
  scope,
121
130
  doc: reactive({
122
131
  document,
132
+ sourceFormat,
123
133
  content: { frontmatter: frontmatter || {} },
124
- mode: documentPath?.endsWith(".md") ? "content" : "component",
134
+ mode:
135
+ sourceFormat === "md"
136
+ ? "content"
137
+ : documentPath?.endsWith(".md")
138
+ ? "content"
139
+ : "component",
125
140
  handlersSource: null,
126
141
  dirty: false,
127
142
  }),
@@ -26,8 +26,12 @@ const HISTORY_LIMIT = 100;
26
26
  export function transactDoc(tab, mutationFn, { skipHistory = false } = {}) {
27
27
  mutationFn(tab);
28
28
 
29
+ // Replace the document root reference so effects tracking tab.doc.document re-trigger.
30
+ // Nested objects are shared — the mutation already modified them in place.
31
+ const raw = toRaw(tab.doc.document);
32
+ tab.doc.document = { ...raw };
33
+
29
34
  if (!skipHistory) {
30
- const raw = toRaw(tab.doc.document);
31
35
  const snapshot = {
32
36
  document: structuredClone(raw),
33
37
  selection: tab.session.selection ? [...tab.session.selection] : null,
@@ -270,6 +274,20 @@ export function mutateUpdateMediaNestedStyle(tab, path, mediaName, selector, pro
270
274
  if (Object.keys(node.style).length === 0) delete node.style;
271
275
  }
272
276
 
277
+ /**
278
+ * @param {Tab} tab
279
+ * @param {JxPath} path
280
+ * @param {Record<string, any> | undefined} style
281
+ */
282
+ export function mutateReplaceStyle(tab, path, style) {
283
+ const node = getNodeAtPath(tab.doc.document, path);
284
+ if (style && Object.keys(style).length > 0) {
285
+ node.style = style;
286
+ } else {
287
+ delete node.style;
288
+ }
289
+ }
290
+
273
291
  /**
274
292
  * @param {Tab} tab
275
293
  * @param {string} name
@@ -13,7 +13,8 @@
13
13
  import { LitElement, html, nothing } from "lit";
14
14
  import { html as litHtml } from "lit-html";
15
15
  import { live } from "lit-html/directives/live.js";
16
- import { getState, debouncedStyleCommit } from "../store.js";
16
+ import { debouncedStyleCommit } from "../store.js";
17
+ import { activeTab } from "../workspace/workspace.js";
17
18
  import { getEffectiveStyle } from "../site-context.js";
18
19
  import { kebabToLabel } from "../utils/studio-utils.js";
19
20
 
@@ -21,8 +22,7 @@ import { kebabToLabel } from "../utils/studio-utils.js";
21
22
 
22
23
  /** Extract --color-* CSS custom properties from the effective (site + document) style. */
23
24
  function getColorVars() {
24
- const S = getState();
25
- const style = getEffectiveStyle(S?.document?.style);
25
+ const style = getEffectiveStyle(activeTab.value?.doc.document?.style);
26
26
  if (!style) return [];
27
27
  const vars = [];
28
28
  for (const [k, v] of Object.entries(style)) {
@@ -46,8 +46,7 @@ function resolveColorForDisplay(/** @type {any} */ val) {
46
46
  if (!val) return "transparent";
47
47
  const m = val.match(/^var\((--[^)]+)\)$/);
48
48
  if (m) {
49
- const S = getState();
50
- const style = getEffectiveStyle(S?.document?.style);
49
+ const style = getEffectiveStyle(activeTab.value?.doc.document?.style);
51
50
  const resolved = style?.[m[1]];
52
51
  if (typeof resolved === "string") return resolved;
53
52
  return "transparent";
package/src/view.js CHANGED
@@ -64,6 +64,9 @@ export const view = {
64
64
  // Layout selection (when user clicks a layout element)
65
65
  layoutSelection: null,
66
66
 
67
+ // Global UI state (persists across tab switches)
68
+ leftTab: "layers",
69
+
67
70
  // Autosave
68
71
  autosaveTimer: null,
69
72
  };