@jxsuite/studio 0.11.0 → 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 (47) hide show
  1. package/dist/studio.js +4248 -3412
  2. package/dist/studio.js.map +49 -43
  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 +3 -2
  7. package/src/canvas/canvas-render.js +152 -20
  8. package/src/canvas/canvas-utils.js +21 -23
  9. package/src/editor/component-inline-edit.js +54 -41
  10. package/src/editor/content-inline-edit.js +46 -47
  11. package/src/editor/context-menu.js +63 -39
  12. package/src/editor/convert-to-component.js +11 -14
  13. package/src/editor/insertion-helper.js +8 -10
  14. package/src/editor/shortcuts.js +69 -39
  15. package/src/files/components.js +15 -4
  16. package/src/files/files.js +72 -24
  17. package/src/panels/activity-bar.js +29 -7
  18. package/src/panels/block-action-bar.js +104 -80
  19. package/src/panels/dnd.js +32 -28
  20. package/src/panels/editors.js +7 -14
  21. package/src/panels/elements-panel.js +9 -3
  22. package/src/panels/events-panel.js +44 -39
  23. package/src/panels/git-panel.js +45 -3
  24. package/src/panels/layers-panel.js +16 -11
  25. package/src/panels/left-panel.js +108 -43
  26. package/src/panels/overlays.js +91 -41
  27. package/src/panels/panel-events.js +9 -12
  28. package/src/panels/properties-panel.js +179 -104
  29. package/src/panels/right-panel.js +85 -37
  30. package/src/panels/shared.js +0 -22
  31. package/src/panels/signals-panel.js +125 -54
  32. package/src/panels/statusbar.js +23 -8
  33. package/src/panels/style-inputs.js +4 -2
  34. package/src/panels/style-panel.js +128 -105
  35. package/src/panels/stylebook-panel.js +11 -15
  36. package/src/panels/tab-strip.js +124 -0
  37. package/src/panels/toolbar.js +39 -9
  38. package/src/reactivity.js +28 -0
  39. package/src/settings/content-types-editor.js +56 -7
  40. package/src/settings/defs-editor.js +56 -7
  41. package/src/settings/schema-field-ui.js +60 -25
  42. package/src/state.js +0 -456
  43. package/src/store.js +97 -124
  44. package/src/studio.js +112 -121
  45. package/src/tabs/tab.js +153 -0
  46. package/src/tabs/transact.js +406 -0
  47. package/src/workspace/workspace.js +89 -0
@@ -7,18 +7,19 @@ import { html, nothing } from "lit-html";
7
7
  import { live } from "lit-html/directives/live.js";
8
8
  import { ifDefined } from "lit-html/directives/if-defined.js";
9
9
  import {
10
- getState,
11
- update,
12
- updateUi,
13
10
  getNodeAtPath,
14
- updateStyle,
15
- updateMediaStyle,
16
- updateNestedStyle,
17
- updateMediaNestedStyle,
18
11
  COMMON_SELECTORS,
19
12
  isNestedSelector,
20
13
  debouncedStyleCommit,
21
14
  } from "../store.js";
15
+ import { activeTab } from "../workspace/workspace.js";
16
+ import {
17
+ transactDoc,
18
+ mutateUpdateStyle,
19
+ mutateUpdateMediaStyle,
20
+ mutateUpdateNestedStyle,
21
+ mutateUpdateMediaNestedStyle,
22
+ } from "../tabs/transact.js";
22
23
  import { inferInputType, propLabel } from "../utils/studio-utils.js";
23
24
  import { renderFieldRow } from "../ui/field-row.js";
24
25
  import { parseMediaEntries } from "../utils/canvas-media.js";
@@ -65,15 +66,15 @@ function renderStyleRow(
65
66
  }
66
67
 
67
68
  /**
68
- * @param {any} shortProp @param {any} entry @param {any} style @param {any} commitFn
69
+ * @param {any} shortProp @param {any} entry @param {any} style @param {any} mutateFn
69
70
  * @param {any} _deleteFn @param {Record<string, any>} inherited
70
71
  */
