@deepseek-ai/dsh-client-ui-dockkit 0.2.1-rc.4 → 0.2.2-alpha.2

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",
@@ -2370,10 +2468,12 @@ function TabTitle({ children }) {
2370
2468
  * A chip is a capsule carrying one control, its close, shown over its right
2371
2469
  * end while the chip is active, hovered, or focused; the context menu
2372
2470
  * (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.
2471
+ * A middle click anywhere on the chip, including its close control, closes
2472
+ * without selecting the tab, unless another mouse button is still held.
2473
+ * The embedder's `canCloseTab`
2474
+ * governs all close routes. A pane's lone chip whose close is withheld draws
2475
+ * quiet — no capsule, no hover fill — since there is nothing to select against
2476
+ * and nothing to do to it.
2377
2477
  * Between neighbouring chips sits a slot: a fixed-width box drawing a
2378
2478
  * hairline, blank beside the active chip, and the drop caret when a drag
2379
2479
  * targets that index, so a caret never widens the row; the two end slots
@@ -2525,28 +2625,68 @@ function useStripScrollFades(box, tabs) {
2525
2625
  }, [box, tabs]);
2526
2626
  }
2527
2627
  /**
2628
+ * Move the chip box to `left` at once, overriding the stylesheet's smooth
2629
+ * `scroll-behavior` for this one assignment.
2630
+ */
2631
+ function jumpTo(element, left) {
2632
+ element.style.scrollBehavior = "auto";
2633
+ element.scrollLeft = left;
2634
+ element.style.removeProperty("scroll-behavior");
2635
+ }
2636
+ /**
2528
2637
  * Bring the active chip into the chip box's view whenever the active tab or
2529
2638
  * the row of chips changes: a tab opened or selected past the box's edge, or
2530
2639
  * moved there by a close or a reorder, scrolls the box to it, with the fade
2531
2640
  * band (24px) cleared so the chip is not under it. A chip already in view
2532
2641
  * moves nothing. Direct DOM, like the fades above: the box's scroll position
2533
2642
  * renders nothing.
2534
- */
2535
- function useActiveChipInView(box, chips, tabs, activeTabId) {
2643
+ *
2644
+ * A box newly mounted, or newly showing another pane, first jumps to that
2645
+ * pane's offset in `offsets`, so a strip that replaces another for the same
2646
+ * pane continues from where that one stood and glides only the remaining
2647
+ * distance. With no recorded offset the first placement jumps rather than
2648
+ * glides from the row's start. The box records its offset in `offsets` when
2649
+ * it unmounts or leaves its pane.
2650
+ */
2651
+ function useActiveChipInView(box, chips, tabs, activeTabId, paneId, offsets) {
2652
+ const placed = useRef(void 0);
2653
+ useLayoutEffect(() => {
2654
+ if (offsets === void 0) return void 0;
2655
+ const element = box.current;
2656
+ /* v8 ignore next -- the box is rendered unconditionally with the strip. */
2657
+ if (element === null) return void 0;
2658
+ return () => {
2659
+ offsets.set(paneId, element.scrollLeft);
2660
+ };
2661
+ }, [
2662
+ box,
2663
+ paneId,
2664
+ offsets
2665
+ ]);
2536
2666
  useLayoutEffect(() => {
2537
2667
  const element = box.current;
2538
2668
  const chip = activeTabId === void 0 ? void 0 : chips.get(activeTabId);
2539
2669
  /* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */
2540
2670
  if (element === null || chip === void 0) return;
2671
+ const first = placed.current !== paneId;
2672
+ placed.current = paneId;
2673
+ const recorded = first ? offsets?.get(paneId) : void 0;
2674
+ if (recorded !== void 0) jumpTo(element, recorded);
2541
2675
  const bounds = element.getBoundingClientRect();
2542
2676
  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;
2677
+ let delta = 0;
2678
+ if (rect.left < bounds.left) delta = rect.left - bounds.left - STRIP_FADE;
2679
+ else if (rect.right > bounds.right) delta = rect.right - bounds.right + STRIP_FADE;
2680
+ if (delta === 0) return;
2681
+ if (first && recorded === void 0) jumpTo(element, element.scrollLeft + delta);
2682
+ else element.scrollLeft += delta;
2545
2683
  }, [
2546
2684
  box,
2547
2685
  chips,
2548
2686
  tabs,
2549
- activeTabId
2687
+ activeTabId,
2688
+ paneId,
2689
+ offsets
2550
2690
  ]);
2551
2691
  }
2552
2692
  /** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */
@@ -2558,13 +2698,13 @@ function splitBlockedTitle(labels, block) {
2558
2698
  case "width": return labels.splitPaneNarrow;
2559
2699
  }
2560
2700
  }
