@dashfoo/react 0.6.4 → 0.7.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,12 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { canRedo, canUndo, dashfooMachine, dragDockMachine, findTabset, fromJSON, normalize, reducer, resolveDockTarget, snapEnabled, snapSizes, stackModel, tabNodeSchema, toJSON, zoneRect } from "@dashfoo/core";
3
+ import { canRedo, canUndo, dashfooMachine, decideSnap, dockLocationFor, dragDockMachine, findTabset, fromJSON, normalize, resolveDockTarget, settleSnap, snapEnabled, splitEdge, stackModel, tabNodeSchema, toJSON, zoneRect } from "@dashfoo/core";
4
4
  import { Fragment, createContext, createElement, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
5
  import { Accessibility, DragDropManager, Draggable, Droppable, Feedback, PointerSensor } from "@dnd-kit/dom";
6
- import { createStore, useStore } from "zustand";
7
- import { CollisionPriority, CollisionType } from "@dnd-kit/abstract";
8
6
  import { useActorRef, useSelector } from "@xstate/react";
7
+ import { CollisionPriority, CollisionType } from "@dnd-kit/abstract";
9
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
+ import { createStore, useStore } from "zustand";
10
10
  import { Group, Panel as Panel$1, Separator } from "react-resizable-panels";
11
11
 
12
12
  //#region src/lib/topmost-collision.ts
@@ -63,17 +63,15 @@ const createDragManager = () => new DragDropManager({
63
63
  plugins: (defaults) => [...defaults.filter((plugin) => plugin !== Accessibility && plugin !== Feedback), Feedback.configure({ dropAnimation: null })],
64
64
  sensors: () => [PointerSensor.configure({ preventActivation })]
65
65
  });
66
+ const DragRootContext = createContext(null);
66
67
  const DragContext = createContext(null);
67
68
  const SharedDragManagerContext = createContext(null);
68
- const createDragSubjectStore = () => createStore(() => ({
69
- intent: null,
70
- intentOwner: null,
71
- subject: null
72
- }));
69
+ /**
70
+ * The actor assigns a freshly built intent on every pointer move, so selecting
71
+ * `context.intent` by reference would re-render every reader of `useDropIntent`
72
+ * for each move that resolved to the same slot.
73
+ */
73
74
  const sameDropIntent = (a, b) => a === b || a !== null && b !== null && a.index === b.index && a.location === b.location && a.targetId === b.targetId;
74
- const sameDragSubject = (a, b) => a === b || a !== null && b !== null && a.id === b.id && a.kind === b.kind;
75
- const nullSubjectStore = createDragSubjectStore();
76
- const DragSubjectStoreContext = createContext(null);
77
75
  const useDraggableHandle = () => {
78
76
  const elementRef = useRef(null);
79
77
  const draggableRef = useRef(null);
@@ -194,11 +192,20 @@ const useExternalTabSource = ({ createTab, disabled = false, label = "" }) => {
194
192
  id: `external-${id}`
195
193
  }, disabled, label);
196
194
  };
195
+ /**
196
+ * Both drag readers are documented as working anywhere under the provider, which
197
+ * includes a custom overlay mounted beside the layout rather than inside a drag
198
+ * layer, so they read the root's actor. Outside a provider entirely there is no
199
+ * actor to read and they report the empty state instead of throwing: a component
200
+ * that renders both inside and outside a layout should not crash in one of them.
201
+ */
197
202
  const useDragSubject = () => {
198
- return useStore(useContext(DragSubjectStoreContext) ?? nullSubjectStore, (state) => state.subject);
203
+ const actorRef = useContext(DragRootContext)?.actorRef;
204
+ return useSelector(actorRef, (snapshot) => snapshot?.context.subject ?? null);
199
205
  };
200
206
  const useDropIntent = () => {
201
- return useStore(useContext(DragSubjectStoreContext) ?? nullSubjectStore, (state) => state.intent);
207
+ const actorRef = useContext(DragRootContext)?.actorRef;
208
+ return useSelector(actorRef, (snapshot) => snapshot?.context.intent ?? null, sameDropIntent);
202
209
  };
203
210
 
204
211
  //#endregion
@@ -393,8 +400,18 @@ const useResponsiveModel = ({ breakpoints }) => {
393
400
  }, [active, containerRef]);
394
401
  };
395
402
 
403
+ //#endregion
404
+ //#region src/lib/warn-once.ts
405
+ const warned = /* @__PURE__ */ new Set();
406
+ const warnOnce = (key, message) => {
407
+ if (warned.has(key)) return;
408
+ warned.add(key);
409
+ console.warn(`[dashfoo] ${message}`);
410
+ };
411
+
396
412
  //#endregion
397
413
  //#region src/hooks/store.ts
