From 30d13bc7f0ce13c2de76760460671844765649f0 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Fri, 14 Aug 2026 14:45:56 +0800 Subject: [PATCH] fix(grid): ignore pointer jitter during drag selection --- apps/desktop/src/components/grid/DataGrid.vue | 3 +- .../__tests__/useDataGridSelection.spec.ts | 114 ++++++++++++++++++ .../src/composables/useDataGridSelection.ts | 36 +++++- 3 files changed, 150 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 03d006150b..ccdcbb3d5b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -4328,6 +4328,7 @@ const { selectionFocus, finishCellSelection, extendCellSelection, + isCellSelectionDragConfirmed, restoreCellSelectionState, cellIsSelected, columnIsSelected, @@ -4505,7 +4506,7 @@ function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: quickDownloadMenuCell.value = retainBinaryCellDownloadMenuForHover(quickDownloadMenuCell.value, { rowIndex, col: actualColIdx }); if (!isScrolling.value) hoveredDetailCell.value = { rowIndex, col: actualColIdx }; } - extendCellSelection(rowIndex, visibleColIdx); + if (isCellSelectionDragConfirmed()) extendCellSelection(rowIndex, visibleColIdx); } function onCellMouseleave(rowIndex: number, actualColIdx: number) { diff --git a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts index 80949f4317..c6a9f32de2 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts @@ -39,6 +39,45 @@ function rowEvent(options: { meta?: boolean; shift?: boolean } = {}): MouseEvent } as MouseEvent; } +function installPointerDocument() { + const originalDocument = globalThis.document; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + const listeners = new Map>(); + const animationFrames: FrameRequestCallback[] = []; + const fakeDocument = { + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + const handlers = listeners.get(type) ?? new Set(); + handlers.add(listener); + listeners.set(type, handlers); + }, + removeEventListener(type: string, listener: EventListenerOrEventListenerObject) { + listeners.get(type)?.delete(listener); + }, + } as Document; + Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument }); + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame; + + return { + animationFrames, + dispatch(type: string, event: MouseEvent) { + listeners.get(type)?.forEach((listener) => { + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); + }); + }, + restore() { + Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument }); + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; + }, + }; +} + describe("useDataGridSelection", () => { it("keeps selected columns and the range anchor attached to their columns after reordering", () => { const selection = createSelection(); @@ -237,6 +276,81 @@ describe("useDataGridSelection", () => { expect(selection.hasCellSelection.value).toBe(false); }); + it("ignores cell jitter until the drag threshold and exposes confirmation for hover extension", () => { + const pointerDocument = installPointerDocument(); + const selection = createSelection({ cellFromClientPoint: () => ({ rowIndex: 2, colIndex: 2 }) }); + + try { + selection.beginCellSelection(0, 0, { button: 0, clientX: 100, clientY: 100, preventDefault() {} } as MouseEvent); + pointerDocument.dispatch("mousemove", { clientX: 111, clientY: 100 } as MouseEvent); + if (selection.isCellSelectionDragConfirmed()) selection.extendCellSelection(2, 2); + + expect(selection.isCellSelectionDragConfirmed()).toBe(false); + expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 0, startCol: 0, endCol: 0 }); + + pointerDocument.dispatch("mousemove", { clientX: 112, clientY: 100 } as MouseEvent); + + expect(selection.isCellSelectionDragConfirmed()).toBe(true); + expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 2, startCol: 0, endCol: 2 }); + } finally { + selection.finishCellSelection(); + pointerDocument.restore(); + } + }); + + it("does not extend an unconfirmed cell drag on mouseup", () => { + const pointerDocument = installPointerDocument(); + const selection = createSelection({ cellFromClientPoint: () => ({ rowIndex: 2, colIndex: 2 }) }); + + try { + selection.beginCellSelection(0, 0, { button: 0, clientX: 100, clientY: 100, preventDefault() {} } as MouseEvent); + pointerDocument.dispatch("mousemove", { clientX: 111, clientY: 100 } as MouseEvent); + pointerDocument.dispatch("mouseup", { clientX: 111, clientY: 100 } as MouseEvent); + + expect(selection.isSelectingCells.value).toBe(false); + expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 0, startCol: 0, endCol: 0 }); + } finally { + selection.finishCellSelection(); + pointerDocument.restore(); + } + }); + + it("ignores row gutter jitter until the drag threshold", () => { + const pointerDocument = installPointerDocument(); + const selection = createSelection({ rowFromClientPoint: () => 3 }); + + try { + selection.beginRowSelection(1, 2, { button: 0, clientX: 100, clientY: 100, preventDefault() {} } as MouseEvent); + pointerDocument.dispatch("mousemove", { clientX: 111, clientY: 100 } as MouseEvent); + + expect(selection.selectedRowIds.value).toEqual(new Set([2])); + + pointerDocument.dispatch("mousemove", { clientX: 112, clientY: 100 } as MouseEvent); + pointerDocument.animationFrames.shift()?.(0); + + expect(selection.selectedRowIds.value).toEqual(new Set([2, 3, 4])); + } finally { + selection.finishRowSelection(); + pointerDocument.restore(); + } + }); + + it("does not extend an unconfirmed row gutter drag on mouseup", () => { + const pointerDocument = installPointerDocument(); + const selection = createSelection({ rowFromClientPoint: () => 3 }); + + try { + selection.beginRowSelection(1, 2, { button: 0, clientX: 100, clientY: 100, preventDefault() {} } as MouseEvent); + pointerDocument.dispatch("mouseup", { clientX: 111, clientY: 100 } as MouseEvent); + + expect(selection.isSelectingRows.value).toBe(false); + expect(selection.selectedRowIds.value).toEqual(new Set([2])); + } finally { + selection.finishRowSelection(); + pointerDocument.restore(); + } + }); + it("selects a continuous row range while dragging the row-number gutter", () => { const originalDocument = globalThis.document; const originalRequestAnimationFrame = globalThis.requestAnimationFrame; diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index bd32ec9306..97f349abbe 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -39,6 +39,11 @@ interface RestoredCellSelectionState { 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. +const GRID_SELECTION_DRAG_THRESHOLD_PX = 12; type RowSelectionOperation = "replace" | "add" | "remove"; export function useDataGridSelection(options: UseDataGridSelectionOptions) { @@ -50,6 +55,9 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { const isSelectingRows = ref(false); let selectionPointerClientX = 0; let selectionPointerClientY = 0; + let selectionPointerDownClientX = 0; + let selectionPointerDownClientY = 0; + let selectionDragConfirmed = false; let selectionAutoScrollFrame = 0; let rowSelectionRangeAnchorIndex = -1; let rowSelectionFocusIndex = -1; @@ -419,8 +427,17 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { applyDraggedRowRange(rowIndex); } + function confirmSelectionDrag(clientX: number, clientY: number): boolean { + if (selectionDragConfirmed) return true; + const distance = Math.hypot(clientX - selectionPointerDownClientX, clientY - selectionPointerDownClientY); + if (distance < GRID_SELECTION_DRAG_THRESHOLD_PX) return false; + selectionDragConfirmed = true; + return true; + } + function handleRowSelectionPointerMove(event: MouseEvent) { if (!isSelectingRows.value) return; + if (!confirmSelectionDrag(event.clientX, event.clientY)) return; selectionPointerClientX = event.clientX; selectionPointerClientY = event.clientY; if (!selectionAutoScrollFrame) selectionAutoScrollFrame = requestAnimationFrame(runSelectionAutoScroll); @@ -428,11 +445,11 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { function finishRowSelection(event?: MouseEvent) { if (!isSelectingRows.value) return; - if (event) { + if (event && confirmSelectionDrag(event.clientX, event.clientY)) { selectionPointerClientX = event.clientX; selectionPointerClientY = event.clientY; + updateRowSelectionFromPointer(); } - updateRowSelectionFromPointer(); isSelectingRows.value = false; // Keep the range origin stable across repeated Shift selections. For a // plain or meta selection this is the row where the gesture started; for @@ -461,6 +478,9 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { isSelectingRows.value = true; selectionPointerClientX = event.clientX; selectionPointerClientY = event.clientY; + selectionPointerDownClientX = event.clientX; + selectionPointerDownClientY = event.clientY; + selectionDragConfirmed = false; document.addEventListener("mouseup", finishRowSelection); document.addEventListener("mousemove", handleRowSelectionPointerMove); } @@ -529,6 +549,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { function handleSelectionPointerMove(event: MouseEvent) { if (!isSelectingCells.value) return; + if (!confirmSelectionDrag(event.clientX, event.clientY)) return; selectionPointerClientX = event.clientX; selectionPointerClientY = event.clientY; updateSelectionFromPointer(); @@ -549,6 +570,9 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { isSelectingCells.value = true; selectionPointerClientX = event.clientX; selectionPointerClientY = event.clientY; + selectionPointerDownClientX = event.clientX; + selectionPointerDownClientY = event.clientY; + selectionDragConfirmed = false; lastClickedColumnIndex.value = colIndex; if (showTranspose.value) transposeRowIndex.value = rowIndex; document.addEventListener("mouseup", finishCellSelection); @@ -563,6 +587,13 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { if (options.runtimeScope) options.runtimeScope.addCleanup(finishSelection); else if (getCurrentScope()) onScopeDispose(finishSelection); + // Some callers extend the selection off native DOM hover events rather than the pointermove + // handler above, with no pixel-distance info of their own. They should check this first, so + // hovering into an adjacent cell during click jitter doesn't grow the selection. + function isCellSelectionDragConfirmed(): boolean { + return selectionDragConfirmed; + } + function extendCellSelection(rowIndex: number, colIndex: number) { if (!isSelectingCells.value || !selectionAnchor.value) return; const current = selectionFocus.value; @@ -645,6 +676,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { restoreCellSelectionState, beginCellSelection, extendCellSelection, + isCellSelectionDragConfirmed, cellIsSelected, columnIsSelected, selectedRangeStart,