@jxsuite/studio 0.10.2 → 0.13.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 (53) hide show
  1. package/README.md +82 -0
  2. package/dist/studio.js +5114 -3424
  3. package/dist/studio.js.map +65 -49
  4. package/package.json +6 -3
  5. package/src/browse/browse.js +27 -3
  6. package/src/canvas/canvas-diff.js +184 -0
  7. package/src/canvas/canvas-helpers.js +10 -14
  8. package/src/canvas/canvas-live-render.js +136 -17
  9. package/src/canvas/canvas-render.js +154 -21
  10. package/src/canvas/canvas-utils.js +21 -23
  11. package/src/editor/component-inline-edit.js +54 -41
  12. package/src/editor/content-inline-edit.js +46 -47
  13. package/src/editor/context-menu.js +63 -39
  14. package/src/editor/convert-to-component.js +11 -14
  15. package/src/editor/insertion-helper.js +8 -10
  16. package/src/editor/shortcuts.js +69 -39
  17. package/src/files/components.js +15 -4
  18. package/src/files/files.js +72 -24
  19. package/src/panels/activity-bar.js +29 -7
  20. package/src/panels/block-action-bar.js +104 -80
  21. package/src/panels/canvas-dnd.js +132 -50
  22. package/src/panels/dnd.js +32 -28
  23. package/src/panels/editors.js +7 -14
  24. package/src/panels/elements-panel.js +9 -3
  25. package/src/panels/events-panel.js +44 -39
  26. package/src/panels/git-panel.js +45 -3
  27. package/src/panels/layers-panel.js +16 -11
  28. package/src/panels/left-panel.js +108 -43
  29. package/src/panels/overlays.js +97 -35
  30. package/src/panels/panel-events.js +18 -11
  31. package/src/panels/properties-panel.js +467 -98
  32. package/src/panels/right-panel.js +85 -37
  33. package/src/panels/shared.js +0 -22
  34. package/src/panels/signals-panel.js +125 -54
  35. package/src/panels/statusbar.js +23 -8
  36. package/src/panels/style-inputs.js +4 -2
  37. package/src/panels/style-panel.js +128 -105
  38. package/src/panels/stylebook-panel.js +42 -17
  39. package/src/panels/tab-strip.js +124 -0
  40. package/src/panels/toolbar.js +39 -9
  41. package/src/reactivity.js +28 -0
  42. package/src/settings/content-types-editor.js +78 -8
  43. package/src/settings/defs-editor.js +56 -7
  44. package/src/settings/schema-field-ui.js +99 -11
  45. package/src/site-context.js +105 -0
  46. package/src/state.js +0 -456
  47. package/src/store.js +105 -124
  48. package/src/studio.js +112 -121
  49. package/src/tabs/tab.js +153 -0
  50. package/src/tabs/transact.js +406 -0
  51. package/src/ui/spectrum.js +2 -0
  52. package/src/view.js +3 -0
  53. package/src/workspace/workspace.js +89 -0
package/src/studio.js CHANGED
@@ -7,36 +7,32 @@
7
7
 
8
8
  import {
9
9
  createState,
10
- selectNode,
11
- updateProperty,
12
- updateDef,
13
10
  pushDocument,
14
11
  popDocument,
15
12
  getNodeAtPath,
16
- pathsEqual,
17
13
  canvasWrap,
18
14
  toolbarEl,
19
15
  canvasPanels,
20
16
  registerRenderer,
21
17
  render,
22
- update,
23
18
  setUpdateFn,
24
19
  setGetStateFn,
25
20
  addUpdateMiddleware,
26
21
  runUpdateMiddleware,
27
22
  addPostRenderHook,
28
23
  runPostRenderHooks,
29
- notify,
30
24
  projectState,
31
25
  setProjectState,
32
26
  updateUi,
33
27
  setUpdateSessionFn,
34
- setGetDocFn,
35
- setGetSessionFn,
36
28
  toFlat,
37
29
  fromFlat,
38
30
  } from "./store.js";
