@deepseek-ai/dsh-client-ui-dockkit 0.2.1-rc.5 → 0.2.2-alpha.3

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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { Children, Fragment as Fragment$1, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Children, Fragment as Fragment$1, memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import clsx from "clsx";
4
4
  import { IconCloseFillRegular, IconCloseOutlineRegular, IconPanelLeftOutlineRegular, IconPlusOutlineRegular, MenuSurface, Tooltip, focusWithoutRing, modalSelector, observeComposition } from "@deepseek-ai/dsh-client-ui-primitives";
5
5
  import { createPortal } from "react-dom";
@@ -1328,6 +1328,89 @@ function resizedRect(rect, dx, dy, min) {
1328
1328
  height: Math.max(min.height, rect.height + dy)
1329
1329
  };
1330
1330
  }
1331
+ /** One axis clamped into `[min, max]`; an empty span pins it at `min`. */
1332
+ function clampAxis(value, min, max) {
1333
+ return Math.max(min, Math.min(value, max));
1334
+ }
1335
+ /**
1336
+ * The nearest rectangle that lies inside the bounds, per axis. A panel larger
1337
+ * than the bounds on an axis takes the bounds' size on it, so its header
1338
+ * controls and resize corner stay reachable.
1339
+ * @param rect - any rectangle, possibly outside the bounds.
1340
+ * @param bounds - the viewport region.
1341
+ * @returns the rectangle moved inside the bounds, its size capped by them.
1342
+ */
1343
+ function legalFloatRect(rect, bounds) {
1344
+ const width = Math.min(rect.width, bounds.width);
1345
+ const height = Math.min(rect.height, bounds.height - bounds.top);
1346
+ return {
1347
+ x: clampAxis(rect.x, 0, bounds.width - width),
1348
+ y: clampAxis(rect.y, bounds.top, bounds.height - height),
1349
+ width,
1350
+ height
1351
+ };
1352
+ }
1353
+ /** Displacement past an edge for a pointer overflow: slope 1 at the edge, approaching `FLOAT_RESISTANCE`. */
1354
+ function resist(overflow) {
1355
+ return Math.sign(overflow) * 120 * Math.abs(overflow) / (120 + Math.abs(overflow));
1356
+ }
1357
+ /**
1358
+ * Where a dragged panel draws when the pointer has taken it past an edge: the
1359
+ * legal rectangle plus a resisted share of the overflow on each axis.
1360
+ * @param desired - the rectangle the pointer travel asks for.
1361
+ * @param bounds - the viewport region.
1362
+ * @returns the drawn rectangle at the legal size; equal to `desired` while it is legal.
1363
+ */
1364
+ function resistedRect(desired, bounds) {
1365
+ const legal = legalFloatRect(desired, bounds);
1366
+ return {
1367
+ ...legal,
1368
+ x: legal.x + resist(desired.x - legal.x),
1369
+ y: legal.y + resist(desired.y - legal.y)
1370
+ };
1371
+ }
1372
+ /** The largest displacement `unresistedRect` inverts; `resist` only approaches `FLOAT_RESISTANCE`. */
1373
+ const MAX_INVERTIBLE = 120 * .99;
1374
+ /**
1375
+ * Pointer overflow that `resist` maps to a displacement, the displacement capped
1376
+ * at `MAX_INVERTIBLE`. A panel drawn further out than that is picked up at the
1377
+ * cap, which moves it inward by less than 1.2 px.
1378
+ */
1379
+ function unresist(displacement) {
1380
+ const shown = Math.min(Math.abs(displacement), MAX_INVERTIBLE);
1381
+ return Math.sign(displacement) * 120 * shown / (120 - shown);
1382
+ }
1383
+ /**
1384
+ * The rectangle a drag must start from so that zero pointer travel draws
1385
+ * `drawn`, the inverse of `resistedRect`: a press on a panel still outside the
1386
+ * bounds picks it up where it is.
1387
+ * @param drawn - where the panel is drawn now.
1388
+ * @param bounds - the viewport region.
1389
+ * @returns the start rectangle at the legal size; equal to `drawn` while it is legal.
1390
+ */
1391
+ function unresistedRect(drawn, bounds) {
1392
+ const legal = legalFloatRect(drawn, bounds);
1393
+ return {
1394
+ ...legal,
1395
+ x: legal.x + unresist(drawn.x - legal.x),
1396
+ y: legal.y + unresist(drawn.y - legal.y)
1397
+ };
1398
+ }
1399
+ /**
1400
+ * A resized rectangle whose bottom-right corner stays inside the bounds, never
1401
+ * below the minimum size.
1402
+ * @param rect - the resized rectangle; its origin may lie outside the bounds.
1403
+ * @param bounds - the viewport region.
1404
+ * @param min - smallest size the panel may take.
1405
+ * @returns the rectangle with its size capped by the bounds.
1406
+ */
1407
+ function boundedResize(rect, bounds, min) {
1408
+ return {
1409
+ ...rect,
1410
+ width: Math.max(min.width, Math.min(rect.width, bounds.width - rect.x)),
1411
+ height: Math.max(min.height, Math.min(rect.height, bounds.height - rect.y))
1412
+ };
1413
+ }
1331
1414
  /**
1332
1415
  * Where a panel should appear when a tab is dropped outside the docked area.
1333
1416
  * @param x - drop point x.
@@ -2222,6 +2305,18 @@ function useGesture(reset) {
2222
2305
  };
2223
2306
  }
2224
2307
  //#endregion
2308
+ //#region lib/types/components/TabBody.js
2309
+ /**
2310
+ * A tab's body from the embedder's renderer, shared by every layout. A layout
2311
+ * re-renders for its own previews (a float gesture or return, a divider or tab
2312
+ * drag) on every frame; the body is memoized on the renderer and the tab
2313
+ * record, so such a frame leaves it untouched.
2314
+ */
2315
+ /** One tab's body, re-rendered only when `renderTab` or `tab` is a new object. */
2316
+ const TabBody = memo(function TabBody({ tab, renderTab }) {
2317
+ return renderTab(tab);
2318
+ });
2319
+ //#endregion
2225
2320
  //#region lib/types/components/TabMenu.js
