@dashfoo/core 1.0.0 → 1.1.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.
package/dist/index.js CHANGED
@@ -2,11 +2,8 @@ import { z } from "zod";
2
2
  import { assign, emit, setup } from "xstate";
3
3
 
4
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
- */
5
+ /** In `lib/` because both the parse boundary and `normalize` need it, and
6
+ * importing it from either would close a module cycle. */
10
7
  const clampSelected = (length, selected) => {
11
8
  if (length === 0) return 0;
12
9
  const base = Number.isFinite(selected) ? Math.trunc(selected) : 0;
@@ -17,14 +14,7 @@ const clampSelected = (length, selected) => {
17
14
 
18
15
  //#endregion
19
16
  //#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
- */
17
+ /** Weights are relative shares of a row, not percentages. */
28
18
  const DEFAULT_WEIGHT = 1;
29
19
  const jsonValueSchema = z.lazy(() => z.union([
30
20
  z.string(),
@@ -69,11 +59,8 @@ const tabNodeSchema = z.object({
69
59
  type: z.literal("tab")
70
60
  });
71
61
  /**
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.
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.
77
64
  */
78
65
  const tabsetNodeObjectSchema = z.object({
79
66
  children: z.array(tabNodeSchema),
@@ -91,8 +78,11 @@ const tabsetNodeSchema = tabsetNodeObjectSchema.transform((tabset) => ({
91
78
  ...tabset,
92
79
  selected: clampSelected(tabset.children.length, tabset.selected)
93
80
  }));
94
- const rowNodeSchema = z.lazy(() => z.object({
95
- children: z.array(z.union([rowNodeSchema, tabsetNodeSchema])),
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
+ },
96
86
  id: z.string(),
97
87
  max: dimensionSchema.optional(),
98
88
  min: dimensionSchema.optional(),
@@ -100,7 +90,7 @@ const rowNodeSchema = z.lazy(() => z.object({
100
90
  snap: snapSchema.optional(),
101
91
  type: z.literal("row"),
102
92
  weight: z.number().default(1)
103
- }));
93
+ });
104
94
  const geometrySchema = z.object({
105
95
  height: z.number(),
106
96
  left: z.number(),
@@ -146,11 +136,7 @@ const mutableTabAttrsSchema = tabNodeSchema.pick({
146
136
  enableRename: true,
147
137
  name: true
148
138
  }).partial();
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
- */
139
+ /** `weight` is re-declared, not picked: `.partial()` over a defaulted field still injects the default. */
154
140
  const mutableTabsetAttrsSchema = tabsetNodeObjectSchema.pick({
155
141
  enableClose: true,
156
142
  enableMaximize: true,
@@ -166,13 +152,8 @@ const mutableRowAttrsSchema = z.object({
166
152
  weight: z.number()
167
153
  }).partial();
168
154
  /**
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.
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.
176
157
  */
177
158
  const mutableNodeAttrsSchema = mutableTabAttrsSchema.extend(mutableTabsetAttrsSchema.shape).extend(mutableRowAttrsSchema.shape);
178
159
 
@@ -202,7 +183,6 @@ const actionSchema = z.discriminatedUnion("type", [
202
183
  type: z.literal("moveNode")
203
184
  }),
204
185
  z.object({
205
- index: z.number().int().optional(),
206
186
  location: dockLocationSchema,
207
187
  sourceId: z.string(),
208
188
  targetId: z.string(),
@@ -293,16 +273,9 @@ const createTabId = () => createNodeId("tab");
293
273
  const collectRoots = (model) => [model.layout, ...model.floats.map((float) => float.layout)];
294
274
  const findFloat = (model, floatId) => model.floats.find((float) => float.id === floatId);
295
275
  /**
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.
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.
306
279
  */
307
280
  const walk = (row, visit, parent, index = -1) => {
308
281
  const atRoot = visit({
@@ -333,7 +306,20 @@ const walk = (row, visit, parent, index = -1) => {
333
306
  }
334
307
  }
335
308
  };
336
- 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
+ };
337
323
  const collectTabsetsInRow = (row, acc) => {
338
324
  walk(row, ({ node }) => {
339
325
  if (node.type === "tabset") acc.push(node);
@@ -345,41 +331,28 @@ const collectTabsets = (model) => {
345
331
  return acc;
346
332
  };
347
333
  const getFirstTabset = (model) => collectTabsets(model)[0];
348
- const findTabset = (model, tabsetId) => collectTabsets(model).find((tabset) => tabset.id === tabsetId);
349
- const findTabInContainer = (container, tabId) => {
350
- const index = container.children.findIndex((tab) => tab.id === tabId);
351
- if (index === -1) return;
352
- const tab = container.children.at(index);
353
- if (!tab) return;
354
- return {
355
- container,
356
- index,
357
- tab
358
- };
359
- };
334
+ const findTabset = (model, tabsetId) => locate(model, tabsetId, isTabset)?.node;
360
335
  const findTab = (model, tabId) => {
361
- for (const tabset of collectTabsets(model)) {
362
- const found = findTabInContainer(tabset, tabId);
363
- if (found) return found;
364
- }
365
- };
366
- const findRow = (row, id) => {
367
- const found = locate(row, id)?.node;
368
- 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;
369
342
  };
370
- const findAttributedNodeInRow = (row, id) => locate(row, id)?.node;
343
+ const findRow = (model, id) => locate(model, id, isRow)?.node;
371
344
  const findAttributedNode = (model, id) => {
372
345
  for (const root of collectRoots(model)) {
373
- const found = findAttributedNodeInRow(root, id);
346
+ const found = walk(root, ({ node }) => node.id === id ? node : void 0);
374
347
  if (found) return found;
375
348
  }
376
349
  };
377
350
  const findRootContaining = (model, nodeId) => {
378
- 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;
379
352
  };
380
- const findTabsetParent = (row, tabsetId) => {
381
- const found = locate(row, tabsetId);
382
- 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" ? {
383
356
  index: found.index,
384
357
  parent: found.parent
385
358
  } : void 0;
@@ -417,29 +390,31 @@ const tab = (component, name, options = {}) => ({
417
390
  });
418
391
  const tabset = (children, options = {}) => {
419
392
  const { max, min, ...rest } = options;
420
- return {
393
+ const built = {
421
394
  children,
422
395
  type: "tabset",
423
396
  ...rest,
424
- ...max === void 0 ? {} : { max: toDimension(max) },
425
- ...min === void 0 ? {} : { min: toDimension(min) },
426
397
  id: options.id ?? createNodeId("tabset"),
427
398
  selected: clampSelected(children.length, options.selected ?? 0),
428
399
  weight: options.weight ?? 1
429
400
  };
401
+ if (max !== void 0) built.max = toDimension(max);
402
+ if (min !== void 0) built.min = toDimension(min);
403
+ return built;
430
404
  };
431
405
  const row = (children, options = {}) => {
432
406
  const { max, min, ...rest } = options;
433
- return {
407
+ const built = {
434
408
  children,
435
409
  type: "row",
436
410
  ...rest,
437
- ...max === void 0 ? {} : { max: toDimension(max) },
438
- ...min === void 0 ? {} : { min: toDimension(min) },
439
411
  id: options.id ?? createNodeId("row"),
440
412
  orientation: options.orientation ?? "row",
441
413
  weight: options.weight ?? 1
442
414
  };
415
+ if (max !== void 0) built.max = toDimension(max);
416
+ if (min !== void 0) built.min = toDimension(min);
417
+ return built;
443
418
  };
444
419
  const floatNode = (layout, geometry, options = {}) => ({
445
420
  geometry,
@@ -487,13 +462,6 @@ const closestEdge = (d) => {
487
462
  if (min === d.top) return "top";
488
463
  return "bottom";
489
464
  };
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
465
  const splitEdge = (location) => {
498
466
  switch (location) {
499
467
  case "center": return;
@@ -506,13 +474,10 @@ const splitEdge = (location) => {
506
474
  };
507
475
  const resolveDockTarget = (pointer, rect, opts) => {
508
476
  const band = opts?.bandFraction ?? DEFAULT_BAND_FRACTION;
509
- if (rect.width <= 0 || rect.height <= 0) return { kind: "tab" };
477
+ if (rect.width <= 0 || rect.height <= 0) return "center";
510
478
  const distances = edgeDistances(pointer, rect);
511
- if (Math.min(distances.left, distances.right, distances.top, distances.bottom) > band) return { kind: "tab" };
512
- return {
513
- edge: closestEdge(distances),
514
- kind: "split"
515
- };
479
+ if (Math.min(distances.left, distances.right, distances.top, distances.bottom) > band) return "center";
480
+ return `split-${closestEdge(distances)}`;
516
481
  };
517
482
  const dockZonePolygons = (rect, opts) => {
518
483
  const { height: h, width: w, x, y } = rect;
@@ -661,21 +626,20 @@ const resolveSnapTargets = (config, panelCount) => {
661
626
  }
662
627
  return [...targets].toSorted((a, b) => a - b);
663
628
  };
664
- const snapEnabled = (config) => config !== null && ((config.step ?? 0) > 0 || config.divisions === "panels" || typeof config.divisions === "number" && config.divisions >= 2);
629
+ const resolveSnapGrid = (config, panelCount) => ({
630
+ targets: config === null ? [] : resolveSnapTargets(config, panelCount),
631
+ threshold: config?.threshold ?? 4
632
+ });
633
+ const snapEnabled = (grid) => grid.targets.length > 0;
665
634
  const nearestTarget = (position, targets) => targets.reduce((best, target) => {
666
635
  if (best === void 0 || Math.abs(target - position) < Math.abs(best - position)) return target;
667
636
  return best;
668
637
  }, void 0);
669
- const snapSizes = (sizes, boundaryIndex, config) => {
670
- const threshold = config.threshold ?? 4;
638
+ const snapSizes = (sizes, boundaryIndex, grid) => {
639
+ const { targets, threshold } = grid;
671
640
  const left = sizes[boundaryIndex];
672
641
  const right = sizes[boundaryIndex + 1];
673
- if (boundaryIndex < 0 || left === void 0 || right === void 0) return {
674
- sizes,
675
- snapped: false
676
- };
677
- const targets = resolveSnapTargets(config, sizes.length);
678
- if (targets.length === 0) return {
642
+ if (boundaryIndex < 0 || left === void 0 || right === void 0 || targets.length === 0) return {
679
643
  sizes,
680
644
  snapped: false
681
645
  };
@@ -699,50 +663,51 @@ const snapSizes = (sizes, boundaryIndex, config) => {
699
663
  snapped: true
700
664
  };
701
665
  };
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);
666
+ const decideSnap = (sizes, boundaryIndex, grid) => {
667
+ if (boundaryIndex === null || !snapEnabled(grid)) return { kind: "inactive" };
668
+ const { sizes: snappedSizes, snapped } = snapSizes(sizes, boundaryIndex, grid);
705
669
  return snapped ? {
706
670
  kind: "engage",
707
671
  sizes: snappedSizes
708
672
  } : { kind: "clear" };
709
673
  };
710
- const settleSnap = (sizes, boundaryIndex, config) => {
711
- const decision = decideSnap(sizes, boundaryIndex, config);
674
+ const settleSnap = (sizes, boundaryIndex, grid) => {
675
+ const decision = decideSnap(sizes, boundaryIndex, grid);
712
676
  return decision.kind === "engage" ? decision.sizes : sizes;
713
677
  };
714
678
 
715
679
  //#endregion
716
680
  //#region src/model/invariants.ts
717
681
  /**
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.
682
+ * The survivor takes over the collapsed row's slot, so it has to take the slot's
683
+ * sizing too: dropping it widens a pinned sidebar, permanently, since this runs
684
+ * on every persist. `snap` is row-only, so it travels only to a row survivor.
724
685
  */
725
686
  const inheritSlot = (row, survivor) => {
726
687
  const sized = {
727
688
  ...survivor,
728
- weight: row.weight,
729
- ...row.min === void 0 ? {} : { min: row.min },
730
- ...row.max === void 0 ? {} : { max: row.max }
689
+ weight: row.weight
731
690
  };
691
+ if (row.min !== void 0) sized.min = row.min;
692
+ if (row.max !== void 0) sized.max = row.max;
732
693
  if (sized.type === "row" && row.snap !== void 0) return {
733
694
  ...sized,
734
695
  snap: row.snap
735
696
  };
736
697
  return sized;
737
698
  };
738
- const normalizeRowChildren = (children) => {
699
+ /** Records the id of every tabset it keeps, in document order. */
700
+ const normalizeRowChildren = (children, keptTabsetIds) => {
739
701
  const out = [];
740
702
  for (const child of children) {
741
703
  if (child.type === "tabset") {
742
- if (child.children.length > 0) out.push(child);
704
+ if (child.children.length > 0) {
705
+ out.push(child);
706
+ keptTabsetIds.push(child.id);
707
+ }
743
708
  continue;
744
709
  }
745
- const grandchildren = normalizeRowChildren(child.children);
710
+ const grandchildren = normalizeRowChildren(child.children, keptTabsetIds);
746
711
  if (grandchildren.length === 0) continue;
747
712
  if (grandchildren.length === 1) {
748
713
  const only = grandchildren[0];
@@ -756,9 +721,8 @@ const normalizeRowChildren = (children) => {
756
721
  }
757
722
  return out;
758
723
  };
759
- const rowContainsTabset = (row) => row.children.some((child) => child.type === "tabset" ? true : rowContainsTabset(child));
760
- const normalizeLayout = (root) => {
761
- let children = normalizeRowChildren(root.children);
724
+ const normalizeLayout = (root, keptTabsetIds) => {
725
+ let children = normalizeRowChildren(root.children, keptTabsetIds);
762
726
  let orientation = root.orientation;
763
727
  let inner = children[0];
764
728
  while (children.length === 1 && inner?.type === "row") {
@@ -773,53 +737,67 @@ const normalizeLayout = (root) => {
773
737
  };
774
738
  };
775
739
  const normalize = (model) => {
776
- const layout = normalizeLayout(model.layout);
740
+ const mainTabsetIds = [];
741
+ const layout = normalizeLayout(model.layout, mainTabsetIds);
777
742
  const floats = [];
743
+ const floatTabsetIds = [];
778
744
  for (const float of model.floats) {
779
- const floatLayout = normalizeLayout(float.layout);
780
- if (rowContainsTabset(floatLayout)) floats.push({
781
- ...float,
782
- layout: floatLayout
783
- });
745
+ const ownTabsetIds = [];
746
+ const floatLayout = normalizeLayout(float.layout, ownTabsetIds);
747
+ if (floatLayout.children.length > 0) {
748
+ floats.push({
749
+ ...float,
750
+ layout: floatLayout
751
+ });
752
+ floatTabsetIds.push(...ownTabsetIds);
753
+ }
784
754
  }
785
- const withRoots = {
786
- ...model,
787
- floats,
788
- layout
789
- };
790
- const tabsets = collectTabsets(withRoots);
791
- const tabsetIds = new Set(tabsets.map((tabset) => tabset.id));
792
- const firstTabsetId = tabsets[0]?.id;
793
- const activeTabsetId = model.activeTabsetId !== void 0 && tabsetIds.has(model.activeTabsetId) ? model.activeTabsetId : firstTabsetId;
794
- const maximizedTabsetId = model.maximizedTabsetId !== void 0 && tabsetIds.has(model.maximizedTabsetId) ? model.maximizedTabsetId : void 0;
755
+ const mainIds = new Set(mainTabsetIds);
756
+ const liveIds = /* @__PURE__ */ new Set([...mainTabsetIds, ...floatTabsetIds]);
757
+ const firstTabsetId = mainTabsetIds[0] ?? floatTabsetIds[0];
758
+ const activeTabsetId = model.activeTabsetId !== void 0 && liveIds.has(model.activeTabsetId) ? model.activeTabsetId : firstTabsetId;
759
+ const maximizedTabsetId = model.maximizedTabsetId !== void 0 && mainIds.has(model.maximizedTabsetId) ? model.maximizedTabsetId : void 0;
795
760
  return {
796
- ...withRoots,
761
+ ...model,
797
762
  activeTabsetId,
763
+ floats,
764
+ layout,
798
765
  maximizedTabsetId
799
766
  };
800
767
  };
801
768
 
802
769
  //#endregion
803
770
  //#region src/state/surgery.ts
804
- const removeTabsetReturning = (row, tabsetId) => {
805
- const found = findTabsetParent(row, tabsetId);
771
+ const removeTabsetReturning = (model, tabsetId) => {
772
+ const found = findTabsetParent(model, tabsetId);
806
773
  if (!found) return;
807
774
  const [removed] = found.parent.children.splice(found.index, 1);
808
775
  return removed?.type === "tabset" ? removed : void 0;
809
776
  };
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
- };
777
+ /** Total, so a sixth `DockLocation` is a compile error here rather than a wrong default. */
778
+ const SPLIT_PLACEMENT = {
779
+ "split-bottom": {
780
+ before: false,
781
+ orientation: "column"
782
+ },
783
+ "split-left": {
784
+ before: true,
785
+ orientation: "row"
786
+ },
787
+ "split-right": {
788
+ before: false,
789
+ orientation: "row"
790
+ },
791
+ "split-top": {
792
+ before: true,
793
+ orientation: "column"
794
+ }
817
795
  };
818
796
  const placeBesideTarget = (draft, placed, targetId, location) => {
819
797
  const targetTabset = findTabset(draft, targetId);
820
- const found = findTabsetParent(findRootContaining(draft, targetId) ?? draft.layout, targetId);
821
- if (!targetTabset || !found) return;
822
- const { before, orientation } = splitPlacement(location);
798
+ const found = findTabsetParent(draft, targetId);
799
+ if (!targetTabset || !found) return false;
800
+ const { before, orientation } = SPLIT_PLACEMENT[location];
823
801
  if (found.parent.orientation === orientation) {
824
802
  const half = targetTabset.weight / 2;
825
803
  targetTabset.weight = half;
@@ -838,16 +816,17 @@ const placeBesideTarget = (draft, placed, targetId, location) => {
838
816
  found.parent.children.splice(found.index, 1, newRow);
839
817
  }
840
818
  draft.activeTabsetId = placed.id;
819
+ return true;
841
820
  };
842
821
  const insertTab = (draft, tab, target) => {
843
822
  const { id: targetId, index, location } = target;
844
823
  const targetTabset = findTabset(draft, targetId);
845
- if (!targetTabset) return;
824
+ if (!targetTabset) return false;
846
825
  if (location === "center") {
847
826
  const at = Math.min(Math.max(index ?? targetTabset.children.length, 0), targetTabset.children.length);
848
827
  targetTabset.children.splice(at, 0, tab);
849
828
  targetTabset.selected = at;
850
- return;
829
+ return true;
851
830
  }
852
831
  const newTabset = {
853
832
  children: [tab],
@@ -856,13 +835,9 @@ const insertTab = (draft, tab, target) => {
856
835
  type: "tabset",
857
836
  weight: 1
858
837
  };
859
- placeBesideTarget(draft, newTabset, targetId, location);
838
+ return placeBesideTarget(draft, newTabset, targetId, location);
860
839
  };
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
- */
840
+ /** Re-clamps here rather than leaving the whole tree to be re-walked afterwards. */
866
841
  const detachTab = (container, index) => {
867
842
  const removed = container.children.splice(index, 1).at(0);
868
843
  container.selected = clampSelected(container.children.length, container.selected);
@@ -924,10 +899,10 @@ const pushFloat = (draft, layout, geometry, floatId) => {
924
899
  if (first) draft.activeTabsetId = first.id;
925
900
  };
926
901
  const floatTabsetById = (draft, tabsetId, geometry, floatId) => {
927
- const root = findRootContaining(draft, tabsetId);
928
- if (!root) return;
929
- const detached = removeTabsetReturning(root, tabsetId);
930
- if (detached) pushFloat(draft, wrapTabsetInLayout(detached), geometry, floatId);
902
+ const detached = removeTabsetReturning(draft, tabsetId);
903
+ if (!detached) return false;
904
+ pushFloat(draft, wrapTabsetInLayout(detached), geometry, floatId);
905
+ return true;
931
906
  };
932
907
  const resolveMainTarget = (draft, targetId) => {
933
908
  const mainTabsets = [];
@@ -943,36 +918,35 @@ const resolveMainTarget = (draft, targetId) => {
943
918
  return mainTabsets[0];
944
919
  };
945
920
  const dockFloat = (draft, floatId, targetId, location) => {
946
- const floats = draft.floats;
947
- const index = floats.findIndex((float) => float.id === floatId);
948
- if (index === -1) return;
949
- const float = floats.splice(index, 1).at(0);
950
- if (!float) return;
921
+ const index = draft.floats.findIndex((float) => float.id === floatId);
922
+ const float = draft.floats.at(index);
923
+ if (index === -1 || !float) return false;
951
924
  const tabsets = [];
952
925
  collectTabsetsInRow(float.layout, tabsets);
953
926
  const tabs = tabsets.flatMap((tabset) => tabset.children);
954
- if (tabs.length === 0) return;
927
+ if (tabs.length === 0) return false;
955
928
  const leadTabset = tabsets.at(0);
956
929
  const selectedOffset = leadTabset ? clampSelected(leadTabset.children.length, leadTabset.selected) : 0;
957
930
  const target = resolveMainTarget(draft, targetId);
931
+ draft.floats.splice(index, 1);
958
932
  if (!target) {
959
933
  draft.layout = float.layout;
960
934
  draft.activeTabsetId = leadTabset?.id;
961
- return;
935
+ return true;
962
936
  }
963
937
  if (location === "center") {
964
938
  mergeTabsInto(target, tabs, selectedOffset);
965
939
  draft.activeTabsetId = target.id;
966
- return;
940
+ return true;
967
941
  }
968
- const where = location ?? "split-right";
969
- placeBesideTarget(draft, tabsets.length === 1 && leadTabset ? leadTabset : {
942
+ const placed = tabsets.length === 1 && leadTabset ? leadTabset : {
970
943
  children: tabs,
971
944
  id: createNodeId("tabset"),
972
945
  selected: selectedOffset,
973
946
  type: "tabset",
974
947
  weight: 1
975
- }, target.id, where);
948
+ };
949
+ return placeBesideTarget(draft, placed, target.id, location ?? "split-right");
976
950
  };
977
951
 
978
952
  //#endregion
@@ -991,127 +965,138 @@ const parseNodeAttrs = (node, attrs) => {
991
965
  default: return assertNever(node);
992
966
  }
993
967
  };
968
+ const sameGeometry = (a, b) => a.height === b.height && a.left === b.left && a.top === b.top && a.width === b.width;
969
+ const alreadyApplied = (target, attrs) => {
970
+ const current = new Map(Object.entries(target));
971
+ return Object.entries(attrs).every(([key, value]) => Object.is(current.get(key), value));
972
+ };
973
+ /** Reports whether it changed anything, so `history.dispatch` can skip a no-op. */
994
974
  const applyAction = (draft, action) => {
995
975
  switch (action.type) {
996
- case "addNode":
997
- insertTab(draft, action.tab, {
998
- id: action.targetId,
999
- index: action.index,
1000
- location: action.location
1001
- });
1002
- return;
976
+ case "addNode": return insertTab(draft, action.tab, {
977
+ id: action.targetId,
978
+ index: action.index,
979
+ location: action.location
980
+ });
1003
981
  case "adjustSplit": {
1004
- const row = findRow(findRootContaining(draft, action.rowId) ?? draft.layout, action.rowId);
1005
- if (!row) return;
982
+ const row = findRow(draft, action.rowId);
983
+ if (!row) return false;
984
+ let changed = false;
1006
985
  for (let index = 0; index < action.weights.length; index++) {
1007
986
  const child = row.children.at(index);
1008
987
  const weight = action.weights[index];
1009
- if (!child || weight === void 0) continue;
988
+ if (!child || weight === void 0 || child.weight === weight) continue;
1010
989
  child.weight = weight;
990
+ changed = true;
1011
991
  }
1012
- return;
992
+ return changed;
1013
993
  }
1014
994
  case "deleteTab": {
1015
995
  const location = findTab(draft, action.tabId);
1016
- if (location) detachTab(location.container, location.index);
1017
- return;
996
+ if (!location) return false;
997
+ detachTab(location.container, location.index);
998
+ return true;
1018
999
  }
1019
- case "deleteTabset": {
1020
- const root = findRootContaining(draft, action.tabsetId);
1021
- if (root) removeTabset(root, action.tabsetId);
1022
- return;
1023
- }
1024
- case "dockFloat":
1025
- dockFloat(draft, action.floatId, action.targetId, action.location);
1026
- return;
1000
+ case "deleteTabset": return removeTabsetReturning(draft, action.tabsetId) !== void 0;
1001
+ case "dockFloat": return dockFloat(draft, action.floatId, action.targetId, action.location);
1027
1002
  case "floatTab": {
1028
1003
  const location = findTab(draft, action.tabId);
1029
- if (!location) return;
1004
+ if (!location) return false;
1030
1005
  const removed = detachTab(location.container, location.index);
1031
- if (removed) pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
1032
- return;
1006
+ if (!removed) return false;
1007
+ pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
1008
+ return true;
1033
1009
  }
1034
- case "floatTabset":
1035
- floatTabsetById(draft, action.tabsetId, action.geometry, action.floatId);
1036
- return;
1010
+ case "floatTabset": return floatTabsetById(draft, action.tabsetId, action.geometry, action.floatId);
1037
1011
  case "moveFloat": {
1038
1012
  const float = findFloat(draft, action.floatId);
1039
- if (float) float.geometry = action.geometry;
1040
- return;
1013
+ if (!float || sameGeometry(float.geometry, action.geometry)) return false;
1014
+ float.geometry = action.geometry;
1015
+ return true;
1041
1016
  }
1042
1017
  case "moveNode": {
1043
1018
  const source = findTab(draft, action.sourceId);
1044
- if (!source) return;
1019
+ if (!source || !findTabset(draft, action.targetId)) return false;
1045
1020
  const removed = detachTab(source.container, source.index);
1046
- if (!removed) return;
1047
- insertTab(draft, removed, {
1021
+ if (!removed) return false;
1022
+ return insertTab(draft, removed, {
1048
1023
  id: action.targetId,
1049
1024
  index: action.index,
1050
1025
  location: action.location
1051
1026
  });
1052
- return;
1053
1027
  }
1054
1028
  case "moveTabset": {
1055
- if (action.sourceId === action.targetId) return;
1029
+ if (action.sourceId === action.targetId) return false;
1056
1030
  const source = findTabset(draft, action.sourceId);
1057
- if (!source) return;
1031
+ const target = findTabset(draft, action.targetId);
1032
+ if (!source || !target) return false;
1058
1033
  if (action.location === "center") {
1059
- const target = findTabset(draft, action.targetId);
1060
- if (target) {
1061
- mergeTabsInto(target, source.children, clampSelected(source.children.length, source.selected));
1062
- const sourceRoot = findRootContaining(draft, action.sourceId);
1063
- if (sourceRoot) removeTabset(sourceRoot, action.sourceId);
1064
- }
1065
- return;
1034
+ mergeTabsInto(target, source.children, clampSelected(source.children.length, source.selected));
1035
+ removeTabsetReturning(draft, action.sourceId);
1036
+ return true;
1066
1037
  }
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);
1070
- return;
1038
+ const detached = removeTabsetReturning(draft, action.sourceId);
1039
+ return detached ? placeBesideTarget(draft, detached, action.targetId, action.location) : false;
1071
1040
  }
1072
1041
  case "renameFloat": {
1073
1042
  const float = findFloat(draft, action.floatId);
1074
- if (float) {
1075
- const others = draft.floats.filter((other) => other.id !== action.floatId);
1076
- float.name = uniqueFloatName(action.name, others);
1077
- }
1078
- return;
1043
+ if (!float) return false;
1044
+ const others = draft.floats.filter((other) => other.id !== action.floatId);
1045
+ const name = uniqueFloatName(action.name, others);
1046
+ if (float.name === name) return false;
1047
+ float.name = name;
1048
+ return true;
1079
1049
  }
1080
1050
  case "renameTab": {
1081
1051
  const location = findTab(draft, action.tabId);
1082
- if (location) location.tab.name = action.name;
1083
- return;
1052
+ if (!location || location.tab.name === action.name) return false;
1053
+ location.tab.name = action.name;
1054
+ return true;
1084
1055
  }
1085
1056
  case "selectTab": {
1086
1057
  const tabset = findTabset(draft, action.tabsetId);
1087
- if (tabset) tabset.selected = clampSelected(tabset.children.length, action.index);
1088
- return;
1058
+ if (!tabset) return false;
1059
+ const selected = clampSelected(tabset.children.length, action.index);
1060
+ if (tabset.selected === selected) return false;
1061
+ tabset.selected = selected;
1062
+ return true;
1089
1063
  }
1090
1064
  case "setActiveTabset":
1091
- if (findTabset(draft, action.tabsetId)) draft.activeTabsetId = action.tabsetId;
1092
- return;
1065
+ if (draft.activeTabsetId === action.tabsetId || !findTabset(draft, action.tabsetId)) return false;
1066
+ draft.activeTabsetId = action.tabsetId;
1067
+ return true;
1093
1068
  case "setFloatMinimized": {
1094
1069
  const float = findFloat(draft, action.floatId);
1095
- if (float) float.minimized = action.minimized;
1096
- return;
1070
+ if (!float || (float.minimized ?? false) === action.minimized) return false;
1071
+ float.minimized = action.minimized;
1072
+ return true;
1073
+ }
1074
+ case "setMaximizedTabset": {
1075
+ const next = action.tabsetId ?? void 0;
1076
+ if (draft.maximizedTabsetId === next) return false;
1077
+ draft.maximizedTabsetId = next;
1078
+ return true;
1097
1079
  }
1098
- case "setMaximizedTabset":
1099
- draft.maximizedTabsetId = action.tabsetId ?? void 0;
1100
- return;
1101
1080
  case "updateGlobalAttributes":
1081
+ if (alreadyApplied(draft.global, action.attrs)) return false;
1102
1082
  Object.assign(draft.global, action.attrs);
1103
- return;
1083
+ return true;
1104
1084
  case "updateNodeAttributes": {
1105
1085
  const node = findAttributedNode(draft, action.nodeId);
1106
- if (node) Object.assign(node, parseNodeAttrs(node, action.attrs));
1107
- return;
1086
+ if (!node) return false;
1087
+ const parsed = parseNodeAttrs(node, action.attrs);
1088
+ if (alreadyApplied(node, parsed)) return false;
1089
+ Object.assign(node, parsed);
1090
+ return true;
1108
1091
  }
1109
- default: assertNever(action);
1092
+ default: return assertNever(action);
1110
1093
  }
1111
1094
  };
1095
+ /** Identity-equal output means "no edit". A rejected action skips `normalize`, so
1096
+ * pass a model that has already been through it. */
1112
1097
  const reducer = (model, action) => {
1113
1098
  const draft = structuredClone(model);
1114
- applyAction(draft, action);
1099
+ if (!applyAction(draft, action)) return model;
1115
1100
  return normalize(draft);
1116
1101
  };
1117
1102
 
@@ -1125,8 +1110,14 @@ const createHistory = (present) => ({
1125
1110
  });
1126
1111
  const canUndo = (history) => history.past.length > 0;
1127
1112
  const canRedo = (history) => history.future.length > 0;
1113
+ /**
1114
+ * An action that changed nothing is not an edit: recording it would spend an
1115
+ * undo slot and clear a redo stack the user just built. Not coalescing (ADR
1116
+ * 0002): two distinct edits are still two entries.
1117
+ */
1128
1118
  const dispatch = (history, action) => {
1129
1119
  const present = reducer(history.present, action);
1120
+ if (Object.is(present, history.present)) return history;
1130
1121
  return {
1131
1122
  future: [],
1132
1123
  past: [...history.past, history.present].slice(-100),
@@ -1172,25 +1163,50 @@ const dashfooMachine = setup({ types: {
1172
1163
 
1173
1164
  //#endregion
1174
1165
  //#region src/machines/drag-dock-machine.ts
1175
- const requireDrop = (context) => {
1176
- if (context.subject === null || context.intent === null) throw new Error("drop requires a subject and a drop intent");
1166
+ /** One definition of both "is this drop valid" and "what does it do", so the guard and the emit agree. */
1167
+ const dropAction = (drag) => {
1168
+ if (drag.kind !== "dragging" || drag.drop === null) return null;
1169
+ const { intent, scope } = drag.drop;
1170
+ const { subject } = drag;
1171
+ if (subject.kind === "external") return {
1172
+ action: {
1173
+ index: intent.index,
1174
+ location: intent.location,
1175
+ tab: subject.tab,
1176
+ targetId: intent.targetId,
1177
+ type: "addNode"
1178
+ },
1179
+ scope
1180
+ };
1181
+ if (subject.kind === "tabset") return {
1182
+ action: {
1183
+ location: intent.location,
1184
+ sourceId: subject.id,
1185
+ targetId: intent.targetId,
1186
+ type: "moveTabset"
1187
+ },
1188
+ scope
1189
+ };
1177
1190
  return {
1178
- intent: context.intent,
1179
- subject: context.subject
1191
+ action: {
1192
+ index: intent.index,
1193
+ location: intent.location,
1194
+ sourceId: subject.id,
1195
+ targetId: intent.targetId,
1196
+ type: "moveNode"
1197
+ },
1198
+ scope
1180
1199
  };
1181
1200
  };
1182
1201
  const dragDockMachine = setup({
1183
- guards: { hasValidDrop: ({ context }) => context.subject !== null && context.intent !== null },
1202
+ guards: { hasValidDrop: ({ context }) => dropAction(context.drag) !== null },
1184
1203
  types: {
1185
1204
  context: {},
1186
1205
  emitted: {},
1187
1206
  events: {}
1188
1207
  }
1189
1208
  }).createMachine({
1190
- context: {
1191
- intent: null,
1192
- subject: null
1193
- },
1209
+ context: { drag: { kind: "idle" } },
1194
1210
  id: "dragDock",
1195
1211
  initial: "idle",
1196
1212
  states: {
@@ -1198,36 +1214,30 @@ const dragDockMachine = setup({
1198
1214
  CANCEL: { target: "idle" },
1199
1215
  DROP: [{
1200
1216
  actions: emit(({ context }) => {
1201
- const { intent, subject } = requireDrop(context);
1217
+ const resolved = dropAction(context.drag);
1218
+ if (resolved === null) throw new Error("drop requires a subject and a resolved drop target");
1202
1219
  return {
1203
- action: subject.kind === "external" ? {
1204
- index: intent.index,
1205
- location: intent.location,
1206
- tab: subject.tab,
1207
- targetId: intent.targetId,
1208
- type: "addNode"
1209
- } : {
1210
- index: intent.index,
1211
- location: intent.location,
1212
- sourceId: subject.id,
1213
- targetId: intent.targetId,
1214
- type: subject.kind === "tabset" ? "moveTabset" : "moveNode"
1215
- },
1220
+ action: resolved.action,
1221
+ scope: resolved.scope,
1216
1222
  type: "COMMIT"
1217
1223
  };
1218
1224
  }),
1219
1225
  guard: "hasValidDrop",
1220
1226
  target: "idle"
1221
1227
  }, { target: "idle" }],
1222
- OVER: { actions: assign({ intent: ({ event }) => event.intent }) }
1228
+ OVER: { actions: assign({ drag: ({ context, event }) => context.drag.kind === "dragging" ? {
1229
+ ...context.drag,
1230
+ drop: event.drop
1231
+ } : context.drag }) }
1223
1232
  } },
1224
1233
  idle: {
1225
- entry: assign({
1226
- intent: null,
1227
- subject: null
1228
- }),
1234
+ entry: assign({ drag: { kind: "idle" } }),
1229
1235
  on: { START: {
1230
- actions: assign({ subject: ({ event }) => event.subject }),
1236
+ actions: assign({ drag: ({ event }) => ({
1237
+ drop: null,
1238
+ kind: "dragging",
1239
+ subject: event.subject
1240
+ }) }),
1231
1241
  target: "dragging"
1232
1242
  } }
1233
1243
  }
@@ -1243,7 +1253,7 @@ const parseModel = (value) => {
1243
1253
  return model;
1244
1254
  };
1245
1255
  const toJSON = (model) => JSON.stringify(model);
1246
- const fromJSON = (json) => parseModel(JSON.parse(json));
1256
+ const fromJSON = (json) => parseModel(jsonValueSchema.parse(JSON.parse(json)));
1247
1257
 
1248
1258
  //#endregion
1249
1259
  //#region src/model/stack.ts
@@ -1269,5 +1279,5 @@ const stackModel = (model, orientation = "column") => {
1269
1279
  };
1270
1280
 
1271
1281
  //#endregion
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 };
1282
+ 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 };
1273
1283
  //# sourceMappingURL=index.js.map