39
31
 
32
+ import { activeTab, openTab } from "./workspace/workspace.js";
33
+ import { transactDoc, mutateUpdateDef, mutateUpdateProperty } from "./tabs/transact.js";
34
+ import { effect } from "./reactivity.js";
35
+
40
36
  import { view } from "./view.js";
41
37
 
42
38
  import { isEditing, isEditableBlock } from "./editor/inline-edit.js";
@@ -71,9 +67,9 @@ import {
71
67
  openProject as _openProject,
72
68
  renderFilesTemplate as _renderFilesTemplate,
73
69
  openFileFromTree as _openFileFromTree,
70
+ openFileInTab,
74
71
  setupTreeKeyboard,
75
72
  } from "./files/files.js";
76
- import { eventsSidebarTemplate as _eventsSidebarTemplate } from "./panels/events-panel.js";
77
73
  import { renderImportsTemplate } from "./panels/imports-panel.js";
78
74
  import { renderHeadTemplate } from "./panels/head-panel.js";
79
75
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export.js";
@@ -97,11 +93,12 @@ import { renderGitPanel } from "./panels/git-panel.js";
97
93
  import { components as _swc } from "./ui/spectrum.js"; // eslint-disable-line no-unused-vars
98
94
  import "./ui/panel-resize.js";
99
95
  import { initShortcuts } from "./editor/shortcuts.js";
100
- import { renderActivityBar } from "./panels/activity-bar.js";
96
+ import { renderActivityBar, mount as mountActivityBar } from "./panels/activity-bar.js";
101
97
  import * as toolbarPanel from "./panels/toolbar.js";
102
98
  import * as overlaysPanel from "./panels/overlays.js";
103
99
  import * as rightPanelMod from "./panels/right-panel.js";
104
100
  import * as leftPanelMod from "./panels/left-panel.js";
101
+ import * as tabStrip from "./panels/tab-strip.js";
105
102
  import { renderStylebookOverlays } from "./panels/stylebook-panel.js";
106
103
  import { registerLayersDnD, registerComponentsDnD, registerElementsDnD } from "./panels/dnd.js";
107
104
  import { defaultDef } from "./panels/shared.js";