414
+ const CONTROLLED_HISTORY_MESSAGE = "undo/redo are unavailable while `model` is controlled; keep the history in the state you own and drive it through onModelChange";
398
415
  const useDashfooStore = (options) => {
399
416
  const { defaultModel, model: controlledModel, onAction, onActiveTabsetChange, onMaximizedTabsetChange, onModelChange } = options;
400
417
  const initialModel = controlledModel ?? defaultModel;
@@ -421,32 +438,22 @@ const useDashfooStore = (options) => {
421
438
  const dispatch = useCallback((action) => {
422
439
  const resolved = onAction ? onAction(action) : action;
423
440
  if (!resolved) return;
424
- const transition = (() => {
425
- if (controlledModel === void 0) {
426
- const before = actorRef.getSnapshot().context.history.present;
427
- actorRef.send({
428
- action: resolved,
429
- type: "DISPATCH"
430
- });
431
- return {
432
- after: actorRef.getSnapshot().context.history.present,
433
- before
434
- };
435
- }
436
- return {
437
- after: reducer(controlledModel, resolved),
438
- before: normalize(controlledModel)
439
- };
440
- })();
441
- notify(transition.before, transition.after, resolved);
441
+ const before = actorRef.getSnapshot().context.history.present;
442
+ actorRef.send({
443
+ action: resolved,
444
+ type: "DISPATCH"
445
+ });
446
+ notify(before, actorRef.getSnapshot().context.history.present, resolved);
442
447
  }, [
443
448
  actorRef,
444
- controlledModel,
445
449
  notify,
446
450
  onAction
447
451
  ]);
448
452
  const undo = useCallback(() => {
449
- if (controlledModel !== void 0) return;
453
+ if (controlledModel !== void 0) {
454
+ warnOnce("controlled-undo", CONTROLLED_HISTORY_MESSAGE);
455
+ return;
456
+ }
450
457
  const before = actorRef.getSnapshot().context.history.present;
451
458
  actorRef.send({ type: "UNDO" });
452
459
  const after = actorRef.getSnapshot().context.history.present;
@@ -457,7 +464,10 @@ const useDashfooStore = (options) => {
457
464
  notify
458
465
  ]);
459
466
  const redo = useCallback(() => {
460
- if (controlledModel !== void 0) return;
467
+ if (controlledModel !== void 0) {
468
+ warnOnce("controlled-redo", CONTROLLED_HISTORY_MESSAGE);
469
+ return;
470
+ }
461
471
  const before = actorRef.getSnapshot().context.history.present;
462
472
  actorRef.send({ type: "REDO" });
463
473
  const after = actorRef.getSnapshot().context.history.present;
@@ -474,8 +484,8 @@ const useDashfooStore = (options) => {
474
484
  });
475
485
  }, [actorRef]);
476
486
  return useMemo(() => ({
477
- canRedo: () => canRedo(actorRef.getSnapshot().context.history),
478
- canUndo: () => canUndo(actorRef.getSnapshot().context.history),
487
+ canRedo: () => controlledModel === void 0 && canRedo(actorRef.getSnapshot().context.history),
488
+ canUndo: () => controlledModel === void 0 && canUndo(actorRef.getSnapshot().context.history),
479
489
  dispatch,
480
490
  model,
481
491
  redo,
@@ -483,6 +493,7 @@ const useDashfooStore = (options) => {
483
493
  undo
484
494
  }), [
485
495
  actorRef,
496
+ controlledModel,
486
497
  dispatch,
487
498
  model,
488
499
  redo,
@@ -491,6 +502,49 @@ const useDashfooStore = (options) => {
491
502
  ]);
492
503
  };
493
504
 
505
+ //#endregion
506
+ //#region src/lib/drag-subject.ts
507
+ const isTabFactory = (value) => typeof value === "function";
508
+ /**
509
+ * Reads what a dnd-kit source represents. It lives beside the manager wiring
510
+ * rather than in the layer adapter because `dragstart` fires once per gesture on
511
+ * the shared manager, above any one layer.
512
+ */
513
+ const subjectFor = (source) => {
514
+ const data = source.data;
515
+ if (data?.type === "tabset") return {
516
+ id: String(data.tabsetId),
517
+ kind: "tabset"
518
+ };
519
+ if (data?.type === "external") {
520
+ if (!isTabFactory(data.createTab)) {
521
+ console.warn("[dashfoo] external drag source is missing its createTab function");
522
+ return null;
523
+ }
524
+ let candidate;
525
+ try {
526
+ candidate = data.createTab();
527
+ } catch (error) {
528
+ console.warn("[dashfoo] external drag source createTab threw", error);
529
+ return null;
530
+ }
531
+ const parsed = tabNodeSchema.safeParse(candidate);
532
+ if (!parsed.success) {
533
+ console.warn("[dashfoo] external drag source returned an invalid tab", parsed.error);
534
+ return null;
535
+ }
536
+ return {
537
+ id: String(source.id),
538
+ kind: "external",
539
+ tab: parsed.data
540
+ };
541
+ }
542
+ return {
543
+ id: String(source.id),
544
+ kind: "tab"
545
+ };
546
+ };
547
+
494
548
  //#endregion
495
549
  //#region src/lib/tab-insertion.ts
496
550
  const pointInRect = (point, rect) => point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
@@ -626,160 +680,43 @@ const DashfooDragProvider = ({ children }) => {
626
680
  useInsertionEffect(() => () => {
627
681
  manager.destroy();
628
682
  }, [manager]);
629
- const [subjectStore] = useState(createDragSubjectStore);
630
- return /* @__PURE__ */ jsx(SharedDragManagerContext.Provider, {
631
- value: manager,
632
- children: /* @__PURE__ */ jsxs(DragSubjectStoreContext.Provider, {
633
- value: subjectStore,
634
- children: [children, /* @__PURE__ */ jsx(DragPreviewOverlay, { manager })]
635
- })
636
- });
637
- };
638
-
639
- //#endregion
640
- //#region src/hooks/layout-store.ts
641
- const createLayoutStore = (initial) => createStore(() => initial);
642
- const LayoutStoreContext = createContext(null);
643
- const useLayout = (selector) => {
644
- const store = useContext(LayoutStoreContext);
645
- if (store === null) throw new Error("dashfoo components must be rendered inside <DashfooLayout> or <Layout.Root>.");
646
- return useStore(store, selector);
647
- };
648
-
649
- //#endregion
650
- //#region src/lib/warn-once.ts
651
- const warned = /* @__PURE__ */ new Set();
652
- const warnOnce = (key, message) => {
653
- if (warned.has(key)) return;
654
- warned.add(key);
655
- console.warn(`[dashfoo] ${message}`);
656
- };
657
-
658
- //#endregion
659
- //#region src/components/drag-adapter.tsx
660
- const tabRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab\"]")].flatMap((tab) => tab.dataset.tabId === excludeId ? [] : [tab.getBoundingClientRect()]);
661
- const intentForTabset = (id, element, point, draggedId) => {
662
- const strip = element.querySelector("[data-dashfoo=\"tabstrip\"]");
663
- if (strip && pointInRect(point, strip.getBoundingClientRect())) return {
664
- index: insertionIndex(tabRects(strip, draggedId), point.x),
665
- location: "center",
666
- targetId: id
667
- };
668
- const target = resolveDockTarget(point, element.getBoundingClientRect());
669
- const location = target.kind === "tab" ? "center" : `split-${target.edge}`;
670
- if (location === "center" && strip) return {
671
- index: tabRects(strip, draggedId).length,
672
- location,
673
- targetId: id
674
- };
675
- return {
676
- location,
677
- targetId: id
678
- };
679
- };
680
- const isTabFactory = (value) => typeof value === "function";
681
- const subjectFor = (source) => {
682
- const data = source.data;
683
- if (data?.type === "tabset") return {
684
- id: String(data.tabsetId),
685
- kind: "tabset"
686
- };
687
- if (data?.type === "external") {
688
- if (!isTabFactory(data.createTab)) {
689
- console.warn("[dashfoo] external drag source is missing its createTab function");
690
- return null;
691
- }
692
- let candidate;
693
- try {
694
- candidate = data.createTab();
695
- } catch (error) {
696
- console.warn("[dashfoo] external drag source createTab threw", error);
697
- return null;
698
- }
699
- const parsed = tabNodeSchema.safeParse(candidate);
700
- if (!parsed.success) {
701
- console.warn("[dashfoo] external drag source returned an invalid tab", parsed.error);
702
- return null;
703
- }
704
- return {
705
- id: String(source.id),
706
- kind: "external",
707
- tab: parsed.data
708
- };
709
- }
710
- return {
711
- id: String(source.id),
712
- kind: "tab"
713
- };
714
- };
715
- const DragProvider = ({ children, onCommit, splitDock }) => {
716
683
  const actorRef = useActorRef(dragDockMachine);
717
- const layoutStore = useContext(LayoutStoreContext);
718
- const sharedManager = useContext(SharedDragManagerContext);
719
- const [ownManager] = useState(() => sharedManager ? null : createDragManager());
720
- useInsertionEffect(() => () => ownManager?.destroy(), [ownManager]);
721
- const manager = sharedManager ?? ownManager;
722
- if (manager === null) throw new Error("[dashfoo] DashfooDragProvider unmounted while its layout is still mounted; keep the provider above the layout or remount the layout");
723
- const inheritedStore = useContext(DragSubjectStoreContext);
724
- const [subjectStore] = useState(() => inheritedStore ?? createDragSubjectStore());
725
- const ownerId = useId();
726
- useEffect(() => {
727
- const subscription = actorRef.subscribe((snapshot) => {
728
- const { intent, subject } = snapshot.context;
729
- const state = subjectStore.getState();
730
- if (intent !== null) {
731
- if (!sameDropIntent(state.intent, intent) || state.intentOwner !== ownerId) subjectStore.setState({
732
- intent,
733
- intentOwner: ownerId
734
- });
735
- } else if (state.intent !== null && state.intentOwner === ownerId) subjectStore.setState({
736
- intent: null,
737
- intentOwner: null
738
- });
739
- if (!sameDragSubject(subjectStore.getState().subject, subject)) subjectStore.setState({ subject });
740
- });
684
+ const [scopes] = useState(() => /* @__PURE__ */ new Map());
685
+ const registerScope = useCallback((scope) => {
686
+ scopes.set(scope.layerId, scope);
741
687
  return () => {
742
- subscription.unsubscribe();
688
+ scopes.delete(scope.layerId);
743
689
  };
744
- }, [
745
- actorRef,
746
- ownerId,
747
- subjectStore
748
- ]);
749
- const onCommitRef = useRef(onCommit);
750
- const splitDockRef = useRef(splitDock);
690
+ }, [scopes]);
691
+ /**
692
+ * One listener set and one actor for the whole tree. Attached per layer, a
693
+ * single pointer move fanned out to every float's actor, and each one that did
694
+ * not own the target resolved `intent: null` and then swallowed the drop.
695
+ */
751
696
  useEffect(() => {
752
- onCommitRef.current = onCommit;
753
- splitDockRef.current = splitDock;
754
- });
755
- const getTabsetElement = useCallback((id) => {
756
- const element = manager.registry.droppables.get(`${ownerId}:${id}`)?.element;
757
- return element instanceof HTMLElement ? element : void 0;
758
- }, [manager, ownerId]);
759
- useEffect(() => {
760
- const subscription = actorRef.on("COMMIT", (emitted) => {
761
- if (layoutStore?.getState().editable === false) return;
762
- const commit = onCommitRef.current ?? layoutStore?.getState().dispatch;
763
- if (!commit) {
764
- warnOnce("drag-layer-no-commit", "Layout.DragLayer has neither an onCommit prop nor a <Layout.Root> above it; drops are ignored");
765
- return;
766
- }
767
- commit(emitted.action);
768
- });
769
- return () => {
770
- subscription.unsubscribe();
771
- };
772
- }, [actorRef, layoutStore]);
773
- useEffect(() => {
774
- const resolveIntent = (targetId, element, point, draggedId) => {
775
- if (layoutStore?.getState().editable === false) return null;
776
- if (!shouldAllowDrop(draggedId, targetId, [...element.querySelectorAll("[data-dashfoo=\"tab\"]")].map((tab) => tab.dataset.tabId ?? ""))) return null;
777
- const intent = intentForTabset(targetId, element, point, draggedId);
778
- if (!(splitDockRef.current ?? layoutStore?.getState().splitDock ?? true) && intent.location.startsWith("split-")) return {
779
- location: "center",
780
- targetId
697
+ const scopedTarget = () => {
698
+ const target = manager.dragOperation.target;
699
+ const data = target?.data;
700
+ const scope = data?.layerId === void 0 ? void 0 : scopes.get(data.layerId);
701
+ const element = target?.element;
702
+ if (!scope || data?.tabsetId === void 0 || !(element instanceof HTMLElement)) return null;
703
+ return {
704
+ element,
705
+ scope,
706
+ tabsetId: data.tabsetId
781
707
  };
782
- return intent;
708
+ };
709
+ let intentLayerId = null;
710
+ const syncIntent = () => {
711
+ const operation = manager.dragOperation;
712
+ const found = scopedTarget();
713
+ const draggedId = operation.source ? String(operation.source.id) : void 0;
714
+ const intent = found === null ? null : found.scope.resolveIntent(found.tabsetId, found.element, operation.position.current, draggedId);
715
+ intentLayerId = intent === null || found === null ? null : found.scope.layerId;
716
+ actorRef.send({
717
+ intent,
718
+ type: "OVER"
719
+ });
783
720
  };
784
721
  const handleStart = (event) => {
785
722
  const source = event.operation.source;
@@ -789,24 +726,12 @@ const DragProvider = ({ children, onCommit, splitDock }) => {
789
726
  manager.actions.stop({ canceled: true });
790
727
  return;
791
728
  }
729
+ intentLayerId = null;
792
730
  actorRef.send({
793
731
  subject,
794
732
  type: "START"
795
733
  });
796
734
  };
797
- const syncIntent = () => {
798
- const operation = manager.dragOperation;
799
- const point = operation.position.current;
800
- const target = operation.target;
801
- const data = target?.data;
802
- const tabsetId = data?.layerId === ownerId ? data.tabsetId : void 0;
803
- const element = tabsetId === void 0 ? void 0 : target?.element;
804
- const draggedId = operation.source ? String(operation.source.id) : void 0;
805
- actorRef.send({
806
- intent: tabsetId !== void 0 && element instanceof HTMLElement ? resolveIntent(tabsetId, element, point, draggedId) : null,
807
- type: "OVER"
808
- });
809
- };
810
735
  const handleMove = () => {
811
736
  syncIntent();
812
737
  };
@@ -821,11 +746,15 @@ const DragProvider = ({ children, onCommit, splitDock }) => {
821
746
  syncIntent();
822
747
  actorRef.send({ type: "DROP" });
823
748
  };
749
+ const commitSubscription = actorRef.on("COMMIT", (emitted) => {
750
+ (intentLayerId === null ? void 0 : scopes.get(intentLayerId))?.commit(emitted.action);
751
+ });
824
752
  const offStart = manager.monitor.addEventListener("dragstart", handleStart);
825
753
  const offMove = manager.monitor.addEventListener("dragmove", handleMove);
826
754
  const offCollision = manager.monitor.addEventListener("collision", handleCollision);
827
755
  const offEnd = manager.monitor.addEventListener("dragend", handleEnd);
828
756
  return () => {
757
+ commitSubscription.unsubscribe();
829
758
  offStart();
830
759
  offMove();
831
760
  offCollision();
@@ -833,114 +762,134 @@ const DragProvider = ({ children, onCommit, splitDock }) => {
833
762
  };
834
763
  }, [
835
764
  actorRef,
836
- layoutStore,
837
765
  manager,
838
- ownerId
766
+ scopes
839
767
  ]);
840
- const contextValue = useMemo(() => ({
841
- layerId: ownerId,
842
- manager
843
- }), [manager, ownerId]);
844
- return /* @__PURE__ */ jsx(DragContext.Provider, {
845
- value: contextValue,
846
- children: /* @__PURE__ */ jsxs(DragSubjectStoreContext.Provider, {
847
- value: subjectStore,
848
- children: [
849
- children,
850
- /* @__PURE__ */ jsx(DockIndicator, {
851
- actorRef,
852
- getTabsetElement
853
- }),
854
- ownManager === null ? null : /* @__PURE__ */ jsx(DragPreviewOverlay, { manager: ownManager })
855
- ]
768
+ const rootValue = useMemo(() => ({
769
+ actorRef,
770
+ manager,
771
+ registerScope
772
+ }), [
773
+ actorRef,
774
+ manager,
775
+ registerScope
776
+ ]);
777
+ return /* @__PURE__ */ jsx(SharedDragManagerContext.Provider, {
778
+ value: manager,
779
+ children: /* @__PURE__ */ jsxs(DragRootContext.Provider, {
780
+ value: rootValue,
781
+ children: [children, /* @__PURE__ */ jsx(DragPreviewOverlay, { manager })]
856
782
  })
857
783
  });
858
784
  };
859
785
 
860
786
  //#endregion
861
- //#region src/components/float-context.ts
862
- const FloatLayerContext = createContext(false);
863
- const useHasFloatLayer = () => useContext(FloatLayerContext);
787
+ //#region src/hooks/layout-store.ts
788
+ const createLayoutStore = (initial) => createStore(() => initial);
789
+ const LayoutStoreContext = createContext(null);
790
+ const useLayout = (selector) => {
791
+ const store = useContext(LayoutStoreContext);
792
+ if (store === null) throw new Error("dashfoo components must be rendered inside <DashfooLayout> or <Layout.Root>.");
793
+ return useStore(store, selector);
794
+ };
864
795
 
865
796
  //#endregion
866
- //#region src/hooks/use-inline-rename.ts
867
- const useInlineRename = ({ currentName, inputRef, onCommit, onDone }) => {
868
- const done = useRef(false);
869
- useEffect(() => {
870
- inputRef.current?.focus();
871
- inputRef.current?.select();
872
- }, [inputRef]);
873
- const commit = (value) => {
874
- const trimmed = value.trim();
875
- if (trimmed && trimmed !== currentName) onCommit(trimmed);
876
- onDone();
877
- };
878
- const handleKeyDown = (event) => {
879
- if (event.key === "Enter") {
880
- done.current = true;
881
- commit(event.currentTarget.value);
882
- } else if (event.key === "Escape") {
883
- done.current = true;
884
- onDone();
885
- }
797
+ //#region src/components/drag-adapter.tsx
798
+ const MISSING_ROOT = "[dashfoo] DashfooDragProvider unmounted while its layout is still mounted; keep the provider above the layout or remount the layout";
799
+ const tabRects = (strip, excludeId) => [...strip.querySelectorAll("[data-dashfoo=\"tab\"]")].flatMap((tab) => tab.dataset.tabId === excludeId ? [] : [tab.getBoundingClientRect()]);
800
+ const intentForTabset = (id, element, point, draggedId) => {
801
+ const strip = element.querySelector("[data-dashfoo=\"tabstrip\"]");
802
+ if (strip && pointInRect(point, strip.getBoundingClientRect())) return {
803
+ index: insertionIndex(tabRects(strip, draggedId), point.x),
804
+ location: "center",
805
+ targetId: id
886
806
  };
887
- const handleBlur = (event) => {
888
- if (done.current) return;
889
- done.current = true;
890
- commit(event.currentTarget.value);
807
+ const location = dockLocationFor(resolveDockTarget(point, element.getBoundingClientRect()));
808
+ if (location === "center" && strip) return {
809
+ index: tabRects(strip, draggedId).length,
810
+ location,
811
+ targetId: id
891
812
  };
892
813
  return {
893
- handleBlur,
894
- handleKeyDown
814
+ location,
815
+ targetId: id
895
816
  };
896
817
  };
897
-
898
- //#endregion
899
- //#region src/lib/float-geometry.ts
900
- const MIN_WIDTH = 200;
901
- const MIN_HEIGHT = 120;
902
- const DEFAULT_WIDTH = 480;
903
- const DEFAULT_HEIGHT = 340;
904
- const LIFT_OFFSET = 16;
905
- const clampToBounds = (rect, bounds, size = rect) => ({
906
- height: rect.height,
907
- left: Math.max(0, Math.min(rect.left, Math.max(0, bounds.width - size.width))),
908
- top: Math.max(0, Math.min(rect.top, Math.max(0, bounds.height - size.height))),
909
- width: rect.width
910
- });
911
- const measureFloatRect = (element) => {
912
- const rect = (element.closest("[data-dashfoo=\"tabset\"]") ?? element).getBoundingClientRect();
913
- const seeded = {
914
- height: Math.max(MIN_HEIGHT, Math.min(Math.round(rect.height), DEFAULT_HEIGHT)),
915
- left: Math.round(rect.left) + LIFT_OFFSET,
916
- top: Math.round(rect.top) + LIFT_OFFSET,
917
- width: Math.max(MIN_WIDTH, Math.min(Math.round(rect.width), DEFAULT_WIDTH))
918
- };
919
- return clampToBounds(seeded, {
920
- height: window.innerHeight,
921
- width: window.innerWidth
818
+ /**
819
+ * One drag layer: the main tree, or one float. It holds no drag state of its
820
+ * own. It registers how its own tabsets turn a pointer into an intent and where
821
+ * a committed action goes, then draws the indicator over its own droppables.
822
+ */
823
+ const DragScope = ({ children, onCommit, splitDock }) => {
824
+ const root = useContext(DragRootContext);
825
+ const layoutStore = useContext(LayoutStoreContext);
826
+ const layerId = useId();
827
+ const manager = root?.manager;
828
+ const getTabsetElement = useCallback((id) => {
829
+ const element = manager?.registry.droppables.get(`${layerId}:${id}`)?.element;
830
+ return element instanceof HTMLElement ? element : void 0;
831
+ }, [layerId, manager]);
832
+ const scope = useMemo(() => ({
833
+ commit: (action) => {
834
+ if (layoutStore?.getState().editable === false) return;
835
+ const commit = onCommit ?? layoutStore?.getState().dispatch;
836
+ if (!commit) {
837
+ warnOnce("drag-layer-no-commit", "Layout.DragLayer has neither an onCommit prop nor a <Layout.Root> above it; drops are ignored");
838
+ return;
839
+ }
840
+ commit(action);
841
+ },
842
+ layerId,
843
+ resolveIntent: (targetId, element, point, draggedId) => {
844
+ if (layoutStore?.getState().editable === false) return null;
845
+ if (!shouldAllowDrop(draggedId, targetId, [...element.querySelectorAll("[data-dashfoo=\"tab\"]")].map((tab) => tab.dataset.tabId ?? ""))) return null;
846
+ const intent = intentForTabset(targetId, element, point, draggedId);
847
+ if (!(splitDock ?? layoutStore?.getState().splitDock ?? true) && splitEdge(intent.location) !== void 0) return {
848
+ location: "center",
849
+ targetId
850
+ };
851
+ return intent;
852
+ }
853
+ }), [
854
+ layerId,
855
+ layoutStore,
856
+ onCommit,
857
+ splitDock
858
+ ]);
859
+ const registerScope = root?.registerScope;
860
+ useEffect(() => registerScope?.(scope), [registerScope, scope]);
861
+ const contextValue = useMemo(() => root === null ? null : {
862
+ actorRef: root.actorRef,
863
+ layerId,
864
+ manager: root.manager
865
+ }, [layerId, root]);
866
+ if (contextValue === null) throw new Error(MISSING_ROOT);
867
+ return /* @__PURE__ */ jsxs(DragContext.Provider, {
868
+ value: contextValue,
869
+ children: [children, /* @__PURE__ */ jsx(DockIndicator, {
870
+ actorRef: contextValue.actorRef,
871
+ getTabsetElement
872
+ })]
922
873
  });
923
874
  };
924
- const resizeRect = (start, edges, dx, dy) => {
925
- let { height, left, top, width } = start;
926
- if (edges.x === 1) width = Math.max(MIN_WIDTH, start.width + dx);
927
- else if (edges.x === -1) {
928
- width = Math.max(MIN_WIDTH, start.width - dx);
929
- left = Math.max(0, start.left + (start.width - width));
930
- }
931
- if (edges.y === 1) height = Math.max(MIN_HEIGHT, start.height + dy);
932
- else if (edges.y === -1) {
933
- height = Math.max(MIN_HEIGHT, start.height - dy);
934
- top = Math.max(0, start.top + (start.height - height));
935
- }
936
- return {
937
- height,
938
- left,
939
- top,
940
- width
941
- };
875
+ /**
876
+ * Mounts its own root when there is none above it, so a hand-composed
877
+ * `Layout.DragLayer` still works standalone. The choice is made on the first
878
+ * render, as it was for the manager this used to create itself: a root that
879
+ * disappears later means a torn-down tree, which `DragScope` reports.
880
+ */
881
+ const DragProvider = (props) => {
882
+ const hasRoot = useContext(DragRootContext) !== null;
883
+ const [ownsRoot] = useState(() => !hasRoot);
884
+ const layer = /* @__PURE__ */ jsx(DragScope, { ...props });
885
+ return ownsRoot ? /* @__PURE__ */ jsx(DashfooDragProvider, { children: layer }) : layer;
942
886
  };
943
887
 
888
+ //#endregion
889
+ //#region src/components/float-context.ts
890
+ const FloatLayerContext = createContext(false);
891
+ const useHasFloatLayer = () => useContext(FloatLayerContext);
892
+
944
893
  //#endregion
945
894
  //#region src/components/float-resize-handles.ts
946
895
  const EDGE_THICKNESS = 12;
@@ -1056,9 +1005,205 @@ const RESIZE_HANDLES = [
1056
1005
  left: CORNER_OFFSET,
1057
1006
  width: CORNER_SIZE
1058
1007
  }
1059
- }
1060
- ];
1061
- const EDGE_BY_KEY = new Map(RESIZE_HANDLES.map((h) => [h.key, h.edges]));
1008
+ }
1009
+ ];
1010
+ const EDGE_BY_KEY = new Map(RESIZE_HANDLES.map((h) => [h.key, h.edges]));
1011
+
1012
+ //#endregion
1013
+ //#region src/lib/float-geometry.ts
1014
+ const MIN_WIDTH = 200;
1015
+ const MIN_HEIGHT = 120;
1016
+ const DEFAULT_WIDTH = 480;
1017
+ const DEFAULT_HEIGHT = 340;
1018
+ const LIFT_OFFSET = 16;
1019
+ const clampToBounds = (rect, bounds, size = rect) => ({
1020
+ height: rect.height,
1021
+ left: Math.max(0, Math.min(rect.left, Math.max(0, bounds.width - size.width))),
1022
+ top: Math.max(0, Math.min(rect.top, Math.max(0, bounds.height - size.height))),
1023
+ width: rect.width
1024
+ });
1025
+ const measureFloatRect = (element) => {
1026
+ const rect = (element.closest("[data-dashfoo=\"tabset\"]") ?? element).getBoundingClientRect();
1027
+ const seeded = {
1028
+ height: Math.max(MIN_HEIGHT, Math.min(Math.round(rect.height), DEFAULT_HEIGHT)),
1029
+ left: Math.round(rect.left) + LIFT_OFFSET,
1030
+ top: Math.round(rect.top) + LIFT_OFFSET,
1031
+ width: Math.max(MIN_WIDTH, Math.min(Math.round(rect.width), DEFAULT_WIDTH))
1032
+ };
1033
+ return clampToBounds(seeded, {
1034
+ height: window.innerHeight,
1035
+ width: window.innerWidth
1036
+ });
1037
+ };
1038
+ const resizeRect = (start, edges, dx, dy) => {
1039
+ let { height, left, top, width } = start;
1040
+ if (edges.x === 1) width = Math.max(MIN_WIDTH, start.width + dx);
1041
+ else if (edges.x === -1) {
1042
+ width = Math.max(MIN_WIDTH, start.width - dx);
1043
+ left = Math.max(0, start.left + (start.width - width));
1044
+ }
1045
+ if (edges.y === 1) height = Math.max(MIN_HEIGHT, start.height + dy);
1046
+ else if (edges.y === -1) {
1047
+ height = Math.max(MIN_HEIGHT, start.height - dy);
1048
+ top = Math.max(0, start.top + (start.height - height));
1049
+ }
1050
+ return {
1051
+ height,
1052
+ left,
1053
+ top,
1054
+ width
1055
+ };
1056
+ };
1057
+
1058
+ //#endregion
1059
+ //#region src/hooks/use-float-gesture.ts
1060
+ const CHIP_SIZE = {
1061
+ height: 34,
1062
+ width: 168
1063
+ };
1064
+ const TAP_SLOP = 4;
1065
+ /**
1066
+ * Moving and resizing writes straight to the panel's inline style and only
1067
+ * dispatches on pointer-up: routing every pointermove through the model would
1068
+ * re-render the whole layout tree behind the float.
1069
+ */
1070
+ const useFloatGesture = ({ editable, geometry, minimized, onCommit, onTap }) => {
1071
+ const panelRef = useRef(null);
1072
+ const gestureRef = useRef(null);
1073
+ const latestRef = useRef(geometry);
1074
+ const movedRef = useRef(false);
1075
+ const setPanel = (element) => {
1076
+ panelRef.current = element;
1077
+ };
1078
+ const handlePointerDown = (event) => {
1079
+ const panel = panelRef.current;
1080
+ if (!panel || !editable) return;
1081
+ const prior = gestureRef.current;
1082
+ if (prior && panel.hasPointerCapture?.(prior.pointerId)) panel.releasePointerCapture(prior.pointerId);
1083
+ movedRef.current = false;
1084
+ const edgeKey = event.currentTarget.dataset.edge;
1085
+ const parent = panel.offsetParent instanceof HTMLElement ? panel.offsetParent : null;
1086
+ const gesture = {
1087
+ bounds: {
1088
+ height: parent?.clientHeight ?? 0,
1089
+ width: parent?.clientWidth ?? 0
1090
+ },
1091
+ edges: edgeKey === void 0 ? null : EDGE_BY_KEY.get(edgeKey) ?? null,
1092
+ pointerId: event.pointerId,
1093
+ start: geometry,
1094
+ startX: event.clientX,
1095
+ startY: event.clientY
1096
+ };
1097
+ gestureRef.current = gesture;
1098
+ latestRef.current = geometry;
1099
+ if (gesture.edges) panel.setPointerCapture(event.pointerId);
1100
+ };
1101
+ const handlePointerUp = (event) => {
1102
+ const gesture = gestureRef.current;
1103
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
1104
+ gestureRef.current = null;
1105
+ const panel = panelRef.current;
1106
+ if (panel?.hasPointerCapture?.(event.pointerId) === true) panel.releasePointerCapture(event.pointerId);
1107
+ if (!movedRef.current) {
1108
+ onTap();
1109
+ return;
1110
+ }
1111
+ onCommit(latestRef.current);
1112
+ };
1113
+ const handlePointerMove = (event) => {
1114
+ const gesture = gestureRef.current;
1115
+ const panel = panelRef.current;
1116
+ if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
1117
+ if (event.buttons === 0) {
1118
+ handlePointerUp(event);
1119
+ return;
1120
+ }
1121
+ const dx = event.clientX - gesture.startX;
1122
+ const dy = event.clientY - gesture.startY;
1123
+ if (!movedRef.current) {
1124
+ if (Math.hypot(dx, dy) <= TAP_SLOP) return;
1125
+ movedRef.current = true;
1126
+ if (!gesture.edges) panel.setPointerCapture(gesture.pointerId);
1127
+ }
1128
+ const next = gesture.edges ? resizeRect(gesture.start, gesture.edges, dx, dy) : clampToBounds({
1129
+ ...gesture.start,
1130
+ left: gesture.start.left + dx,
1131
+ top: gesture.start.top + dy
1132
+ }, gesture.bounds, minimized ? CHIP_SIZE : void 0);
1133
+ latestRef.current = next;
1134
+ panel.style.left = `${next.left}px`;
1135
+ panel.style.top = `${next.top}px`;
1136
+ if (!minimized) {
1137
+ panel.style.width = `${next.width}px`;
1138
+ panel.style.height = `${next.height}px`;
1139
+ }
1140
+ };
1141
+ const handlePointerCancel = (event) => {
1142
+ const gesture = gestureRef.current;
1143
+ const panel = panelRef.current;
1144
+ if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
1145
+ gestureRef.current = null;
1146
+ if (panel.hasPointerCapture?.(event.pointerId)) panel.releasePointerCapture(event.pointerId);
1147
+ if (!movedRef.current) return;
1148
+ panel.style.left = `${gesture.start.left}px`;
1149
+ panel.style.top = `${gesture.start.top}px`;
1150
+ if (!minimized) {
1151
+ panel.style.width = `${gesture.start.width}px`;
1152
+ panel.style.height = `${gesture.start.height}px`;
1153
+ }
1154
+ };
1155
+ return {
1156
+ handlers: {
1157
+ onPointerCancel: handlePointerCancel,
1158
+ onPointerDown: handlePointerDown,
1159
+ onPointerMove: handlePointerMove,
1160
+ onPointerUp: handlePointerUp
1161
+ },
1162
+ ref: setPanel,
1163
+ style: minimized ? {
1164
+ left: geometry.left,
1165
+ top: geometry.top
1166
+ } : {
1167
+ height: geometry.height,
1168
+ left: geometry.left,
1169
+ top: geometry.top,
1170
+ width: geometry.width
1171
+ }
1172
+ };
1173
+ };
1174
+
1175
+ //#endregion
1176
+ //#region src/hooks/use-inline-rename.ts
1177
+ const useInlineRename = ({ currentName, inputRef, onCommit, onDone }) => {
1178
+ const done = useRef(false);
1179
+ useEffect(() => {
1180
+ inputRef.current?.focus();
1181
+ inputRef.current?.select();
1182
+ }, [inputRef]);
1183
+ const commit = (value) => {
1184
+ const trimmed = value.trim();
1185
+ if (trimmed && trimmed !== currentName) onCommit(trimmed);
1186
+ onDone();
1187
+ };
1188
+ const handleKeyDown = (event) => {
1189
+ if (event.key === "Enter") {
1190
+ done.current = true;
1191
+ commit(event.currentTarget.value);
1192
+ } else if (event.key === "Escape") {
1193
+ done.current = true;
1194
+ onDone();
1195
+ }
1196
+ };
1197
+ const handleBlur = (event) => {
1198
+ if (done.current) return;
1199
+ done.current = true;
1200
+ commit(event.currentTarget.value);
1201
+ };
1202
+ return {
1203
+ handleBlur,
1204
+ handleKeyDown
1205
+ };
1206
+ };
1062
1207
 
1063
1208
  //#endregion
1064
1209
  //#region src/components/layout-root.tsx
@@ -1069,15 +1214,15 @@ const rootStyle = {
1069
1214
  position: "relative",
1070
1215
  width: "100%"
1071
1216
  };
1072
- const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = true, draggableTabsets = true, editable = true, floatable = false, keepMounted = false, maximizable = true, model, renamableTabs = true, renderTab, renderTabLabel, renderTabsetToolbar, resizableSplits = true, rootRef, snap, style, ...props }) => {
1217
+ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = true, draggableTabsets = true, editable = true, floatable = false, keepMounted = false, maximizable = true, model, renamableTabs = true, renderTab, renderTabLabel, renderTabsetToolbar, resizableSplits = true, restructurable = true, rootRef, snap, style, ...props }) => {
1073
1218
  const global = model.global;
1074
1219
  const snapshot = {
1075
1220
  closableTabs: editable && closableTabs && global.tabEnableClose !== false,
1076
1221
  dispatch,
1077
- draggableTabs: editable && draggableTabs && global.tabEnableDrag !== false,
1078
- draggableTabsets: editable && draggableTabsets,
1222
+ draggableTabs: editable && restructurable && draggableTabs && global.tabEnableDrag !== false,
1223
+ draggableTabsets: editable && restructurable && draggableTabsets,
1079
1224
  editable,
1080
- floatable: editable && floatable,
1225
+ floatable: editable && restructurable && floatable,
1081
1226
  keepMounted,
1082
1227
  maximizable: maximizable && global.tabSetEnableMaximize !== false,
1083
1228
  maximizedTabsetId: model.maximizedTabsetId,
@@ -1085,7 +1230,7 @@ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = t
1085
1230
  renderTab,
1086
1231
  renderTabLabel,
1087
1232
  renderTabsetToolbar,
1088
- resizableSplits: editable && resizableSplits && global.enableSplitResize !== false,
1233
+ resizableSplits: editable && restructurable && resizableSplits && global.enableSplitResize !== false,
1089
1234
  snap: snap ?? global.snap ?? null,
1090
1235
  splitDock: global.enableSplitDock !== false,
1091
1236
  tabLocation: global.tabLocation ?? "top",
@@ -1115,6 +1260,133 @@ const LayoutRoot = ({ children, closableTabs = true, dispatch, draggableTabs = t
1115
1260
  })
1116
1261
  });
1117
1262
  };
1263
+ /**
1264
+ * A nested subtree (a float) renders through the same components as the main
1265
+ * layout, minus a few capabilities. It republishes the parent store with the
1266
+ * overrides applied instead of re-deriving every flag from a synthetic model,
1267
+ * so anything the parent changes keeps flowing into the subtree.
1268
+ */
1269
+ const LayoutOverrides = ({ children, overrides }) => {
1270
+ const snapshot = {
1271
+ ...useLayout((state) => state),
1272
+ ...overrides
1273
+ };
1274
+ const [store] = useState(() => createLayoutStore(snapshot));
1275
+ useLayoutEffect(() => {
1276
+ store.setState(snapshot);
1277
+ });
1278
+ return /* @__PURE__ */ jsx(LayoutStoreContext.Provider, {
1279
+ value: store,
1280
+ children: /* @__PURE__ */ jsx("div", {
1281
+ "data-dashfoo": "layout",
1282
+ style: rootStyle,
1283
+ children
1284
+ })
1285
+ });
1286
+ };
1287
+
1288
+ //#endregion
1289
+ //#region src/hooks/use-snap-resize.ts
1290
+ const WEIGHT_EPSILON = .01;
1291
+ const IDLE = { kind: "idle" };
1292
+ const boundaryOf = (state) => state.kind === "dragging" || state.kind === "snapped" ? state.boundary : null;
1293
+ const snappedBoundaryOf = (state) => state.kind === "snapped" ? state.boundary : null;
1294
+ const layoutFromWeights = (children) => {
1295
+ const total = children.reduce((sum, child) => sum + child.weight, 0);
1296
+ return children.reduce((layout, child) => {
1297
+ layout[child.id] = child.weight / total * 100;
1298
+ return layout;
1299
+ }, {});
1300
+ };
1301
+ const orderedFromLayout = (layout, children) => children.map((child) => layout[child.id] ?? 0);
1302
+ const layoutFromOrdered = (sizes, children) => children.reduce((layout, child, index) => {
1303
+ layout[child.id] = sizes[index] ?? 0;
1304
+ return layout;
1305
+ }, {});
1306
+ const layoutsMatch = (current, expected) => {
1307
+ const currentIds = Object.keys(current);
1308
+ const expectedIds = Object.keys(expected);
1309
+ if (currentIds.length !== expectedIds.length) return false;
1310
+ return expectedIds.every((id) => Math.abs((current[id] ?? NaN) - expected[id]) <= WEIGHT_EPSILON);
1311
+ };
1312
+ const useSnapResize = ({ children, onWeightsSettled, resizableSplits, snap }) => {
1313
+ const groupRef = useRef(null);
1314
+ const state = useRef(IDLE);
1315
+ const [snappedBoundary, setSnappedBoundary] = useState(null);
1316
+ const transition = useCallback((next) => {
1317
+ state.current = next;
1318
+ setSnappedBoundary(snappedBoundaryOf(next));
1319
+ }, []);
1320
+ const desiredLayout = useMemo(() => layoutFromWeights(children), [children]);
1321
+ useLayoutEffect(() => {
1322
+ const group = groupRef.current;
1323
+ if (!group) return;
1324
+ const current = group.getLayout();
1325
+ if (!(Object.keys(current).length === Object.keys(desiredLayout).length && Object.keys(desiredLayout).every((id) => id in current)) || layoutsMatch(current, desiredLayout)) return;
1326
+ transition({ kind: "syncing" });
1327
+ group.setLayout(desiredLayout);
1328
+ const frame = requestAnimationFrame(() => {
1329
+ transition(IDLE);
1330
+ });
1331
+ return () => {
1332
+ cancelAnimationFrame(frame);
1333
+ transition(IDLE);
1334
+ };
1335
+ }, [desiredLayout, transition]);
1336
+ const onLayoutChange = (layout) => {
1337
+ if (state.current.kind === "syncing" || !resizableSplits) return;
1338
+ const boundary = boundaryOf(state.current);
1339
+ if (boundary === null) return;
1340
+ const decision = decideSnap(orderedFromLayout(layout, children), boundary, snap);
1341
+ if (decision.kind === "inactive") return;
1342
+ if (decision.kind === "clear") {
1343
+ transition({
1344
+ boundary,
1345
+ kind: "dragging"
1346
+ });
1347
+ return;
1348
+ }
1349
+ transition({
1350
+ boundary,
1351
+ kind: "snapped"
1352
+ });
1353
+ const group = groupRef.current;
1354
+ const target = layoutFromOrdered(decision.sizes, children);
1355
+ if (!group || layoutsMatch(layout, target)) return;
1356
+ transition({ kind: "syncing" });
1357
+ group.setLayout(target);
1358
+ transition({
1359
+ boundary,
1360
+ kind: "snapped"
1361
+ });
1362
+ };
1363
+ const onLayoutChanged = (layout, meta) => {
1364
+ if (!meta.isUserInteraction) return;
1365
+ if (state.current.kind === "syncing") return;
1366
+ const boundary = boundaryOf(state.current);
1367
+ transition(IDLE);
1368
+ if (!resizableSplits) return;
1369
+ const weights = settleSnap(children.map((child) => layout[child.id] ?? child.weight), boundary, snap);
1370
+ if (!weights.some((weight, index) => {
1371
+ const child = children[index];
1372
+ return child !== void 0 && Math.abs(weight - child.weight) > WEIGHT_EPSILON;
1373
+ })) return;
1374
+ onWeightsSettled(weights);
1375
+ };
1376
+ const beginBoundaryDrag = (boundary) => {
1377
+ transition({
1378
+ boundary,
1379
+ kind: "dragging"
1380
+ });
1381
+ };
1382
+ return {
1383
+ beginBoundaryDrag,
1384
+ groupRef,
1385
+ onLayoutChange,
1386
+ onLayoutChanged,
1387
+ snappedBoundary
1388
+ };
1389
+ };
1118
1390
 
1119
1391
  //#endregion
1120
1392
  //#region src/lib/tab-ids.ts
@@ -1983,22 +2255,6 @@ const groupStyle = {
1983
2255
  minHeight: 0,
1984
2256
  minWidth: 0
1985
2257
  };
1986
- const WEIGHT_EPSILON = .01;
1987
- const layoutFromWeights = (children, total) => children.reduce((layout, child) => {
1988
- layout[child.id] = (child.weight ?? 1) / total * 100;
1989
- return layout;
1990
- }, {});
1991
- const orderedFromLayout = (layout, children) => children.map((child) => layout[child.id] ?? 0);
1992
- const layoutFromOrdered = (sizes, children) => children.reduce((layout, child, index) => {
1993
- layout[child.id] = sizes[index] ?? 0;
1994
- return layout;
1995
- }, {});
1996
- const layoutsMatch = (current, expected) => {
1997
- const currentIds = Object.keys(current);
1998
- const expectedIds = Object.keys(expected);
1999
- if (currentIds.length !== expectedIds.length) return false;
2000
- return expectedIds.every((id) => Math.abs((current[id] ?? NaN) - expected[id]) <= WEIGHT_EPSILON);
2001
- };
2002
2258
  const descendantMinSize = (child, axis, tabsetMinSize) => {
2003
2259
  const explicit = dimensionToPixels(child.min);
2004
2260
  if (explicit !== void 0) return explicit;
@@ -2021,89 +2277,30 @@ const RowView = ({ node, renderTabset }) => {
2021
2277
  const effectiveSnap = node.snap ?? globalSnap;
2022
2278
  const snapActive = snapEnabled(effectiveSnap);
2023
2279
  const orientation = node.orientation === "row" ? "horizontal" : "vertical";
2024
- const total = node.children.reduce((sum, child) => sum + (child.weight ?? 1), 0);
2025
- const desiredLayout = useMemo(() => layoutFromWeights(node.children, total), [node.children, total]);
2026
- const groupRef = useRef(null);
2027
- const groupElement = useRef(null);
2028
- const syncing = useRef(false);
2029
- const activeBoundary = useRef(null);
2030
- const snapping = useRef(false);
2031
- const snappedBoundaryRef = useRef(null);
2032
- const [snappedBoundary, setSnappedBoundary] = useState(null);
2033
- const setHighlight = (boundary) => {
2034
- if (boundary !== snappedBoundaryRef.current) {
2035
- snappedBoundaryRef.current = boundary;
2036
- setSnappedBoundary(boundary);
2037
- }
2038
- };
2039
- const setSnapTransition = (active) => {
2040
- const element = groupElement.current;
2041
- if (!element) return;
2042
- if (active) element.dataset.dashfooSnapping = "true";
2043
- else delete element.dataset.dashfooSnapping;
2044
- };
2045
- useLayoutEffect(() => {
2046
- const group = groupRef.current;
2047
- if (!group) return;
2048
- const current = group.getLayout();
2049
- if (!(Object.keys(current).length === Object.keys(desiredLayout).length && Object.keys(desiredLayout).every((id) => id in current)) || layoutsMatch(current, desiredLayout)) return;
2050
- syncing.current = true;
2051
- group.setLayout(desiredLayout);
2052
- const frame = requestAnimationFrame(() => {
2053
- syncing.current = false;
2054
- });
2055
- return () => {
2056
- cancelAnimationFrame(frame);
2057
- syncing.current = false;
2058
- };
2059
- }, [desiredLayout]);
2060
- const handleLayoutChange = (layout) => {
2061
- if (snapping.current || syncing.current || !resizableSplits) return;
2062
- if (effectiveSnap === null || !snapActive) return;
2063
- const boundary = activeBoundary.current;
2064
- if (boundary === null) return;
2065
- const { sizes, snapped } = snapSizes(orderedFromLayout(layout, node.children), boundary, effectiveSnap);
2066
- setHighlight(snapped ? boundary : null);
2067
- if (!snapped) {
2068
- setSnapTransition(false);
2069
- return;
2070
- }
2071
- setSnapTransition(true);
2072
- const group = groupRef.current;
2073
- const target = layoutFromOrdered(sizes, node.children);
2074
- if (!group || layoutsMatch(layout, target)) return;
2075
- snapping.current = true;
2076
- group.setLayout(target);
2077
- snapping.current = false;
2078
- };
2079
- const handleLayoutChanged = (layout, meta) => {
2080
- if (!meta.isUserInteraction) return;
2081
- if (syncing.current) return;
2082
- const boundary = activeBoundary.current;
2083
- activeBoundary.current = null;
2084
- setHighlight(null);
2085
- setSnapTransition(false);
2086
- if (!resizableSplits) return;
2087
- let weights = node.children.map((child) => layout[child.id] ?? child.weight ?? 1);
2088
- if (boundary !== null && effectiveSnap !== null && snapActive) weights = snapSizes(weights, boundary, effectiveSnap).sizes;
2089
- if (!weights.some((weight, index) => Math.abs(weight - (node.children[index]?.weight ?? 1)) > WEIGHT_EPSILON)) return;
2090
- dispatch({
2091
- rowId: node.id,
2092
- type: "adjustSplit",
2093
- weights
2094
- });
2095
- };
2280
+ const total = node.children.reduce((sum, child) => sum + child.weight, 0);
2281
+ const { beginBoundaryDrag, groupRef, onLayoutChange, onLayoutChanged, snappedBoundary } = useSnapResize({
2282
+ children: node.children,
2283
+ onWeightsSettled: (weights) => {
2284
+ dispatch({
2285
+ rowId: node.id,
2286
+ type: "adjustSplit",
2287
+ weights
2288
+ });
2289
+ },
2290
+ resizableSplits,
2291
+ snap: effectiveSnap
2292
+ });
2096
2293
  return /* @__PURE__ */ jsx(Group, {
2097
2294
  "data-dashfoo": "row",
2295
+ "data-dashfoo-snapping": snappedBoundary === null ? void 0 : "true",
2098
2296
  disabled: !resizableSplits,
2099
- elementRef: groupElement,
2100
2297
  groupRef,
2101
- onLayoutChange: snapActive ? handleLayoutChange : void 0,
2102
- onLayoutChanged: handleLayoutChanged,
2298
+ onLayoutChange: snapActive ? onLayoutChange : void 0,
2299
+ onLayoutChanged,
2103
2300
  orientation,
2104
2301
  style: groupStyle,
2105
2302
  children: node.children.map((child, index) => {
2106
- const percent = (child.weight ?? 1) / total * 100;
2303
+ const percent = child.weight / total * 100;
2107
2304
  let min = child.min ? dimensionToSize(child.min) : void 0;
2108
2305
  if (min === void 0) {
2109
2306
  const minimum = descendantMinSize(child, node.orientation, tabsetMinSize);
@@ -2115,7 +2312,7 @@ const RowView = ({ node, renderTabset }) => {
2115
2312
  "data-dashfoo-snapped": snappedBoundary === index - 1 ? "true" : void 0,
2116
2313
  disabled: !resizableSplits,
2117
2314
  onPointerDown: snapActive && resizableSplits ? () => {
2118
- activeBoundary.current = index - 1;
2315
+ beginBoundaryDrag(index - 1);
2119
2316
  } : void 0
2120
2317
  }) : null, /* @__PURE__ */ jsx(Panel$1, {
2121
2318
  defaultSize: `${percent}%`,
@@ -2159,11 +2356,12 @@ const bodyStyle = {
2159
2356
  minHeight: 0,
2160
2357
  position: "relative"
2161
2358
  };
2162
- const CHIP_SIZE = {
2163
- height: 34,
2164
- width: 168
2359
+ const FLOAT_OVERRIDES = {
2360
+ draggableTabsets: false,
2361
+ floatable: false,
2362
+ maximizable: false,
2363
+ maximizedTabsetId: void 0
2165
2364
  };
2166
- const TAP_SLOP = 4;
2167
2365
  const FloatTitleEditor = ({ dispatch, node, onDone }) => {
2168
2366
  const inputRef = useRef(null);
2169
2367
  const { handleBlur, handleKeyDown } = useInlineRename({
@@ -2211,25 +2409,10 @@ const FloatTitle = ({ dispatch, node, renamable }) => {
2211
2409
  children: floatTitle(node)
2212
2410
  });
2213
2411
  };
2214
- const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2412
+ const FloatPanel = ({ node, onFocus, zIndex }) => {
2215
2413
  const dispatch = useLayout((state) => state.dispatch);
2216
- const closableTabs = useLayout((state) => state.closableTabs);
2217
- const draggableTabs = useLayout((state) => state.draggableTabs);
2218
2414
  const editable = useLayout((state) => state.editable);
2219
- const renamableTabs = useLayout((state) => state.renamableTabs);
2220
- const resizableSplits = useLayout((state) => state.resizableSplits);
2221
- const keepMounted = useLayout((state) => state.keepMounted);
2222
- const renderTab = useLayout((state) => state.renderTab);
2223
- const renderTabLabel = useLayout((state) => state.renderTabLabel);
2224
- const renderTabsetToolbar = useLayout((state) => state.renderTabsetToolbar);
2225
- const snap = useLayout((state) => state.snap);
2226
- const panelRef = useRef(null);
2227
- const gestureRef = useRef(null);
2228
- const latestRef = useRef(node.geometry);
2229
- const movedRef = useRef(false);
2230
- const setPanel = (element) => {
2231
- panelRef.current = element;
2232
- };
2415
+ const minimized = node.minimized === true;
2233
2416
  const restore = () => {
2234
2417
  if (!editable) return;
2235
2418
  dispatch({
@@ -2238,88 +2421,30 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2238
2421
  type: "setFloatMinimized"
2239
2422
  });
2240
2423
  };
2241
- const handlePointerDown = (event) => {
2242
- const panel = panelRef.current;
2243
- if (!panel || !editable) return;
2244
- const prior = gestureRef.current;
2245
- if (prior && panel.hasPointerCapture?.(prior.pointerId)) panel.releasePointerCapture(prior.pointerId);
2246
- movedRef.current = false;
2247
- const edgeKey = event.currentTarget.dataset.edge;
2248
- const parent = panel.offsetParent instanceof HTMLElement ? panel.offsetParent : null;
2249
- const gesture = {
2250
- bounds: {
2251
- height: parent?.clientHeight ?? 0,
2252
- width: parent?.clientWidth ?? 0
2253
- },
2254
- edges: edgeKey === void 0 ? null : EDGE_BY_KEY.get(edgeKey) ?? null,
2255
- pointerId: event.pointerId,
2256
- start: node.geometry,
2257
- startX: event.clientX,
2258
- startY: event.clientY
2259
- };
2260
- gestureRef.current = gesture;
2261
- latestRef.current = node.geometry;
2262
- if (gesture.edges) panel.setPointerCapture(event.pointerId);
2263
- };
2264
- const handlePointerUp = (event) => {
2265
- const gesture = gestureRef.current;
2266
- if (!gesture || event.pointerId !== gesture.pointerId) return;
2267
- gestureRef.current = null;
2268
- const panel = panelRef.current;
2269
- if (panel?.hasPointerCapture?.(event.pointerId) === true) panel.releasePointerCapture(event.pointerId);
2270
- if (!movedRef.current) {
2271
- if (node.minimized === true) restore();
2272
- return;
2273
- }
2274
- dispatch({
2275
- floatId: node.id,
2276
- geometry: latestRef.current,
2277
- type: "moveFloat"
2278
- });
2279
- };
2280
- const handlePointerMove = (event) => {
2281
- const gesture = gestureRef.current;
2282
- const panel = panelRef.current;
2283
- if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
2284
- if (event.buttons === 0) {
2285
- handlePointerUp(event);
2286
- return;
2287
- }
2288
- const dx = event.clientX - gesture.startX;
2289
- const dy = event.clientY - gesture.startY;
2290
- if (!movedRef.current) {
2291
- if (Math.hypot(dx, dy) <= TAP_SLOP) return;
2292
- movedRef.current = true;
2293
- if (!gesture.edges) panel.setPointerCapture(gesture.pointerId);
2294
- }
2295
- const next = gesture.edges ? resizeRect(gesture.start, gesture.edges, dx, dy) : clampToBounds({
2296
- ...gesture.start,
2297
- left: gesture.start.left + dx,
2298
- top: gesture.start.top + dy
2299
- }, gesture.bounds, node.minimized === true ? CHIP_SIZE : void 0);
2300
- latestRef.current = next;
2301
- panel.style.left = `${next.left}px`;
2302
- panel.style.top = `${next.top}px`;
2303
- if (node.minimized !== true) {
2304
- panel.style.width = `${next.width}px`;
2305
- panel.style.height = `${next.height}px`;
2306
- }
2307
- };
2308
- const handlePointerCancel = (event) => {
2309
- const gesture = gestureRef.current;
2310
- const panel = panelRef.current;
2311
- if (!gesture || !panel || event.pointerId !== gesture.pointerId) return;
2312
- gestureRef.current = null;
2313
- if (panel.hasPointerCapture?.(event.pointerId)) panel.releasePointerCapture(event.pointerId);
2314
- if (!movedRef.current) return;
2315
- panel.style.left = `${gesture.start.left}px`;
2316
- panel.style.top = `${gesture.start.top}px`;
2317
- if (node.minimized !== true) {
2318
- panel.style.width = `${gesture.start.width}px`;
2319
- panel.style.height = `${gesture.start.height}px`;
2424
+ const { handlers, ref, style } = useFloatGesture({
2425
+ editable,
2426
+ geometry: node.geometry,
2427
+ minimized,
2428
+ onCommit: (geometry) => {
2429
+ dispatch({
2430
+ floatId: node.id,
2431
+ geometry,
2432
+ type: "moveFloat"
2433
+ });
2434
+ },
2435
+ onTap: () => {
2436
+ if (minimized) restore();
2320
2437
  }
2438
+ });
2439
+ const { onPointerDown, ...rootHandlers } = handlers;
2440
+ const frameStyle = {
2441
+ ...style,
2442
+ pointerEvents: "auto",
2443
+ position: "absolute",
2444
+ zIndex
2321
2445
  };
2322
- if (node.minimized === true) return /* @__PURE__ */ jsxs("button", {
2446
+ if (minimized) return /* @__PURE__ */ jsxs("button", {
2447
+ ...rootHandlers,
2323
2448
  "aria-label": `Restore ${floatTitle(node)} panel`,
2324
2449
  "data-dashfoo": "float-chip",
2325
2450
  onKeyDown: (event) => {
@@ -2328,19 +2453,10 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2328
2453
  restore();
2329
2454
  }
2330
2455
  },
2331
- onPointerCancel: handlePointerCancel,
2332
- onPointerDown: handlePointerDown,
2456
+ onPointerDown,
2333
2457
  onPointerDownCapture: onFocus,
2334
- onPointerMove: handlePointerMove,
2335
- onPointerUp: handlePointerUp,
2336
- ref: setPanel,
2337
- style: {
2338
- left: node.geometry.left,
2339
- pointerEvents: "auto",
2340
- position: "absolute",
2341
- top: node.geometry.top,
2342
- zIndex
2343
- },
2458
+ ref,
2459
+ style: frameStyle,
2344
2460
  title: floatTitle(node),
2345
2461
  type: "button",
2346
2462
  children: [/* @__PURE__ */ jsx(FloatIcon, {}), /* @__PURE__ */ jsx("span", {
@@ -2348,31 +2464,16 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2348
2464
  children: floatTitle(node)
2349
2465
  })]
2350
2466
  });
2351
- const floatModel = {
2352
- global,
2353
- layout: node.layout,
2354
- version: 1
2355
- };
2356
2467
  return /* @__PURE__ */ jsxs("div", {
2468
+ ...rootHandlers,
2357
2469
  "data-dashfoo": "float",
2358
- onPointerCancel: handlePointerCancel,
2359
2470
  onPointerDownCapture: onFocus,
2360
- onPointerMove: handlePointerMove,
2361
- onPointerUp: handlePointerUp,
2362
- ref: setPanel,
2363
- style: {
2364
- height: node.geometry.height,
2365
- left: node.geometry.left,
2366
- pointerEvents: "auto",
2367
- position: "absolute",
2368
- top: node.geometry.top,
2369
- width: node.geometry.width,
2370
- zIndex
2371
- },
2471
+ ref,
2472
+ style: frameStyle,
2372
2473
  children: [
2373
2474
  /* @__PURE__ */ jsxs("div", {
2374
2475
  "data-dashfoo": "float-titlebar",
2375
- onPointerDown: handlePointerDown,
2476
+ onPointerDown,
2376
2477
  style: editable ? titleBarStyle : {
2377
2478
  ...titleBarStyle,
2378
2479
  cursor: "default"
@@ -2427,29 +2528,15 @@ const FloatPanel = ({ global, node, onFocus, zIndex }) => {
2427
2528
  /* @__PURE__ */ jsx("div", {
2428
2529
  "data-dashfoo": "float-body",
2429
2530
  style: bodyStyle,
2430
- children: /* @__PURE__ */ jsx(LayoutRoot, {
2431
- closableTabs,
2432
- dispatch,
2433
- draggableTabs,
2434
- draggableTabsets: false,
2435
- editable,
2436
- floatable: false,
2437
- keepMounted,
2438
- maximizable: false,
2439
- model: floatModel,
2440
- renamableTabs,
2441
- renderTab,
2442
- renderTabLabel,
2443
- renderTabsetToolbar,
2444
- resizableSplits,
2445
- snap: snap ?? void 0,
2531
+ children: /* @__PURE__ */ jsx(LayoutOverrides, {
2532
+ overrides: FLOAT_OVERRIDES,
2446
2533
  children: /* @__PURE__ */ jsx(DragProvider, { children: /* @__PURE__ */ jsx(RowView, { node: node.layout }) })
2447
2534
  })
2448
2535
  }),
2449
2536
  editable && RESIZE_HANDLES.map((handle) => /* @__PURE__ */ jsx("div", {
2450
2537
  "data-dashfoo": "float-resize",
2451
2538
  "data-edge": handle.key,
2452
- onPointerDown: handlePointerDown,
2539
+ onPointerDown,
2453
2540
  style: {
2454
2541
  position: "absolute",
2455
2542
  touchAction: "none",
@@ -2469,14 +2556,13 @@ const overlayStyle = {
2469
2556
  position: "fixed",
2470
2557
  zIndex: 1
2471
2558
  };
2472
- const FloatOverlay = ({ floats, global }) => {
2559
+ const FloatOverlay = ({ floats }) => {
2473
2560
  const [topId, setTopId] = useState(null);
2474
2561
  if (floats.length === 0) return null;
2475
2562
  return /* @__PURE__ */ jsx("div", {
2476
2563
  "data-dashfoo": "float-overlay",
2477
2564
  style: overlayStyle,
2478
2565
  children: floats.map((node) => /* @__PURE__ */ jsx(FloatPanel, {
2479
- global,
2480
2566
  node,
2481
2567
  onFocus: () => {
2482
2568
  setTopId(node.id);
@@ -2485,12 +2571,9 @@ const FloatOverlay = ({ floats, global }) => {
2485
2571
  }, node.id))
2486
2572
  });
2487
2573
  };
2488
- const FloatLayer = ({ children, floats, global }) => /* @__PURE__ */ jsxs(FloatLayerContext.Provider, {
2574
+ const FloatLayer = ({ children, floats }) => /* @__PURE__ */ jsxs(FloatLayerContext.Provider, {
2489
2575
  value: true,
2490
- children: [children, /* @__PURE__ */ jsx(FloatOverlay, {
2491
- floats,
2492
- global
2493
- })]
2576
+ children: [children, /* @__PURE__ */ jsx(FloatOverlay, { floats })]
2494
2577
  });
2495
2578
 
2496
2579
  //#endregion
@@ -2618,24 +2701,18 @@ const DashfooLayout = forwardRef((props, ref) => {
2618
2701
  responsive?.orientation,
2619
2702
  store.model
2620
2703
  ]);
2621
- const storeDispatch = store.dispatch;
2622
- const dispatch = useCallback((action) => {
2623
- if (isCompact && action.type === "adjustSplit") return;
2624
- storeDispatch(action);
2625
- }, [isCompact, storeDispatch]);
2626
2704
  const maximized = view.maximizedTabsetId === void 0 ? void 0 : findTabset(view, view.maximizedTabsetId);
2627
2705
  const tree = /* @__PURE__ */ jsx(Layout.FloatLayer, {
2628
- floats: store.model.floats ?? [],
2629
- global: store.model.global,
2706
+ floats: store.model.floats,
2630
2707
  children: /* @__PURE__ */ jsx(Layout.DragLayer, { children: maximized ? /* @__PURE__ */ jsx(Layout.Tabset, { node: maximized }) : /* @__PURE__ */ jsx(Layout.Rows, { node: view.layout }) })
2631
2708
  });
2632
2709
  return /* @__PURE__ */ jsx(Layout.Root, {
2633
2710
  closableTabs,
2634
- dispatch,
2635
- draggableTabs: isCompact ? false : draggableTabs,
2636
- draggableTabsets: isCompact ? false : draggableTabsets,
2711
+ dispatch: store.dispatch,
2712
+ draggableTabs,
2713
+ draggableTabsets,
2637
2714
  editable,
2638
- floatable: isCompact ? false : floatable,
2715
+ floatable,
2639
2716
  keepMounted,
2640
2717
  maximizable,
2641
2718
  model: view,
@@ -2643,7 +2720,8 @@ const DashfooLayout = forwardRef((props, ref) => {
2643
2720
  renderTab,
2644
2721
  renderTabLabel,
2645
2722
  renderTabsetToolbar,
2646
- resizableSplits: isCompact ? false : resizableSplits,
2723
+ resizableSplits,
2724
+ restructurable: !isCompact,
2647
2725
  rootRef: containerRef,
2648
2726
  snap,
2649
2727
  children: hasSharedManager ? tree : /* @__PURE__ */ jsx(DashfooDragProvider, { children: tree })