@jxsuite/studio 0.11.0 → 0.14.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 (55) hide show
  1. package/dist/studio.js +6517 -5870
  2. package/dist/studio.js.map +57 -51
  3. package/package.json +5 -3
  4. package/src/canvas/canvas-diff.js +184 -0
  5. package/src/canvas/canvas-helpers.js +10 -14
  6. package/src/canvas/canvas-live-render.js +28 -2
  7. package/src/canvas/canvas-render.js +170 -20
  8. package/src/canvas/canvas-utils.js +22 -25
  9. package/src/editor/component-inline-edit.js +55 -44
  10. package/src/editor/content-inline-edit.js +47 -50
  11. package/src/editor/context-menu.js +78 -53
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -11
  14. package/src/editor/shortcuts.js +88 -64
  15. package/src/files/components.js +15 -4
  16. package/src/files/file-ops.js +57 -108
  17. package/src/files/files.js +81 -28
  18. package/src/panels/activity-bar.js +31 -7
  19. package/src/panels/block-action-bar.js +104 -80
  20. package/src/panels/canvas-dnd.js +4 -10
  21. package/src/panels/dnd.js +77 -35
  22. package/src/panels/editors.js +17 -26
  23. package/src/panels/elements-panel.js +17 -11
  24. package/src/panels/events-panel.js +44 -39
  25. package/src/panels/git-panel.js +47 -4
  26. package/src/panels/layers-panel.js +25 -21
  27. package/src/panels/left-panel.js +109 -44
  28. package/src/panels/overlays.js +91 -41
  29. package/src/panels/panel-events.js +28 -37
  30. package/src/panels/preview-render.js +7 -3
  31. package/src/panels/properties-panel.js +179 -104
  32. package/src/panels/pseudo-preview.js +9 -8
  33. package/src/panels/right-panel.js +85 -37
  34. package/src/panels/shared.js +0 -22
  35. package/src/panels/signals-panel.js +125 -54
  36. package/src/panels/statusbar.js +26 -19
  37. package/src/panels/style-inputs.js +5 -4
  38. package/src/panels/style-panel.js +128 -105
  39. package/src/panels/style-utils.js +8 -6
  40. package/src/panels/stylebook-layers-panel.js +5 -5
  41. package/src/panels/stylebook-panel.js +27 -31
  42. package/src/panels/tab-strip.js +124 -0
  43. package/src/panels/toolbar.js +40 -10
  44. package/src/reactivity.js +28 -0
  45. package/src/settings/content-types-editor.js +56 -7
  46. package/src/settings/defs-editor.js +56 -7
  47. package/src/settings/schema-field-ui.js +60 -25
  48. package/src/state.js +2 -459
  49. package/src/store.js +61 -219
  50. package/src/studio.js +163 -300
  51. package/src/tabs/tab.js +168 -0
  52. package/src/tabs/transact.js +406 -0
  53. package/src/ui/color-selector.js +4 -5
  54. package/src/view.js +3 -0
  55. package/src/workspace/workspace.js +90 -0
package/src/studio.js CHANGED
@@ -6,51 +6,26 @@
6
6
  */
7
7
 
8
8
  import {
9
- createState,
10
- selectNode,
11
- updateProperty,
12
- updateDef,
13
- pushDocument,
14
- popDocument,
15
9
  getNodeAtPath,
16
- pathsEqual,
17
10
  canvasWrap,
18
11
  toolbarEl,
19
- canvasPanels,
20
12
  registerRenderer,
21
13
  render,
22
- update,
23
- setUpdateFn,
24
- setGetStateFn,
25
- addUpdateMiddleware,
26
- runUpdateMiddleware,
27
- addPostRenderHook,
28
- runPostRenderHooks,
29
- notify,
30
14
  projectState,
31
15
  setProjectState,
32
16
  updateUi,
33
- setUpdateSessionFn,
34
- setGetDocFn,
35
- setGetSessionFn,
36
- toFlat,
37
- fromFlat,
38
17
  } from "./store.js";
39
18
 
19
+ import { activeTab, openTab } from "./workspace/workspace.js";
20
+ import { transactDoc, mutateUpdateDef, mutateUpdateProperty } from "./tabs/transact.js";
21
+ import { effect } from "./reactivity.js";
22
+
40
23
  import { view } from "./view.js";
41
24
 
