| 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 |