71
- function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inherited = {}) {
72
- const S = getState();
72
+ function renderShorthandRow(shortProp, entry, style, mutateFn, _deleteFn, inherited = {}) {
73
+ const tab = activeTab.value;
73
74
  const longhands = getLonghands(shortProp);
74
75
  const shortVal = style[shortProp];
75
76
  const hasLonghands = longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
76
- const isExpanded = S.ui.styleShorthands[shortProp] ?? hasLonghands;
77
+ const isExpanded = tab.session.ui.styleShorthands[shortProp] ?? hasLonghands;
77
78
  const hasAnyVal =
78
79
  shortVal !== undefined || longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
79
80
 
@@ -86,12 +87,12 @@ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inheri
86
87
  title="Clear ${shortProp}"
87
88
  @click=${(/** @type {any} */ e) => {
88
89
  e.stopPropagation();
89
- let s = getState();
90
- if (shortVal !== undefined) s = commitFn(s, shortProp, undefined);
91
- for (const l of longhands) {
92
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
93
- }
94
- update(s);
90
+ transactDoc(activeTab.value, (t) => {
91
+ if (shortVal !== undefined) mutateFn(t, shortProp, undefined);
92
+ for (const l of longhands) {
93
+ if (style[l.name] !== undefined) mutateFn(t, l.name, undefined);
94
+ }
95
+ });
95
96
  }}
96
97
  ></span>`
97
98
  : nothing}
@@ -109,12 +110,12 @@ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inheri
109
110
  ? longhands.map((/** @type {any} */ l) => inherited[l.name] || "0").join(" ")
110
111
  : ""}
111
112
  @input=${debouncedStyleCommit(`short:${shortProp}`, 400, (/** @type {any} */ e) => {
112
- let s = getState();
113
- for (const l of longhands) {
114
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
115
- }
116
- s = commitFn(s, shortProp, e.target.value || undefined);
117
- update(s);
113
+ transactDoc(activeTab.value, (t) => {
114
+ for (const l of longhands) {
115
+ if (style[l.name] !== undefined) mutateFn(t, l.name, undefined);
116
+ }
117
+ mutateFn(t, shortProp, e.target.value || undefined);
118
+ });
118
119
  })}
119
120
  ></sp-textfield>
120
121
  <sp-action-button
@@ -122,10 +123,10 @@ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inheri
122
123
  quiet
123
124
  @click=${(/** @type {any} */ e) => {
124
125
  e.stopPropagation();
125
- updateUi("styleShorthands", {
126
- ...getState().ui.styleShorthands,
126
+ activeTab.value.session.ui.styleShorthands = {
127
+ ...activeTab.value.session.ui.styleShorthands,
127
128
  [shortProp]: !isExpanded,
128
- });
129
+ };
129
130
  }}
130
131
  >
131
132
  ${isExpanded
@@ -162,12 +163,12 @@ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inheri
162
163
  ? emptyVal
163
164
  : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
164
165
  );
165
- let s = getState();
166
- for (const l of longhands) {
167
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
168
- }
169
- s = commitFn(s, shortProp, compress(vals));
170
- update(s);
166
+ transactDoc(activeTab.value, (t) => {
167
+ for (const l of longhands) {
168
+ if (style[l.name] !== undefined) mutateFn(t, l.name, undefined);
169
+ }
170
+ mutateFn(t, shortProp, compress(vals));
171
+ });
171
172
  }}
172
173
  ></span>`
173
174
  : nothing}
@@ -186,12 +187,12 @@ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inheri
186
187
  ? newVal || emptyVal
187
188
  : (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
188
189
  );
189
- let s = getState();
190
- for (const l of longhands) {
191
- if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
192
- }
193
- s = commitFn(s, shortProp, compress(vals));
194
- update(s);
190
+ transactDoc(activeTab.value, (t) => {
191
+ for (const l of longhands) {
192
+ if (style[l.name] !== undefined) mutateFn(t, l.name, undefined);
193
+ }
194
+ mutateFn(t, shortProp, compress(vals));
195
+ });
195
196
  },