42
25
  import { isEditing, isEditableBlock } from "./editor/inline-edit.js";
43
- import {
44
- enterComponentInlineEdit,
45
- initComponentInlineEdit,
46
- } from "./editor/component-inline-edit.js";
26
+ import { initComponentInlineEdit } from "./editor/component-inline-edit.js";
47
27
  import { enterInlineEdit } from "./editor/content-inline-edit.js";
48
- import {
49
- initCanvasUtils,
50
- applyTransform,
51
- positionZoomIndicator,
52
- updateActivePanelHeaders,
53
- } from "./canvas/canvas-utils.js";
28
+ import { initCanvasUtils, applyTransform, positionZoomIndicator } from "./canvas/canvas-utils.js";
54
29
  import { initCanvasHelpers, getActivePanel, findCanvasElement } from "./canvas/canvas-helpers.js";
55
30
  import { initCanvasRender, renderCanvas } from "./canvas/canvas-render.js";
56
31
  import { initCanvasLiveRender } from "./canvas/canvas-live-render.js";
@@ -60,20 +35,14 @@ import {
60
35
  setStatusbarRenderer,
61
36
  mountStatusbar,
62
37
  } from "./panels/statusbar.js";
63
- import {
64
- openFile as _openFile,
65
- loadMarkdown as _loadMarkdown,
66
- saveFile as _saveFile,
67
- exportFile as _exportFile,
68
- } from "./files/file-ops.js";
38
+ import { openFile, loadMarkdown, saveFile, exportFile } from "./files/file-ops.js";
69
39
  import {
70
40
  loadProject as _loadProject,
71
41
  openProject as _openProject,
72
42
  renderFilesTemplate as _renderFilesTemplate,
73
- openFileFromTree as _openFileFromTree,
43
+ openFileInTab,
74
44
  setupTreeKeyboard,
75
45
  } from "./files/files.js";
76
- import { eventsSidebarTemplate as _eventsSidebarTemplate } from "./panels/events-panel.js";
77
46
  import { renderImportsTemplate } from "./panels/imports-panel.js";
78
47
  import { renderHeadTemplate } from "./panels/head-panel.js";
79
48
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export.js";
@@ -97,11 +66,12 @@ import { renderGitPanel } from "./panels/git-panel.js";
97
66
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
98
67
  import "./ui/panel-resize.js";
99
68
  import { initShortcuts } from "./editor/shortcuts.js";
100
- import { renderActivityBar } from "./panels/activity-bar.js";
69
+ import { renderActivityBar, mount as mountActivityBar } from "./panels/activity-bar.js";
101
70
  import * as toolbarPanel from "./panels/toolbar.js";
102
71
  import * as overlaysPanel from "./panels/overlays.js";
103
72
  import * as rightPanelMod from "./panels/right-panel.js";
104
73
  import * as leftPanelMod from "./panels/left-panel.js";
74
+ import * as tabStrip from "./panels/tab-strip.js";
105
75
  import { renderStylebookOverlays } from "./panels/stylebook-panel.js";
106
76
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd.js";
107
77
  import { defaultDef } from "./panels/shared.js";
@@ -115,13 +85,6 @@ import { initPanelEvents } from "./panels/panel-events.js";
115
85
  // These mutable variables are local to studio.js for now. As sections are extracted
116
86
  // into their own modules, they will migrate to ctx in store.js.
117
87
 
118
- /** @type {any} */
119
- let S; // current state (flat compatibility view)
120
- /** @type {any} */
121
- let doc = null; // doc slice (persisted, history, autosave)
122
- /** @type {any} */
123
- let session = null; // session slice (selection, hover, ui)
124
-
125
88
  /** Creates a display:contents container appended to sp-theme or body, for floating popovers/menus. */
126
89
  function createFloatingContainer() {
127
90
  const el = document.createElement("div");
@@ -130,7 +93,21 @@ function createFloatingContainer() {
130
93
  return el;
131
94
  }
132
95
 
133
- 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
+ }
108
+
109
+ /** @type {any} */
110
+ let gitDiffState = null;
134
111
 
135
112
  // ─── Component registry ───────────────────────────────────────────────────────
136
113
 
