返回 DeepSeek-Reasonix
useDockTabDrag.ts
根目录 / desktop / frontend / src / lib / useDockTabDrag.ts
1 // Pointer-driven tab reordering for the dock tab strip, plus the strip's
2 // overflow detection. Lifted out of TabBar so the interaction state machine
3 // (press → threshold → live reorder → FLIP slide → teardown) is one unit and
4 // the component stays a view.
5 //
6 // The window listeners installed at drag start must read the *latest* handlers,
7 // not the render in which startTabDrag ran, so a stable pair of window
8 // callbacks forwards through refs.
9 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
10 import type { PointerEvent as ReactPointerEvent, RefObject } from "react";
11 import { useActivityBarStore, type TabItem } from "../store/activityBar";
12
13 // Matches .workbench-dock__tabs' theme CSS gap; the drag layout math inserts
14 // the slot between tabs with the same spacing so it never overlaps a neighbor.
15 const DOCK_TAB_GAP = 8;
16
17 interface DockTabDragInput {
18 tabs: TabItem[];
19 onActivate: (tabId: string) => void;
20 onMoveTab: (fromId: string, toId: string, side: "left" | "right") => void;
21 }
22
23 export function useDockTabDrag({ tabs, onActivate, onMoveTab }: DockTabDragInput) {
24 const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
25 // Mirrors draggingTabId but updates synchronously at drag start, so the
26 // pointer handlers branch correctly even before React repaints (a repaint
27 // delayed by the activation switch would otherwise keep resetting the
28 // previous-pointer anchor and edge crossings would never fire).
29 const draggingTabIdRef = useRef<string | null>(null);
30 const dragStartXRef = useRef(0);
31 const dragStartYRef = useRef(0);
32 const dragPressedTabRef = useRef<string | null>(null);
33 const dragMovedRef = useRef(false);
34 const dragOffsetRef = useRef(0);
35 const dragOffsetYRef = useRef(0);
36 // The floating ghost's position is written straight to its DOM transform
37 // (rAF-throttled) instead of React state: a setState on every pointermove
38 // re-renders the whole tab strip per frame, which is the drag jank. Reorder
39 // checks read refs, so they stay frame-accurate regardless.
40 const dragRafRef = useRef(0);
41 const floatingRef = useRef<HTMLDivElement | null>(null);
42 // The right-edge fade overlay must only appear when the strip actually
43 // overflows (every tab at its 85px min and still too wide). A width:
44 // max-content strip's right edge sits at the last tab, so an always-on
45 // overlay would fade the last tab even with plenty of room.
46 const tabsRef = useRef<HTMLDivElement | null>(null);
47 const [tabsOverflow, setTabsOverflow] = useState(false);
48 const dragBaseLeftRef = useRef(new Map<string, number>());
49 const dragBaseTopRef = useRef(new Map<string, number>());
50 const dragBaseWidthRef = useRef(new Map<string, number>());
51 // The floating ghost's anchor: the dragged tab's base position when the
52 // drag started. Reorders change dragBaseLeftRef (live layout math), but the
53 // ghost must keep following the pointer from where it started.
54 const dragStartLeftRef = useRef(0);
55 const dragStartTopRef = useRef(0);
56 const dragContainerLeftRef = useRef(0);
57 const dragContainerTopRef = useRef(0);
58 // Pointer X of the previous pointermove (container-relative), used to
59 // detect edge crossings instead of "inside the box" so reordering is
60 // symmetric: dragging back across the same edge swaps the tabs back.
61 const lastPointerXRef = useRef<number | null>(null);
62 // Last measured left of each rendered tab, for the FLIP slide animation:
63 // when tabs reorder (or are added/removed) the layout jumps instantly, so
64 // we pin each moved tab at its previous left via transform and let the
65 // transition glide it into place.
66 const prevTabLeftRef = useRef(new Map<string, number>());
67 // Pending FLIP release frames per element, so a new reorder cancels the
68 // previous release and no transform is ever left pinned (a stuck transform
69 // would make tabs look offset after unrelated re-renders).
70 const flipRafIdsRef = useRef(new Map<HTMLElement, number>());
71 const dragElRefs = useRef(new Map<string, HTMLDivElement>());
72 const suppressClickRef = useRef(false);
73
74 // React StrictMode replays mount effects in development; reset the guard so
75 // the replayed mount can still accept opener discoveries.
76 useEffect(() => {
77 return () => {
78 if (dragRafRef.current) {
79 cancelAnimationFrame(dragRafRef.current);
80 dragRafRef.current = 0;
81 }
82 };
83 }, []);
84
85 const clearDragState = useCallback(() => {
86 if (dragRafRef.current) {
87 cancelAnimationFrame(dragRafRef.current);
88 dragRafRef.current = 0;
89 }
90 setDraggingTabId(null);
91 draggingTabIdRef.current = null;
92 dragOffsetRef.current = 0;
93 dragOffsetYRef.current = 0;
94 dragPressedTabRef.current = null;
95 lastPointerXRef.current = null;
96 }, []);
97
98 const dragMoveHandlerRef = useRef<(event: PointerEvent) => void>(() => {});
99 const dragUpHandlerRef = useRef<() => void>(() => {});
100
101 const onWindowPointerMove = useCallback((event: PointerEvent) => {
102 dragMoveHandlerRef.current(event);
103 }, []);
104
105 const onWindowPointerUp = useCallback(() => {
106 dragUpHandlerRef.current();
107 }, []);
108
109 const startTabDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>, tabId: string) => {
110 if (event.button !== 0 || event.pointerType === "touch") return;
111 event.preventDefault();
112 dragPressedTabRef.current = tabId;
113 dragStartXRef.current = event.clientX;
114 dragStartYRef.current = event.clientY;
115 dragMovedRef.current = false;
116 // Capture each tab's layout position and width (no transform) so the
117 // floating layer and the slot both use stable, unshifted coordinates.
118 const container = dragElRefs.current.get(tabId)?.parentElement;
119 const containerRect = container?.getBoundingClientRect();
120 const baseLeft = new Map<string, number>();
121 const baseTop = new Map<string, number>();
122 const baseWidth = new Map<string, number>();
123 if (containerRect) {
124 for (const [id, el] of dragElRefs.current) {
125 const rect = el.getBoundingClientRect();
126 baseLeft.set(id, rect.left - containerRect.left);
127 baseTop.set(id, rect.top - containerRect.top);
128 baseWidth.set(id, el.offsetWidth);
129 }
130 }
131 dragBaseLeftRef.current = baseLeft;
132 dragBaseTopRef.current = baseTop;
133 dragBaseWidthRef.current = baseWidth;
134 dragContainerLeftRef.current = containerRect?.left ?? 0;
135 dragContainerTopRef.current = containerRect?.top ?? 0;
136 dragStartLeftRef.current = baseLeft.get(tabId) ?? 0;
137 dragStartTopRef.current = baseTop.get(tabId) ?? 0;
138 // Listen on the window so the gesture survives the dragged tab leaving
139 // the strip (its element is replaced by the slot mid-drag).
140 window.addEventListener("pointermove", onWindowPointerMove);
141 window.addEventListener("pointerup", onWindowPointerUp);
142 window.addEventListener("pointercancel", onWindowPointerUp);
143 }, [onWindowPointerMove, onWindowPointerUp]);
144
145 // After a live reorder the tabs' DOM order changed but React may not have
146 // repainted yet; rebuild the left coordinates from the store's new order so
147 // the pointer math stays correct frame-to-frame (widths never change).
148 const recomputeDragBase = useCallback(() => {
149 const order = useActivityBarStore.getState().tabs;
150 const left = new Map<string, number>();
151 let x = 0;
152 for (const tab of order) {
153 left.set(tab.id, x);
154 x += (dragBaseWidthRef.current.get(tab.id) ?? 0) + DOCK_TAB_GAP;
155 }
156 dragBaseLeftRef.current = left;
157 }, []);
158
159 // Live reorder: reordering triggers when the pointer CROSSES a neighbor
160 // tab's edge (enters its box from either side), not when it merely hovers
161 // inside — so dragging back across the same edge swaps the tabs back.
162 // The swap direction follows where the dragged tab currently sits relative
163 // to the crossed tab (behind → move before it; ahead → move after it).
164 // Tab widths are measured live (they flex-compress when the strip is tight).
165 const maybeReorder = useCallback((fromId: string, pointerX: number) => {
166 const order = useActivityBarStore.getState().tabs;
167 const dragIndex = order.findIndex((tab) => tab.id === fromId);
168 if (dragIndex < 0) return;
169 const previousX = lastPointerXRef.current;
170 lastPointerXRef.current = pointerX;
171 if (previousX === null) return;
172 for (let i = 0; i < order.length; i++) {
173 const tab = order[i];
174 if (tab.id === fromId) continue;
175 const otherLeft = dragBaseLeftRef.current.get(tab.id);
176 if (otherLeft === undefined) continue;
177 const otherRight = otherLeft + (dragBaseWidthRef.current.get(tab.id) ?? 0);
178 // Entered from the right (pointer crossed the tab's right edge) or from
179 // the left (crossed its left edge) since the previous move.
180 const crossedInto = (previousX >= otherRight && pointerX < otherRight)
181 || (previousX <= otherLeft && pointerX > otherLeft);
182 if (!crossedInto) continue;
183 const tabIndex = order.findIndex((entry) => entry.id === tab.id);
184 // Swap direction: the dragged tab sits behind the crossed tab
185 // (dragIndex > tabIndex) → move before it; ahead → move after it.
186 // This is what makes both crossing directions swap correctly.
187 const side: "left" | "right" = dragIndex > tabIndex ? "left" : "right";
188 const toId = tab.id;
189 if (toId === fromId) return;
190 const without = order.filter((entry) => entry.id !== fromId);
191 const targetIndex = without.findIndex((entry) => entry.id === toId);
192 const insertAt = side === "right" ? targetIndex + 1 : targetIndex;
193 const predicted = [...without];
194 predicted.splice(insertAt, 0, order[dragIndex]);
195 const predictedIndex = predicted.findIndex((entry) => entry.id === fromId);
196 if (predictedIndex === dragIndex) return;
197 onMoveTab(fromId, toId, side);
198 recomputeDragBase();
199 return;
200 }
201 }, [onMoveTab, recomputeDragBase]);
202
203 const handleWindowPointerMove = useCallback((event: PointerEvent) => {
204 const tabId = dragPressedTabRef.current;
205 if (!tabId) return;
206 const dx = event.clientX - dragStartXRef.current;
207 const dy = event.clientY - dragStartYRef.current;
208 // A plain click (press + release without moving) must not enter the
209 // dragging state: only cross the threshold before floating the tab.
210 if (draggingTabIdRef.current !== tabId) {
211 if (Math.abs(dx) <= 4 && Math.abs(dy) <= 4) return;
212 dragMovedRef.current = true;
213 suppressClickRef.current = true;
214 dragOffsetRef.current = dx;
215 lastPointerXRef.current = event.clientX - dragContainerLeftRef.current;
216 // Dragging a tab makes it the active one (same as a plain click would).
217 onActivate(tabId);
218 draggingTabIdRef.current = tabId;
219 // Baseline the FLIP positions at drag start so the first reorder
220 // animates too.
221 const dragStartPositions = new Map<string, number>();
222 for (const [id, el] of dragElRefs.current) dragStartPositions.set(id, el.offsetLeft);
223 prevTabLeftRef.current = dragStartPositions;
224 setDraggingTabId(tabId);
225 return;
226 }
227 dragOffsetRef.current = dx;
228 dragOffsetYRef.current = dy;
229 if (!dragRafRef.current) {
230 dragRafRef.current = requestAnimationFrame(() => {
231 dragRafRef.current = 0;
232 const el = floatingRef.current;
233 if (el) {
234 el.style.transform = `translate3d(${dragOffsetRef.current}px, ${dragOffsetYRef.current}px, 0)`;
235 }
236 });
237 }
238 // Live reorder while dragging: crossing a neighbor tab's edge moves the
239 // tab in the store so the strip reflows immediately.
240 maybeReorder(tabId, event.clientX - dragContainerLeftRef.current);
241 }, [maybeReorder, onActivate]);
242
243 const handleWindowPointerUp = useCallback(() => {
244 const tabId = dragPressedTabRef.current;
245 if (!tabId) return;
246 window.removeEventListener("pointermove", onWindowPointerMove);
247 window.removeEventListener("pointerup", onWindowPointerUp);
248 window.removeEventListener("pointercancel", onWindowPointerUp);
249 dragPressedTabRef.current = null;
250 // A click without motion never entered the dragging state, so the native
251 // click activation runs untouched. Otherwise the live reorder already
252 // settled the final order during pointermove — just tear the drag down.
253 if (draggingTabIdRef.current !== tabId) return;
254 clearDragState();
255 // Drag start set suppressClickRef to swallow the click that trails a
256 // release. If the pointer came up outside any tab (or the gesture was
257 // cancelled) no click fires, so clear the flag on the next tick — the
258 // trailing click, if any, has already been consumed by then, and a stale
259 // true would swallow the user's next real tab click.
260 window.setTimeout(() => {
261 suppressClickRef.current = false;
262 }, 0);
263 }, [clearDragState, onWindowPointerMove, onWindowPointerUp]);
264
265 // Keep the forwarding refs pointing at the current handlers every render.
266 dragMoveHandlerRef.current = handleWindowPointerMove;
267 dragUpHandlerRef.current = handleWindowPointerUp;
268
269 // Watch the strip for overflow (tab count / width / dock width changes) so
270 // the fade overlay only shows while content is actually clipped.
271 useEffect(() => {
272 const el = tabsRef.current;
273 if (!el) return;
274 const update = () => {
275 const next = el.scrollWidth > el.clientWidth + 1;
276 setTabsOverflow((prev) => (prev === next ? prev : next));
277 };
278 update();
279 const observer = new ResizeObserver(update);
280 observer.observe(el);
281 return () => observer.disconnect();
282 }, []);
283
284 // The ghost mounts at its base position; snap it to the pointer offset the
285 // moment it appears (the drag-start pointermove returned before the portal
286 // painted, so its offset is only in the refs here).
287 useLayoutEffect(() => {
288 if (draggingTabId && floatingRef.current) {
289 floatingRef.current.style.transform =
290 `translate3d(${dragOffsetRef.current}px, ${dragOffsetYRef.current}px, 0)`;
291 }
292 }, [draggingTabId]);
293
294 // FLIP slide: while dragging, after a reorder repaints, move each tab whose
295 // left changed back to its previous position (transition disabled), then
296 // release it on the next frame so the 120ms transform transition glides it
297 // into place. Dragged tabs render as slots (absent from dragElRefs), so the
298 // floating ghost is untouched. Only runs during a drag: activation clicks /
299 // file-tab label updates also change the tabs array but must never animate.
300 // Positions come from offsetLeft (layout, transform-free) so an in-flight
301 // or stuck transform can't poison the delta and re-trigger a phantom slide.
302 useLayoutEffect(() => {
303 if (!draggingTabIdRef.current) return;
304 const prev = prevTabLeftRef.current;
305 const next = new Map<string, number>();
306 for (const [id, el] of dragElRefs.current) {
307 const left = el.offsetLeft;
308 next.set(id, left);
309 const oldLeft = prev.get(id);
310 if (oldLeft === undefined || Math.abs(oldLeft - left) < 1) continue;
311 const delta = oldLeft - left;
312 const pending = flipRafIdsRef.current.get(el);
313 if (pending !== undefined) cancelAnimationFrame(pending);
314 el.style.transition = "none";
315 el.style.transform = `translateX(${delta}px)`;
316 const rafId = requestAnimationFrame(() => {
317 flipRafIdsRef.current.delete(el);
318 el.style.transition = "";
319 el.style.transform = "";
320 });
321 flipRafIdsRef.current.set(el, rafId);
322 }
323 prevTabLeftRef.current = next;
324 }, [tabs]);
325
326 // Unmount: release any pinned FLIP transforms so tabs never stay offset.
327 useEffect(() => {
328 const flipRafs = flipRafIdsRef.current;
329 return () => {
330 for (const rafId of flipRafs.values()) cancelAnimationFrame(rafId);
331 for (const el of flipRafs.keys()) {
332 el.style.transition = "";
333 el.style.transform = "";
334 }
335 flipRafs.clear();
336 };
337 }, []);
338
339 // Drop the window listeners if the component unmounts mid-gesture (the
340 // dragged tab's element is replaced by the slot, so listeners can outlive
341 // the tab that started them).
342 useEffect(() => {
343 return () => {
344 window.removeEventListener("pointermove", onWindowPointerMove);
345 window.removeEventListener("pointerup", onWindowPointerUp);
346 window.removeEventListener("pointercancel", onWindowPointerUp);
347 };
348 }, [onWindowPointerMove, onWindowPointerUp]);
349
350 return {
351 draggingTabId,
352 dragElRefs,
353 floatingRef: floatingRef as RefObject<HTMLDivElement | null>,
354 tabsRef: tabsRef as RefObject<HTMLDivElement | null>,
355 tabsOverflow,
356 suppressClickRef,
357 startTabDrag,
358 clearDragState,
359 // The floating ghost anchors at the dragged tab's base rect; both parts
360 // are stable for the duration of the gesture.
361 ghost: {
362 left: dragContainerLeftRef.current + dragStartLeftRef.current,
363 top: dragContainerTopRef.current + dragStartTopRef.current,
364 width: dragBaseWidthRef.current.get(draggingTabId ?? "") ?? 0,
365 },
366 /** Width the dragged tab held before the drag, for its placeholder slot. */
367 slotWidthFor: (tabId: string) => dragBaseWidthRef.current.get(tabId) ?? 0,
368 };
369 }
370
370 lines TYPESCRIPT