2226
2321
  /**
2227
2322
  * The per-tab context menu, opened by a secondary press on the chip. It carries
@@ -2308,6 +2403,9 @@ function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
2308
2403
  onClick: (event) => {
2309
2404
  event.stopPropagation();
2310
2405
  },
2406
+ onAuxClick: (event) => {
2407
+ event.stopPropagation();
2408
+ },
2311
2409
  children: [onClose !== void 0 && jsx("button", {
2312
2410
  type: "button",
2313
2411
  role: "menuitem",
@@ -2363,28 +2461,6 @@ function TabTitle({ children }) {
2363
2461
  //#endregion
2364
2462
  //#region lib/types/components/TabPanel.js
2365
2463
  /**
2366
- * One pane: its tab strip (drag source, drop target, split control) and the
2367
- * active tab's body with the dock preview overlay. Presentational; every gesture
2368
- * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`.
2369
- *
2370
- * A chip is a capsule carrying one control, its close, shown over its right
2371
- * end while the chip is active, hovered, or focused; the context menu
2372
- * (secondary press) carries the same close plus whatever the embedder appends.
2373
- * Both close routes draw only while the embedder's `canCloseTab` allows, and
2374
- * a pane's lone chip whose close is withheld draws quiet — no capsule, no
2375
- * hover fill — since there is nothing to select against and nothing to do to
2376
- * it.
2377
- * Between neighbouring chips sits a slot: a fixed-width box drawing a
2378
- * hairline, blank beside the active chip, and the drop caret when a drag
2379
- * targets that index, so a caret never widens the row; the two end slots
2380
- * exist only while targeted. The chips sit in their own box, the strip's one
2381
- * shrinking part: in a narrow pane their titles fade at the clipped edge down
2382
- * to the chip's floor and then the chips scroll there, keeping the active one
2383
- * in view, so the add control after them (drawn while the embedder's
2384
- * `canAddTab` allows), the pane's split control, and the embedder's chrome keep
2385
- * their width and their place at the strip's end.
2386
- */
2387
- /**
2388
2464
  * The ic_ds_panel_left_outline_16 frame alone: its outer and inner rounded
2389
2465
  * rectangles as one even-odd ring, without the divider. The glyphs below draw
2390
2466
  * inside it so they read as siblings of the panel controls beside them.
@@ -2525,28 +2601,68 @@ function useStripScrollFades(box, tabs) {
2525
2601
  }, [box, tabs]);
2526
2602
  }
2527
2603
  /**
2604
+ * Move the chip box to `left` at once, overriding the stylesheet's smooth
2605
+ * `scroll-behavior` for this one assignment.
2606
+ */
2607
+ function jumpTo(element, left) {
2608
+ element.style.scrollBehavior = "auto";
2609
+ element.scrollLeft = left;
2610
+ element.style.removeProperty("scroll-behavior");
2611
+ }
2612
+ /**
2528
2613
  * Bring the active chip into the chip box's view whenever the active tab or
2529
2614
  * the row of chips changes: a tab opened or selected past the box's edge, or
2530
2615
  * moved there by a close or a reorder, scrolls the box to it, with the fade
2531
2616
  * band (24px) cleared so the chip is not under it. A chip already in view
2532
2617
  * moves nothing. Direct DOM, like the fades above: the box's scroll position
2533
2618
  * renders nothing.
2534
- */
2535
- function useActiveChipInView(box, chips, tabs, activeTabId) {
2619
+ *
2620
+ * A box newly mounted, or newly showing another pane, first jumps to that
2621
+ * pane's offset in `offsets`, so a strip that replaces another for the same
2622
+ * pane continues from where that one stood and glides only the remaining
2623
+ * distance. With no recorded offset the first placement jumps rather than
2624
+ * glides from the row's start. The box records its offset in `offsets` when
2625
+ * it unmounts or leaves its pane.
2626
+ */
2627
+ function useActiveChipInView(box, chips, tabs, activeTabId, paneId, offsets) {
2628
+ const placed = useRef(void 0);
2629
+ useLayoutEffect(() => {
2630
+ if (offsets === void 0) return void 0;
2631
+ const element = box.current;
2632
+ /* v8 ignore next -- the box is rendered unconditionally with the strip. */
2633
+ if (element === null) return void 0;
2634
+ return () => {
2635
+ offsets.set(paneId, element.scrollLeft);
2636
+ };
2637
+ }, [
2638
+ box,
2639
+ paneId,
2640
+ offsets
2641
+ ]);
2536
2642
  useLayoutEffect(() => {
2537
2643
  const element = box.current;
2538
2644
  const chip = activeTabId === void 0 ? void 0 : chips.get(activeTabId);
2539
2645
  /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */
2540
2646
  if (element === null || chip === void 0) return;
2647
+ const first = placed.current !== paneId;
2648
+ placed.current = paneId;
2649
+ const recorded = first ? offsets?.get(paneId) : void 0;
2650
+ if (recorded !== void 0) jumpTo(element, recorded);
2541
2651
  const bounds = element.getBoundingClientRect();
2542
2652
  const rect = chip.getBoundingClientRect();
2543
- if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE;
2544
- else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE;
2653
+ let delta = 0;
2654
+ if (rect.left < bounds.left) delta = rect.left - bounds.left - STRIP_FADE;
2655
+ else if (rect.right > bounds.right) delta = rect.right - bounds.right + STRIP_FADE;
2656
+ if (delta === 0) return;
2657
+ if (first && recorded === void 0) jumpTo(element, element.scrollLeft + delta);
2658
+ else element.scrollLeft += delta;
2545
2659
  }, [
2546
2660
  box,
2547
2661
  chips,
2548
2662
  tabs,
2549
- activeTabId
2663
+ activeTabId,
2664
+ paneId,
2665
+ offsets
2550
2666
  ]);
2551
2667
  }
2552
2668
  /** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */
@@ -2558,13 +2674,13 @@ function splitBlockedTitle(labels, block) {
2558
2674
  case "width": return labels.splitPaneNarrow;
2559
2675
  }
2560
2676
  }
