@dashfoo/core 0.5.1 → 1.0.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.
package/dist/index.js CHANGED
@@ -1,7 +1,31 @@
1
1
  import { z } from "zod";
2
2
  import { assign, emit, setup } from "xstate";
3
3
 
4
+ //#region src/lib/clamp-selected.ts
5
+ /**
6
+ * Lives in `lib/` rather than beside `normalize`, because both the parse
7
+ * boundary (`tabsetNodeSchema`) and the tree walker that `normalize` uses need
8
+ * it, and importing it from either one would close a module cycle.
9
+ */
10
+ const clampSelected = (length, selected) => {
11
+ if (length === 0) return 0;
12
+ const base = Number.isFinite(selected) ? Math.trunc(selected) : 0;
13
+ if (base < 0) return 0;
14
+ if (base > length - 1) return length - 1;
15
+ return base;
16
+ };
17
+
18
+ //#endregion
4
19
  //#region src/model/schema.ts
20
+ /**
21
+ * Weights are relative shares, and every reader that rendered one already
22
+ * treated a missing weight as a single share (`row-view` summed `weight ?? 1`),
23
+ * so filling it in at the boundary changes nothing on screen. The one place that
24
+ * read a missing weight as a percentage was `placeBesideTarget`, which is why
25
+ * docking beside an unweighted target halved it to `50` and left its unweighted
26
+ * siblings rendering as a sliver of the row.
27
+ */
28
+ const DEFAULT_WEIGHT = 1;
5
29
  const jsonValueSchema = z.lazy(() => z.union([
6
30
  z.string(),
7
31
  z.number(),
@@ -44,7 +68,14 @@ const tabNodeSchema = z.object({
44
68
  name: z.string(),
45
69
  type: z.literal("tab")
46
70
  });
47
- const tabsetNodeSchema = z.object({
71
+ /**
72
+ * The pre-transform object, kept separate so `node-attrs` can `.pick()` mutable
73
+ * keys off it. Picking from the transformed schema is impossible (a pipe has no
74
+ * `.pick`), and picking `weight` with its default attached would inject that
75
+ * default: `.partial()` over a defaulted field still fills it in, which would
76
+ * rewrite a tabset's weight on every unrelated attribute update.
77
+ */
78
+ const tabsetNodeObjectSchema = z.object({
48
79
  children: z.array(tabNodeSchema),
49
80
  enableClose: z.boolean().optional(),
50
81
  enableMaximize: z.boolean().optional(),
@@ -54,8 +85,12 @@ const tabsetNodeSchema = z.object({
54
85
  name: z.string().optional(),
55
86
  selected: z.number().int(),
56
87
  type: z.literal("tabset"),
57
- weight: z.number().optional()
88
+ weight: z.number().default(1)
58
89
  });
90
+ const tabsetNodeSchema = tabsetNodeObjectSchema.transform((tabset) => ({
91
+ ...tabset,
92
+ selected: clampSelected(tabset.children.length, tabset.selected)
93
+ }));
59
94
  const rowNodeSchema = z.lazy(() => z.object({
60
95
  children: z.array(z.union([rowNodeSchema, tabsetNodeSchema])),
61
96
  id: z.string(),
@@ -64,7 +99,7 @@ const rowNodeSchema = z.lazy(() => z.object({
64
99
  orientation: orientationSchema,
65
100
  snap: snapSchema.optional(),
66
101
  type: z.literal("row"),
67
- weight: z.number().optional()
102
+ weight: z.number().default(1)
68
103
  }));
69
104
  const geometrySchema = z.object({
70
105
  height: z.number(),
@@ -95,15 +130,15 @@ const globalAttributesSchema = z.object({
95
130
  });
96
131
  const dashfooSchema = z.object({
97
132
  activeTabsetId: z.string().optional(),
133
+ floats: z.array(floatNodeSchema).default(() => []),
98
134
  global: globalAttributesSchema,
99
135
  layout: rowNodeSchema,
100
136
  maximizedTabsetId: z.string().optional(),
101
- version: z.literal(1),
102
- floats: z.array(floatNodeSchema).optional()
137
+ version: z.literal(1)
103
138
  });
104
139
 
105
140
  //#endregion
106
- //#region src/state/actions.ts
141
+ //#region src/state/node-attrs.ts
107
142
  const mutableTabAttrsSchema = tabNodeSchema.pick({
108
143
  config: true,
109
144
  enableClose: true,
@@ -111,14 +146,18 @@ const mutableTabAttrsSchema = tabNodeSchema.pick({
111
146
  enableRename: true,
112
147
  name: true
113
148
  }).partial();
114
- const mutableTabsetAttrsSchema = tabsetNodeSchema.pick({
149
+ /**
150
+ * `weight` is re-declared instead of picked: `.partial()` over the schema's
151
+ * defaulted `weight` still injects the default, so `{ name: "x" }` would arrive
152
+ * at the reducer carrying a weight and silently resize the tabset.
153
+ */
154
+ const mutableTabsetAttrsSchema = tabsetNodeObjectSchema.pick({
115
155
  enableClose: true,
116
156
  enableMaximize: true,
117
157
  max: true,
118
158
  min: true,
119
- selected: true,
120
- weight: true
121
- }).partial();
159
+ selected: true
160
+ }).extend({ weight: z.number() }).partial();
122
161
  const mutableRowAttrsSchema = z.object({
123
162
  max: dimensionSchema.optional(),
124
163
  min: dimensionSchema.optional(),
@@ -126,11 +165,19 @@ const mutableRowAttrsSchema = z.object({
126
165
  snap: snapSchema.optional(),
127
166
  weight: z.number()
128
167
  }).partial();
129
- const mutableNodeAttrsSchema = z.union([
130
- mutableTabAttrsSchema,
131
- mutableTabsetAttrsSchema,
132
- mutableRowAttrsSchema
133
- ]);
168
+ /**
169
+ * One all-optional object carrying every mutable key of every node kind, not a
170
+ * union of the three: a union whose members are all fully optional matches on
171
+ * its first member and strips the keys the others own, which erased every
172
+ * tabset and row attribute at the parse boundary. The four keys that two kinds
173
+ * share (`enableClose`, `max`, `min`, `weight`) declare the same type on both
174
+ * sides, so merging widens nothing. Which keys are legal for a given node is
175
+ * enforced by the reducer against that node's own schema.
176
+ */
177
+ const mutableNodeAttrsSchema = mutableTabAttrsSchema.extend(mutableTabsetAttrsSchema.shape).extend(mutableRowAttrsSchema.shape);
178
+
179
+ //#endregion
180
+ //#region src/state/actions.ts
134
181
  const dockLocationSchema = z.enum([
135
182
  "center",
136
183
  "split-bottom",
@@ -138,6 +185,7 @@ const dockLocationSchema = z.enum([
138
185
  "split-right",
139
186
  "split-top"
140
187
  ]);
188
+ const splitWeightsSchema = z.array(z.number());
141
189
  const actionSchema = z.discriminatedUnion("type", [
142
190
  z.object({
143
191
  index: z.number().int().optional(),
@@ -189,7 +237,7 @@ const actionSchema = z.discriminatedUnion("type", [
189
237
  z.object({
190
238
  rowId: z.string(),
191
239
  type: z.literal("adjustSplit"),
192
- weights: z.array(z.number())
240
+ weights: splitWeightsSchema
193
241
  }),
194
242
  z.object({
195
243
  attrs: mutableNodeAttrsSchema,
@@ -242,11 +290,54 @@ const createTabId = () => createNodeId("tab");
242
290
 
243
291
  //#endregion
244
292
  //#region src/model/tree.ts
245
- const collectRoots = (model) => [model.layout, ...(model.floats ?? []).map((float) => float.layout)];
246
- const findFloat = (model, floatId) => (model.floats ?? []).find((float) => float.id === floatId);
293
+ const collectRoots = (model) => [model.layout, ...model.floats.map((float) => float.layout)];
294
+ const findFloat = (model, floatId) => model.floats.find((float) => float.id === floatId);
295
+ /**
296
+ * Depth-first pre-order over one root: the row itself, then each child in
297
+ * order, descending into nested rows and into a tabset's tabs. Returning a
298
+ * defined value from `visit` stops the walk and becomes the result, so one
299
+ * traversal serves both find-first and collect-all.
300
+ *
301
+ * Every query below used to carry its own copy of this recursion, and they had
302
+ * drifted in child order: some scanned a row's own tabsets before descending,
303
+ * others interleaved. With unique ids all orders agree, so they now share this
304
+ * one; a model with duplicate ids (which the builder and the parser both warn
305
+ * about) can see a different duplicate win than it did before.
306
+ */
307
+ const walk = (row, visit, parent, index = -1) => {
308
+ const atRoot = visit({
309
+ index,
310
+ node: row,
311
+ parent
312
+ });
313
+ if (atRoot !== void 0) return atRoot;
314
+ for (const [childIndex, child] of row.children.entries()) {
315
+ if (child.type === "row") {
316
+ const inRow = walk(child, visit, row, childIndex);
317
+ if (inRow !== void 0) return inRow;
318
+ continue;
319
+ }
320
+ const atTabset = visit({
321
+ index: childIndex,
322
+ node: child,
323
+ parent: row
324
+ });
325
+ if (atTabset !== void 0) return atTabset;
326
+ for (const [tabIndex, tab] of child.children.entries()) {
327
+ const atTab = visit({
328
+ index: tabIndex,
329
+ node: tab,
330
+ parent: child
331
+ });
332
+ if (atTab !== void 0) return atTab;
333
+ }
334
+ }
335
+ };
336
+ const locate = (row, id) => walk(row, (found) => found.node.id === id ? found : void 0);
247
337
  const collectTabsetsInRow = (row, acc) => {
248
- for (const child of row.children) if (child.type === "tabset") acc.push(child);
249
- else collectTabsetsInRow(child, acc);
338
+ walk(row, ({ node }) => {
339
+ if (node.type === "tabset") acc.push(node);
340
+ });
250
341
  };
251
342
  const collectTabsets = (model) => {
252
343
  const acc = [];
@@ -258,7 +349,7 @@ const findTabset = (model, tabsetId) => collectTabsets(model).find((tabset) => t
258
349
  const findTabInContainer = (container, tabId) => {
259
350
  const index = container.children.findIndex((tab) => tab.id === tabId);
260
351
  if (index === -1) return;
261
- const tab = container.children[index];
352
+ const tab = container.children.at(index);
262
353
  if (!tab) return;
263
354
  return {
264
355
  container,
@@ -273,25 +364,10 @@ const findTab = (model, tabId) => {
273
364
  }
274
365
  };
275
366
  const findRow = (row, id) => {
276
- if (row.id === id) return row;
277
- for (const child of row.children) if (child.type === "row") {
278
- const found = findRow(child, id);
279
- if (found) return found;
280
- }
281
- };
282
- const findAttributedNodeInRow = (row, id) => {
283
- if (row.id === id) return row;
284
- for (const child of row.children) {
285
- if (child.type === "row") {
286
- const found = findAttributedNodeInRow(child, id);
287
- if (found) return found;
288
- continue;
289
- }
290
- if (child.id === id) return child;
291
- const tab = child.children.find((candidate) => candidate.id === id);
292
- if (tab) return tab;
293
- }
367
+ const found = locate(row, id)?.node;
368
+ return found?.type === "row" ? found : void 0;
294
369
  };
370
+ const findAttributedNodeInRow = (row, id) => locate(row, id)?.node;
295
371
  const findAttributedNode = (model, id) => {
296
372
  for (const root of collectRoots(model)) {
297
373
  const found = findAttributedNodeInRow(root, id);
@@ -302,27 +378,20 @@ const findRootContaining = (model, nodeId) => {
302
378
  for (const root of collectRoots(model)) if (findAttributedNodeInRow(root, nodeId)) return root;
303
379
  };
304
380
  const findTabsetParent = (row, tabsetId) => {
305
- const index = row.children.findIndex((child) => child.type === "tabset" && child.id === tabsetId);
306
- if (index !== -1) return {
307
- index,
308
- parent: row
309
- };
310
- for (const child of row.children) if (child.type === "row") {
311
- const found = findTabsetParent(child, tabsetId);
312
- if (found) return found;
313
- }
381
+ const found = locate(row, tabsetId);
382
+ return found?.node.type === "tabset" && found.parent?.type === "row" ? {
383
+ index: found.index,
384
+ parent: found.parent
385
+ } : void 0;
314
386
  };
315
387
  const collectIdsInRow = (row, acc) => {
316
- acc.push(row.id);
317
- for (const child of row.children) if (child.type === "row") collectIdsInRow(child, acc);
318
- else {
319
- acc.push(child.id);
320
- for (const tab of child.children) acc.push(tab.id);
321
- }
388
+ walk(row, ({ node }) => {
389
+ acc.push(node.id);
390
+ });
322
391
  };
323
392
  const findDuplicateIds = (model) => {
324
393
  const ids = [];
325
- for (const float of model.floats ?? []) ids.push(float.id);
394
+ for (const float of model.floats) ids.push(float.id);
326
395
  for (const root of collectRoots(model)) collectIdsInRow(root, ids);
327
396
  const seen = /* @__PURE__ */ new Set();
328
397
  const duplicates = /* @__PURE__ */ new Set();
@@ -355,7 +424,8 @@ const tabset = (children, options = {}) => {
355
424
  ...max === void 0 ? {} : { max: toDimension(max) },
356
425
  ...min === void 0 ? {} : { min: toDimension(min) },
357
426
  id: options.id ?? createNodeId("tabset"),
358
- selected: options.selected ?? 0
427
+ selected: clampSelected(children.length, options.selected ?? 0),
428
+ weight: options.weight ?? 1
359
429
  };
360
430
  };
361
431
  const row = (children, options = {}) => {
@@ -367,7 +437,8 @@ const row = (children, options = {}) => {
367
437
  ...max === void 0 ? {} : { max: toDimension(max) },
368
438
  ...min === void 0 ? {} : { min: toDimension(min) },
369
439
  id: options.id ?? createNodeId("row"),
370
- orientation: options.orientation ?? "row"
440
+ orientation: options.orientation ?? "row",
441
+ weight: options.weight ?? 1
371
442
  };
372
443
  };
373
444
  const floatNode = (layout, geometry, options = {}) => ({
@@ -382,13 +453,20 @@ const model = (layout, options = {}) => {
382
453
  layout,
383
454
  version: 1,
384
455
  ...options,
456
+ floats: options.floats ?? [],
385
457
  global: options.global ?? {}
386
458
  };
387
459
  const duplicates = findDuplicateIds(built);
388
- if (duplicates.length > 0) console.warn(`[dashfoo] builder produced duplicate node ids: ${duplicates.join(", ")} — pass explicit ids when reusing a component`);
460
+ if (duplicates.length > 0) console.warn(`[dashfoo] builder produced duplicate node ids: ${duplicates.join(", ")}. Pass explicit ids when reusing a component`);
389
461
  return built;
390
462
  };
391
463
 
464
+ //#endregion
465
+ //#region src/lib/assert-never.ts
466
+ const assertNever = (value) => {
467
+ throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
468
+ };
469
+
392
470
  //#endregion
393
471
  //#region src/geometry/geometry.ts
394
472
  const DEFAULT_BAND_FRACTION = .22;
@@ -409,6 +487,23 @@ const closestEdge = (d) => {
409
487
  if (min === d.top) return "top";
410
488
  return "bottom";
411
489
  };
490
+ const dockLocationFor = (target) => {
491
+ switch (target.kind) {
492
+ case "split": return `split-${target.edge}`;
493
+ case "tab": return "center";
494
+ default: return assertNever(target);
495
+ }
496
+ };
497
+ const splitEdge = (location) => {
498
+ switch (location) {
499
+ case "center": return;
500
+ case "split-bottom": return "bottom";
501
+ case "split-left": return "left";
502
+ case "split-right": return "right";
503
+ case "split-top": return "top";
504
+ default: return assertNever(location);
505
+ }
506
+ };
412
507
  const resolveDockTarget = (pointer, rect, opts) => {
413
508
  const band = opts?.bandFraction ?? DEFAULT_BAND_FRACTION;
414
509
  if (rect.width <= 0 || rect.height <= 0) return { kind: "tab" };
@@ -541,7 +636,8 @@ const zoneRect = (rect, location) => {
541
636
  x: rect.x,
542
637
  y: rect.y
543
638
  };
544
- default: return rect;
639
+ case "center": return rect;
640
+ default: return assertNever(location);
545
641
  }
546
642
  };
547
643
 
@@ -603,35 +699,54 @@ const snapSizes = (sizes, boundaryIndex, config) => {
603
699
  snapped: true
604
700
  };
605
701
  };
702
+ const decideSnap = (sizes, boundaryIndex, config) => {
703
+ if (boundaryIndex === null || config === null || !snapEnabled(config)) return { kind: "inactive" };
704
+ const { sizes: snappedSizes, snapped } = snapSizes(sizes, boundaryIndex, config);
705
+ return snapped ? {
706
+ kind: "engage",
707
+ sizes: snappedSizes
708
+ } : { kind: "clear" };
709
+ };
710
+ const settleSnap = (sizes, boundaryIndex, config) => {
711
+ const decision = decideSnap(sizes, boundaryIndex, config);
712
+ return decision.kind === "engage" ? decision.sizes : sizes;
713
+ };
606
714
 
607
715
  //#endregion
608
716
  //#region src/model/invariants.ts
609
- const clampSelected = (length, selected) => {
610
- if (length === 0) return 0;
611
- const base = Number.isFinite(selected) ? Math.trunc(selected) : 0;
612
- if (base < 0) return 0;
613
- if (base > length - 1) return length - 1;
614
- return base;
717
+ /**
718
+ * A row that collapses to a single child hands its slot in the parent to
719
+ * that child, so the slot's sizing constraints have to travel with it.
720
+ * Dropping them silently widens a pinned sidebar, and `normalize` runs on
721
+ * load and on every persist, so the loss is permanent.
722
+ *
723
+ * `snap` only exists on rows, so it is carried only to a row survivor.
724
+ */
725
+ const inheritSlot = (row, survivor) => {
726
+ const sized = {
727
+ ...survivor,
728
+ weight: row.weight,
729
+ ...row.min === void 0 ? {} : { min: row.min },
730
+ ...row.max === void 0 ? {} : { max: row.max }
731
+ };
732
+ if (sized.type === "row" && row.snap !== void 0) return {
733
+ ...sized,
734
+ snap: row.snap
735
+ };
736
+ return sized;
615
737
  };
616
- const normalizeTabset = (tabset) => ({
617
- ...tabset,
618
- selected: clampSelected(tabset.children.length, tabset.selected)
619
- });
620
738
  const normalizeRowChildren = (children) => {
621
739
  const out = [];
622
740
  for (const child of children) {
623
741
  if (child.type === "tabset") {
624
- if (child.children.length > 0) out.push(normalizeTabset(child));
742
+ if (child.children.length > 0) out.push(child);
625
743
  continue;
626
744
  }
627
745
  const grandchildren = normalizeRowChildren(child.children);
628
746
  if (grandchildren.length === 0) continue;
629
747
  if (grandchildren.length === 1) {
630
748
  const only = grandchildren[0];
631
- if (only !== void 0) out.push(child.weight === void 0 ? only : {
632
- ...only,
633
- weight: child.weight
634
- });
749
+ if (only !== void 0) out.push(inheritSlot(child, only));
635
750
  continue;
636
751
  }
637
752
  out.push({
@@ -659,22 +774,22 @@ const normalizeLayout = (root) => {
659
774
  };
660
775
  const normalize = (model) => {
661
776
  const layout = normalizeLayout(model.layout);
662
- const healedFloats = [];
663
- for (const float of model.floats ?? []) {
777
+ const floats = [];
778
+ for (const float of model.floats) {
664
779
  const floatLayout = normalizeLayout(float.layout);
665
- if (rowContainsTabset(floatLayout)) healedFloats.push({
780
+ if (rowContainsTabset(floatLayout)) floats.push({
666
781
  ...float,
667
782
  layout: floatLayout
668
783
  });
669
784
  }
670
- const floats = healedFloats.length > 0 ? healedFloats : void 0;
671
785
  const withRoots = {
672
786
  ...model,
673
787
  floats,
674
788
  layout
675
789
  };
676
- const tabsetIds = new Set(collectTabsets(withRoots).map((tabset) => tabset.id));
677
- const firstTabsetId = collectTabsets(withRoots)[0]?.id;
790
+ const tabsets = collectTabsets(withRoots);
791
+ const tabsetIds = new Set(tabsets.map((tabset) => tabset.id));
792
+ const firstTabsetId = tabsets[0]?.id;
678
793
  const activeTabsetId = model.activeTabsetId !== void 0 && tabsetIds.has(model.activeTabsetId) ? model.activeTabsetId : firstTabsetId;
679
794
  const maximizedTabsetId = model.maximizedTabsetId !== void 0 && tabsetIds.has(model.maximizedTabsetId) ? model.maximizedTabsetId : void 0;
680
795
  return {
@@ -686,50 +801,40 @@ const normalize = (model) => {
686
801
 
687
802
  //#endregion
688
803
  //#region src/state/surgery.ts
689
- const removeTabset = (row, tabsetId) => {
690
- const index = row.children.findIndex((child) => child.type === "tabset" && child.id === tabsetId);
691
- if (index !== -1) {
692
- row.children.splice(index, 1);
693
- return true;
694
- }
695
- for (const child of row.children) if (child.type === "row" && removeTabset(child, tabsetId)) return true;
696
- return false;
697
- };
698
804
  const removeTabsetReturning = (row, tabsetId) => {
699
- const index = row.children.findIndex((child) => child.type === "tabset" && child.id === tabsetId);
700
- if (index !== -1) {
701
- const [removed] = row.children.splice(index, 1);
702
- return removed?.type === "tabset" ? removed : void 0;
703
- }
704
- for (const child of row.children) if (child.type === "row") {
705
- const found = removeTabsetReturning(child, tabsetId);
706
- if (found) return found;
707
- }
805
+ const found = findTabsetParent(row, tabsetId);
806
+ if (!found) return;
807
+ const [removed] = found.parent.children.splice(found.index, 1);
808
+ return removed?.type === "tabset" ? removed : void 0;
809
+ };
810
+ const removeTabset = (row, tabsetId) => removeTabsetReturning(row, tabsetId) !== void 0;
811
+ const splitPlacement = (location) => {
812
+ const edge = splitEdge(location);
813
+ return {
814
+ before: edge === "left" || edge === "top",
815
+ orientation: edge === "left" || edge === "right" ? "row" : "column"
816
+ };
708
817
  };
709
- const splitOrientation = (location) => location === "split-left" || location === "split-right" ? "row" : "column";
710
- const splitsBefore = (location) => location === "split-left" || location === "split-top";
711
818
  const placeBesideTarget = (draft, placed, targetId, location) => {
712
819
  const targetTabset = findTabset(draft, targetId);
713
820
  const found = findTabsetParent(findRootContaining(draft, targetId) ?? draft.layout, targetId);
714
821
  if (!targetTabset || !found) return;
715
- const orientation = splitOrientation(location);
716
- const before = splitsBefore(location);
822
+ const { before, orientation } = splitPlacement(location);
717
823
  if (found.parent.orientation === orientation) {
718
- const targetWeight = targetTabset.weight ?? 100;
719
- targetTabset.weight = targetWeight / 2;
720
- placed.weight = targetWeight / 2;
824
+ const half = targetTabset.weight / 2;
825
+ targetTabset.weight = half;
826
+ placed.weight = half;
721
827
  found.parent.children.splice(before ? found.index : found.index + 1, 0, placed);
722
828
  } else {
723
- const targetWeight = targetTabset.weight;
724
- targetTabset.weight = 50;
725
- placed.weight = 50;
726
829
  const newRow = {
727
830
  children: before ? [placed, targetTabset] : [targetTabset, placed],
728
831
  id: createNodeId("row"),
729
832
  orientation,
730
- type: "row"
833
+ type: "row",
834
+ weight: targetTabset.weight
731
835
  };
732
- if (targetWeight !== void 0) newRow.weight = targetWeight;
836
+ targetTabset.weight = 50;
837
+ placed.weight = 50;
733
838
  found.parent.children.splice(found.index, 1, newRow);
734
839
  }
735
840
  draft.activeTabsetId = placed.id;
@@ -739,39 +844,54 @@ const insertTab = (draft, tab, target) => {
739
844
  const targetTabset = findTabset(draft, targetId);
740
845
  if (!targetTabset) return;
741
846
  if (location === "center") {
742
- const at = index ?? targetTabset.children.length;
847
+ const at = Math.min(Math.max(index ?? targetTabset.children.length, 0), targetTabset.children.length);
743
848
  targetTabset.children.splice(at, 0, tab);
744
849
  targetTabset.selected = at;
745
850
  return;
746
851
  }
747
- placeBesideTarget(draft, {
852
+ const newTabset = {
748
853
  children: [tab],
749
854
  id: createNodeId("tabset"),
750
855
  selected: 0,
751
856
  type: "tabset",
752
- weight: 50
753
- }, targetId, location);
857
+ weight: 1
858
+ };
859
+ placeBesideTarget(draft, newTabset, targetId, location);
860
+ };
861
+ /**
862
+ * Removing a tab can leave `selected` past the end of the strip, so every
863
+ * detach re-clamps its container rather than leaving the whole tree to be
864
+ * re-walked afterwards.
865
+ */
866
+ const detachTab = (container, index) => {
867
+ const removed = container.children.splice(index, 1).at(0);
868
+ container.selected = clampSelected(container.children.length, container.selected);
869
+ return removed;
754
870
  };
755
- const tabsetsInRow = (row, acc) => {
756
- for (const child of row.children) if (child.type === "tabset") acc.push(child);
757
- else tabsetsInRow(child, acc);
871
+ const mergeTabsInto = (target, tabs, selectedOffset) => {
872
+ const mergeStart = target.children.length;
873
+ target.children.push(...tabs);
874
+ target.selected = mergeStart + selectedOffset;
758
875
  };
759
876
  const wrapTabInLayout = (tab) => ({
760
877
  children: [{
761
878
  children: [tab],
762
879
  id: createNodeId("tabset"),
763
880
  selected: 0,
764
- type: "tabset"
881
+ type: "tabset",
882
+ weight: 1
765
883
  }],
766
884
  id: createNodeId("row"),
767
885
  orientation: "row",
768
- type: "row"
886
+ type: "row",
887
+ weight: 1
769
888
  });
770
889
  const wrapTabsetInLayout = (tabset) => ({
771
890
  children: [tabset],
772
891
  id: createNodeId("row"),
773
892
  orientation: "row",
774
- type: "row"
893
+ type: "row",
894
+ weight: 1
775
895
  });
776
896
 
777
897
  //#endregion
@@ -794,13 +914,13 @@ const pushFloat = (draft, layout, geometry, floatId) => {
794
914
  geometry: geometry ?? DEFAULT_FLOAT_GEOMETRY,
795
915
  id: floatId ?? createNodeId("float"),
796
916
  layout,
797
- name: uniqueFloatName("Panel", draft.floats ?? []),
917
+ name: uniqueFloatName("Panel", draft.floats),
798
918
  type: "float"
799
919
  };
800
- draft.floats = [...draft.floats ?? [], float];
920
+ draft.floats = [...draft.floats, float];
801
921
  const tabsets = [];
802
- tabsetsInRow(layout, tabsets);
803
- const first = tabsets[0];
922
+ collectTabsetsInRow(layout, tabsets);
923
+ const first = tabsets.at(0);
804
924
  if (first) draft.activeTabsetId = first.id;
805
925
  };
806
926
  const floatTabsetById = (draft, tabsetId, geometry, floatId) => {
@@ -811,8 +931,8 @@ const floatTabsetById = (draft, tabsetId, geometry, floatId) => {
811
931
  };
812
932
  const resolveMainTarget = (draft, targetId) => {
813
933
  const mainTabsets = [];
814
- tabsetsInRow(draft.layout, mainTabsets);
815
- if (targetId) {
934
+ collectTabsetsInRow(draft.layout, mainTabsets);
935
+ if (targetId !== void 0) {
816
936
  const explicit = mainTabsets.find((tabset) => tabset.id === targetId);
817
937
  if (explicit) return explicit;
818
938
  }
@@ -823,18 +943,17 @@ const resolveMainTarget = (draft, targetId) => {
823
943
  return mainTabsets[0];
824
944
  };
825
945
  const dockFloat = (draft, floatId, targetId, location) => {
826
- const floats = draft.floats ?? [];
946
+ const floats = draft.floats;
827
947
  const index = floats.findIndex((float) => float.id === floatId);
828
948
  if (index === -1) return;
829
- const [float] = floats.splice(index, 1);
830
- draft.floats = floats;
949
+ const float = floats.splice(index, 1).at(0);
831
950
  if (!float) return;
832
951
  const tabsets = [];
833
- tabsetsInRow(float.layout, tabsets);
952
+ collectTabsetsInRow(float.layout, tabsets);
834
953
  const tabs = tabsets.flatMap((tabset) => tabset.children);
835
954
  if (tabs.length === 0) return;
836
- const leadTabset = tabsets[0];
837
- const selectedOffset = leadTabset ? Math.min(Math.max(leadTabset.selected, 0), leadTabset.children.length - 1) : 0;
955
+ const leadTabset = tabsets.at(0);
956
+ const selectedOffset = leadTabset ? clampSelected(leadTabset.children.length, leadTabset.selected) : 0;
838
957
  const target = resolveMainTarget(draft, targetId);
839
958
  if (!target) {
840
959
  draft.layout = float.layout;
@@ -842,27 +961,35 @@ const dockFloat = (draft, floatId, targetId, location) => {
842
961
  return;
843
962
  }
844
963
  if (location === "center") {
845
- const mergeStart = target.children.length;
846
- target.children.push(...tabs);
847
- target.selected = mergeStart + selectedOffset;
964
+ mergeTabsInto(target, tabs, selectedOffset);
848
965
  draft.activeTabsetId = target.id;
849
966
  return;
850
967
  }
851
968
  const where = location ?? "split-right";
852
- const placed = tabsets.length === 1 && leadTabset ? leadTabset : {
969
+ placeBesideTarget(draft, tabsets.length === 1 && leadTabset ? leadTabset : {
853
970
  children: tabs,
854
971
  id: createNodeId("tabset"),
855
972
  selected: selectedOffset,
856
- type: "tabset"
857
- };
858
- placed.weight = 50;
859
- placeBesideTarget(draft, placed, target.id, where);
973
+ type: "tabset",
974
+ weight: 1
975
+ }, target.id, where);
860
976
  };
861
977
 
862
978
  //#endregion
863
979
  //#region src/state/reducer.ts
864
- const assertNever = (value) => {
865
- throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
980
+ const parseNodeAttrs = (node, attrs) => {
981
+ switch (node.type) {
982
+ case "row": return mutableRowAttrsSchema.parse(attrs);
983
+ case "tab": return mutableTabAttrsSchema.parse(attrs);
984
+ case "tabset": {
985
+ const parsed = mutableTabsetAttrsSchema.parse(attrs);
986
+ return parsed.selected === void 0 ? parsed : {
987
+ ...parsed,
988
+ selected: clampSelected(node.children.length, parsed.selected)
989
+ };
990
+ }
991
+ default: return assertNever(node);
992
+ }
866
993
  };
867
994
  const applyAction = (draft, action) => {
868
995
  switch (action.type) {
@@ -877,7 +1004,7 @@ const applyAction = (draft, action) => {
877
1004
  const row = findRow(findRootContaining(draft, action.rowId) ?? draft.layout, action.rowId);
878
1005
  if (!row) return;
879
1006
  for (let index = 0; index < action.weights.length; index++) {
880
- const child = row.children[index];
1007
+ const child = row.children.at(index);
881
1008
  const weight = action.weights[index];
882
1009
  if (!child || weight === void 0) continue;
883
1010
  child.weight = weight;
@@ -886,7 +1013,7 @@ const applyAction = (draft, action) => {
886
1013
  }
887
1014
  case "deleteTab": {
888
1015
  const location = findTab(draft, action.tabId);
889
- if (location) location.container.children.splice(location.index, 1);
1016
+ if (location) detachTab(location.container, location.index);
890
1017
  return;
891
1018
  }
892
1019
  case "deleteTabset": {
@@ -900,7 +1027,7 @@ const applyAction = (draft, action) => {
900
1027
  case "floatTab": {
901
1028
  const location = findTab(draft, action.tabId);
902
1029
  if (!location) return;
903
- const [removed] = location.container.children.splice(location.index, 1);
1030
+ const removed = detachTab(location.container, location.index);
904
1031
  if (removed) pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
905
1032
  return;
906
1033
  }
@@ -915,7 +1042,7 @@ const applyAction = (draft, action) => {
915
1042
  case "moveNode": {
916
1043
  const source = findTab(draft, action.sourceId);
917
1044
  if (!source) return;
918
- const [removed] = source.container.children.splice(source.index, 1);
1045
+ const removed = detachTab(source.container, source.index);
919
1046
  if (!removed) return;
920
1047
  insertTab(draft, removed, {
921
1048
  id: action.targetId,
@@ -931,26 +1058,21 @@ const applyAction = (draft, action) => {
931
1058
  if (action.location === "center") {
932
1059
  const target = findTabset(draft, action.targetId);
933
1060
  if (target) {
934
- const mergeStart = target.children.length;
935
- const sourceSelected = Math.min(Math.max(source.selected, 0), source.children.length - 1);
936
- target.children.push(...source.children);
937
- target.selected = mergeStart + sourceSelected;
1061
+ mergeTabsInto(target, source.children, clampSelected(source.children.length, source.selected));
938
1062
  const sourceRoot = findRootContaining(draft, action.sourceId);
939
1063
  if (sourceRoot) removeTabset(sourceRoot, action.sourceId);
940
1064
  }
941
1065
  return;
942
1066
  }
943
- if (action.location.startsWith("split-")) {
944
- const sourceRoot = findRootContaining(draft, action.sourceId);
945
- const detached = sourceRoot ? removeTabsetReturning(sourceRoot, action.sourceId) : void 0;
946
- if (detached) placeBesideTarget(draft, detached, action.targetId, action.location);
947
- }
1067
+ const sourceRoot = findRootContaining(draft, action.sourceId);
1068
+ const detached = sourceRoot ? removeTabsetReturning(sourceRoot, action.sourceId) : void 0;
1069
+ if (detached) placeBesideTarget(draft, detached, action.targetId, action.location);
948
1070
  return;
949
1071
  }
950
1072
  case "renameFloat": {
951
1073
  const float = findFloat(draft, action.floatId);
952
1074
  if (float) {
953
- const others = (draft.floats ?? []).filter((other) => other.id !== action.floatId);
1075
+ const others = draft.floats.filter((other) => other.id !== action.floatId);
954
1076
  float.name = uniqueFloatName(action.name, others);
955
1077
  }
956
1078
  return;
@@ -962,7 +1084,7 @@ const applyAction = (draft, action) => {
962
1084
  }
963
1085
  case "selectTab": {
964
1086
  const tabset = findTabset(draft, action.tabsetId);
965
- if (tabset) tabset.selected = action.index;
1087
+ if (tabset) tabset.selected = clampSelected(tabset.children.length, action.index);
966
1088
  return;
967
1089
  }
968
1090
  case "setActiveTabset":
@@ -981,7 +1103,7 @@ const applyAction = (draft, action) => {
981
1103
  return;
982
1104
  case "updateNodeAttributes": {
983
1105
  const node = findAttributedNode(draft, action.nodeId);
984
- if (node) Object.assign(node, action.attrs);
1106
+ if (node) Object.assign(node, parseNodeAttrs(node, action.attrs));
985
1107
  return;
986
1108
  }
987
1109
  default: assertNever(action);
@@ -1139,12 +1261,13 @@ const stackModel = (model, orientation = "column") => {
1139
1261
  children,
1140
1262
  id: model.layout.id,
1141
1263
  orientation,
1142
- type: "row"
1264
+ type: "row",
1265
+ weight: model.layout.weight
1143
1266
  },
1144
1267
  maximizedTabsetId: void 0
1145
1268
  });
1146
1269
  };
1147
1270
 
1148
1271
  //#endregion
1149
- export { DEFAULT_SNAP_THRESHOLD, HISTORY_LIMIT, actionSchema, canRedo, canUndo, collectRoots, collectTabsets, collectTabsetsInRow, createHistory, createNodeId, createTabId, dashfooMachine, dashfooSchema, dimensionSchema, dispatch, dockLocationSchema, dockZonePolygons, dragDockMachine, edgeSchema, findAttributedNode, findDuplicateIds, findFloat, findRootContaining, findRow, findTab, findTabset, findTabsetParent, floatNode, floatNodeSchema, fromJSON, geometrySchema, getFirstTabset, globalAttributesSchema, jsonValueSchema, model, mutableNodeAttrsSchema, normalize, orientationSchema, parseModel, redo, reducer, resolveDockTarget, resolveSnapTargets, row, rowNodeSchema, snapEnabled, snapSchema, snapSizes, stackModel, tab, tabNodeSchema, tabset, tabsetNodeSchema, toJSON, undo, unitSchema, zoneRect };
1272
+ export { DEFAULT_SNAP_THRESHOLD, DEFAULT_WEIGHT, HISTORY_LIMIT, actionSchema, canRedo, canUndo, collectRoots, collectTabsets, collectTabsetsInRow, createHistory, createNodeId, createTabId, dashfooMachine, dashfooSchema, decideSnap, dimensionSchema, dispatch, dockLocationFor, dockLocationSchema, dockZonePolygons, dragDockMachine, edgeSchema, findAttributedNode, findDuplicateIds, findFloat, findRootContaining, findRow, findTab, findTabset, findTabsetParent, floatNode, floatNodeSchema, fromJSON, geometrySchema, getFirstTabset, globalAttributesSchema, jsonValueSchema, model, mutableNodeAttrsSchema, normalize, orientationSchema, parseModel, redo, reducer, resolveDockTarget, resolveSnapTargets, row, rowNodeSchema, settleSnap, snapEnabled, snapSchema, snapSizes, splitEdge, stackModel, tab, tabNodeSchema, tabset, tabsetNodeObjectSchema, tabsetNodeSchema, toJSON, undo, unitSchema, zoneRect };
1150
1273
  //# sourceMappingURL=index.js.map