2561
- /** @param props - pane state and gestures. @returns its tab strip without a content container. */
2562
- function TabStrip({ state, pane, callbacks }) {
2701
+ /** @param props - pane state, gestures and shared offsets. @returns its tab strip without a content container. */
2702
+ function TabStrip({ state, pane, callbacks, offsets }) {
2563
2703
  const [menu, setMenu] = useState(void 0);
2564
2704
  const [chips] = useState(() => /* @__PURE__ */ new Map());
2565
2705
  const stripTabs = useRef(null);
2566
2706
  useStripScrollFades(stripTabs, pane.tabs);
2567
- useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId);
2707
+ useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId, pane.id, offsets);
2568
2708
  const block = callbacks.splitBlock(pane.id);
2569
2709
  const target = callbacks.dropTarget;
2570
2710
  const stripIndex = target !== void 0 && target.kind === "strip" && target.paneId === pane.id ? target.index : void 0;
@@ -2610,9 +2750,18 @@ function TabStrip({ state, pane, callbacks }) {
2610
2750
  else chips.set(tabId, element);
2611
2751
  },
2612
2752
  onPointerDown: (event) => {
2613
- if (event.button === 2) return;
2753
+ if (event.button !== 0) return;
2614
2754
  callbacks.onTabPressed(tabId, event);
2615
2755
  },
