fix(tabs): stop touch input from arming tab drag reorder

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-15 21:54:46 +08:00
1 parent 1d7fa00fe3
commit b6c83838f3
4 files changed
+53 -17

No files matched your search

@@ -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)"
+28 -6
View File
@@ -7,21 +7,21 @@ function createDragHarness(onDrop = vi.fn(() => true)) {
const drag = useTabDrag(onDrop);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
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<typeof useTabDrag>, 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();
});
});
@@ -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";
+15 -5
View File
@@ -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<TabDragState>({
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;