@@ -132,6 +129,9 @@ function createFloatingContainer() {
132
129
 
133
130
  let canvasMode = "design";
134
131
 
132
+ /** @type {any} */
133
+ let gitDiffState = null;
134
+
135
135
  // ─── Component registry ───────────────────────────────────────────────────────
136
136
 
137
137
  /** @param {any} componentPath */
@@ -177,12 +177,14 @@ async function closeFunctionEditor() {
177
177
  const minResult = await codeService("minify", { code: currentCode });
178
178
  const bodyToStore = minResult?.code ?? currentCode;
179
179
  if (editing.type === "def") {
180
- update(updateDef(S, editing.defName, { body: bodyToStore }));
180
+ transactDoc(activeTab.value, (t) =>
181
+ mutateUpdateDef(t, editing.defName, { body: bodyToStore }),
182
+ );
181
183
  } else if (editing.type === "event") {
182
184
  const node = getNodeAtPath(S.document, editing.path);
183
185
  const current = node?.[editing.eventKey] || {};
184
- update(
185
- updateProperty(S, editing.path, editing.eventKey, {
186
+ transactDoc(activeTab.value, (t) =>
187
+ mutateUpdateProperty(t, editing.path, editing.eventKey, {
186
188
  ...current,
187
189
  $prototype: "Function",
188
190
  body: bodyToStore,
@@ -234,6 +236,12 @@ const EMPTY_DOC = {
234
236
 
235
237
  S = createState(structuredClone(EMPTY_DOC));
236
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
+ openTab({ id: "initial", document: structuredClone(EMPTY_DOC) });
237
245
 
238
246
  // ─── Render loop ──────────────────────────────────────────────────────────────
239
247
 
@@ -246,13 +254,19 @@ toolbarPanel.mount(toolbarEl, {
246
254
  saveFile: () => saveFile(),
247
255
  parseMediaEntries,
248
256
  getCanvasMode: () => canvasMode,
249
- setCanvasMode: (/** @type {any} */ m) => {
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
+ }
250
262
  canvasMode = m;
251
263
  },
252
264
  renderCanvas: () => renderCanvas(),
253
265
  safeRenderRightPanel: () => safeRenderRightPanel(),
254
266
  });
255
267
 
268
+ tabStrip.mount(/** @type {HTMLElement} */ (document.querySelector("#tab-strip")));
269
+
256
270
  overlaysPanel.mount({
257
271
  getCanvasMode: () => canvasMode,
258
272
  isEditing,
@@ -266,13 +280,14 @@ initBlockActionBar({
266
280
  });
267
281
 
268
282
  initComponentInlineEdit({ findCanvasElement });
269
- initCanvasHelpers({ getCanvasMode: () => canvasMode });
283
+ initCanvasHelpers({ getCanvasMode: () => canvasMode, getZoom: () => S.ui.zoom });
270
284
  initCanvasUtils({
271
285
  getCanvasMode: () => canvasMode,
272
286
  getZoom: () => S.ui.zoom,
273
287
  setZoomDirect: (zoom) => {
274
288
  session = { ...session, ui: { ...session.ui, zoom } };
275
289
  S = toFlat(doc, session);
290
+ if (activeTab.value) activeTab.value.session.ui.zoom = zoom;
276
291
  },
277
292
  renderStylebookOverlays,
278
293
  });
@@ -287,18 +302,49 @@ initPanelEvents({
287
302
  navigateToComponent,
288
303
  });
289
304
  initCanvasLiveRender({
290
- getState: () => S,
291
305
  getCanvasMode: () => canvasMode,
292
306
  });
293
307
  initCanvasRender({
294
308
  getCanvasMode: () => canvasMode,
295
- setCanvasMode: (mode) => {
309
+ setCanvasMode: (/** @type {string} */ mode) => {
310
+ // Clear gitDiffState when exiting diff mode
311
+ if (canvasMode === "git-diff" && mode !== "git-diff") {
312
+ gitDiffState = null;
313
+ }
296
314
  canvasMode = mode;
297
315
  },
298
- getState: () => S,
299
- update,
300
316
  openFileFromTree,
301
317
  exportFile,
318
+ gitDiffState,
319
+ setGitDiffState: (/** @type {any} */ state) => {
320
+ gitDiffState = state;
321
+ },
322
+ });
323
+
324
+ // Effect-driven canvas rendering: auto-triggers renderCanvas when reactive deps change
325
+ let _canvasRenderScheduled = false;
326
+ effect(() => {
327
+ const tab = activeTab.value;
328
+ if (!tab) return;
329
+ void tab.doc.document;
330
+ void tab.doc.mode;
331
+ void tab.session.ui.editingFunction;
332
+ void tab.session.ui.featureToggles;
333
+ void tab.session.ui.settingsTab;
334
+ void tab.session.ui.stylebookTab;
335
+ void tab.session.ui.stylebookFilter;
336
+ void tab.session.ui.stylebookCustomizedOnly;
337
+ if (!_canvasRenderScheduled) {
338
+ _canvasRenderScheduled = true;
339
+ queueMicrotask(() => {
340
+ _canvasRenderScheduled = false;
341
+ try {
342
+ renderCanvas();
343
+ } catch (e) {
344
+ console.error("renderCanvas error:", e);
345
+ }
346
+ });
347
+ }
302
348
  });
303
349
 
304
350
  rightPanelMod.mount({
@@ -310,6 +356,9 @@ rightPanelMod.mount({
310
356
 
311
357
  leftPanelMod.mount({
312
358
  getCanvasMode: () => canvasMode,
359
+ setCanvasMode: (/** @type {string} */ mode) => {
360
+ canvasMode = mode;
361
+ },
313
362
  renderImportsTemplate,
314
363
  renderFilesTemplate,
315
364
  renderSignalsTemplate,
@@ -329,27 +378,22 @@ leftPanelMod.mount({
329
378
  });
330
379
 
331
380
  // Register all renderers with the store so render()/renderOnly() work
332
- registerRenderer("toolbar", () => toolbarPanel.render());
333
- registerRenderer("activityBar", () => renderActivityBar(S));
381
+ // Register remaining renderers for render()/renderOnly() compat during migration
334
382
  registerRenderer("leftPanel", () => leftPanelMod.render());
335
383
  registerRenderer("canvas", () => renderCanvas());
336
384
  registerRenderer("rightPanel", () => rightPanelMod.render());
337
385
  registerRenderer("overlays", () => overlaysPanel.render());
338
- registerRenderer("statusbar", () => renderStatusbar(S));
339
386
  setStatusbarRenderer(() => renderStatusbar(S));
340
387
  mountStatusbar();
388
+ mountActivityBar();
341
389
 
342
390
  // Clicking on the canvas-wrap background (outside any canvas panel) deselects the current element
343
391
  canvasWrap.addEventListener("click", (/** @type {any} */ e) => {
344
392
  if (e.target !== canvasWrap && e.target !== view.panzoomWrap) return;
345
393
  if (!S.selection) return;
346
- update(selectNode(S, null));
394
+ activeTab.value.session.selection = null;
347
395
  });
348
396
 
349
- function safeRenderLeftPanel() {
350
- leftPanelMod.render();
351
- }
352
-
353
397
  function safeRenderRightPanel() {
354
398
  rightPanelMod.render();
355
399
  }
@@ -357,60 +401,42 @@ function safeRenderRightPanel() {
357
401
  // Register the update implementation with the store
358
402
  setGetStateFn(() => S);
359
403
  setUpdateFn(function _update(/** @type {any} */ newState) {
360
- const prev = S;
361
404
  const prevDoc = S.document;
362
405
  const prevSel = S.selection;
406
+ const prevUi = S.ui;
363
407
  S = newState;
364
408
 
365
409
  // Keep doc/session slices in sync with flat S
366
410
  ({ doc, session } = fromFlat(S));
367
411
 
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);
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;
389
425
  }
390
- safeRenderLeftPanel();
391
- } else if (selChanged || leftUiChanged) {
392
- safeRenderLeftPanel();
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;
393
429
  }
394
430
 
395
- if (uiChanged && prev.ui?.activeMedia !== S.ui?.activeMedia) {
431
+ if (prevUi?.activeMedia !== S.ui?.activeMedia) {
396
432
  updateActivePanelHeaders();
397
433
  }
398
434
 
399
435
  runPostRenderHooks(prevDoc, prevSel);
400
436
  runUpdateMiddleware(S);
401
-
402
- notify({
403
- doc: docChanged,
404
- selection: selChanged,
405
- hover: false,
406
- ui: uiChanged,
407
- mode: modeChanged,
408
- });
409
437
  });
410
438
 
411
439
  // Register session dispatch — lightweight path for selection/hover/ui changes
412
- setGetDocFn(() => doc);
413
- setGetSessionFn(() => session);
414
440
  setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
415
441
  const prev = session;
416
442
  session = { ...session, ...patch };
@@ -422,38 +448,12 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
422
448
  }
423
449
  S = toFlat(doc, session);
424
450
 
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) {
451
+ if (prev.ui?.activeMedia !== session.ui?.activeMedia) {
453
452
  updateActivePanelHeaders();
454
453
  }
455
454
 
456
455
  // Process pending inline edit when canvas becomes ready
456
+ const canvasChanged = prev.canvas !== session.canvas;
457
457
  if (canvasChanged && session.canvas.status === "ready" && session.ui?.pendingInlineEdit) {
458
458
  const { path, mediaName: mn } = session.ui.pendingInlineEdit;
459
459
  updateUi("pendingInlineEdit", null);
@@ -466,9 +466,6 @@ setUpdateSessionFn(function _updateSession(/** @type {any} */ patch) {
466
466
  }
467
467
 
468
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
469
  });
473
470
 
474
471
  // Register post-render hook for pseudo-state preview
@@ -547,17 +544,32 @@ if (_openParam) {
547
544
  const fileRelPath = _fileParam || siteCtx.fileRelPath || _openParam;
548
545
  const content = await platform.readFile(fileRelPath);
549
546
  if (content) {
547
+ let parsedDoc;
550
548
  if (fileRelPath.endsWith(".md")) {
551
- await loadMarkdown(content, null);
549
+ const ns = await _loadMarkdown(content, null);
550
+ S = ns;
552
551
  S.documentPath = fileRelPath;
552
+ parsedDoc = S.document;
553
553
  } else {
554
- const parsed = JSON.parse(content);
555
- S = createState(parsed);
554
+ parsedDoc = JSON.parse(content);
555
+ S = createState(parsedDoc);
556
556
  S.documentPath = fileRelPath;
557
557
  }
558
558
  S.dirty = false;
559
559
  S.ui = { ...S.ui, leftTab: "files" };
560
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
+ }
572
+
561
573
  render();
562
574
  statusMessage(`Opened ${_openParam}`);
563
575
  }
@@ -619,11 +631,6 @@ function fileOpsCtx() {
619
631
  function openFile() {
620
632
  return _openFile(fileOpsCtx());
621
633
  }
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
634
  function saveFile() {
628
635
  return _saveFile(fileOpsCtx());
629
636
  }
@@ -643,7 +650,7 @@ function openProject() {
643
650
  S = ns;
644
651
  ({ doc, session } = fromFlat(S));
645
652
  },
646
- renderActivityBar: () => renderActivityBar(S),
653
+ renderActivityBar: () => renderActivityBar(),
647
654
  renderLeftPanel,
648
655
  });
649
656
  }
@@ -651,23 +658,7 @@ function renderFilesTemplate() {
651
658
  return _renderFilesTemplate({ openProject, openFileFromTree, renderLeftPanel });
652
659
  }
653
660
  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
- );
661
+ return openFileInTab(path);
671
662
  }
672
663
 
673
664
  // ─── Keyboard shortcuts ───────────────────────────────────────────────────────
@@ -717,7 +708,7 @@ function scheduleAutosave() {
717
708
  const writable = await S.fileHandle.createWritable();
718
709
  await writable.write(JSON.stringify(S.document, null, 2));
719
710
  await writable.close();
720
- update({ ...S, dirty: false });
711
+ activeTab.value.doc.dirty = false;
721
712
  statusMessage("Auto-saved");
722
713
  } catch {}
723
714
  }
@@ -0,0 +1,153 @@
1
+ import { reactive, effectScope } from "../reactivity.js";
2
+
3
+ /**
4
+ * @typedef {{
5
+ * leftTab: string;
6
+ * rightTab: string;
7
+ * zoom: number;
8
+ * activeMedia: string | null;
9
+ * activeSelector: string | null;
10
+ * editingFunction: object | null;
11
+ * featureToggles: Record<string, boolean>;
12
+ * styleSections: Record<string, boolean>;
13
+ * inspectorSections: Record<string, boolean>;
14
+ * styleShorthands: Record<string, boolean>;
15
+ * styleFilter: string;
16
+ * styleFilterActive: boolean;
17
+ * stylebookSelection: string | null;
18
+ * stylebookTab: string;
19
+ * stylebookFilter: string;
20
+ * stylebookCustomizedOnly: boolean;
21
+ * settingsTab: string;
22
+ * gitStatus: object | null;
23
+ * gitBranches: object | null;
24
+ * gitCommitMessage: string;
25
+ * gitLoading: boolean;
26
+ * gitError: string | null;
27
+ * pendingInlineEdit: object | null;
28
+ * }} TabUi
29
+ */
30
+
31
+ /**
32
+ * @typedef {{
33
+ * document: Record<string, any>;
34
+ * selection: (string | number)[] | null;
35
+ * }} HistorySnapshot
36
+ */
37
+
38
+ /**
39
+ * @typedef {{
40
+ * id: string;
41
+ * documentPath: string | null;
42
+ * fileHandle: FileSystemFileHandle | null;
43
+ * scope: { stop(): void; run<T>(fn: () => T): T | undefined; [k: string]: any };
44
+ * doc: {
45
+ * document: Record<string, any>;
46
+ * content: { frontmatter: Record<string, unknown> };
47
+ * mode: string;
48
+ * handlersSource: string | null;
49
+ * dirty: boolean;
50
+ * };
51
+ * session: {
52
+ * selection: (string | number)[] | null;
53
+ * hover: (string | number)[] | null;
54
+ * clipboard: object | null;
55
+ * documentStack: object[];
56
+ * ui: TabUi;
57
+ * canvas: {
58
+ * status: string;
59
+ * scope: { stop(): void; [k: string]: any } | null;
60
+ * error: string | null;
61
+ * pendingInlineEdit: object | null;
62
+ * };
63
+ * };
64
+ * history: {
65
+ * snapshots: HistorySnapshot[];
66
+ * index: number;
67
+ * };
68
+ * }} Tab
69
+ */
70
+
71
+ /** @returns {TabUi} */
72
+ function createDefaultUi() {
73
+ return {
74
+ leftTab: "layers",
75
+ rightTab: "properties",
76
+ zoom: 1,
77
+ activeMedia: null,
78
+ activeSelector: null,
79
+ editingFunction: null,
80
+ featureToggles: {},
81
+ styleSections: {},
82
+ inspectorSections: {},
83
+ styleShorthands: {},
84
+ styleFilter: "",
85
+ styleFilterActive: false,
86
+ stylebookSelection: null,
87
+ stylebookTab: "elements",
88
+ stylebookFilter: "",
89
+ stylebookCustomizedOnly: false,
90
+ settingsTab: "stylebook",
91
+ gitStatus: null,
92
+ gitBranches: null,
93
+ gitCommitMessage: "",
94
+ gitLoading: false,
95
+ gitError: null,
96
+ pendingInlineEdit: null,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Create a new tab with reactive doc/session/history trees, owned by an effectScope.
102
+ *
103
+ * @param {{
104
+ * id: string;
105
+ * documentPath?: string | null;
106
+ * fileHandle?: FileSystemFileHandle | null;
107
+ * document: Record<string, any>;
108
+ * frontmatter?: Record<string, unknown>;
109
+ * }} opts
110
+ * @returns {Tab}
111
+ */
112
+ export function createTab({ id, documentPath = null, fileHandle = null, document, frontmatter }) {
113
+ const scope = effectScope();
114
+
115
+ const tab = /** @type {Tab} */ (
116
+ scope.run(() => ({
117
+ id,
118
+ documentPath,
119
+ fileHandle,
120
+ scope,
121
+ doc: reactive({
122
+ document,
123
+ content: { frontmatter: frontmatter || {} },
124
+ mode: documentPath?.endsWith(".md") ? "content" : "component",
125
+ handlersSource: null,
126
+ dirty: false,
127
+ }),
128
+ session: reactive({
129
+ selection: null,
130
+ hover: null,
131
+ clipboard: null,
132
+ documentStack: [],
133
+ ui: createDefaultUi(),
134
+ canvas: { status: "idle", scope: null, error: null, pendingInlineEdit: null },
135
+ }),
136
+ history: reactive({
137
+ snapshots: [{ document: structuredClone(document), selection: null }],
138
+ index: 0,
139
+ }),
140
+ }))
141
+ );
142
+
143
+ return tab;
144
+ }
145
+
146
+ /**
147
+ * Dispose a tab — stops its effectScope, killing all effects created within it.
148
+ *
149
+ * @param {Tab} tab
150
+ */
151
+ export function disposeTab(tab) {
152
+ tab.scope.stop();
153
+ }