@@ -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,36 +151,47 @@ 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
- update(updateDef(S, editing.defName, { body: bodyToStore }));
189
+ transactDoc(tab, (t) => mutateUpdateDef(t, editing.defName, { body: bodyToStore }));
181
190
  } else if (editing.type === "event") {
182
- const node = getNodeAtPath(S.document, editing.path);
191
+ const node = getNodeAtPath(tab.doc.document, editing.path);
183
192
  const current = node?.[editing.eventKey] || {};
184
- update(
185
- updateProperty(S, editing.path, editing.eventKey, {
193
+ transactDoc(tab, (t) =>
194
+ mutateUpdateProperty(t, editing.path, editing.eventKey, {
186
195
  ...current,
187
196
  $prototype: "Function",
188
197
  body: bodyToStore,
@@ -232,8 +241,8 @@ const EMPTY_DOC = {
232
241
  ],
233
242
  };
234
243
 
235
- S = createState(structuredClone(EMPTY_DOC));
236
- ({ doc, session } = fromFlat(S));
244
+ // Create the initial reactive tab — the canonical state container.
245
+ openTab({ id: "initial", document: structuredClone(EMPTY_DOC) });
237
246
 
238
247
  // ─── Render loop ──────────────────────────────────────────────────────────────
239
248
 
@@ -245,71 +254,95 @@ toolbarPanel.mount(toolbarEl, {
245
254
  openFile: () => openFile(),
246
255
  saveFile: () => saveFile(),
247
256
  parseMediaEntries,
248
- getCanvasMode: () => canvasMode,
249
- setCanvasMode: (/** @type {any} */ m) => {
250
- canvasMode = m;
251
- },
257
+ getCanvasMode,
258
+ setCanvasMode,
252
259
  renderCanvas: () => renderCanvas(),
253
260
  safeRenderRightPanel: () => safeRenderRightPanel(),
254
261
  });
255
262
 
263
+ tabStrip.mount(/** @type {HTMLElement} */ (document.querySelector("#tab-strip")));
264
+
256
265
  overlaysPanel.mount({
257
- getCanvasMode: () => canvasMode,
266
+ getCanvasMode,
258
267
  isEditing,
259
268
  renderBlockActionBar,
260
269
  });
261
270
 
262
271
  initBlockActionBar({
263
- getCanvasMode: () => canvasMode,
272
+ getCanvasMode,
264
273
  navigateToComponent,
265
274
  createFloatingContainer,
266
275
  });
267
276
 
268
277
  initComponentInlineEdit({ findCanvasElement });
269
- initCanvasHelpers({ getCanvasMode: () => canvasMode });
278
+ initCanvasHelpers({
279
+ getCanvasMode,
280
+ getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
281
+ });
270
282
  initCanvasUtils({
271
- getCanvasMode: () => canvasMode,
272
- getZoom: () => S.ui.zoom,
283
+ getCanvasMode,
284
+ getZoom: () => activeTab.value?.session.ui.zoom ?? 1,
273
285
  setZoomDirect: (zoom) => {
274
- session = { ...session, ui: { ...session.ui, zoom } };
275
- S = toFlat(doc, session);
286
+ if (activeTab.value) activeTab.value.session.ui.zoom = zoom;
276
287
  },
277
288
  renderStylebookOverlays,
278
289
  });
279
290
  initPanelEvents({
280
- getState: () => S,
281
- setState: (s) => {
282
- S = s;
283
- ({ doc, session } = fromFlat(S));
284
- },
285
- getCanvasMode: () => canvasMode,
291
+ getCanvasMode,
286
292
  enterInlineEdit,
287
293
  navigateToComponent,
288
294
  });
289
295
  initCanvasLiveRender({
290
- getState: () => S,
291
- getCanvasMode: () => canvasMode,
296
+ getCanvasMode,
292
297
  });
293
298
  initCanvasRender({
294
- getCanvasMode: () => canvasMode,
295
- setCanvasMode: (mode) => {
296
- canvasMode = mode;
297
- },
298
- getState: () => S,
299
- update,
299
+ getCanvasMode,
300
+ setCanvasMode,
300
301
  openFileFromTree,
301
302
  exportFile,
303
+ gitDiffState,
304
+ setGitDiffState: (/** @type {any} */ state) => {
305
+ gitDiffState = state;
306
+ },
307
+ });
308
+
309
+ // Effect-driven canvas rendering: auto-triggers renderCanvas when reactive deps change
310
+ let _canvasRenderScheduled = false;
311
+ effect(() => {
312
+ const tab = activeTab.value;
313
+ if (!tab) return;
314
+ void tab.doc.document;
315
+ void tab.doc.mode;
316
+ void tab.session.ui.canvasMode;
317
+ void tab.session.ui.editingFunction;
318
+ void tab.session.ui.featureToggles;
319
+ void tab.session.ui.settingsTab;
320
+ void tab.session.ui.stylebookTab;
321
+ void tab.session.ui.stylebookFilter;
322
+ void tab.session.ui.stylebookCustomizedOnly;
323
+ if (!_canvasRenderScheduled) {
324
+ _canvasRenderScheduled = true;
325
+ queueMicrotask(() => {
326
+ _canvasRenderScheduled = false;
327
+ try {
328
+ renderCanvas();
329
+ } catch (e) {
330
+ console.error("renderCanvas error:", e);
331
+ }
332
+ });
333
+ }
302
334
  });
303
335
 
304
336
  rightPanelMod.mount({
305
337
  navigateToComponent,
306
- getCanvasMode: () => canvasMode,
338
+ getCanvasMode,
307
339
  renderCanvas: () => renderCanvas(),
308
340
  updateForcedPseudoPreview,
309
341
  });
310
342
 
311
343
  leftPanelMod.mount({
312
- getCanvasMode: () => canvasMode,
344
+ getCanvasMode,
345
+ setCanvasMode,
313
346
  renderImportsTemplate,
314
347
  renderFilesTemplate,
315
348
  renderSignalsTemplate,
@@ -329,152 +362,27 @@ leftPanelMod.mount({
329
362
  });
330
363
 
331
364
  // Register all renderers with the store so render()/renderOnly() work
332
- registerRenderer("toolbar", () => toolbarPanel.render());
333
- registerRenderer("activityBar", () => renderActivityBar(S));
365
+ // Register remaining renderers for render()/renderOnly() compat during migration
334
366
  registerRenderer("leftPanel", () => leftPanelMod.render());
335
367
  registerRenderer("canvas", () => renderCanvas());
336
368
  registerRenderer("rightPanel", () => rightPanelMod.render());
337
369
  registerRenderer("overlays", () => overlaysPanel.render());
338
- registerRenderer("statusbar", () => renderStatusbar(S));
339
- setStatusbarRenderer(() => renderStatusbar(S));
370
+ setStatusbarRenderer(() => renderStatusbar());
340
371
  mountStatusbar();
372
+ mountActivityBar();
341
373
 
342
374
  // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
343
375
  canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
344
376
  if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
345
- if (!S.selection) return;
346
- update(selectNode(S, null));
377
+ if (!activeTab.value?.session.selection) return;
378
+ activeTab.value.session.selection = null;
347
379
  });
348
380
 
349
- function safeRenderLeftPanel() {
350
- leftPanelMod.render();
351
- }
352
-
353
381
  function safeRenderRightPanel() {
354
382
  rightPanelMod.render();
355
383
  }
356
384
 
357
- // Register the update implementation with the store
358
- setGetStateFn(() => S);
359
- setUpdateFn(function _update(/** @type {any} */ newState) {
360
- const prev = S;
361
- const prevDoc = S.document;
362
- const prevSel = S.selection;
363
- S = newState;
364
-
365
- // Keep doc/session slices in sync with flat S
366
- ({ doc, session } = fromFlat(S));
367
-
368
- const docChanged = prevDoc !== S.document;
369
- const selChanged = !pathsEqual(prevSel, S.selection);
370
- const modeChanged = prev.mode !== S.mode;
371
- const uiChanged = prev.ui !== S.ui;
372
-
373
- const canvasUiChanged =
374
- uiChanged &&
375
- (prev.ui?.editingFunction !== S.ui?.editingFunction ||
376
- prev.ui?.settingsTab !== S.ui?.settingsTab ||
377
- prev.ui?.stylebookTab !== S.ui?.stylebookTab ||
378
- prev.ui?.stylebookFilter !== S.ui?.stylebookFilter ||
379
- prev.ui?.stylebookCustomizedOnly !== S.ui?.stylebookCustomizedOnly ||
380
- prev.ui?.featureToggles !== S.ui?.featureToggles);
381
- const leftUiChanged =
382
- uiChanged && (prev.ui?.leftTab !== S.ui?.leftTab || prev.ui?.settingsTab !== S.ui?.settingsTab);
383
-
384
- if (docChanged || modeChanged || canvasUiChanged) {
385
- try {
386
- renderCanvas();
387
- } catch (e) {
388
- console.error("renderCanvas error:", e);
389
- }
390
- safeRenderLeftPanel();
391
- } else if (selChanged || leftUiChanged) {
392
- safeRenderLeftPanel();
393
- }
394
-
395
- if (uiChanged && prev.ui?.activeMedia !== S.ui?.activeMedia) {
396
- updateActivePanelHeaders();
397
- }
398
-
399
- runPostRenderHooks(prevDoc, prevSel);
400
- runUpdateMiddleware(S);
401
-
402
- notify({
403
- doc: docChanged,
404
- selection: selChanged,
405
- hover: false,
406
- ui: uiChanged,
407
- mode: modeChanged,
408
- });
409
- });
410
-
411
- // Register session dispatch — lightweight path for selection/hover/ui changes
412
- setGetDocFn(() => doc);
413
- setGetSessionFn(() => session);
414
- setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
415
- const prev = session;
416
- session = { ...session, ...patch };
417
- if (patch.ui) {
418
- session.ui = { ...prev.ui, ...patch.ui };
419
- }
420
- if (patch.canvas) {
421
- session.canvas = { ...prev.canvas, ...patch.canvas };
422
- }
423
- S = toFlat(doc, session);
424
-
425
- const selChanged = !pathsEqual(prev.selection, session.selection);
426
- const uiChanged = prev.ui !== session.ui;
427
- const canvasChanged = prev.canvas !== session.canvas;
428
-
429
- const canvasUiChanged =
430
- uiChanged &&
431
- (prev.ui?.editingFunction !== session.ui?.editingFunction ||
432
- prev.ui?.settingsTab !== session.ui?.settingsTab ||
433
- prev.ui?.stylebookTab !== session.ui?.stylebookTab ||
434
- prev.ui?.stylebookFilter !== session.ui?.stylebookFilter ||
435
- prev.ui?.stylebookCustomizedOnly !== session.ui?.stylebookCustomizedOnly ||
436
- prev.ui?.featureToggles !== session.ui?.featureToggles);
437
- const leftUiChanged =
438
- uiChanged &&
439
- (prev.ui?.leftTab !== session.ui?.leftTab || prev.ui?.settingsTab !== session.ui?.settingsTab);
440
-
441
- if (canvasUiChanged) {
442
- try {
443
- renderCanvas();
444
- } catch (e) {
445
- console.error("renderCanvas error:", e);
446
- }
447
- safeRenderLeftPanel();
448
- } else if (selChanged || leftUiChanged || canvasChanged) {
449
- safeRenderLeftPanel();
450
- }
451
-
452
- if (uiChanged && prev.ui?.activeMedia !== session.ui?.activeMedia) {
453
- updateActivePanelHeaders();
454
- }
455
-
456
- // Process pending inline edit when canvas becomes ready
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
- const hoverChanged = prev.hover !== session.hover;
471
- notify({ doc: false, selection: selChanged, hover: hoverChanged, ui: uiChanged, mode: false });
472
- });
473
-
474
- // Register post-render hook for pseudo-state preview
475
- addPostRenderHook(() => updateForcedPseudoPreview());
476
-
477
- // Now that renderers and update are registered, bootstrap
385
+ // Now that renderers are registered, bootstrap
478
386
  registerFunctionCompletions();
479
387
 
480
388
  const _openParam = new URLSearchParams(location.search).get("open");
@@ -547,17 +455,30 @@ if (_openParam) {
547
455
  const fileRelPath = _fileParam || siteCtx.fileRelPath || _openParam;
548
456
  const content = await platform.readFile(fileRelPath);
549
457
  if (content) {
550
- if (fileRelPath.endsWith(".md")) {
551
- await loadMarkdown(content, null);
552
- S.documentPath = fileRelPath;
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
- const parsed = JSON.parse(content);
555
- S = createState(parsed);
556
- S.documentPath = fileRelPath;
465
+ parsedDoc = JSON.parse(content);
557
466
  }
558
- S.dirty = false;
559
- S.ui = { ...S.ui, leftTab: "files" };
560
- ({ doc, session } = fromFlat(S));
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
+
561
482
  render();
562
483
  statusMessage(`Opened ${_openParam}`);
563
484
  }
@@ -597,40 +518,6 @@ function renderLeftPanel() {
597
518
 
598
519
  // ─── Source/Function editors: delegated to panels/editors.js ─────────────────
599
520
 
600
- // ─── Toolbar (delegated to panels/toolbar.js) ────────────────────────────────
601
-
602
- function renderToolbar() {
603
- toolbarPanel.render();
604
- }
605
-
606
- // ─── File Operations (delegated to file-ops.js) ─────────────────────────────
607
-
608
- function fileOpsCtx() {
609
- return {
610
- S,
611
- commit: (/** @type {any} */ ns) => {
612
- S = ns;
613
- ({ doc, session } = fromFlat(S));
614
- render();
615
- },
616
- renderToolbar,
617
- };
618
- }
619
- function openFile() {
620
- return _openFile(fileOpsCtx());
621
- }
622
- async function loadMarkdown(/** @type {any} */ source, /** @type {any} */ fileHandle) {
623
- const ns = await _loadMarkdown(source, fileHandle);
624
- S = ns;
625
- ({ doc, session } = fromFlat(S));
626
- }
627
- function saveFile() {
628
- return _saveFile(fileOpsCtx());
629
- }
630
- function exportFile() {
631
- return _exportFile(fileOpsCtx());
632
- }
633
-
634
521
  // ─── File tree (delegated to files.js) ───────────────────────────────────────
635
522
 
636
523
  function loadProject() {
@@ -638,12 +525,7 @@ function loadProject() {
638
525
  }
639
526
  function openProject() {
640
527
  return _openProject({
641
- S,
642
- commit: (/** @type {any} */ ns) => {
643
- S = ns;
644
- ({ doc, session } = fromFlat(S));
645
- },
646
- renderActivityBar: () => renderActivityBar(S),
528
+ renderActivityBar: () => renderActivityBar(),
647
529
  renderLeftPanel,
648
530
  });
649
531
  }
@@ -651,33 +533,12 @@ function renderFilesTemplate() {
651
533
  return _renderFilesTemplate({ openProject, openFileFromTree, renderLeftPanel });
652
534
  }
653
535
  function openFileFromTree(/** @type {any} */ path) {
654
- return _openFileFromTree(
655
- {
656
- get S() {
657
- return S;
658
- },
659
- set S(v) {
660
- S = v;
661
- },
662
- commit: (/** @type {any} */ ns) => {
663
- S = ns;
664
- ({ doc, session } = fromFlat(S));
665
- },
666
- render,
667
- loadMarkdown,
668
- },
669
- path,
670
- );
536
+ return openFileInTab(path);
671
537
  }
672
538
 
673
539
  // ─── Keyboard shortcuts ───────────────────────────────────────────────────────
674
540
  initShortcuts(() => ({
675
- S,
676
- setS: (ns) => {
677
- S = ns;
678
- ({ doc, session } = fromFlat(S));
679
- },
680
- canvasMode,
541
+ canvasMode: getCanvasMode(),
681
542
  panX: view.panX,
682
543
  panY: view.panY,
683
544
  setPan: (x, y) => {
@@ -709,21 +570,23 @@ initShortcuts(() => ({
709
570
  const AUTO_SAVE_DELAY = 2000;
710
571
 
711
572
  function scheduleAutosave() {
712
- if (!S.fileHandle || !S.dirty) return;
573
+ const tab = activeTab.value;
574
+ if (!tab?.fileHandle || !tab.doc.dirty) return;
713
575
  clearTimeout(view.autosaveTimer);
714
576
  view.autosaveTimer = setTimeout(async () => {
715
- 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) {
716
579
  try {
717
- const writable = await S.fileHandle.createWritable();
718
- 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));
719
582
  await writable.close();
720
- update({ ...S, dirty: false });
583
+ t.doc.dirty = false;
721
584
  statusMessage("Auto-saved");
722
585
  } catch {}
723
586
  }
724
587
  }, AUTO_SAVE_DELAY);
725
588
  }
726
589
 
727
- addUpdateMiddleware((/** @type {any} */ state) => {
728
- if (state.dirty) scheduleAutosave();
590
+ effect(() => {
591
+ if (activeTab.value?.doc.dirty) scheduleAutosave();
729
592
  });