From b6c83838f3f3f279be54ae99b3fb5cd921577cc7 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Sat, 15 Aug 2026 21:54:46 +0800 Subject: [PATCH] fix(tabs): stop touch input from arming tab drag reorder --- .../src/components/layout/AppTabBar.vue | 11 +++--- .../composables/__tests__/useTabDrag.spec.ts | 34 +++++++++++++++---- .../src/composables/useDataGridSelection.ts | 5 +-- apps/desktop/src/composables/useTabDrag.ts | 20 ++++++++--- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index c914832b18..97b0b36a49 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -544,10 +544,13 @@ function handleTabClick(tab: QueryTab) { activateTab(tab.id); } -function handleTabMouseDown(event: MouseEvent, tabId: string) { +function handleTabMouseDown(event: PointerEvent, tabId: string) { if (event.button === 0) { dispatchBeforeTabSwitch(tabId); - event.preventDefault(); + // 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(); } tabDrag.startDrag(event, tabId); } @@ -663,7 +666,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul @click="handleTabClick(tab)" @dblclick.stop="startRenameTab(tab)" @mousedown.middle.prevent="queryStore.closeTab(tab.id)" - @mousedown="handleTabMouseDown($event, tab.id)" + @pointerdown="handleTabMouseDown($event, tab.id)" @mouseenter="handleTabDragTarget($event, tab)" @mousemove="handleTabDragTarget($event, tab)" @mouseleave="tabDrag.clearTarget(tab.id)" @@ -861,7 +864,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul @click="handleTabClick(tab)" @dblclick.stop="startRenameTab(tab)" @mousedown.middle.prevent="queryStore.closeTab(tab.id)" - @mousedown="handleTabMouseDown($event, tab.id)" + @pointerdown="handleTabMouseDown($event, tab.id)" @mouseenter="handleTabDragTarget($event, tab)" @mousemove="handleTabDragTarget($event, tab)" @mouseleave="tabDrag.clearTarget(tab.id)" diff --git a/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts b/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts index ad3e32a3ac..95a2016733 100644 --- a/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts +++ b/apps/desktop/src/composables/__tests__/useTabDrag.spec.ts @@ -7,21 +7,21 @@ function createDragHarness(onDrop = vi.fn(() => true)) { const drag = useTabDrag(onDrop); const source = document.createElement("div"); source.innerHTML = 'Source'; - source.addEventListener("mousedown", (event) => drag.startDrag(event, "source")); + source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source")); document.body.appendChild(source); return { drag, source, onDrop }; } -function beginDrag(source: HTMLElement, x = 100, y = 20) { - source.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y })); +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 movePointer(x: number, y = 20) { - document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y })); +function movePointer(x: number, y = 20, pointerType = "mouse") { + document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: x, clientY: y, pointerType })); } function releasePointer() { - document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); } function enterDropTarget(drag: ReturnType, tabId = "target", x = 10) { @@ -90,4 +90,26 @@ describe("useTabDrag", () => { beginDrag(source); 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); + + movePointer(100 - 18, 24); + + expect(drag.state.active).toBe(false); + expect(drag.state.suppressClick).toBe(false); + expect(onDrop).not.toHaveBeenCalled(); + }); + + it("never arms a drag for touch input, even past the horizontal threshold", () => { + const { drag, source, onDrop } = createDragHarness(); + beginDrag(source, 100, 20, "touch"); + + movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 20, 20, "touch"); + + expect(drag.state.active).toBe(false); + expect(drag.state.suppressClick).toBe(false); + expect(onDrop).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index 97f349abbe..b26163229c 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -41,8 +41,9 @@ const AUTO_SCROLL_EDGE_SIZE = 40; const AUTO_SCROLL_MAX_SPEED = 28; // Trackpad/mouse jitter during a plain click can move the pointer a few pixels between // mousedown and mouseup. Without a minimum drag distance, that jitter reads as an -// intentional drag and turns a single click into a multi-cell/row selection. Matches -// TAB_DRAG_HORIZONTAL_THRESHOLD in useTabDrag.ts, which guards the same class of gesture. +// intentional drag and turns a single click into a multi-cell/row selection. Guards the +// same class of gesture as TAB_DRAG_HORIZONTAL_THRESHOLD in useTabDrag.ts, though that one +// is tuned higher to also absorb touchscreen tap jitter, which doesn't apply here. const GRID_SELECTION_DRAG_THRESHOLD_PX = 12; type RowSelectionOperation = "replace" | "add" | "remove"; diff --git a/apps/desktop/src/composables/useTabDrag.ts b/apps/desktop/src/composables/useTabDrag.ts index d28826045e..7dd7e0eb16 100644 --- a/apps/desktop/src/composables/useTabDrag.ts +++ b/apps/desktop/src/composables/useTabDrag.ts @@ -12,7 +12,11 @@ interface TabDragState { startY: number; } -export const TAB_DRAG_HORIZONTAL_THRESHOLD = 12; +// Many touchscreen digitizers report to the OS/webview as a plain HID mouse +// (no distinguishable pointerType), with tap contact-point drift well beyond +// what real mouse/trackpad click jitter produces. 24px absorbs that jitter +// while still requiring a clearly deliberate drag to reorder a tab. +export const TAB_DRAG_HORIZONTAL_THRESHOLD = 24; const state = reactive({ active: false, @@ -74,7 +78,7 @@ function removeGhost() { } } -function onMouseMove(event: MouseEvent) { +function onPointerMove(event: PointerEvent) { if (!pending && !state.active) return; if (pending && !state.active) { @@ -122,8 +126,8 @@ let listenersAttached = false; function ensureListeners() { if (listenersAttached) return; - document.addEventListener("mousemove", onMouseMove, true); - document.addEventListener("mouseup", onMouseUp, true); + document.addEventListener("pointermove", onPointerMove, true); + document.addEventListener("pointerup", onMouseUp, true); listenersAttached = true; } @@ -131,8 +135,14 @@ export function useTabDrag(onDrop: (draggedId: string, targetId: string, positio ensureListeners(); onDropCallback = onDrop; - function startDrag(event: MouseEvent, tabId: string) { + function startDrag(event: PointerEvent, tabId: string) { if (event.button !== 0) 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 + // arming the drag for touch entirely and let the tab strip's native + // horizontal scroll handle the gesture instead. + if (event.pointerType === "touch") return; const target = event.target as HTMLElement; if (target.closest("button, input, [data-tab-title-input]")) return; state.suppressClick = false;