@dashfoo/core 0.5.2 → 1.1.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,21 @@
1
1
  import { z } from "zod";
2
2
  import { assign, emit, setup } from "xstate";
3
3
 
4
+ //#region src/lib/clamp-selected.ts
5
+ /** In `lib/` because both the parse boundary and `normalize` need it, and
6
+ * importing it from either would close a module cycle. */
7
+ const clampSelected = (length, selected) => {
8
+ if (length === 0) return 0;
9
+ const base = Number.isFinite(selected) ? Math.trunc(selected) : 0;
10
+ if (base < 0) return 0;
11
+ if (base > length - 1) return length - 1;
12
+ return base;
13
+ };
14
+
15
+ //#endregion
4
16
  //#region src/model/schema.ts
17
+ /** Weights are relative shares of a row, not percentages. */
18
+ const DEFAULT_WEIGHT = 1;
5
19
  const jsonValueSchema = z.lazy(() => z.union([
6
20
  z.string(),
7
21
  z.number(),
@@ -44,7 +58,11 @@ const tabNodeSchema = z.object({
44
58
  name: z.string(),
45
59
  type: z.literal("tab")
46
60
  });
47
- const tabsetNodeSchema = z.object({
61
+ /**
62
+ * Kept pre-transform so `node-attrs` can `.pick()` from it: a pipe has no
63
+ * `.pick`, and `.partial()` over a defaulted field still fills the default in.
64
+ */
65
+ const tabsetNodeObjectSchema = z.object({
48
66
  children: z.array(tabNodeSchema),
49
67
  enableClose: z.boolean().optional(),
50
68
  enableMaximize: z.boolean().optional(),
@@ -54,18 +72,25 @@ const tabsetNodeSchema = z.object({
54
72
  name: z.string().optional(),
55
73
  selected: z.number().int(),
56
74
  type: z.literal("tabset"),
57
- weight: z.number().optional()
75
+ weight: z.number().default(1)
58
76
  });
59
- const rowNodeSchema = z.lazy(() => z.object({
60
- children: z.array(z.union([rowNodeSchema, tabsetNodeSchema])),
77
+ const tabsetNodeSchema = tabsetNodeObjectSchema.transform((tabset) => ({
78
+ ...tabset,
79
+ selected: clampSelected(tabset.children.length, tabset.selected)
80
+ }));
81
+ /** The getter's explicit return type is required: without it, TS7023. */
82
+ const rowNodeSchema = z.object({
83
+ get children() {
84
+ return z.array(z.union([rowNodeSchema, tabsetNodeSchema]));
85
+ },
61
86
  id: z.string(),
62
87
  max: dimensionSchema.optional(),
63
88
  min: dimensionSchema.optional(),
64
89
  orientation: orientationSchema,
65
90
  snap: snapSchema.optional(),
66
91
  type: z.literal("row"),
67
- weight: z.number().optional()
68
- }));
92
+ weight: z.number().default(1)
93
+ });
69
94
  const geometrySchema = z.object({
70
95
  height: z.number(),
71
96
  left: z.number(),
@@ -95,7 +120,7 @@ const globalAttributesSchema = z.object({
95
120
  });
96
121
  const dashfooSchema = z.object({
97
122
  activeTabsetId: z.string().optional(),
98
- floats: z.array(floatNodeSchema).optional(),
123
+ floats: z.array(floatNodeSchema).default(() => []),
99
124
  global: globalAttributesSchema,
100
125
  layout: rowNodeSchema,
101
126
  maximizedTabsetId: z.string().optional(),
@@ -103,7 +128,7 @@ const dashfooSchema = z.object({
103
128
  });
104
129
 
105
130
  //#endregion
106
- //#region src/state/actions.ts
131
+ //#region src/state/node-attrs.ts
107
132
  const mutableTabAttrsSchema = tabNodeSchema.pick({
108
133
  config: true,
109
134
  enableClose: true,
@@ -111,14 +136,14 @@ const mutableTabAttrsSchema = tabNodeSchema.pick({
111
136
  enableRename: true,
112
137
  name: true
113
138
  }).partial();
114
- const mutableTabsetAttrsSchema = tabsetNodeSchema.pick({
139
+ /** `weight` is re-declared, not picked: `.partial()` over a defaulted field still injects the default. */
140
+ const mutableTabsetAttrsSchema = tabsetNodeObjectSchema.pick({
115
141
  enableClose: true,
116
142
  enableMaximize: true,
117
143
  max: true,
118
144
  min: true,
119
- selected: true,
120
- weight: true
121
- }).partial();
145
+ selected: true
146
+ }).extend({ weight: z.number() }).partial();
122
147
  const mutableRowAttrsSchema = z.object({
123
148
  max: dimensionSchema.optional(),
124
149
  min: dimensionSchema.optional(),
@@ -126,11 +151,14 @@ const mutableRowAttrsSchema = z.object({
126
151
  snap: snapSchema.optional(),
127
152
  weight: z.number()
128
153
  }).partial();
129
- const mutableNodeAttrsSchema = z.union([
130
- mutableTabAttrsSchema,
131
- mutableTabsetAttrsSchema,
132
- mutableRowAttrsSchema
133
- ]);
154
+ /**
155
+ * Merged, not a union: a union of all-optional members matches its first member
156
+ * and strips the rest. The reducer enforces which keys a given node accepts.
157
+ */
158
+ const mutableNodeAttrsSchema = mutableTabAttrsSchema.extend(mutableTabsetAttrsSchema.shape).extend(mutableRowAttrsSchema.shape);
159
+
160
+ //#endregion
161
+ //#region src/state/actions.ts
134
162
  const dockLocationSchema = z.enum([
135
163
  "center",
136
164
  "split-bottom",
@@ -155,7 +183,6 @@ const actionSchema = z.discriminatedUnion("type", [
155
183
  type: z.literal("moveNode")
156
184
  }),
157
185
  z.object({
158
- index: z.number().int().optional(),
159
186
  location: dockLocationSchema,
160
187
  sourceId: z.string(),
161
188
  targetId: z.string(),
@@ -243,19 +270,12 @@ const createTabId = () => createNodeId("tab");
243
270
 
244
271
  //#endregion
245
272
  //#region src/model/tree.ts
246
- const collectRoots = (model) => [model.layout, ...(model.floats ?? []).map((float) => float.layout)];
247
- const findFloat = (model, floatId) => (model.floats ?? []).find((float) => float.id === floatId);
273
+ const collectRoots = (model) => [model.layout, ...model.floats.map((float) => float.layout)];
274
+ const findFloat = (model, floatId) => model.floats.find((float) => float.id === floatId);
248
275
  /**
249
- * Depth-first pre-order over one root: the row itself, then each child in
250
- * order, descending into nested rows and into a tabset's tabs. Returning a
251
- * defined value from `visit` stops the walk and becomes the result, so one
252
- * traversal serves both find-first and collect-all.
253
- *
254
- * Every query below used to carry its own copy of this recursion, and they had
255
- * drifted in child order: some scanned a row's own tabsets before descending,
256
- * others interleaved. With unique ids all orders agree, so they now share this
257
- * one; a model with duplicate ids (which the builder and the parser both warn
258
- * about) can see a different duplicate win than it did before.
276
+ * Depth-first pre-order; a defined `visit` result stops the walk. With unique
277
+ * ids the order is immaterial, but a duplicate-id model (which the builder and
278
+ * the parser both warn about) resolves to whichever copy this reaches first.
259
279
  */
260
280
  const walk = (row, visit, parent, index = -1) => {
261
281
  const atRoot = visit({
@@ -286,7 +306,20 @@ const walk = (row, visit, parent, index = -1) => {
286
306
  }
287
307
  }
288
308
  };
289
- const locate = (row, id) => walk(row, (found) => found.node.id === id ? found : void 0);
309
+ const isRow = (node) => node.type === "row";
310
+ const isTab = (node) => node.type === "tab";
311
+ const isTabset = (node) => node.type === "tabset";
312
+ /** Predicate-first, so an id shared across node kinds resolves the same way here as anywhere else. */
313
+ const locate = (model, id, is) => {
314
+ for (const root of collectRoots(model)) {
315
+ const found = walk(root, ({ index, node, parent }) => node.id === id && is(node) ? {
316
+ index,
317
+ node,
318
+ parent
319
+ } : void 0);
320
+ if (found) return found;
321
+ }
322
+ };
290
323
  const collectTabsetsInRow = (row, acc) => {
291
324
  walk(row, ({ node }) => {
292
325
  if (node.type === "tabset") acc.push(node);
@@ -298,41 +331,28 @@ const collectTabsets = (model) => {
298
331
  return acc;
299
332
  };
300
333
  const getFirstTabset = (model) => collectTabsets(model)[0];
301
- const findTabset = (model, tabsetId) => collectTabsets(model).find((tabset) => tabset.id === tabsetId);
302
- const findTabInContainer = (container, tabId) => {
303
- const index = container.children.findIndex((tab) => tab.id === tabId);
304
- if (index === -1) return;
305
- const tab = container.children.at(index);
306
- if (!tab) return;
307
- return {
308
- container,
309
- index,
310
- tab
311
- };
312
- };
334
+ const findTabset = (model, tabsetId) => locate(model, tabsetId, isTabset)?.node;
313
335
  const findTab = (model, tabId) => {
314
- for (const tabset of collectTabsets(model)) {
315
- const found = findTabInContainer(tabset, tabId);
316
- if (found) return found;
317
- }
318
- };
319
- const findRow = (row, id) => {
320
- const found = locate(row, id)?.node;
321
- return found?.type === "row" ? found : void 0;
336
+ const found = locate(model, tabId, isTab);
337
+ return found?.parent?.type === "tabset" ? {
338
+ container: found.parent,
339
+ index: found.index,
340
+ tab: found.node
341
+ } : void 0;
322
342
  };
323
- const findAttributedNodeInRow = (row, id) => locate(row, id)?.node;
343
+ const findRow = (model, id) => locate(model, id, isRow)?.node;
324
344
  const findAttributedNode = (model, id) => {
325
345
  for (const root of collectRoots(model)) {
326
- const found = findAttributedNodeInRow(root, id);
346
+ const found = walk(root, ({ node }) => node.id === id ? node : void 0);
327
347
  if (found) return found;
328
348
  }
329
349
  };
330
350
  const findRootContaining = (model, nodeId) => {
331
- for (const root of collectRoots(model)) if (findAttributedNodeInRow(root, nodeId)) return root;
351
+ for (const root of collectRoots(model)) if (walk(root, ({ node }) => node.id === nodeId ? true : void 0)) return root;
332
352
  };
333
- const findTabsetParent = (row, tabsetId) => {
334
- const found = locate(row, tabsetId);
335
- return found?.node.type === "tabset" && found.parent?.type === "row" ? {
353
+ const findTabsetParent = (model, tabsetId) => {
354
+ const found = locate(model, tabsetId, isTabset);
355
+ return found?.parent?.type === "row" ? {
336
356
  index: found.index,
337
357
  parent: found.parent
338
358
  } : void 0;
@@ -344,7 +364,7 @@ const collectIdsInRow = (row, acc) => {
344
364
  };
345
365
  const findDuplicateIds = (model) => {
346
366
  const ids = [];
347
- for (const float of model.floats ?? []) ids.push(float.id);
367
+ for (const float of model.floats) ids.push(float.id);
348
368
  for (const root of collectRoots(model)) collectIdsInRow(root, ids);
349
369
  const seen = /* @__PURE__ */ new Set();
350
370
  const duplicates = /* @__PURE__ */ new Set();
@@ -377,7 +397,8 @@ const tabset = (children, options = {}) => {
377
397
  ...max === void 0 ? {} : { max: toDimension(max) },
378
398
  ...min === void 0 ? {} : { min: toDimension(min) },
379
399
  id: options.id ?? createNodeId("tabset"),
380
- selected: options.selected ?? 0
400
+ selected: clampSelected(children.length, options.selected ?? 0),
401
+ weight: options.weight ?? 1
381
402
  };
382
403
  };
383
404
  const row = (children, options = {}) => {
@@ -389,7 +410,8 @@ const row = (children, options = {}) => {
389
410
  ...max === void 0 ? {} : { max: toDimension(max) },
390
411
  ...min === void 0 ? {} : { min: toDimension(min) },
391
412
  id: options.id ?? createNodeId("row"),
392
- orientation: options.orientation ?? "row"
413
+ orientation: options.orientation ?? "row",
414
+ weight: options.weight ?? 1
393
415
  };
394
416
  };
395
417
  const floatNode = (layout, geometry, options = {}) => ({
@@ -404,6 +426,7 @@ const model = (layout, options = {}) => {
404
426
  layout,
405
427
  version: 1,
406
428
  ...options,
429
+ floats: options.floats ?? [],
407
430
  global: options.global ?? {}
408
431
  };
409
432
  const duplicates = findDuplicateIds(built);
@@ -411,6 +434,12 @@ const model = (layout, options = {}) => {
411
434
  return built;
412
435
  };
413
436
 
437
+ //#endregion
438
+ //#region src/lib/assert-never.ts
439
+ const assertNever = (value) => {
440
+ throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
441
+ };
442
+
414
443
  //#endregion
415
444
  //#region src/geometry/geometry.ts
416
445
  const DEFAULT_BAND_FRACTION = .22;
@@ -431,15 +460,22 @@ const closestEdge = (d) => {
431
460
  if (min === d.top) return "top";
432
461
  return "bottom";
433
462
  };
463
+ const splitEdge = (location) => {
464
+ switch (location) {
465
+ case "center": return;
466
+ case "split-bottom": return "bottom";
467
+ case "split-left": return "left";
468
+ case "split-right": return "right";
469
+ case "split-top": return "top";
470
+ default: return assertNever(location);
471
+ }
472
+ };
434
473
  const resolveDockTarget = (pointer, rect, opts) => {
435
474
  const band = opts?.bandFraction ?? DEFAULT_BAND_FRACTION;
436
- if (rect.width <= 0 || rect.height <= 0) return { kind: "tab" };
475
+ if (rect.width <= 0 || rect.height <= 0) return "center";
437
476
  const distances = edgeDistances(pointer, rect);
438
- if (Math.min(distances.left, distances.right, distances.top, distances.bottom) > band) return { kind: "tab" };
439
- return {
440
- edge: closestEdge(distances),
441
- kind: "split"
442
- };
477
+ if (Math.min(distances.left, distances.right, distances.top, distances.bottom) > band) return "center";
478
+ return `split-${closestEdge(distances)}`;
443
479
  };
444
480
  const dockZonePolygons = (rect, opts) => {
445
481
  const { height: h, width: w, x, y } = rect;
@@ -564,7 +600,7 @@ const zoneRect = (rect, location) => {
564
600
  y: rect.y
565
601
  };
566
602
  case "center": return rect;
567
- default: return rect;
603
+ default: return assertNever(location);
568
604
  }
569
605
  };
570
606
 
@@ -588,21 +624,20 @@ const resolveSnapTargets = (config, panelCount) => {
588
624
  }
589
625
  return [...targets].toSorted((a, b) => a - b);
590
626
  };
591
- const snapEnabled = (config) => config !== null && ((config.step ?? 0) > 0 || config.divisions === "panels" || typeof config.divisions === "number" && config.divisions >= 2);
627
+ const resolveSnapGrid = (config, panelCount) => ({
628
+ targets: config === null ? [] : resolveSnapTargets(config, panelCount),
629
+ threshold: config?.threshold ?? 4
630
+ });
631
+ const snapEnabled = (grid) => grid.targets.length > 0;
592
632
  const nearestTarget = (position, targets) => targets.reduce((best, target) => {
593
633
  if (best === void 0 || Math.abs(target - position) < Math.abs(best - position)) return target;
594
634
  return best;
595
635
  }, void 0);
596
- const snapSizes = (sizes, boundaryIndex, config) => {
597
- const threshold = config.threshold ?? 4;
636
+ const snapSizes = (sizes, boundaryIndex, grid) => {
637
+ const { targets, threshold } = grid;
598
638
  const left = sizes[boundaryIndex];
599
639
  const right = sizes[boundaryIndex + 1];
600
- if (boundaryIndex < 0 || left === void 0 || right === void 0) return {
601
- sizes,
602
- snapped: false
603
- };
604
- const targets = resolveSnapTargets(config, sizes.length);
605
- if (targets.length === 0) return {
640
+ if (boundaryIndex < 0 || left === void 0 || right === void 0 || targets.length === 0) return {
606
641
  sizes,
607
642
  snapped: false
608
643
  };
@@ -626,32 +661,30 @@ const snapSizes = (sizes, boundaryIndex, config) => {
626
661
  snapped: true
627
662
  };
628
663
  };
664
+ const decideSnap = (sizes, boundaryIndex, grid) => {
665
+ if (boundaryIndex === null || !snapEnabled(grid)) return { kind: "inactive" };
666
+ const { sizes: snappedSizes, snapped } = snapSizes(sizes, boundaryIndex, grid);
667
+ return snapped ? {
668
+ kind: "engage",
669
+ sizes: snappedSizes
670
+ } : { kind: "clear" };
671
+ };
672
+ const settleSnap = (sizes, boundaryIndex, grid) => {
673
+ const decision = decideSnap(sizes, boundaryIndex, grid);
674
+ return decision.kind === "engage" ? decision.sizes : sizes;
675
+ };
629
676
 
630
677
  //#endregion
631
678
  //#region src/model/invariants.ts
632
- const clampSelected = (length, selected) => {
633
- if (length === 0) return 0;
634
- const base = Number.isFinite(selected) ? Math.trunc(selected) : 0;
635
- if (base < 0) return 0;
636
- if (base > length - 1) return length - 1;
637
- return base;
638
- };
639
- const normalizeTabset = (tabset) => ({
640
- ...tabset,
641
- selected: clampSelected(tabset.children.length, tabset.selected)
642
- });
643
679
  /**
644
- * A row that collapses to a single child hands its slot in the parent to
645
- * that child, so the slot's sizing constraints have to travel with it.
646
- * Dropping them silently widens a pinned sidebar, and `normalize` runs on
647
- * load and on every persist, so the loss is permanent.
648
- *
649
- * `snap` only exists on rows, so it is carried only to a row survivor.
680
+ * The survivor takes over the collapsed row's slot, so it has to take the slot's
681
+ * sizing too: dropping it widens a pinned sidebar, permanently, since this runs
682
+ * on every persist. `snap` is row-only, so it travels only to a row survivor.
650
683
  */
651
684
  const inheritSlot = (row, survivor) => {
652
685
  const sized = {
653
686
  ...survivor,
654
- ...row.weight === void 0 ? {} : { weight: row.weight },
687
+ weight: row.weight,
655
688
  ...row.min === void 0 ? {} : { min: row.min },
656
689
  ...row.max === void 0 ? {} : { max: row.max }
657
690
  };
@@ -661,14 +694,18 @@ const inheritSlot = (row, survivor) => {
661
694
  };
662
695
  return sized;
663
696
  };
664
- const normalizeRowChildren = (children) => {
697
+ /** Records the id of every tabset it keeps, in document order. */
698
+ const normalizeRowChildren = (children, keptTabsetIds) => {
665
699
  const out = [];
666
700
  for (const child of children) {
667
701
  if (child.type === "tabset") {
668
- if (child.children.length > 0) out.push(normalizeTabset(child));
702
+ if (child.children.length > 0) {
703
+ out.push(child);
704
+ keptTabsetIds.push(child.id);
705
+ }
669
706
  continue;
670
707
  }
671
- const grandchildren = normalizeRowChildren(child.children);
708
+ const grandchildren = normalizeRowChildren(child.children, keptTabsetIds);
672
709
  if (grandchildren.length === 0) continue;
673
710
  if (grandchildren.length === 1) {
674
711
  const only = grandchildren[0];
@@ -682,9 +719,8 @@ const normalizeRowChildren = (children) => {
682
719
  }
683
720
  return out;
684
721
  };
685
- const rowContainsTabset = (row) => row.children.some((child) => child.type === "tabset" ? true : rowContainsTabset(child));
686
- const normalizeLayout = (root) => {
687
- let children = normalizeRowChildren(root.children);
722
+ const normalizeLayout = (root, keptTabsetIds) => {
723
+ let children = normalizeRowChildren(root.children, keptTabsetIds);
688
724
  let orientation = root.orientation;
689
725
  let inner = children[0];
690
726
  while (children.length === 1 && inner?.type === "row") {
@@ -699,87 +735,111 @@ const normalizeLayout = (root) => {
699
735
  };
700
736
  };
701
737
  const normalize = (model) => {
702
- const layout = normalizeLayout(model.layout);
703
- const healedFloats = [];
704
- for (const float of model.floats ?? []) {
705
- const floatLayout = normalizeLayout(float.layout);
706
- if (rowContainsTabset(floatLayout)) healedFloats.push({
707
- ...float,
708
- layout: floatLayout
709
- });
738
+ const mainTabsetIds = [];
739
+ const layout = normalizeLayout(model.layout, mainTabsetIds);
740
+ const floats = [];
741
+ const floatTabsetIds = [];
742
+ for (const float of model.floats) {
743
+ const ownTabsetIds = [];
744
+ const floatLayout = normalizeLayout(float.layout, ownTabsetIds);
745
+ if (floatLayout.children.length > 0) {
746
+ floats.push({
747
+ ...float,
748
+ layout: floatLayout
749
+ });
750
+ floatTabsetIds.push(...ownTabsetIds);
751
+ }
710
752
  }
711
- const floats = healedFloats.length > 0 ? healedFloats : void 0;
712
- const withRoots = {
713
- ...model,
714
- floats,
715
- layout
716
- };
717
- const tabsetIds = new Set(collectTabsets(withRoots).map((tabset) => tabset.id));
718
- const firstTabsetId = collectTabsets(withRoots)[0]?.id;
719
- const activeTabsetId = model.activeTabsetId !== void 0 && tabsetIds.has(model.activeTabsetId) ? model.activeTabsetId : firstTabsetId;
720
- const maximizedTabsetId = model.maximizedTabsetId !== void 0 && tabsetIds.has(model.maximizedTabsetId) ? model.maximizedTabsetId : void 0;
753
+ const mainIds = new Set(mainTabsetIds);
754
+ const liveIds = /* @__PURE__ */ new Set([...mainTabsetIds, ...floatTabsetIds]);
755
+ const firstTabsetId = mainTabsetIds[0] ?? floatTabsetIds[0];
756
+ const activeTabsetId = model.activeTabsetId !== void 0 && liveIds.has(model.activeTabsetId) ? model.activeTabsetId : firstTabsetId;
757
+ const maximizedTabsetId = model.maximizedTabsetId !== void 0 && mainIds.has(model.maximizedTabsetId) ? model.maximizedTabsetId : void 0;
721
758
  return {
722
- ...withRoots,
759
+ ...model,
723
760
  activeTabsetId,
761
+ floats,
762
+ layout,
724
763
  maximizedTabsetId
725
764
  };
726
765
  };
727
766
 
728
767
  //#endregion
729
768
  //#region src/state/surgery.ts
730
- const removeTabsetReturning = (row, tabsetId) => {
731
- const found = findTabsetParent(row, tabsetId);
769
+ const removeTabsetReturning = (model, tabsetId) => {
770
+ const found = findTabsetParent(model, tabsetId);
732
771
  if (!found) return;
733
772
  const [removed] = found.parent.children.splice(found.index, 1);
734
773
  return removed?.type === "tabset" ? removed : void 0;
735
774
  };
736
- const removeTabset = (row, tabsetId) => removeTabsetReturning(row, tabsetId) !== void 0;
737
- const splitOrientation = (location) => location === "split-left" || location === "split-right" ? "row" : "column";
738
- const splitsBefore = (location) => location === "split-left" || location === "split-top";
775
+ /** Total, so a sixth `DockLocation` is a compile error here rather than a wrong default. */
776
+ const SPLIT_PLACEMENT = {
777
+ "split-bottom": {
778
+ before: false,
779
+ orientation: "column"
780
+ },
781
+ "split-left": {
782
+ before: true,
783
+ orientation: "row"
784
+ },
785
+ "split-right": {
786
+ before: false,
787
+ orientation: "row"
788
+ },
789
+ "split-top": {
790
+ before: true,
791
+ orientation: "column"
792
+ }
793
+ };
739
794
  const placeBesideTarget = (draft, placed, targetId, location) => {
740
795
  const targetTabset = findTabset(draft, targetId);
741
- const found = findTabsetParent(findRootContaining(draft, targetId) ?? draft.layout, targetId);
742
- if (!targetTabset || !found) return;
743
- const orientation = splitOrientation(location);
744
- const before = splitsBefore(location);
796
+ const found = findTabsetParent(draft, targetId);
797
+ if (!targetTabset || !found) return false;
798
+ const { before, orientation } = SPLIT_PLACEMENT[location];
745
799
  if (found.parent.orientation === orientation) {
746
- const targetWeight = targetTabset.weight ?? 100;
747
- targetTabset.weight = targetWeight / 2;
748
- placed.weight = targetWeight / 2;
800
+ const half = targetTabset.weight / 2;
801
+ targetTabset.weight = half;
802
+ placed.weight = half;
749
803
  found.parent.children.splice(before ? found.index : found.index + 1, 0, placed);
750
804
  } else {
751
- const targetWeight = targetTabset.weight;
752
- targetTabset.weight = 50;
753
- placed.weight = 50;
754
805
  const newRow = {
755
806
  children: before ? [placed, targetTabset] : [targetTabset, placed],
756
807
  id: createNodeId("row"),
757
808
  orientation,
758
- type: "row"
809
+ type: "row",
810
+ weight: targetTabset.weight
759
811
  };
760
- if (targetWeight !== void 0) newRow.weight = targetWeight;
812
+ targetTabset.weight = 50;
813
+ placed.weight = 50;
761
814
  found.parent.children.splice(found.index, 1, newRow);
762
815
  }
763
816
  draft.activeTabsetId = placed.id;
817
+ return true;
764
818
  };
765
819
  const insertTab = (draft, tab, target) => {
766
820
  const { id: targetId, index, location } = target;
767
821
  const targetTabset = findTabset(draft, targetId);
768
- if (!targetTabset) return;
822
+ if (!targetTabset) return false;
769
823
  if (location === "center") {
770
- const at = index ?? targetTabset.children.length;
824
+ const at = Math.min(Math.max(index ?? targetTabset.children.length, 0), targetTabset.children.length);
771
825
  targetTabset.children.splice(at, 0, tab);
772
826
  targetTabset.selected = at;
773
- return;
827
+ return true;
774
828
  }
775
829
  const newTabset = {
776
830
  children: [tab],
777
831
  id: createNodeId("tabset"),
778
832
  selected: 0,
779
833
  type: "tabset",
780
- weight: 50
834
+ weight: 1
781
835
  };
782
- placeBesideTarget(draft, newTabset, targetId, location);
836
+ return placeBesideTarget(draft, newTabset, targetId, location);
837
+ };
838
+ /** Re-clamps here rather than leaving the whole tree to be re-walked afterwards. */
839
+ const detachTab = (container, index) => {
840
+ const removed = container.children.splice(index, 1).at(0);
841
+ container.selected = clampSelected(container.children.length, container.selected);
842
+ return removed;
783
843
  };
784
844
  const mergeTabsInto = (target, tabs, selectedOffset) => {
785
845
  const mergeStart = target.children.length;
@@ -791,17 +851,20 @@ const wrapTabInLayout = (tab) => ({
791
851
  children: [tab],
792
852
  id: createNodeId("tabset"),
793
853
  selected: 0,
794
- type: "tabset"
854
+ type: "tabset",
855
+ weight: 1
795
856
  }],
796
857
  id: createNodeId("row"),
797
858
  orientation: "row",
798
- type: "row"
859
+ type: "row",
860
+ weight: 1
799
861
  });
800
862
  const wrapTabsetInLayout = (tabset) => ({
801
863
  children: [tabset],
802
864
  id: createNodeId("row"),
803
865
  orientation: "row",
804
- type: "row"
866
+ type: "row",
867
+ weight: 1
805
868
  });
806
869
 
807
870
  //#endregion
@@ -824,20 +887,20 @@ const pushFloat = (draft, layout, geometry, floatId) => {
824
887
  geometry: geometry ?? DEFAULT_FLOAT_GEOMETRY,
825
888
  id: floatId ?? createNodeId("float"),
826
889
  layout,
827
- name: uniqueFloatName("Panel", draft.floats ?? []),
890
+ name: uniqueFloatName("Panel", draft.floats),
828
891
  type: "float"
829
892
  };
830
- draft.floats = [...draft.floats ?? [], float];
893
+ draft.floats = [...draft.floats, float];
831
894
  const tabsets = [];
832
895
  collectTabsetsInRow(layout, tabsets);
833
896
  const first = tabsets.at(0);
834
897
  if (first) draft.activeTabsetId = first.id;
835
898
  };
836
899
  const floatTabsetById = (draft, tabsetId, geometry, floatId) => {
837
- const root = findRootContaining(draft, tabsetId);
838
- if (!root) return;
839
- const detached = removeTabsetReturning(root, tabsetId);
840
- if (detached) pushFloat(draft, wrapTabsetInLayout(detached), geometry, floatId);
900
+ const detached = removeTabsetReturning(draft, tabsetId);
901
+ if (!detached) return false;
902
+ pushFloat(draft, wrapTabsetInLayout(detached), geometry, floatId);
903
+ return true;
841
904
  };
842
905
  const resolveMainTarget = (draft, targetId) => {
843
906
  const mainTabsets = [];
@@ -853,168 +916,185 @@ const resolveMainTarget = (draft, targetId) => {
853
916
  return mainTabsets[0];
854
917
  };
855
918
  const dockFloat = (draft, floatId, targetId, location) => {
856
- const floats = draft.floats ?? [];
857
- const index = floats.findIndex((float) => float.id === floatId);
858
- if (index === -1) return;
859
- const float = floats.splice(index, 1).at(0);
860
- draft.floats = floats;
861
- if (!float) return;
919
+ const index = draft.floats.findIndex((float) => float.id === floatId);
920
+ const float = draft.floats.at(index);
921
+ if (index === -1 || !float) return false;
862
922
  const tabsets = [];
863
923
  collectTabsetsInRow(float.layout, tabsets);
864
924
  const tabs = tabsets.flatMap((tabset) => tabset.children);
865
- if (tabs.length === 0) return;
925
+ if (tabs.length === 0) return false;
866
926
  const leadTabset = tabsets.at(0);
867
927
  const selectedOffset = leadTabset ? clampSelected(leadTabset.children.length, leadTabset.selected) : 0;
868
928
  const target = resolveMainTarget(draft, targetId);
929
+ draft.floats.splice(index, 1);
869
930
  if (!target) {
870
931
  draft.layout = float.layout;
871
932
  draft.activeTabsetId = leadTabset?.id;
872
- return;
933
+ return true;
873
934
  }
874
935
  if (location === "center") {
875
936
  mergeTabsInto(target, tabs, selectedOffset);
876
937
  draft.activeTabsetId = target.id;
877
- return;
938
+ return true;
878
939
  }
879
- const where = location ?? "split-right";
880
940
  const placed = tabsets.length === 1 && leadTabset ? leadTabset : {
881
941
  children: tabs,
882
942
  id: createNodeId("tabset"),
883
943
  selected: selectedOffset,
884
- type: "tabset"
944
+ type: "tabset",
945
+ weight: 1
885
946
  };
886
- placed.weight = 50;
887
- placeBesideTarget(draft, placed, target.id, where);
947
+ return placeBesideTarget(draft, placed, target.id, location ?? "split-right");
888
948
  };
889
949
 
890
950
  //#endregion
891
951
  //#region src/state/reducer.ts
892
- const assertNever = (value) => {
893
- throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
952
+ const parseNodeAttrs = (node, attrs) => {
953
+ switch (node.type) {
954
+ case "row": return mutableRowAttrsSchema.parse(attrs);
955
+ case "tab": return mutableTabAttrsSchema.parse(attrs);
956
+ case "tabset": {
957
+ const parsed = mutableTabsetAttrsSchema.parse(attrs);
958
+ return parsed.selected === void 0 ? parsed : {
959
+ ...parsed,
960
+ selected: clampSelected(node.children.length, parsed.selected)
961
+ };
962
+ }
963
+ default: return assertNever(node);
964
+ }
894
965
  };
966
+ const sameGeometry = (a, b) => a.height === b.height && a.left === b.left && a.top === b.top && a.width === b.width;
967
+ const alreadyApplied = (target, attrs) => {
968
+ const current = new Map(Object.entries(target));
969
+ return Object.entries(attrs).every(([key, value]) => Object.is(current.get(key), value));
970
+ };
971
+ /** Reports whether it changed anything, so `history.dispatch` can skip a no-op. */
895
972
  const applyAction = (draft, action) => {
896
973
  switch (action.type) {
897
- case "addNode":
898
- insertTab(draft, action.tab, {
899
- id: action.targetId,
900
- index: action.index,
901
- location: action.location
902
- });
903
- return;
974
+ case "addNode": return insertTab(draft, action.tab, {
975
+ id: action.targetId,
976
+ index: action.index,
977
+ location: action.location
978
+ });
904
979
  case "adjustSplit": {
905
- const row = findRow(findRootContaining(draft, action.rowId) ?? draft.layout, action.rowId);
906
- if (!row) return;
980
+ const row = findRow(draft, action.rowId);
981
+ if (!row) return false;
982
+ let changed = false;
907
983
  for (let index = 0; index < action.weights.length; index++) {
908
984
  const child = row.children.at(index);
909
985
  const weight = action.weights[index];
910
- if (!child || weight === void 0) continue;
986
+ if (!child || weight === void 0 || child.weight === weight) continue;
911
987
  child.weight = weight;
988
+ changed = true;
912
989
  }
913
- return;
990
+ return changed;
914
991
  }
915
992
  case "deleteTab": {
916
993
  const location = findTab(draft, action.tabId);
917
- if (location) location.container.children.splice(location.index, 1);
918
- return;
919
- }
920
- case "deleteTabset": {
921
- const root = findRootContaining(draft, action.tabsetId);
922
- if (root) removeTabset(root, action.tabsetId);
923
- return;
994
+ if (!location) return false;
995
+ detachTab(location.container, location.index);
996
+ return true;
924
997
  }
925
- case "dockFloat":
926
- dockFloat(draft, action.floatId, action.targetId, action.location);
927
- return;
998
+ case "deleteTabset": return removeTabsetReturning(draft, action.tabsetId) !== void 0;
999
+ case "dockFloat": return dockFloat(draft, action.floatId, action.targetId, action.location);
928
1000
  case "floatTab": {
929
1001
  const location = findTab(draft, action.tabId);
930
- if (!location) return;
931
- const removed = location.container.children.splice(location.index, 1).at(0);
932
- if (removed) pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
933
- return;
1002
+ if (!location) return false;
1003
+ const removed = detachTab(location.container, location.index);
1004
+ if (!removed) return false;
1005
+ pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
1006
+ return true;
934
1007
  }
935
- case "floatTabset":
936
- floatTabsetById(draft, action.tabsetId, action.geometry, action.floatId);
937
- return;
1008
+ case "floatTabset": return floatTabsetById(draft, action.tabsetId, action.geometry, action.floatId);
938
1009
  case "moveFloat": {
939
1010
  const float = findFloat(draft, action.floatId);
940
- if (float) float.geometry = action.geometry;
941
- return;
1011
+ if (!float || sameGeometry(float.geometry, action.geometry)) return false;
1012
+ float.geometry = action.geometry;
1013
+ return true;
942
1014
  }
943
1015
  case "moveNode": {
944
1016
  const source = findTab(draft, action.sourceId);
945
- if (!source) return;
946
- const removed = source.container.children.splice(source.index, 1).at(0);
947
- if (!removed) return;
948
- insertTab(draft, removed, {
1017
+ if (!source || !findTabset(draft, action.targetId)) return false;
1018
+ const removed = detachTab(source.container, source.index);
1019
+ if (!removed) return false;
1020
+ return insertTab(draft, removed, {
949
1021
  id: action.targetId,
950
1022
  index: action.index,
951
1023
  location: action.location
952
1024
  });
953
- return;
954
1025
  }
955
1026
  case "moveTabset": {
956
- if (action.sourceId === action.targetId) return;
1027
+ if (action.sourceId === action.targetId) return false;
957
1028
  const source = findTabset(draft, action.sourceId);
958
- if (!source) return;
1029
+ const target = findTabset(draft, action.targetId);
1030
+ if (!source || !target) return false;
959
1031
  if (action.location === "center") {
960
- const target = findTabset(draft, action.targetId);
961
- if (target) {
962
- mergeTabsInto(target, source.children, clampSelected(source.children.length, source.selected));
963
- const sourceRoot = findRootContaining(draft, action.sourceId);
964
- if (sourceRoot) removeTabset(sourceRoot, action.sourceId);
965
- }
966
- return;
1032
+ mergeTabsInto(target, source.children, clampSelected(source.children.length, source.selected));
1033
+ removeTabsetReturning(draft, action.sourceId);
1034
+ return true;
967
1035
  }
968
- if (action.location.startsWith("split-")) {
969
- const sourceRoot = findRootContaining(draft, action.sourceId);
970
- const detached = sourceRoot ? removeTabsetReturning(sourceRoot, action.sourceId) : void 0;
971
- if (detached) placeBesideTarget(draft, detached, action.targetId, action.location);
972
- }
973
- return;
1036
+ const detached = removeTabsetReturning(draft, action.sourceId);
1037
+ return detached ? placeBesideTarget(draft, detached, action.targetId, action.location) : false;
974
1038
  }
975
1039
  case "renameFloat": {
976
1040
  const float = findFloat(draft, action.floatId);
977
- if (float) {
978
- const others = (draft.floats ?? []).filter((other) => other.id !== action.floatId);
979
- float.name = uniqueFloatName(action.name, others);
980
- }
981
- return;
1041
+ if (!float) return false;
1042
+ const others = draft.floats.filter((other) => other.id !== action.floatId);
1043
+ const name = uniqueFloatName(action.name, others);
1044
+ if (float.name === name) return false;
1045
+ float.name = name;
1046
+ return true;
982
1047
  }
983
1048
  case "renameTab": {
984
1049
  const location = findTab(draft, action.tabId);
985
- if (location) location.tab.name = action.name;
986
- return;
1050
+ if (!location || location.tab.name === action.name) return false;
1051
+ location.tab.name = action.name;
1052
+ return true;
987
1053
  }
988
1054
  case "selectTab": {
989
1055
  const tabset = findTabset(draft, action.tabsetId);
990
- if (tabset) tabset.selected = action.index;
991
- return;
1056
+ if (!tabset) return false;
1057
+ const selected = clampSelected(tabset.children.length, action.index);
1058
+ if (tabset.selected === selected) return false;
1059
+ tabset.selected = selected;
1060
+ return true;
992
1061
  }
993
1062
  case "setActiveTabset":
994
- if (findTabset(draft, action.tabsetId)) draft.activeTabsetId = action.tabsetId;
995
- return;
1063
+ if (draft.activeTabsetId === action.tabsetId || !findTabset(draft, action.tabsetId)) return false;
1064
+ draft.activeTabsetId = action.tabsetId;
1065
+ return true;
996
1066
  case "setFloatMinimized": {
997
1067
  const float = findFloat(draft, action.floatId);
998
- if (float) float.minimized = action.minimized;
999
- return;
1068
+ if (!float || (float.minimized ?? false) === action.minimized) return false;
1069
+ float.minimized = action.minimized;
1070
+ return true;
1071
+ }
1072
+ case "setMaximizedTabset": {
1073
+ const next = action.tabsetId ?? void 0;
1074
+ if (draft.maximizedTabsetId === next) return false;
1075
+ draft.maximizedTabsetId = next;
1076
+ return true;
1000
1077
  }
1001
- case "setMaximizedTabset":
1002
- draft.maximizedTabsetId = action.tabsetId ?? void 0;
1003
- return;
1004
1078
  case "updateGlobalAttributes":
1079
+ if (alreadyApplied(draft.global, action.attrs)) return false;
1005
1080
  Object.assign(draft.global, action.attrs);
1006
- return;
1081
+ return true;
1007
1082
  case "updateNodeAttributes": {
1008
1083
  const node = findAttributedNode(draft, action.nodeId);
1009
- if (node) Object.assign(node, action.attrs);
1010
- return;
1084
+ if (!node) return false;
1085
+ const parsed = parseNodeAttrs(node, action.attrs);
1086
+ if (alreadyApplied(node, parsed)) return false;
1087
+ Object.assign(node, parsed);
1088
+ return true;
1011
1089
  }
1012
- default: assertNever(action);
1090
+ default: return assertNever(action);
1013
1091
  }
1014
1092
  };
1093
+ /** Identity-equal output means "no edit". A rejected action skips `normalize`, so
1094
+ * pass a model that has already been through it. */
1015
1095
  const reducer = (model, action) => {
1016
1096
  const draft = structuredClone(model);
1017
- applyAction(draft, action);
1097
+ if (!applyAction(draft, action)) return model;
1018
1098
  return normalize(draft);
1019
1099
  };
1020
1100
 
@@ -1028,8 +1108,14 @@ const createHistory = (present) => ({
1028
1108
  });
1029
1109
  const canUndo = (history) => history.past.length > 0;
1030
1110
  const canRedo = (history) => history.future.length > 0;
1111
+ /**
1112
+ * An action that changed nothing is not an edit: recording it would spend an
1113
+ * undo slot and clear a redo stack the user just built. Not coalescing (ADR
1114
+ * 0002): two distinct edits are still two entries.
1115
+ */
1031
1116
  const dispatch = (history, action) => {
1032
1117
  const present = reducer(history.present, action);
1118
+ if (Object.is(present, history.present)) return history;
1033
1119
  return {
1034
1120
  future: [],
1035
1121
  past: [...history.past, history.present].slice(-100),
@@ -1075,25 +1161,50 @@ const dashfooMachine = setup({ types: {
1075
1161
 
1076
1162
  //#endregion
1077
1163
  //#region src/machines/drag-dock-machine.ts
1078
- const requireDrop = (context) => {
1079
- if (context.subject === null || context.intent === null) throw new Error("drop requires a subject and a drop intent");
1164
+ /** One definition of both "is this drop valid" and "what does it do", so the guard and the emit agree. */
1165
+ const dropAction = (drag) => {
1166
+ if (drag.kind !== "dragging" || drag.drop === null) return null;
1167
+ const { intent, scope } = drag.drop;
1168
+ const { subject } = drag;
1169
+ if (subject.kind === "external") return {
1170
+ action: {
1171
+ index: intent.index,
1172
+ location: intent.location,
1173
+ tab: subject.tab,
1174
+ targetId: intent.targetId,
1175
+ type: "addNode"
1176
+ },
1177
+ scope
1178
+ };
1179
+ if (subject.kind === "tabset") return {
1180
+ action: {
1181
+ location: intent.location,
1182
+ sourceId: subject.id,
1183
+ targetId: intent.targetId,
1184
+ type: "moveTabset"
1185
+ },
1186
+ scope
1187
+ };
1080
1188
  return {
1081
- intent: context.intent,
1082
- subject: context.subject
1189
+ action: {
1190
+ index: intent.index,
1191
+ location: intent.location,
1192
+ sourceId: subject.id,
1193
+ targetId: intent.targetId,
1194
+ type: "moveNode"
1195
+ },
1196
+ scope
1083
1197
  };
1084
1198
  };
1085
1199
  const dragDockMachine = setup({
1086
- guards: { hasValidDrop: ({ context }) => context.subject !== null && context.intent !== null },
1200
+ guards: { hasValidDrop: ({ context }) => dropAction(context.drag) !== null },
1087
1201
  types: {
1088
1202
  context: {},
1089
1203
  emitted: {},
1090
1204
  events: {}
1091
1205
  }
1092
1206
  }).createMachine({
1093
- context: {
1094
- intent: null,
1095
- subject: null
1096
- },
1207
+ context: { drag: { kind: "idle" } },
1097
1208
  id: "dragDock",
1098
1209
  initial: "idle",
1099
1210
  states: {
@@ -1101,36 +1212,30 @@ const dragDockMachine = setup({
1101
1212
  CANCEL: { target: "idle" },
1102
1213
  DROP: [{
1103
1214
  actions: emit(({ context }) => {
1104
- const { intent, subject } = requireDrop(context);
1215
+ const resolved = dropAction(context.drag);
1216
+ if (resolved === null) throw new Error("drop requires a subject and a resolved drop target");
1105
1217
  return {
1106
- action: subject.kind === "external" ? {
1107
- index: intent.index,
1108
- location: intent.location,
1109
- tab: subject.tab,
1110
- targetId: intent.targetId,
1111
- type: "addNode"
1112
- } : {
1113
- index: intent.index,
1114
- location: intent.location,
1115
- sourceId: subject.id,
1116
- targetId: intent.targetId,
1117
- type: subject.kind === "tabset" ? "moveTabset" : "moveNode"
1118
- },
1218
+ action: resolved.action,
1219
+ scope: resolved.scope,
1119
1220
  type: "COMMIT"
1120
1221
  };
1121
1222
  }),
1122
1223
  guard: "hasValidDrop",
1123
1224
  target: "idle"
1124
1225
  }, { target: "idle" }],
1125
- OVER: { actions: assign({ intent: ({ event }) => event.intent }) }
1226
+ OVER: { actions: assign({ drag: ({ context, event }) => context.drag.kind === "dragging" ? {
1227
+ ...context.drag,
1228
+ drop: event.drop
1229
+ } : context.drag }) }
1126
1230
  } },
1127
1231
  idle: {
1128
- entry: assign({
1129
- intent: null,
1130
- subject: null
1131
- }),
1232
+ entry: assign({ drag: { kind: "idle" } }),
1132
1233
  on: { START: {
1133
- actions: assign({ subject: ({ event }) => event.subject }),
1234
+ actions: assign({ drag: ({ event }) => ({
1235
+ drop: null,
1236
+ kind: "dragging",
1237
+ subject: event.subject
1238
+ }) }),
1134
1239
  target: "dragging"
1135
1240
  } }
1136
1241
  }
@@ -1164,12 +1269,13 @@ const stackModel = (model, orientation = "column") => {
1164
1269
  children,
1165
1270
  id: model.layout.id,
1166
1271
  orientation,
1167
- type: "row"
1272
+ type: "row",
1273
+ weight: model.layout.weight
1168
1274
  },
1169
1275
  maximizedTabsetId: void 0
1170
1276
  });
1171
1277
  };
1172
1278
 
1173
1279
  //#endregion
1174
- 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 };
1280
+ export { DEFAULT_SNAP_THRESHOLD, DEFAULT_WEIGHT, HISTORY_LIMIT, actionSchema, canRedo, canUndo, collectRoots, collectTabsets, collectTabsetsInRow, createHistory, createNodeId, createTabId, dashfooMachine, dashfooSchema, decideSnap, dimensionSchema, dispatch, dockLocationSchema, dockZonePolygons, dragDockMachine, dropAction, edgeSchema, findAttributedNode, findDuplicateIds, findFloat, findRootContaining, findRow, findTab, findTabset, findTabsetParent, floatNode, floatNodeSchema, fromJSON, geometrySchema, getFirstTabset, globalAttributesSchema, jsonValueSchema, model, mutableNodeAttrsSchema, normalize, orientationSchema, parseModel, redo, reducer, resolveDockTarget, resolveSnapGrid, resolveSnapTargets, row, rowNodeSchema, settleSnap, snapEnabled, snapSchema, snapSizes, splitEdge, stackModel, tab, tabNodeSchema, tabset, tabsetNodeObjectSchema, tabsetNodeSchema, toJSON, undo, unitSchema, zoneRect };
1175
1281
  //# sourceMappingURL=index.js.map