From c28248cd673189c3bc88d1363c4424d87e8c102a Mon Sep 17 00:00:00 2001 From: Weiki Date: Thu, 3 Sep 2026 18:10:26 +0800 Subject: [PATCH] fix(tabs): prevent trackpad taps from starting tab drag --- .../src/components/layout/AppTabBar.vue | 2 +- .../composables/__tests__/useTabDrag.spec.ts | 76 +++++++++++++++++-- apps/desktop/src/composables/useTabDrag.ts | 61 +++++++++++---- 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index c44d6a85f6..6747b0f26b 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -722,7 +722,7 @@ function handleTabMouseDown(event: PointerEvent, tabId: string) { // Don't preventDefault touch pointerdowns: that would cancel the tab // strip's native horizontal scroll, which is how touch users browse an // overflowing tab bar. - if (event.pointerType !== "touch") event.preventDefault(); + if (event.pointerType !== "touch" && (event.buttons & 1) === 1) event.preventDefault(); } if (isManualTabOrder.value) tabDrag.startDrag(event, tabId); } diff --git a/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts b/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts index ea685e7dd8..121f05af72 100644 --- a/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts +++ b/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts @@ -12,20 +12,20 @@ function createDragHarness(onDrop = vi.fn(() => true)) { return { drag, source, onDrop }; } -function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse") { - source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: x, clientY: y, pointerType })); +function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) { + source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons, clientX: x, clientY: y, pointerId, pointerType })); } -function movePointer(x: number, y = 20, pointerType = "mouse") { - document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: x, clientY: y, pointerType })); +function movePointer(x: number, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) { + document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons, clientX: x, clientY: y, pointerId, pointerType })); } -function releasePointer(x = 0, y = 0) { - document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, screenX: x, screenY: y })); +function releasePointer(x = 0, y = 0, pointerId = 1) { + document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId, screenX: x, screenY: y })); } -function cancelPointer() { - document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true })); +function cancelPointer(pointerId = 1) { + document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId })); } function enterDropTarget(drag: ReturnType, tabId = "target", x = 10) { @@ -95,6 +95,20 @@ describe("useTabDrag", () => { expect(drag.state.suppressClick).toBe(false); }); + it("clears completed-drag click suppression on a later trackpad tap", () => { + const { drag, source } = createDragHarness(); + beginDrag(source); + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD); + enterDropTarget(drag, "target", 90); + releasePointer(); + expect(drag.state.suppressClick).toBe(true); + + beginDrag(source, 100, 20, "mouse", 0); + + expect(drag.state.active).toBe(false); + expect(drag.state.suppressClick).toBe(false); + }); + it("ignores 18px of jitter, matching a typical touchscreen tap reported as generic mouse input", () => { const { drag, source, onDrop } = createDragHarness(); beginDrag(source); @@ -106,6 +120,52 @@ describe("useTabDrag", () => { expect(onDrop).not.toHaveBeenCalled(); }); + it("does not arm a drag when the primary button is not held", () => { + const { drag, source, onDrop } = createDragHarness(); + beginDrag(source, 100, 20, "mouse", 0); + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0); + + releasePointer(); + + expect(drag.state.active).toBe(false); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it("cancels a pending drag when the primary button is released before movement", () => { + const { drag, source, onDrop } = createDragHarness(); + beginDrag(source); + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0); + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10); + + expect(drag.state.active).toBe(false); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it("cancels an active drag when the primary button is no longer held", () => { + const { drag, source, onDrop } = createDragHarness(); + beginDrag(source); + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD); + expect(drag.state.active).toBe(true); + + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10, 20, "mouse", 0); + + expect(drag.state.active).toBe(false); + expect(drag.state.draggedId).toBe(null); + expect(document.body.style.cursor).toBe(""); + expect(document.body.style.userSelect).toBe(""); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it("ignores movement from a different pointer", () => { + const { drag, source, onDrop } = createDragHarness(); + beginDrag(source, 100, 20, "mouse", 1, 7); + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 1, 8); + + expect(drag.state.active).toBe(false); + expect(onDrop).not.toHaveBeenCalled(); + releasePointer(0, 0, 7); + }); + it("never arms a drag for touch input, even past the horizontal threshold", () => { const { drag, source, onDrop } = createDragHarness(); beginDrag(source, 100, 20, "touch"); diff --git a/apps/desktop/src/composables/useTabDrag.ts b/apps/desktop/src/composables/useTabDrag.ts index 8e7588e5a0..5a60287137 100644 --- a/apps/desktop/src/composables/useTabDrag.ts +++ b/apps/desktop/src/composables/useTabDrag.ts @@ -32,6 +32,7 @@ let pending: { id: string; x: number; y: number; + pointerId: number; sourceEl: HTMLElement | null; } | null = null; let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null; @@ -81,22 +82,39 @@ function removeGhost() { } } +function activateDrag(x: number, y: number) { + if (!pending) return; + + activePointerId = pending.pointerId; + state.active = true; + state.draggedId = pending.id; + state.startX = pending.x; + state.startY = pending.y; + if (pending.sourceEl) { + ghostEl = createGhost(pending.sourceEl, x, y); + } + pending = null; + document.body.style.cursor = "grabbing"; + document.body.style.userSelect = "none"; +} + function onPointerMove(event: PointerEvent) { if (!pending && !state.active) return; + const trackedPointerId = pending?.pointerId ?? activePointerId; + if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return; + + // macOS reports a trackpad tap as a mouse pointer with button=0, but + // buttons=0. It is a click gesture, not a held primary-button drag. + if ((event.buttons & 1) !== 1) { + reset(); + return; + } + if (pending && !state.active) { const delta = dragAxis() === "vertical" ? event.clientY - pending.y : event.clientX - pending.x; if (Math.abs(delta) < TAB_DRAG_HORIZONTAL_THRESHOLD) return; - state.active = true; - state.draggedId = pending.id; - state.startX = pending.x; - state.startY = pending.y; - if (pending.sourceEl) { - ghostEl = createGhost(pending.sourceEl, event.clientX, event.clientY); - } - pending = null; - document.body.style.cursor = "grabbing"; - document.body.style.userSelect = "none"; + activateDrag(event.clientX, event.clientY); } if (state.active) { @@ -105,6 +123,9 @@ function onPointerMove(event: PointerEvent) { } function onMouseUp(event: PointerEvent) { + const trackedPointerId = pending?.pointerId ?? activePointerId; + if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return; + state.suppressClick = false; if (state.active && state.draggedId && onDetachCallback && isOutsideDetachBounds(event.clientX, event.clientY)) { const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1; @@ -115,7 +136,10 @@ function onMouseUp(event: PointerEvent) { reset(); } -function onPointerCancel() { +function onPointerCancel(event?: Event) { + const trackedPointerId = pending?.pointerId ?? activePointerId; + if (event && "pointerId" in event && trackedPointerId !== null && trackedPointerId !== undefined && (event as PointerEvent).pointerId !== trackedPointerId) return; + state.suppressClick = false; reset(); } @@ -132,6 +156,7 @@ function reset() { state.dropPosition = null; state.startX = 0; state.startY = 0; + activePointerId = null; pending = null; removeGhost(); document.body.style.cursor = ""; @@ -139,6 +164,7 @@ function reset() { } let listenersAttached = false; +let activePointerId: number | null = null; function ensureListeners() { if (listenersAttached) return; @@ -161,6 +187,10 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio function startDrag(event: PointerEvent, tabId: string) { if (event.button !== 0) return; + // Consume click suppression on the next primary-button gesture even when + // macOS reports a trackpad tap with buttons=0 and no drag can be armed. + state.suppressClick = false; + if ((event.buttons & 1) !== 1) return; // Touch input has no reliable equivalent of mouse jitter: a real finger's // contact point commonly drifts well past TAB_DRAG_HORIZONTAL_THRESHOLD // during an ordinary tap, which would misread the tap as a drag. Skip @@ -169,9 +199,14 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio if (event.pointerType === "touch") return; const target = event.target as HTMLElement; if (target.closest("button, input, [data-tab-title-input]")) return; - state.suppressClick = false; const el = (event.currentTarget as HTMLElement) || null; - pending = { id: tabId, x: event.clientX, y: event.clientY, sourceEl: el }; + pending = { + id: tabId, + x: event.clientX, + y: event.clientY, + pointerId: event.pointerId, + sourceEl: el, + }; } function updateTarget(event: MouseEvent, tabId: string) {