@jxsuite/studio 0.28.5 → 0.30.1

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 (86) hide show
  1. package/dist/studio.css +98 -98
  2. package/dist/studio.js +9368 -6586
  3. package/dist/studio.js.map +109 -83
  4. package/dist/workers/editor.worker.js +79 -79
  5. package/dist/workers/json.worker.js +109 -109
  6. package/dist/workers/ts.worker.js +82 -82
  7. package/package.json +11 -10
  8. package/src/browse/browse-modal.ts +2 -2
  9. package/src/browse/browse.ts +20 -19
  10. package/src/canvas/canvas-diff.ts +3 -3
  11. package/src/canvas/canvas-helpers.ts +15 -2
  12. package/src/canvas/canvas-live-render.ts +168 -86
  13. package/src/canvas/canvas-patcher.ts +656 -0
  14. package/src/canvas/canvas-perf.ts +68 -0
  15. package/src/canvas/canvas-render.ts +135 -23
  16. package/src/canvas/canvas-subtree-render.ts +113 -0
  17. package/src/canvas/canvas-utils.ts +4 -0
  18. package/src/editor/component-inline-edit.ts +4 -35
  19. package/src/editor/context-menu.ts +96 -76
  20. package/src/editor/convert-to-component.ts +11 -2
  21. package/src/editor/convert-to-repeater.ts +4 -5
  22. package/src/editor/inline-edit.ts +9 -9
  23. package/src/editor/inline-format.ts +11 -11
  24. package/src/editor/merge-tags.ts +120 -0
  25. package/src/editor/shortcuts.ts +4 -4
  26. package/src/files/components.ts +37 -0
  27. package/src/files/file-ops.ts +28 -7
  28. package/src/files/files.ts +68 -24
  29. package/src/files/fs-events.ts +162 -0
  30. package/src/github/github-auth.ts +14 -2
  31. package/src/github/github-publish.ts +12 -2
  32. package/src/panels/activity-bar.ts +1 -1
  33. package/src/panels/ai-panel.ts +67 -39
  34. package/src/panels/block-action-bar.ts +72 -1
  35. package/src/panels/canvas-dnd.ts +55 -26
  36. package/src/panels/data-explorer.ts +5 -3
  37. package/src/panels/dnd.ts +14 -17
  38. package/src/panels/editors.ts +5 -23
  39. package/src/panels/elements-panel.ts +2 -12
  40. package/src/panels/events-panel.ts +1 -1
  41. package/src/panels/git-panel.ts +6 -6
  42. package/src/panels/head-panel.ts +1 -1
  43. package/src/panels/layers-panel.ts +177 -147
  44. package/src/panels/preview-render.ts +15 -0
  45. package/src/panels/properties-panel.ts +16 -27
  46. package/src/panels/quick-search.ts +2 -2
  47. package/src/panels/right-panel.ts +2 -6
  48. package/src/panels/shared.ts +3 -0
  49. package/src/panels/signals-panel.ts +43 -33
  50. package/src/panels/statusbar.ts +15 -6
  51. package/src/panels/style-panel.ts +3 -3
  52. package/src/panels/style-utils.ts +3 -3
  53. package/src/panels/stylebook-panel.ts +4 -4
  54. package/src/panels/tab-bar.ts +198 -0
  55. package/src/panels/tab-strip.ts +2 -2
  56. package/src/panels/toolbar.ts +6 -75
  57. package/src/platforms/devserver.ts +113 -35
  58. package/src/reactivity.ts +2 -0
  59. package/src/services/cem-export.ts +23 -2
  60. package/src/services/code-services.ts +16 -2
  61. package/src/services/monaco-setup.ts +2 -1
  62. package/src/settings/content-types-editor.ts +16 -16
  63. package/src/settings/css-vars-editor.ts +2 -2
  64. package/src/settings/defs-editor.ts +14 -14
  65. package/src/settings/general-settings.ts +6 -6
  66. package/src/settings/head-editor.ts +2 -2
  67. package/src/site-context.ts +1 -1
  68. package/src/state.ts +66 -12
  69. package/src/store.ts +15 -3
  70. package/src/studio.ts +88 -35
  71. package/src/tabs/patch-ops.ts +143 -0
  72. package/src/tabs/tab.ts +15 -1
  73. package/src/tabs/transact.ts +454 -27
  74. package/src/types.ts +71 -1
  75. package/src/ui/color-selector.ts +7 -7
  76. package/src/ui/icons.ts +0 -30
  77. package/src/ui/media-picker.ts +2 -2
  78. package/src/ui/panel-resize.ts +14 -8
  79. package/src/ui/unit-selector.ts +5 -2
  80. package/src/ui/value-selector.ts +3 -3
  81. package/src/utils/canvas-media.ts +6 -6
  82. package/src/utils/edit-display.ts +125 -83
  83. package/src/utils/google-fonts.ts +1 -1
  84. package/src/utils/inherited-style.ts +3 -2
  85. package/src/utils/studio-utils.ts +1 -1
  86. package/src/view.ts +4 -1
