@dashfoo/core 1.0.0 → 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
@@ -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;
@@ -487,13 +460,6 @@ const closestEdge = (d) => {
487
460
  if (min === d.top) return "top";
488
461
  return "bottom";
489
462
  };
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
463
  const splitEdge = (location) => {
498
464
  switch (location) {
499
465
  case "center": return;
@@ -506,13 +472,10 @@ const splitEdge = (location) => {
506
472
  };
507
473
  const resolveDockTarget = (pointer, rect, opts) => {
508
474
  const band = opts?.bandFraction ?? DEFAULT_BAND_FRACTION;
509
- if (rect.width <= 0 || rect.height <= 0) return { kind: "tab" };
475
+ if (rect.width <= 0 || rect.height <= 0) return "center";
510
476
  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
- };
477
+ if (Math.min(distances.left, distances.right, distances.top, distances.bottom) > band) return "center";
478
+ return `split-${closestEdge(distances)}`;
516
479
  };
517
480
  const dockZonePolygons = (rect, opts) => {
518
481
  const { height: h, width: w, x, y } = rect;
@@ -661,21 +624,20 @@ const resolveSnapTargets = (config, panelCount) => {
661
624
  }
662
625
  return [...targets].toSorted((a, b) => a - b);
663
626
  };
664
- 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;
665
632
  const nearestTarget = (position, targets) => targets.reduce((best, target) => {
666
633
  if (best === void 0 || Math.abs(target - position) < Math.abs(best - position)) return target;
667
634
  return best;
668
635
  }, void 0);
669
- const snapSizes = (sizes, boundaryIndex, config) => {
670
- const threshold = config.threshold ?? 4;
636
+ const snapSizes = (sizes, boundaryIndex, grid) => {
637
+ const { targets, threshold } = grid;
671
638
  const left = sizes[boundaryIndex];
672
639
  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 {
640
+ if (boundaryIndex < 0 || left === void 0 || right === void 0 || targets.length === 0) return {
679
641
  sizes,
680
642
  snapped: false
681
643
  };
@@ -699,28 +661,25 @@ const snapSizes = (sizes, boundaryIndex, config) => {
699
661
  snapped: true
700
662
  };
701
663
  };
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);
664
+ const decideSnap = (sizes, boundaryIndex, grid) => {
665
+ if (boundaryIndex === null || !snapEnabled(grid)) return { kind: "inactive" };
666
+ const { sizes: snappedSizes, snapped } = snapSizes(sizes, boundaryIndex, grid);
705
667
  return snapped ? {
706
668
  kind: "engage",
707
669
  sizes: snappedSizes
708
670
  } : { kind: "clear" };
709
671
  };
710
- const settleSnap = (sizes, boundaryIndex, config) => {
711
- const decision = decideSnap(sizes, boundaryIndex, config);
672
+ const settleSnap = (sizes, boundaryIndex, grid) => {
673
+ const decision = decideSnap(sizes, boundaryIndex, grid);
712
674
  return decision.kind === "engage" ? decision.sizes : sizes;
713
675
  };
714
676
 
715
677
  //#endregion
716
678
  //#region src/model/invariants.ts
717
679
  /**
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.
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.
724
683
  */
725
684
  const inheritSlot = (row, survivor) => {
726
685
  const sized = {
@@ -735,14 +694,18 @@ const inheritSlot = (row, survivor) => {
735
694
  };
736
695
  return sized;
737
696
  };
738
- const normalizeRowChildren = (children) => {
697
+ /** Records the id of every tabset it keeps, in document order. */
698
+ const normalizeRowChildren = (children, keptTabsetIds) => {
739
699
  const out = [];
740
700
  for (const child of children) {
741
701
  if (child.type === "tabset") {
742
- if (child.children.length > 0) out.push(child);
702
+ if (child.children.length > 0) {
703
+ out.push(child);
704
+ keptTabsetIds.push(child.id);
705
+ }
743
706
  continue;
744
707
  }
745
- const grandchildren = normalizeRowChildren(child.children);
708
+ const grandchildren = normalizeRowChildren(child.children, keptTabsetIds);
746
709
  if (grandchildren.length === 0) continue;
747
710
  if (grandchildren.length === 1) {
748
711
  const only = grandchildren[0];
@@ -756,9 +719,8 @@ const normalizeRowChildren = (children) => {
756
719
  }
757
720
  return out;
758
721
  };
759
- const rowContainsTabset = (row) => row.children.some((child) => child.type === "tabset" ? true : rowContainsTabset(child));
760
- const normalizeLayout = (root) => {
761
- let children = normalizeRowChildren(root.children);
722
+ const normalizeLayout = (root, keptTabsetIds) => {
723
+ let children = normalizeRowChildren(root.children, keptTabsetIds);
762
724
  let orientation = root.orientation;
763
725
  let inner = children[0];
764
726
  while (children.length === 1 && inner?.type === "row") {
@@ -773,53 +735,67 @@ const normalizeLayout = (root) => {
773
735
  };
774
736
  };
775
737
  const normalize = (model) => {
776
- const layout = normalizeLayout(model.layout);
738
+ const mainTabsetIds = [];
739
+ const layout = normalizeLayout(model.layout, mainTabsetIds);
777
740
  const floats = [];
741
+ const floatTabsetIds = [];
778
742
  for (const float of model.floats) {
779
- const floatLayout = normalizeLayout(float.layout);
780
- if (rowContainsTabset(floatLayout)) floats.push({
781
- ...float,
782
- layout: floatLayout
783
- });
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
+ }
784
752
  }
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;
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;
795
758
  return {
796
- ...withRoots,
759
+ ...model,
797
760
  activeTabsetId,
761
+ floats,
762
+ layout,
798
763
  maximizedTabsetId
799
764
  };
800
765
  };
801
766
 
802
767
  //#endregion
803
768
  //#region src/state/surgery.ts
804
- const removeTabsetReturning = (row, tabsetId) => {
805
- const found = findTabsetParent(row, tabsetId);
769
+ const removeTabsetReturning = (model, tabsetId) => {
770
+ const found = findTabsetParent(model, tabsetId);
806
771
  if (!found) return;
807
772
  const [removed] = found.parent.children.splice(found.index, 1);
808
773
  return removed?.type === "tabset" ? removed : void 0;
809
774
  };
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
- };
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
+ }
817
793
  };
818
794
  const placeBesideTarget = (draft, placed, targetId, location) => {
819
795
  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);
796
+ const found = findTabsetParent(draft, targetId);
797
+ if (!targetTabset || !found) return false;
798
+ const { before, orientation } = SPLIT_PLACEMENT[location];
823
799
  if (found.parent.orientation === orientation) {
824
800
  const half = targetTabset.weight / 2;
825
801
  targetTabset.weight = half;
@@ -838,16 +814,17 @@ const placeBesideTarget = (draft, placed, targetId, location) => {
838
814
  found.parent.children.splice(found.index, 1, newRow);
839
815
  }
840
816
  draft.activeTabsetId = placed.id;
817
+ return true;
841
818
  };
842
819
  const insertTab = (draft, tab, target) => {
843
820
  const { id: targetId, index, location } = target;
844
821
  const targetTabset = findTabset(draft, targetId);
845
- if (!targetTabset) return;
822
+ if (!targetTabset) return false;
846
823
  if (location === "center") {
847
824
  const at = Math.min(Math.max(index ?? targetTabset.children.length, 0), targetTabset.children.length);
848
825
  targetTabset.children.splice(at, 0, tab);
849
826
  targetTabset.selected = at;
850
- return;
827
+ return true;
851
828
  }
852
829
  const newTabset = {
853
830
  children: [tab],
@@ -856,13 +833,9 @@ const insertTab = (draft, tab, target) => {
856
833
  type: "tabset",
857
834
  weight: 1
858
835
  };
859
- placeBesideTarget(draft, newTabset, targetId, location);
836
+ return placeBesideTarget(draft, newTabset, targetId, location);
860
837
  };
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
- */
838
+ /** Re-clamps here rather than leaving the whole tree to be re-walked afterwards. */
866
839
  const detachTab = (container, index) => {
867
840
  const removed = container.children.splice(index, 1).at(0);
868
841
  container.selected = clampSelected(container.children.length, container.selected);
@@ -924,10 +897,10 @@ const pushFloat = (draft, layout, geometry, floatId) => {
924
897
  if (first) draft.activeTabsetId = first.id;
925
898
  };
926
899
  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);
900
+ const detached = removeTabsetReturning(draft, tabsetId);
901
+ if (!detached) return false;
902
+ pushFloat(draft, wrapTabsetInLayout(detached), geometry, floatId);
903
+ return true;
931
904
  };
932
905
  const resolveMainTarget = (draft, targetId) => {
933
906
  const mainTabsets = [];
@@ -943,36 +916,35 @@ const resolveMainTarget = (draft, targetId) => {
943
916
  return mainTabsets[0];
944
917
  };
945
918
  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;
919
+ const index = draft.floats.findIndex((float) => float.id === floatId);
920
+ const float = draft.floats.at(index);
921
+ if (index === -1 || !float) return false;
951
922
  const tabsets = [];
952
923
  collectTabsetsInRow(float.layout, tabsets);
953
924
  const tabs = tabsets.flatMap((tabset) => tabset.children);
954
- if (tabs.length === 0) return;
925
+ if (tabs.length === 0) return false;
955
926
  const leadTabset = tabsets.at(0);
956
927
  const selectedOffset = leadTabset ? clampSelected(leadTabset.children.length, leadTabset.selected) : 0;
957
928
  const target = resolveMainTarget(draft, targetId);
929
+ draft.floats.splice(index, 1);
958
930
  if (!target) {
959
931
  draft.layout = float.layout;
960
932
  draft.activeTabsetId = leadTabset?.id;
961
- return;
933
+ return true;
962
934
  }
963
935
  if (location === "center") {
964
936
  mergeTabsInto(target, tabs, selectedOffset);
965
937
  draft.activeTabsetId = target.id;
966
- return;
938
+ return true;
967
939
  }
968
- const where = location ?? "split-right";
969
- placeBesideTarget(draft, tabsets.length === 1 && leadTabset ? leadTabset : {
940
+ const placed = tabsets.length === 1 && leadTabset ? leadTabset : {
970
941
  children: tabs,
971
942
  id: createNodeId("tabset"),
972
943
  selected: selectedOffset,
973
944
  type: "tabset",
974
945
  weight: 1
975
- }, target.id, where);
946
+ };
947
+ return placeBesideTarget(draft, placed, target.id, location ?? "split-right");
976
948
  };
977
949
 
978
950
  //#endregion
@@ -991,127 +963,138 @@ const parseNodeAttrs = (node, attrs) => {
991
963
  default: return assertNever(node);
992
964
  }
993
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. */
994
972
  const applyAction = (draft, action) => {
995
973
  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;
974
+ case "addNode": return insertTab(draft, action.tab, {
975
+ id: action.targetId,
976
+ index: action.index,
977
+ location: action.location
978
+ });
1003
979
  case "adjustSplit": {
1004
- const row = findRow(findRootContaining(draft, action.rowId) ?? draft.layout, action.rowId);
1005
- if (!row) return;
980
+ const row = findRow(draft, action.rowId);
981
+ if (!row) return false;
982
+ let changed = false;
1006
983
  for (let index = 0; index < action.weights.length; index++) {
1007
984
  const child = row.children.at(index);
1008
985
  const weight = action.weights[index];
1009
- if (!child || weight === void 0) continue;
986
+ if (!child || weight === void 0 || child.weight === weight) continue;
1010
987
  child.weight = weight;
988
+ changed = true;
1011
989
  }
1012
- return;
990
+ return changed;
1013
991
  }
1014
992
  case "deleteTab": {
1015
993
  const location = findTab(draft, action.tabId);
1016
- if (location) detachTab(location.container, location.index);
1017
- return;
1018
- }
1019
- case "deleteTabset": {
1020
- const root = findRootContaining(draft, action.tabsetId);
1021
- if (root) removeTabset(root, action.tabsetId);
1022
- return;
994
+ if (!location) return false;
995
+ detachTab(location.container, location.index);
996
+ return true;
1023
997
  }
1024
- case "dockFloat":
1025
- dockFloat(draft, action.floatId, action.targetId, action.location);
1026
- return;
998
+ case "deleteTabset": return removeTabsetReturning(draft, action.tabsetId) !== void 0;
999
+ case "dockFloat": return dockFloat(draft, action.floatId, action.targetId, action.location);
1027
1000
  case "floatTab": {
1028
1001
  const location = findTab(draft, action.tabId);
1029
- if (!location) return;
1002
+ if (!location) return false;
1030
1003
  const removed = detachTab(location.container, location.index);
1031
- if (removed) pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
1032
- return;
1004
+ if (!removed) return false;
1005
+ pushFloat(draft, wrapTabInLayout(removed), action.geometry, action.floatId);
1006
+ return true;
1033
1007
  }
1034
- case "floatTabset":
1035
- floatTabsetById(draft, action.tabsetId, action.geometry, action.floatId);
1036
- return;
1008
+ case "floatTabset": return floatTabsetById(draft, action.tabsetId, action.geometry, action.floatId);
1037
1009
  case "moveFloat": {
1038
1010
  const float = findFloat(draft, action.floatId);
1039
- if (float) float.geometry = action.geometry;
1040
- return;
1011
+ if (!float || sameGeometry(float.geometry, action.geometry)) return false;
1012
+ float.geometry = action.geometry;
1013
+ return true;
1041
1014
  }
1042
1015
  case "moveNode": {
1043
1016
  const source = findTab(draft, action.sourceId);
1044
- if (!source) return;
1017
+ if (!source || !findTabset(draft, action.targetId)) return false;
1045
1018
  const removed = detachTab(source.container, source.index);
1046
- if (!removed) return;
1047
- insertTab(draft, removed, {
1019
+ if (!removed) return false;
1020
+ return insertTab(draft, removed, {
1048
1021
  id: action.targetId,
1049
1022
  index: action.index,
1050
1023
  location: action.location
1051
1024
  });
1052
- return;
1053
1025
  }
1054
1026
  case "moveTabset": {
1055
- if (action.sourceId === action.targetId) return;
1027
+ if (action.sourceId === action.targetId) return false;
1056
1028
  const source = findTabset(draft, action.sourceId);
1057
- if (!source) return;
1029
+ const target = findTabset(draft, action.targetId);
1030
+ if (!source || !target) return false;
1058
1031
  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;
1032
+ mergeTabsInto(target, source.children, clampSelected(source.children.length, source.selected));
1033
+ removeTabsetReturning(draft, action.sourceId);
1034
+ return true;
1066
1035
  }
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;
1036
+ const detached = removeTabsetReturning(draft, action.sourceId);
1037
+ return detached ? placeBesideTarget(draft, detached, action.targetId, action.location) : false;
1071
1038
  }
1072
1039
  case "renameFloat": {
1073
1040
  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;
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;
1079
1047
  }
1080
1048
  case "renameTab": {
1081
1049
  const location = findTab(draft, action.tabId);
1082
- if (location) location.tab.name = action.name;
1083
- return;
1050
+ if (!location || location.tab.name === action.name) return false;
1051
+ location.tab.name = action.name;
1052
+ return true;
1084
1053
  }
1085
1054
  case "selectTab": {
1086
1055
  const tabset = findTabset(draft, action.tabsetId);
1087
- if (tabset) tabset.selected = clampSelected(tabset.children.length, action.index);
1088
- 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;
1089
1061
  }
1090
1062
  case "setActiveTabset":
1091
- if (findTabset(draft, action.tabsetId)) draft.activeTabsetId = action.tabsetId;
1092
- return;
1063
+ if (draft.activeTabsetId === action.tabsetId || !findTabset(draft, action.tabsetId)) return false;
1064
+ draft.activeTabsetId = action.tabsetId;
1065
+ return true;
1093
1066
  case "setFloatMinimized": {
1094
1067
  const float = findFloat(draft, action.floatId);
1095
- if (float) float.minimized = action.minimized;
1096
- 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;
1097
1077
  }
1098
- case "setMaximizedTabset":
1099
- draft.maximizedTabsetId = action.tabsetId ?? void 0;
1100
- return;
1101
1078
  case "updateGlobalAttributes":
1079
+ if (alreadyApplied(draft.global, action.attrs)) return false;
1102
1080
  Object.assign(draft.global, action.attrs);
1103
- return;
1081
+ return true;
1104
1082
  case "updateNodeAttributes": {
1105
1083
  const node = findAttributedNode(draft, action.nodeId);
1106
- if (node) Object.assign(node, parseNodeAttrs(node, action.attrs));
1107
- 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;
1108
1089
  }
1109
- default: assertNever(action);
1090
+ default: return assertNever(action);
1110
1091
  }
1111
1092
  };
1093
+ /** Identity-equal output means "no edit". A rejected action skips `normalize`, so
1094
+ * pass a model that has already been through it. */
1112
1095
  const reducer = (model, action) => {
1113
1096
  const draft = structuredClone(model);
1114
- applyAction(draft, action);
1097
+ if (!applyAction(draft, action)) return model;
1115
1098
  return normalize(draft);
1116
1099
  };
1117
1100
 
@@ -1125,8 +1108,14 @@ const createHistory = (present) => ({
1125
1108
  });
1126
1109
  const canUndo = (history) => history.past.length > 0;
1127
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
+ */
1128
1116
  const dispatch = (history, action) => {
1129
1117
  const present = reducer(history.present, action);
1118
+ if (Object.is(present, history.present)) return history;
1130
1119
  return {
1131
1120
  future: [],
1132
1121
  past: [...history.past, history.present].slice(-100),
@@ -1172,25 +1161,50 @@ const dashfooMachine = setup({ types: {
1172
1161
 
1173
1162
  //#endregion
1174
1163
  //#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");
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
+ };
1177
1188
  return {
1178
- intent: context.intent,
1179
- 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
1180
1197
  };
1181
1198
  };
1182
1199
  const dragDockMachine = setup({
1183
- guards: { hasValidDrop: ({ context }) => context.subject !== null && context.intent !== null },
1200
+ guards: { hasValidDrop: ({ context }) => dropAction(context.drag) !== null },
1184
1201
  types: {
1185
1202
  context: {},
1186
1203
  emitted: {},
1187
1204
  events: {}
1188
1205
  }
1189
1206
  }).createMachine({
1190
- context: {
1191
- intent: null,
1192
- subject: null
1193
- },
1207
+ context: { drag: { kind: "idle" } },
1194
1208
  id: "dragDock",
1195
1209
  initial: "idle",
1196
1210
  states: {
@@ -1198,36 +1212,30 @@ const dragDockMachine = setup({
1198
1212
  CANCEL: { target: "idle" },
1199
1213
  DROP: [{
1200
1214
  actions: emit(({ context }) => {
1201
- 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");
1202
1217
  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
- },
1218
+ action: resolved.action,
1219
+ scope: resolved.scope,
1216
1220
  type: "COMMIT"
1217
1221
  };
1218
1222
  }),
1219
1223
  guard: "hasValidDrop",
1220
1224
  target: "idle"
1221
1225
  }, { target: "idle" }],
1222
- 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 }) }
1223
1230
  } },
1224
1231
  idle: {
1225
- entry: assign({
1226
- intent: null,
1227
- subject: null
1228
- }),
1232
+ entry: assign({ drag: { kind: "idle" } }),
1229
1233
  on: { START: {
1230
- actions: assign({ subject: ({ event }) => event.subject }),
1234
+ actions: assign({ drag: ({ event }) => ({
1235
+ drop: null,
1236
+ kind: "dragging",
1237
+ subject: event.subject
1238
+ }) }),
1231
1239
  target: "dragging"
1232
1240
  } }
1233
1241
  }
@@ -1269,5 +1277,5 @@ const stackModel = (model, orientation = "column") => {
1269
1277
  };
1270
1278
 
1271
1279
  //#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 };
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 };
1273
1281
  //# sourceMappingURL=index.js.map