196
197
  { placeholder: !lVal && inherited[name] ? String(inherited[name]) : "" },
197
198
  )}
@@ -212,11 +213,12 @@ function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inheri
212
213
  * @param {any} activeSelector
213
214
  */
214
215
  function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
215
- const S = getState();
216
+ const tab = activeTab.value;
217
+ const sel = /** @type {import("../state.js").JxPath} */ (tab.session.selection);
216
218
  const style = node.style || {};
217
- const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(S.document.$media));
219
+ const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(tab.doc.document.$media));
218
220
  const mediaNames = sizeBreakpoints.map((bp) => bp.name);
219
- const activeTab = activeMediaTab;
221
+ const mediaTab = activeMediaTab;
220
222
 
221
223
  // ── Media tabs template ──────────────────────────────────────────────────
222
224
  const mediaTabsT =
@@ -224,12 +226,12 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
224
226
  ? html`
225
227
  <sp-tabs
226
228
  size="s"
227
- selected=${activeTab || "base"}
229
+ selected=${mediaTab || "base"}
228
230
  @change=${(/** @type {any} */ e) => {
229
231
  const val = e.target.selected;
230
232
  const newMedia = val === "base" ? null : val;
231
- if (newMedia !== S.ui.activeMedia) {
232
- updateUi("activeMedia", newMedia);
233
+ if (newMedia !== tab.session.ui.activeMedia) {
234
+ tab.session.ui.activeMedia = newMedia;
233
235
  }
234
236
  }}
235
237
  >
@@ -242,7 +244,7 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
242
244
  : nothing;
243
245
 
244
246
  // ── Selector dropdown ──────────────────────────────────────────────────────
245
- const contextStyle = activeTab ? style[`@${activeTab}`] || {} : style;
247
+ const contextStyle = mediaTab ? style[`@${mediaTab}`] || {} : style;
246
248
  const existingSelectors = Object.keys(contextStyle).filter(isNestedSelector);
247
249
  const existingSet = new Set(existingSelectors);
248
250
  const commonSet = new Set(COMMON_SELECTORS);
@@ -281,7 +283,7 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
281
283
  inp.remove();
282
284
  picker.style.display = "";
283
285
  if (accept && v && isNestedSelector(v)) {
284
- updateUi("activeSelector", v);
286
+ activeTab.value.session.ui.activeSelector = v;
285
287
  }
286
288
  };
287
289
  inp.addEventListener("keydown", (ev) => {
@@ -292,7 +294,7 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
292
294
  return;
293
295
  }
294
296
  const newSelector = val === "__base__" ? null : val;
295
- updateUi("activeSelector", newSelector);
297
+ activeTab.value.session.ui.activeSelector = newSelector;
296
298
  }}
297
299
  >
298
300
  <sp-menu-item value="__base__">(base)</sp-menu-item>
@@ -328,14 +330,19 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
328
330
  size="s"
329
331
  class="style-filter-input"
330
332
  placeholder="Filter properties…"
331
- .value=${live(S.ui.styleFilter || "")}
332
- @input=${(/** @type {any} */ e) => updateUi("styleFilter", e.target.value)}
333
+ .value=${live(tab.session.ui.styleFilter || "")}
334
+ @input=${(/** @type {any} */ e) => {
335
+ activeTab.value.session.ui.styleFilter = e.target.value;
336
+ }}
333
337
  ></sp-textfield>
334
338
  <sp-action-button
335
339
  size="xs"
336
340
  class="style-filter-toggle"
337
- ?selected=${S.ui.styleFilterActive}
338
- @click=${() => updateUi("styleFilterActive", !S.ui.styleFilterActive)}
341
+ ?selected=${tab.session.ui.styleFilterActive}
342
+ @click=${() => {
343
+ activeTab.value.session.ui.styleFilterActive =
344
+ !activeTab.value.session.ui.styleFilterActive;
345
+ }}
339
346
  >
340
347
  Active
341
348
  </sp-action-button>
@@ -345,40 +352,50 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
345
352
  // ── Determine the active style object ──────────────────────────────────────
346
353
  /** @type {Record<string, any>} */
347
354
  let activeStyle;
348
- /** @type {any} */
355
+ /** @type {(prop: any, val: any) => void} */
349
356
  let commitStyle;
350
- if (activeSelector && activeTab && mediaNames.length > 0) {
351
- activeStyle = (style[`@${activeTab}`] || {})[activeSelector] || {};
352
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
353
- updateMediaNestedStyle(s, S.selection, activeTab, activeSelector, prop, val);
357
+ /** @type {(t: any, prop: any, val: any) => void} */
358
+ let commitMutate;
359
+ if (activeSelector && mediaTab && mediaNames.length > 0) {
360
+ activeStyle = (style[`@${mediaTab}`] || {})[activeSelector] || {};
361
+ commitMutate = (/** @type {any} */ t, /** @type {any} */ prop, /** @type {any} */ val) =>
362
+ mutateUpdateMediaNestedStyle(t, sel, mediaTab, activeSelector, prop, val);
363
+ commitStyle = (/** @type {any} */ prop, /** @type {any} */ val) =>
364
+ transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
354
365
  } else if (activeSelector) {
355
366
  activeStyle = style[activeSelector] || {};
356
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
357
- updateNestedStyle(s, S.selection, activeSelector, prop, val);
358
- } else if (activeTab !== null && mediaNames.length > 0) {
367
+ commitMutate = (/** @type {any} */ t, /** @type {any} */ prop, /** @type {any} */ val) =>
368
+ mutateUpdateNestedStyle(t, sel, activeSelector, prop, val);
369
+ commitStyle = (/** @type {any} */ prop, /** @type {any} */ val) =>
370
+ transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
371
+ } else if (mediaTab !== null && mediaNames.length > 0) {
359
372
  activeStyle = {};
360
- for (const [p, v] of Object.entries(style[`@${activeTab}`] || {})) {
373
+ for (const [p, v] of Object.entries(style[`@${mediaTab}`] || {})) {
361
374
  if (typeof v !== "object") activeStyle[p] = v;
362
375
  }
363
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
364
- updateMediaStyle(s, S.selection, activeTab, prop, val);
376
+ commitMutate = (/** @type {any} */ t, /** @type {any} */ prop, /** @type {any} */ val) =>
377
+ mutateUpdateMediaStyle(t, sel, mediaTab, prop, val);
378
+ commitStyle = (/** @type {any} */ prop, /** @type {any} */ val) =>
379
+ transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
365
380
  } else {
366
381
  activeStyle = {};
367
382
  for (const [p, v] of Object.entries(style)) {
368
383
  if (typeof v !== "object") activeStyle[p] = v;
369
384
  }
370
- commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
371
- updateStyle(s, S.selection, prop, val);
385
+ commitMutate = (/** @type {any} */ t, /** @type {any} */ prop, /** @type {any} */ val) =>
386
+ mutateUpdateStyle(t, sel, prop, val);
387
+ commitStyle = (/** @type {any} */ prop, /** @type {any} */ val) =>
388
+ transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
372
389
  }
373
390
 
374
391
  // ── Compute inherited style from higher breakpoints ──────────────────────
375
392
  /** @type {Record<string, any>} */
376
- const inheritedStyle = computeInheritedStyle(style, mediaNames, activeTab, activeSelector);
393
+ const inheritedStyle = computeInheritedStyle(style, mediaNames, mediaTab, activeSelector);
377
394
 
378
395
  // Auto-open sections that have properties
379
- const newSections = autoOpenSections({ style: activeStyle }, S.ui.styleSections);
380
- if (JSON.stringify(newSections) !== JSON.stringify(S.ui.styleSections)) {
381
- updateUi("styleSections", newSections);
396
+ const newSections = autoOpenSections({ style: activeStyle }, tab.session.ui.styleSections);
397
+ if (JSON.stringify(newSections) !== JSON.stringify(tab.session.ui.styleSections)) {
398
+ tab.session.ui.styleSections = newSections;
382
399
  }
383
400
 
384
401
  // Partition properties into sections
@@ -402,8 +419,8 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
402
419
  }
403
420
 
404
421
  // ── Filter state ─────────────────────────────────────────────────────────
405
- const filterText = (S.ui.styleFilter || "").toLowerCase();
406
- const filterActive = S.ui.styleFilterActive;
422
+ const filterText = (tab.session.ui.styleFilter || "").toLowerCase();
423
+ const filterActive = tab.session.ui.styleFilterActive;
407
424
  const isFiltering = filterText.length > 0 || filterActive;
408
425
 
409
426
  // ── Section templates ────────────────────────────────────────────────────
@@ -449,7 +466,7 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
449
466
  hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
450
467
  if (!hasAny && !condMet) continue;
451
468
  rows.push(
452
- renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}, inheritedStyle),
469
+ renderShorthandRow(prop, entry, activeStyle, commitMutate, () => {}, inheritedStyle),
453
470
  );
454
471
  } else {
455
472
  const isWarning = hasVal && !condMet;
@@ -459,9 +476,8 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
459
476
  entry,
460
477
  prop,
461
478
  val ?? "",
462
- (/** @type {any} */ newVal) =>
463
- update(commitStyle(getState(), prop, newVal || undefined)),
464
- () => update(commitStyle(getState(), prop, undefined)),
479
+ (/** @type {any} */ newVal) => commitStyle(prop, newVal || undefined),
480
+ () => commitStyle(prop, undefined),
465
481
  isWarning,
466
482
  sec.$layout === "grid",
467
483
  inheritedStyle[prop],
@@ -472,14 +488,17 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
472
488
  }
473
489
 
474
490
  if (isFiltering && rows.length === 0) return nothing;
475
- const isOpen = isFiltering ? true : (S.ui.styleSections[sec.key] ?? false);
491
+ const isOpen = isFiltering ? true : (tab.session.ui.styleSections[sec.key] ?? false);
476
492
 
477
493
  return html`