@@ -214,7 +214,7 @@ export function defBadgeLabel(def: SignalDef | unknown) {
214
214
  * @param {string} name
215
215
  * @param {SignalDef | null | undefined} def
216
216
  */
217
- export function defHint(name: string, def: SignalDef | null | undefined) {
217
+ export function defHint(_name: string, def: SignalDef | null | undefined) {
218
218
  if (!def) {
219
219
  return "";
220
220
  }
@@ -413,15 +413,15 @@ export function renderSignalsTemplate(S: SignalsPanelState, ctx: SignalsPanelCtx
413
413
  state: [],
414
414
  } as Record<string, [string, SignalDef][]>;
415
415
  for (const [name, def] of entries) {
416
- groups[defCategory(def)].push([name, asSignalDef(def)]);
416
+ groups[defCategory(def)]!.push([name, asSignalDef(def)]);
417
417
  }
418
418
 
419
419
  const categories = [
420
- { items: groups.state, key: "state", label: "State" },
421
- { items: groups.computed, key: "computed", label: "Computed" },
422
- { items: groups.data, key: "data", label: "Data" },
423
- { items: groups.expression, key: "expression", label: "Expressions" },
424
- { items: groups.function, key: "function", label: "Functions" },
420
+ { items: groups.state!, key: "state", label: "State" },
421
+ { items: groups.computed!, key: "computed", label: "Computed" },
422
+ { items: groups.data!, key: "data", label: "Data" },
423
+ { items: groups.expression!, key: "expression", label: "Expressions" },
424
+ { items: groups.function!, key: "function", label: "Functions" },
425
425
  ];
426
426
 
427
427
  const collapsedCats = (S._collapsedSignalCats ||= new Set());
@@ -515,7 +515,7 @@ export function renderSignalsTemplate(S: SignalsPanelState, ctx: SignalsPanelCtx
515
515
  );
516
516
  expandedSignal = n;
517
517
  if (src) {
518
- fetchPluginSchema(
518
+ void fetchPluginSchema(
519
519
  { $prototype: protoName, $src: src },
520
520
  {
521
521
  ...(S.documentPath != null && {
@@ -706,9 +706,9 @@ function renderSignalEditorTemplate(
706
706
  : signalFieldRow("Default", defaultVal, (v: string) => {
707
707
  let parsed: unknown = v;
708
708
  if (def.type === "integer") {
709
- parsed = Number.parseInt(v, 10) || 0;
709
+ parsed = Math.trunc(Number(v)) || 0;
710
710
  } else if (def.type === "number") {
711
- parsed = Number.parseFloat(v) || 0;
711
+ parsed = Number(v) || 0;
712
712
  } else if (def.type === "boolean") {
713
713
  parsed = v === "true";
714
714
  } else if (def.type === "array" || def.type === "object") {
@@ -860,7 +860,7 @@ function renderDataSourceFields(
860
860
  )}
861
861
  ${signalFieldRow("Version", String(def.version || 1), (v: string) =>
862
862
  transactDoc(activeTab.value, (t) =>
863
- mutateUpdateDef(t, name, { version: Number.parseInt(v, 10) || 1 }),
863
+ mutateUpdateDef(t, name, { version: Math.trunc(Number(v)) || 1 }),
864
864
  ),
865
865
  )}
866
866
  `;
@@ -887,7 +887,7 @@ function renderDataSourceFields(
887
887
  return textareaRow(fieldLabel, defaultStr, (v: string) => {
888
888
  try {
889
889
  transactDoc(activeTab.value, (t) =>
890
- mutateUpdateDef(t, name, { [fieldName]: JSON.parse(v) }),
890
+ mutateUpdateDef(t, name, { [fieldName]: JSON.parse(v) as unknown }),
891
891
  );
892
892
  } catch {}
893
893
  });
@@ -901,7 +901,7 @@ function renderFunctionFields(
901
901
  S: SignalsPanelState,
902
902
  name: string,
903
903
  def: SignalDef,
904
- textareaRow: (
904
+ _textareaRow: (
905
905
  label: string,
906
906
  value: string,
907
907
  onChange: (value: string) => void,
@@ -962,7 +962,7 @@ function renderFunctionFields(
962
962
 
963
963
  /** Render CEM parameter editor with basic/advanced toggle. */
964
964
  function renderParameterEditorTemplate(
965
- S: SignalsPanelState,
965
+ _S: SignalsPanelState,
966
966
  name: string,
967
967
  def: SignalDef,
968
968
  ctx: SignalsPanelCtx,
@@ -1061,7 +1061,7 @@ function renderParameterEditorTemplate(
1061
1061
  @change=${(e: Event) => {
1062
1062
  const next = [...params];
1063
1063
  const val = (e.target as HTMLInputElement).value;
1064
- const { type: _t, ...rest } = next[i];
1064
+ const { type: _t, ...rest } = next[i]!;
1065
1065
  next[i] = val ? { ...rest, type: { text: val } } : rest;
1066
1066
  transactDoc(activeTab.value, (t) =>
1067
1067
  mutateUpdateDef(t, name, { parameters: next }),
@@ -1076,7 +1076,7 @@ function renderParameterEditorTemplate(
1076
1076
  @change=${(e: Event) => {
1077
1077
  const next = [...params];
1078
1078
  const val = (e.target as HTMLInputElement).value;
1079
- const { description: _d, ...rest } = next[i];
1079
+ const { description: _d, ...rest } = next[i]!;
1080
1080
  next[i] = val ? { ...rest, description: val } : rest;
1081
1081
  transactDoc(activeTab.value, (t) =>
1082
1082
  mutateUpdateDef(t, name, { parameters: next }),
@@ -1090,7 +1090,7 @@ function renderParameterEditorTemplate(
1090
1090
  @change=${(e: Event) => {
1091
1091
  const next = [...params];
1092
1092
  const { checked } = e.target as HTMLInputElement;
1093
- const { optional: _o, ...rest } = next[i];
1093
+ const { optional: _o, ...rest } = next[i]!;
1094
1094
  next[i] = checked ? { ...rest, optional: true } : rest;
1095
1095
  transactDoc(activeTab.value, (t) =>
1096
1096
  mutateUpdateDef(t, name, { parameters: next }),
@@ -1174,7 +1174,7 @@ function renderEmitsEditorTemplate(S: SignalsPanelState, name: string, def: Sign
1174
1174
  @change=${(e: Event) => {
1175
1175
  const next = [...emits];
1176
1176
  const val = (e.target as HTMLInputElement).value;
1177
- const { type: _t, ...rest } = next[i];
1177
+ const { type: _t, ...rest } = next[i]!;
1178
1178
  next[i] = val ? { ...rest, type: { text: val } } : rest;
1179
1179
  transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { emits: next }));
1180
1180
  }}
@@ -1187,7 +1187,7 @@ function renderEmitsEditorTemplate(S: SignalsPanelState, name: string, def: Sign
1187
1187
  @change=${(e: Event) => {
1188
1188
  const next = [...emits];
1189
1189
  const val = (e.target as HTMLInputElement).value;
1190
- const { description: _d, ...rest } = next[i];
1190
+ const { description: _d, ...rest } = next[i]!;
1191
1191
  next[i] = val ? { ...rest, description: val } : rest;
1192
1192
  transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { emits: next }));
1193
1193
  }}
@@ -1248,7 +1248,7 @@ function resolveSchemaEnum(
1248
1248
  const match = ref.match(/#\/\$context\/contentTypes\/\{@(\w+)\}\/schema\/properties/);
1249
1249
  if (match && parentDef) {
1250
1250
  const [, paramName] = match;
1251
- const typeName = parentDef[paramName] as string | undefined;
1251
+ const typeName = parentDef[paramName!] as string | undefined;
1252
1252
  if (typeName) {
1253
1253
  const ct = projectState?.projectConfig?.contentTypes?.[typeName] as
1254
1254
  | Record<string, unknown>
@@ -1279,6 +1279,14 @@ function resolveSchemaEnum(
1279
1279
  * @param {(val: unknown) => void} onChange
1280
1280
  * @param {Record<string, unknown>} [parentDef] - Parent def for resolving dependent enum refs
1281
1281
  */
1282
+ /** Parse a numeric field value, returning NaN for blank input (so callers can treat it as unset). */
1283
+ function parseNumericField(raw: string, integer: boolean): number {
1284
+ if (raw.trim() === "") {
1285
+ return Number.NaN;
1286
+ }
1287
+ return integer ? Math.trunc(Number(raw)) : Number(raw);
1288
+ }
1289
+
1282
1290
  function renderInlineField(
1283
1291
  key: string,
1284
1292
  schema: Record<string, unknown>,
@@ -1319,10 +1327,10 @@ function renderInlineField(
1319
1327
  .value=${value !== undefined ? value : nothing}
1320
1328
  step=${schema.type === "integer" ? "1" : nothing}
1321
1329
  @change=${(e: Event) => {
1322
- const parsed =
1323
- schema.type === "integer"
1324
- ? Number.parseInt((e.target as HTMLInputElement).value, 10)
1325
- : Number.parseFloat((e.target as HTMLInputElement).value);
1330
+ const parsed = parseNumericField(
1331
+ (e.target as HTMLInputElement).value,
1332
+ schema.type === "integer",
1333
+ );
1326
1334
  onChange(Number.isNaN(parsed) ? undefined : parsed);
1327
1335
  }}
1328
1336
  ></sp-number-field>`;
@@ -1357,7 +1365,7 @@ function renderJsonTextField(
1357
1365
  try {
1358
1366
  transactDoc(activeTab.value, (t) =>
1359
1367
  mutateUpdateDef(t, name, {
1360
- [prop]: JSON.parse((e.target as HTMLInputElement).value),
1368
+ [prop]: JSON.parse((e.target as HTMLInputElement).value) as unknown,
1361
1369
  }),
1362
1370
  );
1363
1371
  } catch {}
@@ -1439,10 +1447,10 @@ export function renderSchemaFieldsTemplate(
1439
1447
  @change=${(e: Event) => {
1440
1448
  clearTimeout(debounce);
1441
1449
  debounce = setTimeout(() => {
1442
- const parsed =
1443
- ps.type === "integer"
1444
- ? Number.parseInt((e.target as HTMLInputElement).value, 10)
1445
- : Number.parseFloat((e.target as HTMLInputElement).value);
1450
+ const parsed = parseNumericField(
1451
+ (e.target as HTMLInputElement).value,
1452
+ ps.type === "integer",
1453
+ );
1446
1454
  transactDoc(activeTab.value, (t) =>
1447
1455
  mutateUpdateDef(t, name, {
1448
1456
  [prop]: Number.isNaN(parsed) ? undefined : parsed,
@@ -1490,7 +1498,7 @@ export function renderSchemaFieldsTemplate(
1490
1498
  try {
1491
1499
  transactDoc(activeTab.value, (t) =>
1492
1500
  mutateUpdateDef(t, name, {
1493
- [prop]: JSON.parse((e.target as HTMLInputElement).value),
1501
+ [prop]: JSON.parse((e.target as HTMLInputElement).value) as unknown,
1494
1502
  }),
1495
1503
  );
1496
1504
  } catch {}
@@ -1501,7 +1509,9 @@ export function renderSchemaFieldsTemplate(
1501
1509
  `;
1502
1510
  } else if (ps.type === "array" && ps.items?.type === "object" && ps.items?.properties) {
1503
1511
  // Array of objects with defined schema → multi-row inline form
1504
- const rows = Array.isArray(currentValue) ? currentValue : [];
1512
+ const rows: Record<string, unknown>[] = Array.isArray(currentValue)
1513
+ ? (currentValue as Record<string, unknown>[])
1514
+ : [];
1505
1515
  const itemProps = ps.items.properties as Record<string, Record<string, unknown>>;
1506
1516
  control = html`
1507
1517
  <div class="array-object-field">
@@ -1626,7 +1636,7 @@ export function renderExternalPrototypeEditorTemplate(
1626
1636
  ${schema.description
1627
1637
  ? html`<div class="signal-hint" style="padding:4px 0 8px">${schema.description}</div>`
1628
1638
  : nothing}
1629
- ${renderSchemaFieldsTemplate(schema, def, name, S, ctx)}
1639
+ ${renderSchemaFieldsTemplate(schema as JsonSchema, def, name, S, ctx)}
1630
1640
  `;
1631
1641
  }
1632
1642
  } else {
@@ -1636,7 +1646,7 @@ export function renderExternalPrototypeEditorTemplate(
1636
1646
  >
1637
1647
  Loading schema…
1638
1648
  </div>`;
1639
- fetchPluginSchema(def, {
1649
+ void fetchPluginSchema(def, {
1640
1650
  ...(S.documentPath != null && { documentPath: S.documentPath }),
1641
1651
  }).then((schema) => {
1642
1652
  if (schema) {
@@ -70,19 +70,28 @@ export function renderStatusbar() {
70
70
  const node = getNodeAtPath(tab.doc.document, sel);
71
71
  parts.push(`Selected: ${esc(nodeLabel(node))}`);
72
72
 
73
+ // Walk the path one structural step at a time. Most steps are `["children", index]` or
74
+ // `["cases", name]` pairs, but a repeater template is reached by a lone `"map"` segment — so the
75
+ // Step width varies. Emitting a crumb per node keeps the array pseudo-element ("Repeater") and
76
+ // Its template both visible instead of collapsing the array into a bare `[index]`.
73
77
  const pathSegments = [];
74
- for (let i = 0; i < sel.length; i += 2) {
75
- const subPath = sel.slice(0, i + 2);
78
+ for (let i = 0; i < sel.length; ) {
79
+ const seg = sel[i];
80
+ const step = seg === "map" ? 1 : 2;
81
+ const subPath = sel.slice(0, i + step);
76
82
  const childNode = getNodeAtPath(tab.doc.document, subPath);
77
83
  const fallbackTag = childNode?.tag;
78
84
  const label =
79
- childNode?.tagName ||
80
- (typeof fallbackTag === "string" ? fallbackTag : "") ||
81
- `[${sel[i + 1]}]`;
85
+ childNode?.$prototype === "Array"
86
+ ? "Repeater"
87
+ : childNode?.tagName ||
88
+ (typeof fallbackTag === "string" ? fallbackTag : "") ||
89
+ (seg === "cases" ? String(sel[i + 1]) : `[${sel[i + 1]}]`);
82
90
  const dataPath = JSON.stringify(subPath);
83
91
  pathSegments.push(
84
92
  `<span class="sb-path-seg" data-path='${esc(dataPath)}'>${esc(label)}</span>`,
85
93
  );
94
+ i += step;
86
95
  }
87
96
  parts.push(`Path: ${pathSegments.join(' <span class="sb-path-sep">&gt;</span> ')}`);
88
97
  } else if (tab?.session.ui.stylebookSelection) {
@@ -106,7 +115,7 @@ function _onStatusbarClick(e: Event) {
106
115
  return;
107
116
  }
108
117
  try {
109
- const path = JSON.parse(pathStr);
118
+ const path = JSON.parse(pathStr) as JxPath;
110
119
  updateSession({ selection: path });
111
120
  renderOnly("leftPanel", "rightPanel", "canvas");
112
121
  } catch {
@@ -505,10 +505,10 @@ function styleSidebarTemplate(
505
505
  continue;
506
506
  }
507
507
  const sec = ((entry as Record<string, unknown>).$section as string) || "other";
508
- sectionProps[sec].push({ entry, prop });
508
+ sectionProps[sec]!.push({ entry, prop });
509
509
  }
510
510
  for (const sec of cssMeta.$sections) {
511
- sectionProps[sec.key].sort(
511
+ sectionProps[sec.key]!.sort(
512
512
  (
513
513
  a: { prop: string; entry: CssPropertyEntry },
514
514
  b: { prop: string; entry: CssPropertyEntry },
@@ -545,7 +545,7 @@ function styleSidebarTemplate(
545
545
  const sectionTemplates = cssMeta.$sections
546
546
  .filter((sec) => sec.key !== "other")
547
547
  .map((sec) => {
548
- const entries = sectionProps[sec.key];
548
+ const entries = sectionProps[sec.key]!;
549
549
 
550
550
  const sectionActiveProps = entries.filter(
551
551
  ({ prop, entry }: { prop: string; entry: CssPropertyEntry }) => {
@@ -4,7 +4,6 @@ import type { JxMutableNode } from "@jxsuite/schema/types";
4
4
 
5
5
  import { getNodeAtPath } from "../store";
6
6
  import { activeTab } from "../workspace/workspace";
7
- import { camelToKebab } from "../utils/studio-utils";
8
7
  import cssMeta from "../../data/css-meta.json";
9
8
 
10
9
  let cssInitialMap = new Map<string, string>();
@@ -246,9 +245,10 @@ export function currentFontFamily() {
246
245
  }
247
246
  const m = typeof raw === "string" && raw.match(/^var\((--[^)]+)\)$/);
248
247
  if (m) {
249
- return tab?.doc.document?.style?.[m[1]] || "";
248
+ return tab?.doc.document?.style?.[m[1]!] || "";
250
249
  }
251
250
  return raw;
252
251
  }
253
252
 
254
- export { cssMeta, camelToKebab };
253
+ export { default as cssMeta } from "../../data/css-meta.json";
254
+ export { camelToKebab } from "../utils/studio-utils";
@@ -78,7 +78,7 @@ interface StylebookCtx {
78
78
  effectiveZoom: () => number;
79
79
  }
80
80
 
81
- export { stylebookMeta };
81
+ export { default as stylebookMeta } from "../../data/stylebook-meta.json";
82
82
 
83
83
  /**
84
84
  * Resolve a nested tag path in a style object. e.g., "table th" → style["table"]["th"]
@@ -102,7 +102,7 @@ function _resolveNestedStyle(style: Record<string, unknown>, tagPath: string) {
102
102
  let _ctx: StylebookCtx | null = null;
103
103
 
104
104
  /** Lookup: tag → entry from stylebookMeta (built once) */
105
- const _entryByTag = new Map();
105
+ const _entryByTag = new Map<string, StylebookEntry>();
106
106
  for (const section of stylebookMeta.$sections) {
107
107
  for (const entry of section.elements as StylebookEntry[]) {
108
108
  _entryByTag.set(entry.tag, entry);
@@ -289,7 +289,7 @@ export function refreshStylebookStyles() {
289
289
  const htmlEl = el as HTMLElement;
290
290
  // Determine if it's a compound selector (e.g. "ul li") or simple tag
291
291
  const parts = tag.split(" ");
292
- const leafTag = parts.at(-1);
292
+ const leafTag = parts.at(-1)!;
293
293
  const entry = _entryByTag.get(leafTag);
294
294
 
295
295
  // Reset to base style
@@ -379,7 +379,7 @@ export function selectStylebookTag(tag: string, media?: string | null, { panCanv
379
379
  renderStylebookOverlays();
380
380
 
381
381
  if (tag && panCanvas && canvasPanels.length > 0) {
382
- const el = findStylebookEl(canvasPanels[0].canvas, tag);
382
+ const el = findStylebookEl(canvasPanels[0]!.canvas, tag);
383
383
  if (el) {
384
384
  panToCanvasEl(el);
385
385
  }
@@ -0,0 +1,198 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Tab bar — a per-tab contextual action bar rendered between the tab strip and the edit content.
4
+ * Standardizes Back/breadcrumb navigation, media feature toggles, and mode actions (Code-mode
5
+ * Export) into a single bar shared identically by every edit mode.
6
+ *
7
+ * Follows the same module shape as tab-strip.ts: mount(host, ctx) → effectScope/effect → render().
8
+ * The bar collapses (renders `nothing`, so `#tab-bar:empty` hides the row) when there is nothing
9
+ * contextual to show.
10
+ */
11
+
12
+ import { html, render as litRender, nothing } from "lit-html";
13
+ import { updateUi } from "../store";
14
+ import { effect, effectScope } from "../reactivity";
15
+ import { activeTab } from "../workspace/workspace";
16
+ import { getEffectiveMedia } from "../site-context";
17
+ import { mediaDisplayName } from "./shared";
18
+ import type { DocumentStackEntry, FunctionEditDef } from "../types";
19
+ import type { EffectScope } from "@vue/reactivity";
20
+ import type { TemplateResult } from "lit-html";
21
+
22
+ interface TabBarCtx {
23
+ navigateBack: () => void;
24
+ navigateToLevel: (level: number) => void;
25
+ closeFunctionEditor: () => void;
26
+ exportFile: () => void;
27
+ getCanvasMode: () => string;
28
+ parseMediaEntries: (media: Record<string, string> | null | undefined) => {
29
+ sizeBreakpoints: {
30
+ name: string;
31
+ query: string;
32
+ width: number;
33
+ type: string;
34
+ }[];
35
+ featureQueries: { name: string; query: string }[];
36
+ baseWidth: number;
37
+ };
38
+ }
39
+
40
+ let _host: HTMLElement | null = null;
41
+
42
+ let _ctx: TabBarCtx | null = null;
43
+
44
+ let _scope: EffectScope | null = null;
45
+
46
+ /**
47
+ * Mount the tab bar into the given host element.
48
+ *
49
+ * @param {HTMLElement} host
50
+ * @param {TabBarCtx} ctx
51
+ */
52
+ export function mount(host: HTMLElement, ctx: TabBarCtx) {
53
+ _host = host;
54
+ _ctx = ctx;
55
+ _scope = effectScope();
56
+ _scope.run(() => {
57
+ effect(() => {
58
+ const tab = activeTab.value;
59
+ if (tab) {
60
+ // Read reactive properties to establish tracking — mirrors the toolbar's subset
61
+ void tab.doc.document;
62
+ void tab.doc.mode;
63
+ void tab.documentPath;
64
+ void tab.session.documentStack.length;
65
+ void tab.session.ui.canvasMode;
66
+ void tab.session.ui.editingFunction;
67
+ void tab.session.ui.featureToggles;
68
+ }
69
+ render();
70
+ });
71
+ });
72
+ }
73
+
74
+ export function unmount() {
75
+ _scope?.stop();
76
+ _scope = null;
77
+ _host = null;
78
+ _ctx = null;
79
+ }
80
+
81
+ export function render() {
82
+ if (!_host || !_ctx) {
83
+ return;
84
+ }
85
+ try {
86
+ litRender(tabBarTemplate(_ctx), _host);
87
+ } catch (error) {
88
+ console.error("tab-bar render error:", error);
89
+ }
90
+ }
91
+
92
+ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
93
+ const tab = activeTab.value;
94
+ if (!tab) {
95
+ return nothing;
96
+ }
97
+
98
+ const S = {
99
+ document: tab.doc.document,
100
+ documentPath: tab.documentPath,
101
+ documentStack: tab.session.documentStack,
102
+ ui: tab.session.ui,
103
+ };
104
+ const canvasMode = ctx.getCanvasMode();
105
+ const editing = S.ui.editingFunction as FunctionEditDef | null;
106
+ const hasStack = S.documentStack && S.documentStack.length > 0;
107
+
108
+ // ── Left region: navigation context (function editor takes precedence over the stack) ──
109
+ let navTpl: TemplateResult | typeof nothing = nothing;
110
+ if (editing) {
111
+ const docName = S.documentPath?.split("/").pop() || S.document?.tagName || "document";
112
+ const funcLabel = editing.type === "def" ? `ƒ ${editing.defName}` : `ƒ ${editing.eventKey}`;
113
+ navTpl = html`
114
+ <div class="breadcrumb">
115
+ <sp-action-button size="s" title="Close editor" @click=${ctx.closeFunctionEditor}>
116
+ <sp-icon-back slot="icon"></sp-icon-back>
117
+ Back
118
+ </sp-action-button>
119
+ <span class="breadcrumb-item">${docName}</span>
120
+ <span class="breadcrumb-sep"> › </span>
121
+ <span class="breadcrumb-item current">${funcLabel}</span>
122
+ </div>
123
+ `;
124
+ } else if (hasStack) {
125
+ navTpl = html`
126
+ <div class="breadcrumb">
127
+ <sp-action-button size="s" title="Return to parent document" @click=${ctx.navigateBack}>
128
+ <sp-icon-back slot="icon"></sp-icon-back>
129
+ Back
130
+ </sp-action-button>
131
+ ${S.documentStack.map(
132
+ (frame: DocumentStackEntry, i: number) => html`
133
+ <span class="breadcrumb-item clickable" @click=${() => ctx.navigateToLevel(i)}
134
+ >${frame.documentPath?.split("/").pop() || "untitled"}</span
135
+ >
136
+ <span class="breadcrumb-sep"> › </span>
137
+ `,
138
+ )}
139
+ <span class="breadcrumb-item current">
140
+ ${S.documentPath?.split("/").pop() || S.document?.tagName || "document"}
141
+ </span>
142
+ </div>
143
+ `;
144
+ }
145
+
146
+ // ── Right region: media feature toggles ──
147
+ const { featureQueries } = ctx.parseMediaEntries(getEffectiveMedia(S.document?.$media));
148
+ const togglesTpl =
149
+ featureQueries.length > 0
150
+ ? html`
151
+ <sp-action-group compact size="s">
152
+ ${featureQueries.map(
153
+ ({ name, query }: { name: string; query: string }) => html`
154
+ <sp-action-button
155
+ toggles
156
+ size="s"
157
+ title=${query}
158
+ ?selected=${Boolean(S.ui.featureToggles[name])}
159
+ @click=${() => {
160
+ const newToggles = {
161
+ ...S.ui.featureToggles,
162
+ [name]: !S.ui.featureToggles[name],
163
+ };
164
+ updateUi("featureToggles", newToggles);
165
+ }}
166
+ >
167
+ ${mediaDisplayName(name)}
168
+ </sp-action-button>
169
+ `,
170
+ )}
171
+ </sp-action-group>
172
+ `
173
+ : nothing;
174
+
175
+ // ── Right region: mode actions (Code-mode Export) ──
176
+ const exportTpl =
177
+ !editing && canvasMode === "source"
178
+ ? html`
179
+ <sp-action-button size="s" @click=${ctx.exportFile}>
180
+ <sp-icon-export slot="icon"></sp-icon-export>
181
+ Export
182
+ </sp-action-button>
183
+ `
184
+ : nothing;
185
+
186
+ // Collapse the bar entirely when there is nothing contextual to show.
187
+ if (navTpl === nothing && togglesTpl === nothing && exportTpl === nothing) {
188
+ return nothing;
189
+ }
190
+
191
+ return html`
192
+ <div class="tab-bar">
193
+ ${navTpl}
194
+ <div class="tb-spacer"></div>
195
+ ${togglesTpl} ${exportTpl}
196
+ </div>
197
+ `;
198
+ }
@@ -77,7 +77,7 @@ function render() {
77
77
  @auxclick=${(e: MouseEvent) => {
78
78
  if (e.button === 1) {
79
79
  e.preventDefault();
80
- requestClose(id);
80
+ void requestClose(id);
81
81
  }
82
82
  }}
83
83
  title=${tab.documentPath || "Untitled"}
@@ -88,7 +88,7 @@ function render() {
88
88
  class="tab-strip-close"
89
89
  @click=${(e: Event) => {
90
90
  e.stopPropagation();
91
- requestClose(id);
91
+ void requestClose(id);
92
92
  }}
93
93
  >
94
94
  ×