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;