2561
- /** @param props - pane state and gestures. @returns its tab strip without a content container. */
2562
- function TabStrip({ state, pane, callbacks }) {
2677
+ /** @param props - pane state, gestures and shared offsets. @returns its tab strip without a content container. */
2678
+ function TabStrip({ state, pane, callbacks, offsets }) {
2563
2679
  const [menu, setMenu] = useState(void 0);
2564
2680
  const [chips] = useState(() => /* @__PURE__ */ new Map());
2565
2681
  const stripTabs = useRef(null);
2566
2682
  useStripScrollFades(stripTabs, pane.tabs);
2567
- useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId);
2683
+ useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId, pane.id, offsets);
2568
2684
  const block = callbacks.splitBlock(pane.id);
2569
2685
  const target = callbacks.dropTarget;
2570
2686
  const stripIndex = target !== void 0 && target.kind === "strip" && target.paneId === pane.id ? target.index : void 0;
@@ -2595,6 +2711,20 @@ function TabStrip({ state, pane, callbacks }) {
2595
2711
  const selected = tabId === pane.activeTabId;
2596
2712
  const closable = callbacks.canCloseTab(tabId);
2597
2713
  const quiet = !closable && pane.tabs.length === 1;
2714
+ const onKeyDown = (event) => {
2715
+ if (event.target !== event.currentTarget) return;
2716
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return;
2717
+ const next = chipToFocus(event.key, pane.tabs, tabId);
2718
+ if (next !== void 0) {
2719
+ event.preventDefault();
2720
+ focusChip(next);
2721
+ return;
2722
+ }
2723
+ if (selects(event.key)) {
2724
+ event.preventDefault();
2725
+ activate(tabId);
2726
+ }
2727
+ };
2598
2728
  return jsxs(Fragment$1, { children: [(index > 0 || stripIndex === index) && jsx("div", {
2599
2729
  className: clsx(css.slot, stripIndex === index && css.slotCaret),
2600
2730
  "data-dockkit-caret": stripIndex === index ? index : void 0
@@ -2610,39 +2740,41 @@ function TabStrip({ state, pane, callbacks }) {
2610
2740
  else chips.set(tabId, element);
2611
2741
  },
2612
2742
  onPointerDown: (event) => {
2613
- if (event.button === 2) return;
2743
+ if (event.button !== 0) return;
2614
2744
  callbacks.onTabPressed(tabId, event);
2615
2745
  },
2746
+ onMouseDown: (event) => {
2747
+ if (event.button === 1) event.preventDefault();
2748
+ },
2749
+ onAuxClick: (event) => {
2750
+ if (event.button !== 1) return;
2751
+ event.preventDefault();
2752
+ event.stopPropagation();
2753
+ if (closable && event.buttons === 0) callbacks.onCloseTab(tabId);
2754
+ },
2616
2755
  onClick: (event) => {
2617
2756
  event.stopPropagation();
2618
2757
  activate(tabId);
2619
2758
  },
2620
- onKeyDown: (event) => {
2621
- if (event.target !== event.currentTarget) return;
2622
- if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return;
2623
- const next = chipToFocus(event.key, pane.tabs, tabId);
2624
- if (next !== void 0) {
2625
- event.preventDefault();
2626
- focusChip(next);
2627
- return;
2628
- }
2629
- if (selects(event.key)) {
2759
+ ...callbacks.getTabTriggerProps?.(tab, pane.id, {
2760
+ onKeyDown,
2761
+ tabIndex: selected ? 0 : -1
2762
+ }) ?? {
2763
+ onKeyDown,
2764
+ onContextMenu: (event) => {
2630
2765
  event.preventDefault();
2631
- activate(tabId);
2766
+ event.stopPropagation();
2767
+ const anchor = event.currentTarget;
2768
+ setMenu((current) => current?.tabId === tabId ? void 0 : {
2769
+ tabId,
2770
+ anchor
2771
+ });
2632
2772
  }
2633
2773
  },
2634
- onContextMenu: (event) => {
2635
- event.preventDefault();
2636
- const anchor = event.currentTarget;
2637
- setMenu((current) => current?.tabId === tabId ? void 0 : {
2638
- tabId,
2639
- anchor
2640
- });
2641
- },
2642
2774
  children: [
2643
2775
  jsx(TabTitle, { children: callbacks.renderTabTitle?.(tab) ?? tab.title }),
2644
2776
  closable && jsx(Tooltip, {
2645
- disabled: menu !== void 0,
2777
+ disabled: menu !== void 0 || callbacks.tabMenuOpen === true,
2646
2778
  label: callbacks.labels.closeTab,
2647
2779
  shortcutKeys: callbacks.labels.closeTabKeys,
2648
2780
  side: "bottom",
@@ -2709,9 +2841,14 @@ function TabStrip({ state, pane, callbacks }) {
2709
2841
  shortcutKeys: callbacks.labels.splitPaneKeys,
2710
2842
  side: "bottom",
2711
2843
  delayMs: 500,
2844
+ openOnClick: block !== void 0,
2712
2845
  children: jsx("span", {
2846
+ "data-dockkit-split-control": true,
2713
2847
  tabIndex: block === void 0 ? void 0 : 0,
2714
2848
  "aria-label": block === void 0 ? void 0 : splitBlockedTitle(callbacks.labels, block),
2849
+ onClick: (event) => {
2850
+ if (block !== void 0) event.stopPropagation();
2851
+ },
2715
2852
  children: jsx("button", {
2716
2853
  type: "button",
2717
2854
  className: css.iconButton,
@@ -2781,7 +2918,10 @@ function TabPanel({ state, pane, callbacks }) {
2781
2918
  children: [active === void 0 ? jsx("p", {
2782
2919
  className: css.empty,
2783
2920
  children: callbacks.labels.emptyPane
2784
- }) : callbacks.renderTab(active), jsx(PaneDropHints, {
2921
+ }) : jsx(TabBody, {
2922
+ tab: active,
2923
+ renderTab: callbacks.renderTab
2924
+ }), jsx(PaneDropHints, {
2785
2925
  pane,
2786
2926
  callbacks
2787
2927
  })]
@@ -2828,6 +2968,72 @@ function PaneTree({ state, nodeId, callbacks, preview }) {
2828
2968
  });
2829
2969
  }
2830
2970
  //#endregion
2971
+ //#region lib/types/engine/spring.js
2972
+ /**
2973
+ * The spring a floating panel follows back inside the viewport after a release
2974
+ * past an edge. Pure and clock-free: the caller supplies each frame's elapsed
2975
+ * time, so trajectories are deterministic for a given sequence of frames.
2976
+ */
2977
+ /** Seconds the spring takes for one undamped period; sets its stiffness. */
2978
+ const RESPONSE = .32;
2979
+ /** Below 1, so the return overshoots slightly inward (about 3.5%), which always stays legal. */
2980
+ const DAMPING_RATIO = .72;
2981
+ const OMEGA = 2 * Math.PI / RESPONSE;
2982
+ /** Fixed integration step, so the motion does not depend on the frame rate. */
2983
+ const SUBSTEP = 1 / 480;
2984
+ /** Position and speed below which an axis counts as at rest. */
2985
+ const REST_DISTANCE = 1;
2986
+ const REST_SPEED = 60;
2987
+ /** Fastest release velocity a return starts with, in pixels per second. */
2988
+ const SPRING_MAX_VELOCITY = 1500;
2989
+ /**
2990
+ * Advance one axis by an elapsed time, in equal substeps no longer than `SUBSTEP`
2991
+ * (semi-implicit Euler).
2992
+ * @param axis - the axis state.
2993
+ * @param target - the position the spring pulls toward.
2994
+ * @param dtSeconds - elapsed time, already clamped by the caller.
2995
+ * @returns the advanced state.
2996
+ */
2997
+ function stepSpring(axis, target, dtSeconds) {
2998
+ const steps = Math.max(1, Math.ceil(dtSeconds / SUBSTEP));
2999
+ const h = dtSeconds / steps;
3000
+ let { position, velocity } = axis;
3001
+ for (let step = 0; step < steps; step++) {
3002
+ velocity += (-OMEGA * OMEGA * (position - target) - 2 * DAMPING_RATIO * OMEGA * velocity) * h;
3003
+ position += velocity * h;
3004
+ }
3005
+ return {
3006
+ position,
3007
+ velocity
3008
+ };
3009
+ }
3010
+ /**
3011
+ * The velocity an axis starts its return with. An axis already at its target
3012
+ * keeps still. Outward speed is capped at `omega0 * (reach - |offset|)`: the
3013
+ * spring carries a start offset `d` and speed `v` at most `d + 0.46 * v / omega0`
3014
+ * from its target, so the whole return stays within `reach`.
3015
+ * @param position - where the axis is drawn at release.
3016
+ * @param target - the position the spring pulls toward.
3017
+ * @param velocity - the drawn release velocity, in pixels per second.
3018
+ * @param reach - the farthest the return may be from the target.
3019
+ * @returns the starting velocity.
3020
+ */
3021
+ function launchVelocity(position, target, velocity, reach) {
3022
+ const offset = position - target;
3023
+ if (offset === 0) return 0;
3024
+ if (Math.sign(velocity) !== Math.sign(offset)) return velocity;
3025
+ return Math.sign(offset) * Math.min(Math.abs(velocity), OMEGA * Math.max(0, reach - Math.abs(offset)));
3026
+ }
3027
+ /**
3028
+ * Whether an axis has come to rest at its target.
3029
+ * @param axis - the axis state.
3030
+ * @param target - the spring's target.
3031
+ * @returns true within `REST_DISTANCE` of the target and slower than `REST_SPEED`.
3032
+ */
3033
+ function springSettled(axis, target) {
3034
+ return Math.abs(axis.position - target) <= REST_DISTANCE && Math.abs(axis.velocity) <= REST_SPEED;
3035
+ }
3036
+ //#endregion
2831
3037
  //#region lib/types/components/FloatLayer.js
2832
3038
  /**
2833
3039
  * The floating layer: one overlay panel per floating pane, bottom-to-top in the
@@ -2844,7 +3050,7 @@ function PaneTree({ state, nodeId, callbacks, preview }) {
2844
3050
  * only `state` and the outward contracts. An embedder may portal it anywhere,
2845
3051
  * and nothing here assumes the docked tree is an ancestor or even present.
2846
3052
  */
2847
- /** The rectangle a gesture has reached. */
3053
+ /** The rectangle the pointer travel asks for, before the viewport has its say. */
2848
3054
  function draggedRect(drag, x, y) {
2849
3055
  const dx = x - drag.originX;
2850
3056
  const dy = y - drag.originY;
@@ -2859,57 +3065,244 @@ function raised(state, paneId) {
2859
3065
  return state.activePaneId === paneId && state.floats.at(-1) === paneId;
2860
3066
  }
2861
3067
  /**
3068
+ * The viewport region panels stay inside. It reads the window size and the
3069
+ * root's caption attributes only, so starting a gesture never forces layout.
3070
+ */
3071
+ function floatBounds() {
3072
+ const root = document.documentElement;
3073
+ const windows = root.hasAttribute("data-windows-titlebar");
3074
+ const caption = windows && !root.hasAttribute("data-fullscreen") ? Number.parseFloat(root.style.getPropertyValue("--dsh-windows-titlebar-height")) : 0;
3075
+ return {
3076
+ top: windows ? (Number.isFinite(caption) ? caption : 0) + 20 : 0,
3077
+ width: window.innerWidth,
3078
+ height: window.innerHeight
3079
+ };
3080
+ }
3081
+ /** Whether the platform asks for reduced motion; environments without `matchMedia` (jsdom) report no preference. */
3082
+ function reducedMotion() {
3083
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3084
+ }
3085
+ /** How much recent drawing history the release velocity is measured over. */
3086
+ const VELOCITY_WINDOW_MS = 100;
3087
+ /** Append a drawn position and drop the samples more than `VELOCITY_WINDOW_MS` older than it. */
3088
+ function sampleDrawn(samples, t, rect) {
3089
+ samples.push({
3090
+ t,
3091
+ x: rect.x,
3092
+ y: rect.y
3093
+ });
3094
+ while (t - samples[0].t > VELOCITY_WINDOW_MS) samples.shift();
3095
+ }
3096
+ /**
3097
+ * Drawn panel velocity over the kept samples, capped per axis, in pixels per
3098
+ * second. It follows the drawn panel rather than the pointer, so the speed stays
3099
+ * continuous across a release where the resistance slows the panel.
3100
+ */
3101
+ function releaseVelocity(samples) {
3102
+ const first = samples[0];
3103
+ const last = samples[samples.length - 1];
3104
+ if (last.t === first.t) return {
3105
+ x: 0,
3106
+ y: 0
3107
+ };
3108
+ const rate = (travel) => Math.max(-1500, Math.min(SPRING_MAX_VELOCITY, travel / (last.t - first.t) * 1e3));
3109
+ return {
3110
+ x: rate(last.x - first.x),
3111
+ y: rate(last.y - first.y)
3112
+ };
3113
+ }
3114
+ /**
3115
+ * Inline position of a floating panel. A gesture or return draws it exactly at
3116
+ * `rect`. At rest the panel only publishes `rect` to the `.float` rule, which
3117
+ * clamps it into the viewport and below the caption floor, so a stale viewport
3118
+ * reading in `useFloatGestures` still leaves the panel inside.
3119
+ * @param rect - the rectangle to draw.
3120
+ * @param lifted - whether a gesture or return is drawing the panel.
3121
+ * @returns the panel's inline position and size.
3122
+ */
3123
+ function floatStyle(rect, lifted) {
3124
+ if (lifted) return {
3125
+ left: rect.x,
3126
+ top: rect.y,
3127
+ width: rect.width,
3128
+ height: rect.height
3129
+ };
3130
+ return {
3131
+ "--dsh-dockkit-float-x": `${rect.x}px`,
3132
+ "--dsh-dockkit-float-y": `${rect.y}px`,
3133
+ "--dsh-dockkit-float-width": `${rect.width}px`,
3134
+ "--dsh-dockkit-float-height": `${rect.height}px`
3135
+ };
3136
+ }
3137
+ /**
2862
3138
  * Shared move/resize gestures; changing layout mode never owns the content DOM.
3139
+ * Panels draw at their stored rectangle projected into the viewport, so a
3140
+ * shrinking window pushes them in without recording anything. A move past an
3141
+ * edge draws with resistance; its release records the legal rectangle and the
3142
+ * panel springs there from where it was drawn.
2863
3143
  * @param state - committed layout and float stacking order.
2864
3144
  * @param intents - gesture settlements.
2865
- * @returns the current preview and focus/gesture callbacks.
3145
+ * @returns the current preview, the rectangle to draw per panel, and focus/gesture callbacks.
2866
3146
  */
2867
3147
  function useFloatGestures(state, intents) {
2868
3148
  const [preview, setPreview] = useState(void 0);
3149
+ const [bounds, setBounds] = useState(floatBounds);
3150
+ const area = useRef(bounds);
3151
+ const latest = useRef(state);
3152
+ latest.current = state;
3153
+ const settling = useRef(void 0);
2869
3154
  const begin = useGesture(() => {
2870
3155
  setPreview(void 0);
2871
3156
  });
3157
+ /** Stop a running return where it is; reports whether one was running. */
3158
+ const cancelSettling = useRef(() => {
3159
+ const running = settling.current;
3160
+ if (running === void 0) return false;
3161
+ cancelAnimationFrame(running.frame);
3162
+ settling.current = void 0;
3163
+ return true;
3164
+ }).current;
3165
+ useEffect(() => {
3166
+ const resize = () => {
3167
+ if (cancelSettling()) setPreview(void 0);
3168
+ area.current = floatBounds();
3169
+ setBounds(area.current);
3170
+ };
3171
+ window.addEventListener("resize", resize);
3172
+ return () => {
3173
+ window.removeEventListener("resize", resize);
3174
+ cancelSettling();
3175
+ };
3176
+ }, [cancelSettling]);
3177
+ /**
3178
+ * One animation frame of a return: advance the spring, or end it at its
3179
+ * target. It also ends once the panel's record no longer projects to the
3180
+ * target, as after an undo, a dock or a close, so the release's `moveFloat`
3181
+ * or `resizeFloat` must reach `state` before the first frame.
3182
+ */
3183
+ const frame = () => {
3184
+ const running = settling.current;
3185
+ const now = performance.now();
3186
+ const gap = now - running.last;
3187
+ running.last = now;
3188
+ const layout = latest.current;
3189
+ if (layout.floats.includes(running.paneId) && sameRect(legalFloatRect(floatRect(getPane(layout, running.paneId)), area.current), running.target) && gap <= 250 && now - running.started < 900) {
3190
+ const dt = Math.min(gap, 64) / 1e3;
3191
+ running.x = stepSpring(running.x, running.target.x, dt);
3192
+ running.y = stepSpring(running.y, running.target.y, dt);
3193
+ if (!springSettled(running.x, running.target.x) || !springSettled(running.y, running.target.y)) {
3194
+ setPreview({
3195
+ paneId: running.paneId,
3196
+ rect: {
3197
+ ...running.target,
3198
+ x: running.x.position,
3199
+ y: running.y.position
3200
+ }
3201
+ });
3202
+ running.frame = requestAnimationFrame(frame);
3203
+ return;
3204
+ }
3205
+ }
3206
+ settling.current = void 0;
3207
+ setPreview(void 0);
3208
+ };
3209
+ /** Spring a panel from where it was drawn to its recorded rectangle, never farther than `FLOAT_RESISTANCE` outside it. */
3210
+ const settle = (paneId, from, target, velocity) => {
3211
+ const axis = (position, goal, speed) => ({
3212
+ position,
3213
+ velocity: launchVelocity(position, goal, speed, 120)
3214
+ });
3215
+ const now = performance.now();
3216
+ settling.current = {
3217
+ paneId,
3218
+ target,
3219
+ started: now,
3220
+ last: now,
3221
+ x: axis(from.x, target.x, velocity.x),
3222
+ y: axis(from.y, target.y, velocity.y),
3223
+ frame: requestAnimationFrame(frame)
3224
+ };
3225
+ setPreview({
3226
+ paneId,
3227
+ rect: from
3228
+ });
3229
+ };
2872
3230
  /** Focus and raise a panel from a press or click on it, unless it is raised already. */
2873
3231
  const raise = (paneId) => {
2874
3232
  if (raised(state, paneId)) return;
2875
3233
  intents.focusPane(paneId);
2876
3234
  };
2877
- /** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */
3235
+ /**
3236
+ * Where a panel draws: its gesture or return preview, otherwise its stored
3237
+ * rectangle projected into the viewport.
3238
+ * @param paneId - the floating pane.
3239
+ * @param stored - its recorded rectangle.
3240
+ * @returns the rectangle to draw.
3241
+ */
3242
+ const rectOf = (paneId, stored) => preview?.paneId === paneId ? preview.rect : legalFloatRect(stored, bounds);
3243
+ /**
3244
+ * Start a move or resize from a press on the panel's grip or corner. A release
3245
+ * that reaches the same legal rectangle as the press is a click and only raises,
3246
+ * also for a panel drawn projected from its record.
3247
+ */
2878
3248
  const drag = (mode, paneId, event) => {
2879
3249
  event.stopPropagation();
2880
- const root = document.documentElement;
2881
- const windows = root.hasAttribute("data-windows-titlebar");
2882
- const caption = windows && !root.hasAttribute("data-fullscreen") ? Number.parseFloat(root.style.getPropertyValue("--dsh-windows-titlebar-height")) : 0;
2883
- const saved = floatRect(getPane(state, paneId));
2884
- const rect = {
2885
- ...saved,
2886
- y: Math.max(saved.y, windows ? caption + 20 : 0)
2887
- };
3250
+ const drawn = settling.current?.paneId === paneId ? preview?.rect : void 0;
3251
+ if (cancelSettling() && drawn === void 0) setPreview(void 0);
3252
+ area.current = floatBounds();
3253
+ const reduce = reducedMotion();
3254
+ const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current);
3255
+ const rect = mode === "move" && !reduce ? unresistedRect(shown, area.current) : shown;
2888
3256
  const start = {
2889
3257
  mode,
2890
3258
  originX: event.clientX,
2891
3259
  originY: event.clientY,
2892
3260
  rect
2893
3261
  };
3262
+ /** The rectangle to draw for a pointer position, and the legal one a release there records. */
3263
+ const reached = (x, y) => {
3264
+ const desired = draggedRect(start, x, y);
3265
+ if (mode === "resize") {
3266
+ const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE);
3267
+ const legal = legalFloatRect(resized, area.current);
3268
+ return {
3269
+ drawn: reduce ? legal : resized,
3270
+ legal
3271
+ };
3272
+ }
3273
+ const legal = legalFloatRect(desired, area.current);
3274
+ return {
3275
+ drawn: reduce ? legal : resistedRect(desired, area.current),
3276
+ legal
3277
+ };
3278
+ };
3279
+ const pressed = reached(event.clientX, event.clientY);
3280
+ const samples = [];
3281
+ sampleDrawn(samples, performance.now(), pressed.drawn);
2894
3282
  begin(event.currentTarget, event.pointerId, {
2895
3283
  move: (moved) => {
3284
+ const { drawn } = reached(moved.clientX, moved.clientY);
3285
+ sampleDrawn(samples, performance.now(), drawn);
2896
3286
  setPreview({
2897
3287
  paneId,
2898
- rect: draggedRect(start, moved.clientX, moved.clientY)
3288
+ rect: drawn
2899
3289
  });
2900
3290
  },
2901
3291
  up: (released) => {
2902
- const rect = draggedRect(start, released.clientX, released.clientY);
2903
- if (sameRect(rect, start.rect)) raise(paneId);
2904
- else if (mode === "move") intents.moveFloat(paneId, rect.x, rect.y);
2905
- else intents.resizeFloat(paneId, rect);
3292
+ const { drawn, legal } = reached(released.clientX, released.clientY);
3293
+ sampleDrawn(samples, performance.now(), drawn);
3294
+ if (sameRect(legal, pressed.legal)) raise(paneId);
3295
+ else if (mode === "move") intents.moveFloat(paneId, legal.x, legal.y);
3296
+ else intents.resizeFloat(paneId, legal);
3297
+ if (!sameRect(drawn, legal)) settle(paneId, drawn, legal, releaseVelocity(samples));
2906
3298
  }
2907
3299
  });
2908
3300
  };
2909
3301
  return {
2910
3302
  preview,
2911
3303
  raise,
2912
- drag
3304
+ drag,
3305
+ rectOf
2913
3306
  };
2914
3307
  }
2915
3308
  /**
@@ -2973,22 +3366,19 @@ function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canCloseTab
2973
3366
  }
2974
3367
  /** Every floating panel, in z order. */
2975
3368
  function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }) {
2976
- const { preview, raise, drag } = useFloatGestures(state, intents);
3369
+ const { preview, raise, drag, rectOf } = useFloatGestures(state, intents);
2977
3370
  return jsx(Fragment, { children: state.floats.map((paneId, depth) => {
2978
3371
  const pane = getPane(state, paneId);
2979
3372
  const tab = getTab(state, onlyTabId(pane));
2980
3373
  const lifted = preview?.paneId === paneId ? preview.rect : void 0;
2981
- const live = lifted ?? floatRect(pane);
3374
+ const live = rectOf(paneId, floatRect(pane));
2982
3375
  return jsxs("div", {
2983
3376
  className: css.float,
2984
3377
  "data-dockkit-float": paneId,
2985
3378
  tabIndex: -1,
2986
3379
  "data-dockkit-float-active": state.activePaneId === paneId || void 0,
2987
3380
  style: {
2988
- left: live.x,
2989
- top: `max(var(--dsh-dockkit-float-top, 0px), ${live.y}px)`,
2990
- width: live.width,
2991
- height: live.height,
3381
+ ...floatStyle(live, lifted !== void 0),
2992
3382
  zIndex: lifted === void 0 ? depth + 1 : state.floats.length + 1
2993
3383
  },
2994
3384
  onPointerDown: () => {
@@ -3006,7 +3396,10 @@ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canClos
3006
3396
  }),
3007
3397
  jsx("div", {
3008
3398
  className: css.floatBody,
3009
- children: renderTab(tab)
3399
+ children: jsx(TabBody, {
3400
+ tab,
3401
+ renderTab
3402
+ })
3010
3403
  }),
3011
3404
  jsx("div", {
3012
3405
  className: css.floatResize,
@@ -3023,7 +3416,7 @@ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canClos
3023
3416
  //#region lib/types/components/TabLayout.js
3024
3417
  /** Stable tab siblings in one horizontal Grid, including viewport-positioned floats. */
3025
3418
  /** A tab's ancestors stay identical across selection, pane moves and floating. */
3026
- function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted, active = true }) {
3419
+ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, offsets, keepMounted, active = true }) {
3027
3420
  const floating = pane.host === "float";
3028
3421
  const selected = floating || pane.activeTabId === tab.id;
3029
3422
  const visible = active && selected && (floating || state.expanded);
@@ -3067,7 +3460,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3067
3460
  callbacks
3068
3461
  ]);
3069
3462
  const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : void 0;
3070
- const rect = floating ? lifted ?? floatRect(pane) : void 0;
3463
+ const rect = floating ? floats.rectOf(pane.id, floatRect(pane)) : void 0;
3071
3464
  const depth = lifted === void 0 ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1;
3072
3465
  return jsx("div", {
3073
3466
  className: clsx(css.tabCell, floating && css.floatingCell),
@@ -3090,12 +3483,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3090
3483
  "data-dockkit-float": floating ? pane.id : void 0,
3091
3484
  "data-dockkit-float-active": floating && state.activePaneId === pane.id || void 0,
3092
3485
  "data-dockkit-column": !floating ? column : void 0,
3093
- style: rect === void 0 ? void 0 : {
3094
- left: rect.x,
3095
- top: `max(var(--dsh-dockkit-float-top, 0px), ${rect.y}px)`,
3096
- width: rect.width,
3097
- height: rect.height
3098
- },
3486
+ style: rect === void 0 ? void 0 : floatStyle(rect, lifted !== void 0),
3099
3487
  onPointerDown: () => {
3100
3488
  if (floating) floats.raise(pane.id);
3101
3489
  },
@@ -3116,13 +3504,17 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3116
3504
  }) : jsx(TabStrip, {
3117
3505
  state,
3118
3506
  pane,
3119
- callbacks
3507
+ callbacks,
3508
+ offsets
3120
3509
  }))
3121
3510
  }),
3122
3511
  jsxs("div", {
3123
3512
  ref: body,
3124
3513
  className: clsx(css.tabHostBody, floating ? css.floatBody : css.paneBody),
3125
- children: [visited && (retained || active && selected) ? callbacks.renderTab(tab) : null, !floating && jsx(PaneDropHints, {
3514
+ children: [visited && (retained || active && selected) ? jsx(TabBody, {
3515
+ tab,
3516
+ renderTab: callbacks.renderTab
3517
+ }) : null, !floating && jsx(PaneDropHints, {
3126
3518
  pane,
3127
3519
  callbacks
3128
3520
  })]
@@ -3147,6 +3539,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3147
3539
  function TabLayout(props) {
3148
3540
  const { state, callbacks, preview } = props;
3149
3541
  const focusRequest = useRef();
3542
+ const [offsets] = useState(() => /* @__PURE__ */ new Map());
3150
3543
  const tabCallbacks = {
3151
3544
  ...callbacks,
3152
3545
  onFocusTab: (tabId) => {
@@ -3177,7 +3570,8 @@ function TabLayout(props) {
3177
3570
  pane,
3178
3571
  column: panes.findIndex((candidate) => candidate.id === pane.id),
3179
3572
  floats,
3180
- focusRequest
3573
+ focusRequest,
3574
+ offsets
3181
3575
  }, tab.id);
3182
3576
  }),
3183
3577
  panes.filter((pane) => pane.tabs.length === 0).map((pane) => jsx("div", {
@@ -3216,8 +3610,8 @@ function TabLayout(props) {
3216
3610
  *
3217
3611
  * A gesture only previews until it ends, then leaves through one intent, so the
3218
3612
  * embedder's operation sequence stays the single source of truth. Releasing a tab
3219
- * clear of this surface floats it; releasing inside it but on no pane is not a
3220
- * move at all.
3613
+ * clear of this surface floats it; releasing inside it without an effective
3614
+ * drop target changes nothing.
3221
3615
  */
3222
3616
  const NO_PREVIEW = {
3223
3617
  draggingTabId: void 0,
@@ -3231,18 +3625,26 @@ const ALWAYS = () => true;
3231
3625
  /**
3232
3626
  * Resolve where a pointer sits inside the docked surface. An edge zone is only
3233
3627
  * offered where the split it would make is allowed: within the pane budget and
3234
- * with room for two halves; otherwise the release is not a move at all.
3628
+ * with room for two halves. Strip slots that preserve the dragged tab's position
3629
+ * offer no caret or placement intent. An undefined target inside the surface
3630
+ * leaves the layout unchanged on release.
3235
3631
  */
3236
- function hitTest(root, x, y, canSplit, fits, dropZones) {
3632
+ function hitTest(root, x, y, tabId, canSplit, fits, dropZones) {
3237
3633
  for (const [paneId, pane] of paneElements(root)) {
3238
3634
  const rect = pane.getBoundingClientRect();
3239
3635
  if (!containsPoint(rect, x, y)) continue;
3240
3636
  const strip = pane.querySelector("[data-dockkit-strip]");
3241
- if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) return {
3242
- kind: "strip",
3243
- paneId,
3244
- index: insertionIndex([...strip.querySelectorAll("[data-dockkit-tab]")].map((tab) => tab.getBoundingClientRect()), x)
3245
- };
3637
+ if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) {
3638
+ const tabs = [...strip.querySelectorAll("[data-dockkit-tab]")];
3639
+ const index = insertionIndex(tabs.map((tab) => tab.getBoundingClientRect()), x);
3640
+ const sourceIndex = tabs.findIndex((tab) => tab.dataset.dockkitTab === tabId);
3641
+ if (sourceIndex >= 0 && (index === sourceIndex || index === sourceIndex + 1)) return void 0;
3642
+ return {
3643
+ kind: "strip",
3644
+ paneId,
3645
+ index
3646
+ };
3647
+ }
3246
3648
  const zone = dropZones === "horizontal" ? canSplit && fitOf(fits, paneId).row ? x < rect.x + rect.width / 2 ? "left" : "right" : "center" : zoneInRect(rect, x, y);
3247
3649
  if (zone !== "center") {
3248
3650
  const fit = fitOf(fits, paneId);
@@ -3272,7 +3674,7 @@ function sameSizes(a, b) {
3272
3674
  });
3273
3675
  }
3274
3676
  /** The split tree and the gestures over it. */
3275
- function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, draw, dropZones = "edges", minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false }) {
3677
+ function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, getTabTriggerProps, tabMenuOpen, chrome, onRoom, draw, dropZones = "edges", minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false }) {
3276
3678
  const surface = useRef(null);
3277
3679
  const [preview, setPreview] = useState(NO_PREVIEW);
3278
3680
  const [fits, setFits] = useState(NO_FITS);
@@ -3334,12 +3736,12 @@ function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, ren
3334
3736
  setPreview({
3335
3737
  ...NO_PREVIEW,
3336
3738
  draggingTabId: tabId,
3337
- dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones)
3739
+ dropTarget: hitTest(root, moved.clientX, moved.clientY, tabId, canSplit, fits, dropZones)
3338
3740
  });
3339
3741
  },
3340
3742
  up: (released) => {
3341
3743
  if (!dragging) return;
3342
- const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones);
3744
+ const target = hitTest(root, released.clientX, released.clientY, tabId, canSplit, fits, dropZones);
3343
3745
  if (target === void 0) {
3344
3746
  if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return;
3345
3747
  intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE));
@@ -3393,6 +3795,8 @@ function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, ren
3393
3795
  renderTab,
3394
3796
  renderTabTitle,
3395
3797
  renderTabMenuItems,
3798
+ getTabTriggerProps,
3799
+ tabMenuOpen,
3396
3800
  chromePaneId: topRightPaneId(state),
3397
3801
  chrome
3398
3802
  };