478
494
  <sp-accordion-item
479
495
  label=${sec.label}
480
496
  .open=${isOpen}
481
497
  @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
482
- updateUi("styleSections", { ...getState().ui.styleSections, [sec.key]: e.target.open });
498
+ activeTab.value.session.ui.styleSections = {
499
+ ...activeTab.value.session.ui.styleSections,
500
+ [sec.key]: e.target.open,
501
+ };
483
502
  }}
484
503
  >
485
504
  ${sectionActiveProps.length > 0
@@ -492,17 +511,17 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
492
511
  @click=${(/** @type {any} */ e) => {
493
512
  e.stopPropagation();
494
513
  e.preventDefault();
495
- let s = getState();
496
- for (const { prop, entry } of sectionActiveProps) {
497
- if (activeStyle[prop] !== undefined) s = commitStyle(s, prop, undefined);
498
- if (inferInputType(entry) === "shorthand") {
499
- for (const l of getLonghands(prop)) {
500
- if (activeStyle[l.name] !== undefined)
501
- s = commitStyle(s, l.name, undefined);
514
+ transactDoc(activeTab.value, (t) => {
515
+ for (const { prop, entry } of sectionActiveProps) {
516
+ if (activeStyle[prop] !== undefined) commitMutate(t, prop, undefined);
517
+ if (inferInputType(entry) === "shorthand") {
518
+ for (const l of getLonghands(prop)) {
519
+ if (activeStyle[l.name] !== undefined)
520
+ commitMutate(t, l.name, undefined);
521
+ }
502
522
  }
503
523
  }
504
- }
505
- update(s);
524
+ });
506
525
  }}
507
526
  ></span>
508
527
  </span>
@@ -515,13 +534,16 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
515
534
 
516
535
  // ── Custom section ─────────────────────────────────────────────────────────
517
536
  const cssInitialMap = getCssInitialMap();
518
- const customIsOpen = S.ui.styleSections.other ?? otherProps.length > 0;
537
+ const customIsOpen = tab.session.ui.styleSections.other ?? otherProps.length > 0;
519
538
  const customSectionT = html`
520
539
  <sp-accordion-item
521
540
  label="Custom"
522
541
  .open=${customIsOpen}
523
542
  @sp-accordion-item-toggle=${(/** @type {any} */ e) => {
524
- updateUi("styleSections", { ...getState().ui.styleSections, other: e.target.open });
543
+ activeTab.value.session.ui.styleSections = {
544
+ ...activeTab.value.session.ui.styleSections,
545
+ other: e.target.open,
546
+ };
525
547
  }}
526
548
  >
527
549
  <div>
@@ -535,9 +557,10 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
535
557
  @change=${(/** @type {any} */ e) => {
536
558
  const newProp = e.target.value.trim();
537
559
  if (newProp && newProp !== prop) {
538
- let s = commitStyle(getState(), prop, undefined);
539
- s = commitStyle(s, newProp, String(activeStyle[prop]));
540
- update(s);
560
+ transactDoc(activeTab.value, (t) => {
561
+ commitMutate(t, prop, undefined);
562
+ commitMutate(t, newProp, String(activeStyle[prop]));
563
+ });
541
564
  }
542
565
  }}
543
566
  ></sp-textfield>
@@ -547,14 +570,10 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
547
570
  .value=${live(String(activeStyle[prop]))}
548
571
  placeholder=${ifDefined(cssInitialMap.get(prop))}
549
572
  @input=${debouncedStyleCommit(`custom:${prop}`, 400, (/** @type {any} */ e) => {
550
- update(commitStyle(getState(), prop, e.target.value));
573
+ commitStyle(prop, e.target.value);
551
574
  })}
552
575
  ></sp-textfield>
553
- <sp-action-button
554
- size="xs"
555
- quiet
556
- @click=${() => update(commitStyle(getState(), prop, undefined))}
557
- >
576
+ <sp-action-button size="xs" quiet @click=${() => commitStyle(prop, undefined)}>
558
577
  <sp-icon-close slot="icon"></sp-icon-close>
559
578
  </sp-action-button>
560
579
  </div>
@@ -571,7 +590,7 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
571
590
  const prop = e.target.value.trim();
572
591
  if (prop) {
573
592
  const initial = cssInitialMap.get(prop) || "";
574
- update(commitStyle(getState(), prop, initial || ""));
593
+ commitStyle(prop, initial || "");
575
594
  e.target.value = "";
576
595
  }
577
596
  }
@@ -599,19 +618,23 @@ function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
599
618
  * @returns {import("lit-html").TemplateResult}
600
619
  */
601
620
  export function renderStylePanelTemplate(ctx) {
602
- const S = getState();
603
- if (ctx.getCanvasMode() === "settings" && S.ui.stylebookSelection) {
604
- const node = S.document;
621
+ const tab = activeTab.value;
622
+ if (!tab) return html`<div class="empty-state">No document loaded</div>`;
623
+ if (ctx.getCanvasMode() === "settings" && tab.session.ui.stylebookSelection) {
624
+ const node = tab.doc.document;
605
625
  if (!node) return html`<div class="empty-state">No document loaded</div>`;
606
626
  return html`
607
- <div class="stylebook-style-header">Styling: &lt;${S.ui.stylebookSelection}&gt;</div>
608
- ${styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector)}
627
+ <div class="stylebook-style-header">
628
+ Styling: &lt;${tab.session.ui.stylebookSelection}&gt;
629
+ </div>
630
+ ${styleSidebarTemplate(node, tab.session.ui.activeMedia, tab.session.ui.activeSelector)}
609
631
  `;
610
632
  }
611
- if (!S.selection) return html`<div class="empty-state">Select an element to style</div>`;
612
- const node = getNodeAtPath(S.document, S.selection);
633
+ if (!tab.session.selection)
634
+ return html`<div class="empty-state">Select an element to style</div>`;
635
+ const node = getNodeAtPath(tab.doc.document, tab.session.selection);
613
636
  if (!node) return html`<div class="empty-state">Select an element to style</div>`;
614
- return styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector);
637
+ return styleSidebarTemplate(node, tab.session.ui.activeMedia, tab.session.ui.activeSelector);
615
638
  }
616
639
 
617
640
  /** Single property input row (generic field row helper) */
@@ -9,8 +9,6 @@ import { styleMap } from "lit-html/directives/style-map.js";
9
9
 
10
10
  import {
11
11
  getState,
12
- update,
13
- updateStyle,
14
12
  updateSession,
15
13
  updateUi,
16
14
  canvasWrap,
@@ -18,6 +16,8 @@ import {
18
16
  elToPath,
19
17
  projectState,
20
18
  } from "../store.js";
19
+ import { activeTab } from "../workspace/workspace.js";
20
+ import { transactDoc, mutateUpdateStyle } from "../tabs/transact.js";
21
21
  import { view } from "../view.js";
22
22
  import { defineElement, setSkipServerFunctions } from "@jxsuite/runtime";
23
23
  import { componentRegistry } from "../files/components.js";
@@ -272,7 +272,7 @@ export function renderStylebookOverlays() {
272
272
  const selectedTag = S.ui.stylebookSelection;
273
273
 
274
274
  for (const panel of canvasPanels) {
275
- const hoverTag = panel._lastHoverTag;
275
+ const hoverTag = /** @type {any} */ (panel)._lastHoverTag;
276
276
  /**
277
277
  * @type {{
278
278
  * cls: string;
@@ -691,8 +691,9 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
691
691
  const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
692
692
  if (swatch) swatch.style.backgroundColor = colorPicker.value;
693
693
  if (!isNew && varName) {
694
- const S = getState();
695
- update(updateStyle(S, [], varName, colorPicker.value));
694
+ transactDoc(activeTab.value, (t) =>
695
+ mutateUpdateStyle(t, [], varName, colorPicker.value),
696
+ );
696
697
  }
697
698
  }}
698
699
  />
@@ -751,8 +752,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
751
752
  const swatch = /** @type {any} */ (row.querySelector(".sb-var-swatch"));
752
753
  if (swatch) swatch.style.backgroundColor = v;
753
754
  if (!isNew && varName) {
754
- const S = getState();
755
- update(updateStyle(S, [], varName, v));
755
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, v));
756
756
  }
757
757
  }, 400);
758
758
  }}
@@ -765,8 +765,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
765
765
  const bar = /** @type {any} */ (row.querySelector(".sb-var-size-bar"));
766
766
  if (bar) bar.style.width = newVal;
767
767
  if (!isNew && varName) {
768
- const S = getState();
769
- update(updateStyle(S, [], varName, newVal));
768
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, newVal));
770
769
  }
771
770
  },
772
771
  });
@@ -798,8 +797,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
798
797
  const v = textFieldEl.value;
799
798
  const fontPrev = /** @type {any} */ (row.querySelector(".sb-var-font-preview"));
800
799
  if (fontPrev) fontPrev.style.fontFamily = v;
801
- const S = getState();
802
- update(updateStyle(S, [], varName, v));
800
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, v));
803
801
  }, 400);
804
802
  }}
805
803
  ></sp-textfield>
@@ -824,8 +822,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
824
822
  const val = getValueFn();
825
823
  const generatedVar = friendlyNameToVar(name, catMeta.prefix);
826
824
  if (!generatedVar || !val) return;
827
- const S = getState();
828
- update(updateStyle(S, [], generatedVar, val));
825
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], generatedVar, val));
829
826
  }}
830
827
  >Add</sp-action-button
831
828
  >
@@ -858,8 +855,7 @@ function renderVarRow(catKey, catMeta, varName, varVal, isNew) {
858
855
  class="sb-var-del"
859
856
  style="pointer-events:auto"
860
857
  @click=${() => {
861
- const S = getState();
862
- update(updateStyle(S, [], varName, undefined));
858
+ transactDoc(activeTab.value, (t) => mutateUpdateStyle(t, [], varName, undefined));
863
859
  }}
864
860
  >
865
861
  <sp-icon-delete slot="icon"></sp-icon-delete>
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tab strip — renders open tabs above the canvas area.
3
+ *
4
+ * Uses the reactive workspace model: reads from workspace.tabOrder, workspace.tabs,
5
+ * workspace.activeTabId. Clicks call activateTab/closeTab from workspace.js.
6
+ */
7
+
8
+ import { html, render as litRender, nothing } from "lit-html";
9
+ import { effect, effectScope } from "../reactivity.js";
10
+ import { workspace, activateTab, closeTab } from "../workspace/workspace.js";
11
+
12
+ /** @typedef {import("../tabs/tab.js").Tab} Tab */
13
+
14
+ /** @type {HTMLElement | null} */
15
+ let _host = null;
16
+
17
+ /** @type {import("@vue/reactivity").EffectScope | null} */
18
+ let _scope = null;
19
+
20
+ /**
21
+ * Mount the tab strip into the given host element.
22
+ *
23
+ * @param {HTMLElement} host
24
+ */
25
+ export function mount(host) {
26
+ _host = host;
27
+ _scope = effectScope();
28
+ _scope.run(() => {
29
+ effect(() => {
30
+ void workspace.tabOrder;
31
+ void workspace.activeTabId;
32
+ for (const tab of workspace.tabs.values()) {
33
+ void tab.doc.dirty;
34
+ void tab.documentPath;
35
+ }
36
+ render();
37
+ });
38
+ });
39
+ }
40
+
41
+ export function unmount() {
42
+ _scope?.stop();
43
+ _scope = null;
44
+ _host = null;
45
+ }
46
+
47
+ function render() {
48
+ if (!_host) return;
49
+
50
+ if (workspace.tabOrder.length <= 1) {
51
+ litRender(nothing, _host);
52
+ return;
53
+ }
54
+
55
+ litRender(
56
+ html`
57
+ <div class="tab-strip">
58
+ ${workspace.tabOrder.map((id) => {
59
+ const tab = workspace.tabs.get(id);
60
+ if (!tab) return nothing;
61
+ const isActive = id === workspace.activeTabId;
62
+ const isDirty = tab.doc.dirty;
63
+ const label = tabLabel(tab);
64
+ return html`
65
+ <div
66
+ class="tab-strip-tab ${isActive ? "active" : ""}"
67
+ @click=${() => activateTab(id)}
68
+ @auxclick=${(/** @type {MouseEvent} */ e) => {
69
+ if (e.button === 1) {
70
+ e.preventDefault();
71
+ requestClose(id);
72
+ }
73
+ }}
74
+ title=${tab.documentPath || "Untitled"}
75
+ >
76
+ <span class="tab-strip-label">${label}</span>
77
+ ${isDirty ? html`<span class="tab-strip-dirty">●</span>` : nothing}
78
+ <button
79
+ class="tab-strip-close"
80
+ @click=${(/** @type {Event} */ e) => {
81
+ e.stopPropagation();
82
+ requestClose(id);
83
+ }}
84
+ >
85
+ ×
86
+ </button>
87
+ </div>
88
+ `;
89
+ })}
90
+ </div>
91
+ `,
92
+ _host,
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Derive a short label from the tab's documentPath.
98
+ *
99
+ * @param {Tab} tab
100
+ * @returns {string}
101
+ */
102
+ function tabLabel(tab) {
103
+ const path = tab.documentPath;
104
+ if (!path) return "Untitled";
105
+ const parts = path.split("/");
106
+ return parts[parts.length - 1];
107
+ }
108
+
109
+ /**
110
+ * Close a tab, prompting if dirty.
111
+ *
112
+ * @param {string} id
113
+ */
114
+ function requestClose(id) {
115
+ const tab = workspace.tabs.get(id);
116
+ if (!tab) return;
117
+ if (tab.doc.dirty) {
118
+ const confirmed = window.confirm(
119
+ `"${tabLabel(tab)}" has unsaved changes. Close without saving?`,
120
+ );
121
+ if (!confirmed) return;
122
+ }
123
+ closeTab(id);
124
+ }