2756
+ onMouseDown: (event) => {
2757
+ if (event.button === 1) event.preventDefault();
2758
+ },
2759
+ onAuxClick: (event) => {
2760
+ if (event.button !== 1) return;
2761
+ event.preventDefault();
2762
+ event.stopPropagation();
2763
+ if (closable && event.buttons === 0) callbacks.onCloseTab(tabId);
2764
+ },
2616
2765
  onClick: (event) => {
2617
2766
  event.stopPropagation();
2618
2767
  activate(tabId);
@@ -2709,9 +2858,13 @@ function TabStrip({ state, pane, callbacks }) {
2709
2858
  shortcutKeys: callbacks.labels.splitPaneKeys,
2710
2859
  side: "bottom",
2711
2860
  delayMs: 500,
2861
+ openOnClick: block !== void 0,
2712
2862
  children: jsx("span", {
2713
2863
  tabIndex: block === void 0 ? void 0 : 0,
2714
2864
  "aria-label": block === void 0 ? void 0 : splitBlockedTitle(callbacks.labels, block),
2865
+ onClick: (event) => {
2866
+ if (block !== void 0) event.stopPropagation();
2867
+ },
2715
2868
  children: jsx("button", {
2716
2869
  type: "button",
2717
2870
  className: css.iconButton,
@@ -2781,7 +2934,10 @@ function TabPanel({ state, pane, callbacks }) {
2781
2934
  children: [active === void 0 ? jsx("p", {
2782
2935
  className: css.empty,
2783
2936
  children: callbacks.labels.emptyPane
2784
- }) : callbacks.renderTab(active), jsx(PaneDropHints, {
2937
+ }) : jsx(TabBody, {
2938
+ tab: active,
2939
+ renderTab: callbacks.renderTab
2940
+ }), jsx(PaneDropHints, {
2785
2941
  pane,
2786
2942
  callbacks
2787
2943
  })]
@@ -2828,6 +2984,72 @@ function PaneTree({ state, nodeId, callbacks, preview }) {
2828
2984
  });
2829
2985
  }
2830
2986
  //#endregion
2987
+ //#region lib/types/engine/spring.js
2988
+ /**
2989
+ * The spring a floating panel follows back inside the viewport after a release
2990
+ * past an edge. Pure and clock-free: the caller supplies each frame's elapsed
2991
+ * time, so trajectories are deterministic for a given sequence of frames.
2992
+ */
2993
+ /** Seconds the spring takes for one undamped period; sets its stiffness. */
2994
+ const RESPONSE = .32;
2995
+ /** Below 1, so the return overshoots slightly inward (about 3.5%), which always stays legal. */
2996
+ const DAMPING_RATIO = .72;
2997
+ const OMEGA = 2 * Math.PI / RESPONSE;
2998
+ /** Fixed integration step, so the motion does not depend on the frame rate. */
2999
+ const SUBSTEP = 1 / 480;
3000
+ /** Position and speed below which an axis counts as at rest. */
3001
+ const REST_DISTANCE = 1;
3002
+ const REST_SPEED = 60;
3003
+ /** Fastest release velocity a return starts with, in pixels per second. */
3004
+ const SPRING_MAX_VELOCITY = 1500;
3005
+ /**
3006
+ * Advance one axis by an elapsed time, in equal substeps no longer than `SUBSTEP`
3007
+ * (semi-implicit Euler).
3008
+ * @param axis - the axis state.
3009
+ * @param target - the position the spring pulls toward.
3010
+ * @param dtSeconds - elapsed time, already clamped by the caller.
3011
+ * @returns the advanced state.
3012
+ */
3013
+ function stepSpring(axis, target, dtSeconds) {
3014
+ const steps = Math.max(1, Math.ceil(dtSeconds / SUBSTEP));
3015
+ const h = dtSeconds / steps;
3016
+ let { position, velocity } = axis;
3017
+ for (let step = 0; step < steps; step++) {
3018
+ velocity += (-OMEGA * OMEGA * (position - target) - 2 * DAMPING_RATIO * OMEGA * velocity) * h;
3019
+ position += velocity * h;
3020
+ }
3021
+ return {
3022
+ position,
3023
+ velocity
3024
+ };
3025
+ }
3026
+ /**
3027
+ * The velocity an axis starts its return with. An axis already at its target
3028
+ * keeps still. Outward speed is capped at `omega0 * (reach - |offset|)`: the
3029
+ * spring carries a start offset `d` and speed `v` at most `d + 0.46 * v / omega0`
3030
+ * from its target, so the whole return stays within `reach`.
3031
+ * @param position - where the axis is drawn at release.
3032
+ * @param target - the position the spring pulls toward.
3033
+ * @param velocity - the drawn release velocity, in pixels per second.
3034
+ * @param reach - the farthest the return may be from the target.
3035
+ * @returns the starting velocity.
3036
+ */
3037
+ function launchVelocity(position, target, velocity, reach) {
3038
+ const offset = position - target;
3039
+ if (offset === 0) return 0;
3040
+ if (Math.sign(velocity) !== Math.sign(offset)) return velocity;
3041
+ return Math.sign(offset) * Math.min(Math.abs(velocity), OMEGA * Math.max(0, reach - Math.abs(offset)));
3042
+ }
3043
+ /**
3044
+ * Whether an axis has come to rest at its target.
3045
+ * @param axis - the axis state.
3046
+ * @param target - the spring's target.
3047
+ * @returns true within `REST_DISTANCE` of the target and slower than `REST_SPEED`.
3048
+ */
3049
+ function springSettled(axis, target) {
3050
+ return Math.abs(axis.position - target) <= REST_DISTANCE && Math.abs(axis.velocity) <= REST_SPEED;
3051
+ }
3052
+ //#endregion
2831
3053
  //#region lib/types/components/FloatLayer.js
2832
3054
  /**
2833
3055
  * The floating layer: one overlay panel per floating pane, bottom-to-top in the
@@ -2844,7 +3066,7 @@ function PaneTree({ state, nodeId, callbacks, preview }) {
2844
3066
  * only `state` and the outward contracts. An embedder may portal it anywhere,
2845
3067
  * and nothing here assumes the docked tree is an ancestor or even present.
2846
3068
  */
2847
- /** The rectangle a gesture has reached. */
3069
+ /** The rectangle the pointer travel asks for, before the viewport has its say. */
2848
3070
  function draggedRect(drag, x, y) {
2849
3071
  const dx = x - drag.originX;
2850
3072
  const dy = y - drag.originY;
@@ -2859,57 +3081,244 @@ function raised(state, paneId) {
2859
3081
  return state.activePaneId === paneId && state.floats.at(-1) === paneId;
2860
3082
  }
2861
3083
  /**
3084
+ * The viewport region panels stay inside. It reads the window size and the
3085
+ * root's caption attributes only, so starting a gesture never forces layout.
3086
+ */
3087
+ function floatBounds() {
3088
+ const root = document.documentElement;
3089
+ const windows = root.hasAttribute("data-windows-titlebar");
3090
+ const caption = windows && !root.hasAttribute("data-fullscreen") ? Number.parseFloat(root.style.getPropertyValue("--dsh-windows-titlebar-height")) : 0;
3091
+ return {
3092
+ top: windows ? (Number.isFinite(caption) ? caption : 0) + 20 : 0,
3093
+ width: window.innerWidth,
3094
+ height: window.innerHeight
3095
+ };
3096
+ }
3097
+ /** Whether the platform asks for reduced motion; environments without `matchMedia` (jsdom) report no preference. */
3098
+ function reducedMotion() {
3099
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3100
+ }
3101
+ /** How much recent drawing history the release velocity is measured over. */
3102
+ const VELOCITY_WINDOW_MS = 100;
3103
+ /** Append a drawn position and drop the samples more than `VELOCITY_WINDOW_MS` older than it. */
3104
+ function sampleDrawn(samples, t, rect) {
3105
+ samples.push({
3106
+ t,
3107
+ x: rect.x,
3108
+ y: rect.y
3109
+ });
3110
+ while (t - samples[0].t > VELOCITY_WINDOW_MS) samples.shift();
3111
+ }
3112
+ /**
3113
+ * Drawn panel velocity over the kept samples, capped per axis, in pixels per
3114
+ * second. It follows the drawn panel rather than the pointer, so the speed stays
3115
+ * continuous across a release where the resistance slows the panel.
3116
+ */
3117
+ function releaseVelocity(samples) {
3118
+ const first = samples[0];
3119
+ const last = samples[samples.length - 1];
3120
+ if (last.t === first.t) return {
3121
+ x: 0,
3122
+ y: 0
3123
+ };
3124
+ const rate = (travel) => Math.max(-1500, Math.min(SPRING_MAX_VELOCITY, travel / (last.t - first.t) * 1e3));
3125
+ return {
3126
+ x: rate(last.x - first.x),
3127
+ y: rate(last.y - first.y)
3128
+ };
3129
+ }
3130
+ /**
3131
+ * Inline position of a floating panel. A gesture or return draws it exactly at
3132
+ * `rect`. At rest the panel only publishes `rect` to the `.float` rule, which
3133
+ * clamps it into the viewport and below the caption floor, so a stale viewport
3134
+ * reading in `useFloatGestures` still leaves the panel inside.
3135
+ * @param rect - the rectangle to draw.
3136
+ * @param lifted - whether a gesture or return is drawing the panel.
3137
+ * @returns the panel's inline position and size.
3138
+ */
3139
+ function floatStyle(rect, lifted) {
3140
+ if (lifted) return {
3141
+ left: rect.x,
3142
+ top: rect.y,
3143
+ width: rect.width,
3144
+ height: rect.height
3145
+ };
3146
+ return {
3147
+ "--dsh-dockkit-float-x": `${rect.x}px`,
3148
+ "--dsh-dockkit-float-y": `${rect.y}px`,
3149
+ "--dsh-dockkit-float-width": `${rect.width}px`,
3150
+ "--dsh-dockkit-float-height": `${rect.height}px`
3151
+ };
3152
+ }
3153
+ /**
2862
3154
  * Shared move/resize gestures; changing layout mode never owns the content DOM.
3155
+ * Panels draw at their stored rectangle projected into the viewport, so a
3156
+ * shrinking window pushes them in without recording anything. A move past an
3157
+ * edge draws with resistance; its release records the legal rectangle and the
3158
+ * panel springs there from where it was drawn.
2863
3159
  * @param state - committed layout and float stacking order.
2864
3160
  * @param intents - gesture settlements.
2865
- * @returns the current preview and focus/gesture callbacks.
3161
+ * @returns the current preview, the rectangle to draw per panel, and focus/gesture callbacks.
2866
3162
  */
2867
3163
  function useFloatGestures(state, intents) {
2868
3164
  const [preview, setPreview] = useState(void 0);
3165
+ const [bounds, setBounds] = useState(floatBounds);
3166
+ const area = useRef(bounds);
3167
+ const latest = useRef(state);
3168
+ latest.current = state;
3169
+ const settling = useRef(void 0);
2869
3170
  const begin = useGesture(() => {
2870
3171
  setPreview(void 0);
2871
3172
  });
3173
+ /** Stop a running return where it is; reports whether one was running. */
3174
+ const cancelSettling = useRef(() => {
3175
+ const running = settling.current;
3176
+ if (running === void 0) return false;
3177
+ cancelAnimationFrame(running.frame);
3178
+ settling.current = void 0;
3179
+ return true;
3180
+ }).current;
3181
+ useEffect(() => {
3182
+ const resize = () => {
3183
+ if (cancelSettling()) setPreview(void 0);
3184
+ area.current = floatBounds();
3185
+ setBounds(area.current);
3186
+ };
3187
+ window.addEventListener("resize", resize);
3188
+ return () => {
3189
+ window.removeEventListener("resize", resize);
3190
+ cancelSettling();
3191
+ };
3192
+ }, [cancelSettling]);
3193
+ /**
3194
+ * One animation frame of a return: advance the spring, or end it at its
3195
+ * target. It also ends once the panel's record no longer projects to the
3196
+ * target, as after an undo, a dock or a close, so the release's `moveFloat`
3197
+ * or `resizeFloat` must reach `state` before the first frame.
3198
+ */
3199
+ const frame = () => {
3200
+ const running = settling.current;
3201
+ const now = performance.now();
3202
+ const gap = now - running.last;
3203
+ running.last = now;
3204
+ const layout = latest.current;
3205
+ if (layout.floats.includes(running.paneId) && sameRect(legalFloatRect(floatRect(getPane(layout, running.paneId)), area.current), running.target) && gap <= 250 && now - running.started < 900) {
3206
+ const dt = Math.min(gap, 64) / 1e3;
3207
+ running.x = stepSpring(running.x, running.target.x, dt);
3208
+ running.y = stepSpring(running.y, running.target.y, dt);
3209
+ if (!springSettled(running.x, running.target.x) || !springSettled(running.y, running.target.y)) {
3210
+ setPreview({
3211
+ paneId: running.paneId,
3212
+ rect: {
3213
+ ...running.target,
3214
+ x: running.x.position,
3215
+ y: running.y.position
3216
+ }
3217
+ });
3218
+ running.frame = requestAnimationFrame(frame);
3219
+ return;
3220
+ }
3221
+ }
3222
+ settling.current = void 0;
3223
+ setPreview(void 0);
3224
+ };
3225
+ /** Spring a panel from where it was drawn to its recorded rectangle, never farther than `FLOAT_RESISTANCE` outside it. */
3226
+ const settle = (paneId, from, target, velocity) => {
3227
+ const axis = (position, goal, speed) => ({
3228
+ position,
3229
+ velocity: launchVelocity(position, goal, speed, 120)
3230
+ });
3231
+ const now = performance.now();
3232
+ settling.current = {
3233
+ paneId,
3234
+ target,
3235
+ started: now,
3236
+ last: now,
3237
+ x: axis(from.x, target.x, velocity.x),
3238
+ y: axis(from.y, target.y, velocity.y),
3239
+ frame: requestAnimationFrame(frame)
3240
+ };
3241
+ setPreview({
3242
+ paneId,
3243
+ rect: from
3244
+ });
3245
+ };
2872
3246
  /** Focus and raise a panel from a press or click on it, unless it is raised already. */
2873
3247
  const raise = (paneId) => {
2874
3248
  if (raised(state, paneId)) return;
2875
3249
  intents.focusPane(paneId);
2876
3250
  };
2877
- /** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */
3251
+ /**
3252
+ * Where a panel draws: its gesture or return preview, otherwise its stored
3253
+ * rectangle projected into the viewport.
3254
+ * @param paneId - the floating pane.
3255
+ * @param stored - its recorded rectangle.
3256
+ * @returns the rectangle to draw.
3257
+ */
3258
+ const rectOf = (paneId, stored) => preview?.paneId === paneId ? preview.rect : legalFloatRect(stored, bounds);
3259
+ /**
3260
+ * Start a move or resize from a press on the panel's grip or corner. A release
3261
+ * that reaches the same legal rectangle as the press is a click and only raises,
3262
+ * also for a panel drawn projected from its record.
3263
+ */
2878
3264
  const drag = (mode, paneId, event) => {
2879
3265
  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
- };
3266
+ const drawn = settling.current?.paneId === paneId ? preview?.rect : void 0;
3267
+ if (cancelSettling() && drawn === void 0) setPreview(void 0);
3268
+ area.current = floatBounds();
3269
+ const reduce = reducedMotion();
3270
+ const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current);
3271
+ const rect = mode === "move" && !reduce ? unresistedRect(shown, area.current) : shown;
2888
3272
  const start = {
2889
3273
  mode,
2890
3274
  originX: event.clientX,
2891
3275
  originY: event.clientY,
2892
3276
  rect
2893
3277
  };
3278
+ /** The rectangle to draw for a pointer position, and the legal one a release there records. */
3279
+ const reached = (x, y) => {
3280
+ const desired = draggedRect(start, x, y);
3281
+ if (mode === "resize") {
3282
+ const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE);
3283
+ const legal = legalFloatRect(resized, area.current);
3284
+ return {
3285
+ drawn: reduce ? legal : resized,
3286
+ legal
3287
+ };
3288
+ }
3289
+ const legal = legalFloatRect(desired, area.current);
3290
+ return {
3291
+ drawn: reduce ? legal : resistedRect(desired, area.current),
3292
+ legal
3293
+ };
3294
+ };
3295
+ const pressed = reached(event.clientX, event.clientY);
3296
+ const samples = [];
3297
+ sampleDrawn(samples, performance.now(), pressed.drawn);
2894
3298
  begin(event.currentTarget, event.pointerId, {
2895
3299
  move: (moved) => {
3300
+ const { drawn } = reached(moved.clientX, moved.clientY);
3301
+ sampleDrawn(samples, performance.now(), drawn);
2896
3302
  setPreview({
2897
3303
  paneId,
2898
- rect: draggedRect(start, moved.clientX, moved.clientY)
3304
+ rect: drawn
2899
3305
  });
2900
3306
  },
2901
3307
  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);
3308
+ const { drawn, legal } = reached(released.clientX, released.clientY);
3309
+ sampleDrawn(samples, performance.now(), drawn);
3310
+ if (sameRect(legal, pressed.legal)) raise(paneId);
3311
+ else if (mode === "move") intents.moveFloat(paneId, legal.x, legal.y);
3312
+ else intents.resizeFloat(paneId, legal);
3313
+ if (!sameRect(drawn, legal)) settle(paneId, drawn, legal, releaseVelocity(samples));
2906
3314
  }
2907
3315
  });
2908
3316
  };
2909
3317
  return {
2910
3318
  preview,
2911
3319
  raise,
2912
- drag
3320
+ drag,
3321
+ rectOf
2913
3322
  };
2914
3323
  }
2915
3324
  /**
@@ -2973,22 +3382,19 @@ function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canCloseTab
2973
3382
  }
2974
3383
  /** Every floating panel, in z order. */
2975
3384
  function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }) {
2976
- const { preview, raise, drag } = useFloatGestures(state, intents);
3385
+ const { preview, raise, drag, rectOf } = useFloatGestures(state, intents);
2977
3386
  return jsx(Fragment, { children: state.floats.map((paneId, depth) => {
2978
3387
  const pane = getPane(state, paneId);
2979
3388
  const tab = getTab(state, onlyTabId(pane));
2980
3389
  const lifted = preview?.paneId === paneId ? preview.rect : void 0;
2981
- const live = lifted ?? floatRect(pane);
3390
+ const live = rectOf(paneId, floatRect(pane));
2982
3391
  return jsxs("div", {
2983
3392
  className: css.float,
2984
3393
  "data-dockkit-float": paneId,
2985
3394
  tabIndex: -1,
2986
3395
  "data-dockkit-float-active": state.activePaneId === paneId || void 0,
2987
3396
  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,
3397
+ ...floatStyle(live, lifted !== void 0),
2992
3398
  zIndex: lifted === void 0 ? depth + 1 : state.floats.length + 1
2993
3399
  },
2994
3400
  onPointerDown: () => {
@@ -3006,7 +3412,10 @@ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canClos
3006
3412
  }),
3007
3413
  jsx("div", {
3008
3414
  className: css.floatBody,
3009
- children: renderTab(tab)
3415
+ children: jsx(TabBody, {
3416
+ tab,
3417
+ renderTab
3418
+ })
3010
3419
  }),
3011
3420
  jsx("div", {
3012
3421
  className: css.floatResize,
@@ -3023,7 +3432,7 @@ function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canClos
3023
3432
  //#region lib/types/components/TabLayout.js
3024
3433
  /** Stable tab siblings in one horizontal Grid, including viewport-positioned floats. */
3025
3434
  /** 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 }) {
3435
+ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, offsets, keepMounted, active = true }) {
3027
3436
  const floating = pane.host === "float";
3028
3437
  const selected = floating || pane.activeTabId === tab.id;
3029
3438
  const visible = active && selected && (floating || state.expanded);
@@ -3067,7 +3476,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3067
3476
  callbacks
3068
3477
  ]);
3069
3478
  const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : void 0;
3070
- const rect = floating ? lifted ?? floatRect(pane) : void 0;
3479
+ const rect = floating ? floats.rectOf(pane.id, floatRect(pane)) : void 0;
3071
3480
  const depth = lifted === void 0 ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1;
3072
3481
  return jsx("div", {
3073
3482
  className: clsx(css.tabCell, floating && css.floatingCell),
@@ -3090,12 +3499,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3090
3499
  "data-dockkit-float": floating ? pane.id : void 0,
3091
3500
  "data-dockkit-float-active": floating && state.activePaneId === pane.id || void 0,
3092
3501
  "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
- },
3502
+ style: rect === void 0 ? void 0 : floatStyle(rect, lifted !== void 0),
3099
3503
  onPointerDown: () => {
3100
3504
  if (floating) floats.raise(pane.id);
3101
3505
  },
@@ -3116,13 +3520,17 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3116
3520
  }) : jsx(TabStrip, {
3117
3521
  state,
3118
3522
  pane,
3119
- callbacks
3523
+ callbacks,
3524
+ offsets
3120
3525
  }))
3121
3526
  }),
3122
3527
  jsxs("div", {
3123
3528
  ref: body,
3124
3529
  className: clsx(css.tabHostBody, floating ? css.floatBody : css.paneBody),
3125
- children: [visited && (retained || active && selected) ? callbacks.renderTab(tab) : null, !floating && jsx(PaneDropHints, {
3530
+ children: [visited && (retained || active && selected) ? jsx(TabBody, {
3531
+ tab,
3532
+ renderTab: callbacks.renderTab
3533
+ }) : null, !floating && jsx(PaneDropHints, {
3126
3534
  pane,
3127
3535
  callbacks
3128
3536
  })]
@@ -3147,6 +3555,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
3147
3555
  function TabLayout(props) {
3148
3556
  const { state, callbacks, preview } = props;
3149
3557
  const focusRequest = useRef();
3558
+ const [offsets] = useState(() => /* @__PURE__ */ new Map());
3150
3559
  const tabCallbacks = {
3151
3560
  ...callbacks,
3152
3561
  onFocusTab: (tabId) => {
@@ -3177,7 +3586,8 @@ function TabLayout(props) {
3177
3586
  pane,
3178
3587
  column: panes.findIndex((candidate) => candidate.id === pane.id),
3179
3588
  floats,
3180
- focusRequest
3589
+ focusRequest,
3590
+ offsets
3181
3591
  }, tab.id);
3182
3592
  }),
3183
3593
  panes.filter((pane) => pane.tabs.length === 0).map((pane) => jsx("div", {
@@ -3216,8 +3626,8 @@ function TabLayout(props) {
3216
3626
  *
3217
3627
  * A gesture only previews until it ends, then leaves through one intent, so the
3218
3628
  * 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.
3629
+ * clear of this surface floats it; releasing inside it without an effective
3630
+ * drop target changes nothing.
3221
3631
  */
3222
3632
  const NO_PREVIEW = {
3223
3633
  draggingTabId: void 0,
@@ -3231,18 +3641,26 @@ const ALWAYS = () => true;
3231
3641
  /**
3232
3642
  * Resolve where a pointer sits inside the docked surface. An edge zone is only
3233
3643
  * 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.
3644
+ * with room for two halves. Strip slots that preserve the dragged tab's position
3645
+ * offer no caret or placement intent. An undefined target inside the surface
3646
+ * leaves the layout unchanged on release.
3235
3647
  */
3236
- function hitTest(root, x, y, canSplit, fits, dropZones) {
3648
+ function hitTest(root, x, y, tabId, canSplit, fits, dropZones) {
3237
3649
  for (const [paneId, pane] of paneElements(root)) {
3238
3650
  const rect = pane.getBoundingClientRect();
3239
3651
  if (!containsPoint(rect, x, y)) continue;
3240
3652
  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
- };
3653
+ if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) {
3654
+ const tabs = [...strip.querySelectorAll("[data-dockkit-tab]")];
3655
+ const index = insertionIndex(tabs.map((tab) => tab.getBoundingClientRect()), x);
3656
+ const sourceIndex = tabs.findIndex((tab) => tab.dataset.dockkitTab === tabId);
3657
+ if (sourceIndex >= 0 && (index === sourceIndex || index === sourceIndex + 1)) return void 0;
3658
+ return {
3659
+ kind: "strip",
3660
+ paneId,
3661
+ index
3662
+ };
3663
+ }
3246
3664
  const zone = dropZones === "horizontal" ? canSplit && fitOf(fits, paneId).row ? x < rect.x + rect.width / 2 ? "left" : "right" : "center" : zoneInRect(rect, x, y);
3247
3665
  if (zone !== "center") {
3248
3666
  const fit = fitOf(fits, paneId);
@@ -3334,12 +3752,12 @@ function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, ren
3334
3752
  setPreview({
3335
3753
  ...NO_PREVIEW,
3336
3754
  draggingTabId: tabId,
3337
- dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones)
3755
+ dropTarget: hitTest(root, moved.clientX, moved.clientY, tabId, canSplit, fits, dropZones)
3338
3756
  });
3339
3757
  },
3340
3758
  up: (released) => {
3341
3759
  if (!dragging) return;
3342
- const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones);
3760
+ const target = hitTest(root, released.clientX, released.clientY, tabId, canSplit, fits, dropZones);
3343
3761
  if (target === void 0) {
3344
3762
  if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return;
3345
3763
  intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE));