fix(grid): ignore pointer jitter during drag selection

This commit is contained in:
Melody Ruch
2026-08-14 14:45:56 +08:00
committed by GitHub
parent 325127419f
commit 30d13bc7f0
3 changed files with 150 additions and 3 deletions
@@ -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) {
@@ -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<string, Set<EventListenerOrEventListenerObject>>();
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;
@@ -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,