fix(grid): stabilize row drag selection gestures

This commit is contained in:
hanxuanyu authored and GitHub committed 2026-08-19 00:30:40 +08:00
1 parent b7338518d1
commit fd41268432
4 files changed
+228 -4

No files matched your search

+23 -3
View File
@@ -4601,6 +4601,10 @@ const selection = useDataGridSelection({
cellFromClientPoint: dataGridCellFromClientPoint,
rowFromClientPoint: dataGridRowFromClientPoint,
onUserCellSelection: invalidateSyntheticContextSelection,
// Canvas schedules its draw before the document-level mousemove handler runs,
// so its row state must be current before that frame is painted.
shouldUpdateDraggedRowsImmediately: () => useCanvasGridRows.value,
onDraggedRowSelectionChange: scheduleCanvasDraw,
runtimeScope: dataGridRuntimeScope,
});
@@ -4915,7 +4919,22 @@ const domSelectionDragOverlayStyle = computed((): CSSProperties | undefined => {
};
});
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
function stopReleasedSelectionGesture(event: MouseEvent): boolean {
if ((event.buttons & 1) !== 0) return false;
let stopped = false;
if (isSelectingCells.value) {
finishCellSelection();
stopped = true;
}
if (selection.isSelectingRows.value) {
selection.finishRowSelection();
stopped = true;
}
return stopped;
}
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) {
stopReleasedSelectionGesture(event);
if (!isSelectingCells.value) {
quickDownloadMenuCell.value = retainBinaryCellDownloadMenuForHover(quickDownloadMenuCell.value, { rowIndex, col: actualColIdx });
if (!isScrolling.value) hoveredDetailCell.value = { rowIndex, col: actualColIdx };
@@ -6261,6 +6280,7 @@ function onDomGridWheel(event: WheelEvent) {
}
function onCanvasMouseMove(event: MouseEvent) {
stopReleasedSelectionGesture(event);
if (columnHeaderPointerInteractionActive()) {
if (canvasRef.value) canvasRef.value.style.cursor = "default";
onCanvasMouseLeave();
@@ -6290,7 +6310,7 @@ function onCanvasMouseMove(event: MouseEvent) {
if (previousActualColIdx !== undefined) onCellMouseleave(previous.rowIndex, previousActualColIdx);
}
canvasHoverCell.value = next;
if (next && actualColIdx !== undefined) onCellMouseenter(next.rowIndex, next.visibleColIdx, actualColIdx);
if (next && actualColIdx !== undefined) onCellMouseenter(next.rowIndex, next.visibleColIdx, actualColIdx, event);
scheduleCanvasDraw();
}
@@ -11758,7 +11778,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
prepareDataCellMouseDown(item, col.actualColIdx);
handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event);
"
@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx, $event)"
@mouseleave="onCellMouseleave(item.displayIndex, col.actualColIdx)"
@dblclick="onDomCellDblClick(item, col.actualColIdx, $event)"
:data-visible-col-index="col.visibleColIdx"
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid selection gesture ownership", () => {
it("stops released gestures before DOM hover or canvas movement extends them", () => {
expect(source).toContain("function stopReleasedSelectionGesture(event: MouseEvent)");
expect(source).toContain("if ((event.buttons & 1) !== 0) return false;");
expect(source).toContain("function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent)");
expect(source).toContain('@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx, $event)"');
const canvasMove = source.slice(source.indexOf("function onCanvasMouseMove"), source.indexOf("function onCanvasMouseLeave"));
expect(canvasMove).toContain("stopReleasedSelectionGesture(event);");
});
});
@@ -2,7 +2,14 @@ import { computed, ref } from "vue";
import { describe, expect, it, vi } from "vitest";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
function createSelection(options?: { getScrollElement?: () => HTMLElement | null; cellFromClientPoint?: (clientX: number, clientY: number) => { rowIndex: number; colIndex: number } | null; rowFromClientPoint?: (clientX: number, clientY: number) => number | null; onUserCellSelection?: () => void }) {
function createSelection(options?: {
getScrollElement?: () => HTMLElement | null;
cellFromClientPoint?: (clientX: number, clientY: number) => { rowIndex: number; colIndex: number } | null;
rowFromClientPoint?: (clientX: number, clientY: number) => number | null;
onUserCellSelection?: () => void;
shouldUpdateDraggedRowsImmediately?: () => boolean;
onDraggedRowSelectionChange?: () => void;
}) {
const columns = computed(() => ["id", "name", "email"]);
const displayItems = computed(() =>
[1, 2, 3, 4].map((id, index) => ({
@@ -28,6 +35,8 @@ function createSelection(options?: { getScrollElement?: () => HTMLElement | null
cellFromClientPoint: options?.cellFromClientPoint,
rowFromClientPoint: options?.rowFromClientPoint,
onUserCellSelection: options?.onUserCellSelection,
shouldUpdateDraggedRowsImmediately: options?.shouldUpdateDraggedRowsImmediately,
onDraggedRowSelectionChange: options?.onDraggedRowSelectionChange,
});
}
@@ -41,9 +50,11 @@ function rowEvent(options: { meta?: boolean; shift?: boolean } = {}): MouseEvent
function installPointerDocument() {
const originalDocument = globalThis.document;
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
const windowListeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
const animationFrames: FrameRequestCallback[] = [];
const fakeDocument = {
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
@@ -55,7 +66,18 @@ function installPointerDocument() {
listeners.get(type)?.delete(listener);
},
} as Document;
const fakeWindow = {
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const handlers = windowListeners.get(type) ?? new Set();
handlers.add(listener);
windowListeners.set(type, handlers);
},
removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
windowListeners.get(type)?.delete(listener);
},
} as Window;
Object.defineProperty(globalThis, "document", { configurable: true, value: fakeDocument });
Object.defineProperty(globalThis, "window", { configurable: true, value: fakeWindow });
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
@@ -70,8 +92,16 @@ function installPointerDocument() {
else listener.handleEvent(event);
});
},
dispatchWindow(type: string, event: Event = { type } as Event) {
windowListeners.get(type)?.forEach((listener) => {
if (typeof listener === "function") listener(event);
else listener.handleEvent(event);
});
},
restore() {
Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
if (originalWindowDescriptor) Object.defineProperty(globalThis, "window", originalWindowDescriptor);
else Reflect.deleteProperty(globalThis, "window");
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
},
@@ -351,6 +381,117 @@ describe("useDataGridSelection", () => {
}
});
it("ends active cell and row drags when the window loses focus", () => {
const pointerDocument = installPointerDocument();
let pointerRow = 2;
let pointerCell = { rowIndex: 2, colIndex: 2 };
const selection = createSelection({
cellFromClientPoint: () => pointerCell,
rowFromClientPoint: () => pointerRow,
shouldUpdateDraggedRowsImmediately: () => true,
});
try {
selection.beginCellSelection(0, 0, { button: 0, clientX: 10, clientY: 10, preventDefault() {} } as MouseEvent);
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 30, clientY: 30 } as MouseEvent);
expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 2, startCol: 0, endCol: 2 });
pointerDocument.dispatchWindow("blur");
expect(selection.isSelectingCells.value).toBe(false);
pointerCell = { rowIndex: 3, colIndex: 2 };
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 40, clientY: 40 } as MouseEvent);
expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 2, startCol: 0, endCol: 2 });
selection.beginRowSelection(1, 2, { button: 0, clientX: 5, clientY: 10, preventDefault() {} } as MouseEvent);
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 5, clientY: 40 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3]));
pointerDocument.dispatchWindow("blur");
expect(selection.isSelectingRows.value).toBe(false);
pointerRow = 3;
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 5, clientY: 70 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3]));
} finally {
selection.finishCellSelection();
selection.finishRowSelection();
pointerDocument.restore();
}
});
it("recovers from a lost mouseup when mouse movement reports no primary button", () => {
const pointerDocument = installPointerDocument();
let pointerRow = 2;
let pointerCell = { rowIndex: 2, colIndex: 2 };
const selection = createSelection({
cellFromClientPoint: () => pointerCell,
rowFromClientPoint: () => pointerRow,
shouldUpdateDraggedRowsImmediately: () => true,
});
try {
selection.beginRowSelection(1, 2, { button: 0, clientX: 5, clientY: 10, preventDefault() {} } as MouseEvent);
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 5, clientY: 40 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3]));
pointerRow = 3;
pointerDocument.dispatch("mousemove", { buttons: 0, clientX: 5, clientY: 70 } as MouseEvent);
expect(selection.isSelectingRows.value).toBe(false);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3]));
pointerRow = 0;
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 5, clientY: 90 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3]));
selection.clearRowSelection();
selection.beginCellSelection(0, 0, { button: 0, clientX: 10, clientY: 10, preventDefault() {} } as MouseEvent);
pointerDocument.dispatch("mousemove", { buttons: 1, clientX: 30, clientY: 30 } as MouseEvent);
expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 2, startCol: 0, endCol: 2 });
pointerCell = { rowIndex: 3, colIndex: 2 };
pointerDocument.dispatch("mousemove", { buttons: 0, clientX: 40, clientY: 40 } as MouseEvent);
expect(selection.isSelectingCells.value).toBe(false);
expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 2, startCol: 0, endCol: 2 });
} finally {
selection.finishCellSelection();
selection.finishRowSelection();
pointerDocument.restore();
}
});
it("updates row drags synchronously and invalidates the final mouseup selection", () => {
const pointerDocument = installPointerDocument();
const onDraggedRowSelectionChange = vi.fn();
let pointerRow = 1;
const selection = createSelection({
rowFromClientPoint: () => pointerRow,
shouldUpdateDraggedRowsImmediately: () => true,
onDraggedRowSelectionChange,
});
try {
selection.beginRowSelection(1, 2, { button: 0, clientX: 5, clientY: 10, preventDefault() {} } as MouseEvent);
onDraggedRowSelectionChange.mockClear();
pointerRow = 2;
pointerDocument.dispatch("mousemove", { clientX: 5, clientY: 40 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3]));
expect(onDraggedRowSelectionChange).toHaveBeenCalledTimes(1);
pointerRow = 3;
pointerDocument.dispatch("mouseup", { clientX: 5, clientY: 66 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3, 4]));
expect(onDraggedRowSelectionChange).toHaveBeenCalledTimes(2);
pointerRow = 0;
pointerDocument.dispatch("mousemove", { clientX: 5, clientY: 92 } as MouseEvent);
expect(selection.selectedRowIds.value).toEqual(new Set([2, 3, 4]));
expect(onDraggedRowSelectionChange).toHaveBeenCalledTimes(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;
@@ -27,6 +27,8 @@ export interface UseDataGridSelectionOptions {
cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null;
rowFromClientPoint?: (clientX: number, clientY: number) => number | null;
onUserCellSelection?: () => void;
shouldUpdateDraggedRowsImmediately?: () => boolean;
onDraggedRowSelectionChange?: () => void;
runtimeScope?: DataGridRuntimeScope;
}
@@ -60,6 +62,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
let selectionPointerDownClientY = 0;
let selectionDragConfirmed = false;
let selectionAutoScrollFrame = 0;
let selectionInterruptionListenersAttached = false;
let rowSelectionRangeAnchorIndex = -1;
let rowSelectionFocusIndex = -1;
let rowSelectionBaseIds = new Set<number>();
@@ -420,6 +423,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
}
}
rowSelectionFocusIndex = focusRowIndex;
options.onDraggedRowSelectionChange?.();
}
function updateRowSelectionFromPointer() {
@@ -436,11 +440,44 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
return true;
}
function primaryMouseButtonReleased(event: MouseEvent): boolean {
return typeof event.buttons === "number" && (event.buttons & 1) === 0;
}
function handleSelectionVisibilityChange() {
if (document.visibilityState === "hidden") finishSelection();
}
function attachSelectionInterruptionListeners() {
if (selectionInterruptionListenersAttached) return;
selectionInterruptionListenersAttached = true;
if (typeof window !== "undefined") {
window.addEventListener("blur", finishSelection);
window.addEventListener("pointercancel", finishSelection, true);
}
document.addEventListener("visibilitychange", handleSelectionVisibilityChange);
}
function detachSelectionInterruptionListenersIfIdle() {
if (!selectionInterruptionListenersAttached || isSelectingCells.value || isSelectingRows.value) return;
selectionInterruptionListenersAttached = false;
if (typeof window !== "undefined") {
window.removeEventListener("blur", finishSelection);
window.removeEventListener("pointercancel", finishSelection, true);
}
document.removeEventListener("visibilitychange", handleSelectionVisibilityChange);
}
function handleRowSelectionPointerMove(event: MouseEvent) {
if (!isSelectingRows.value) return;
if (primaryMouseButtonReleased(event)) {
finishRowSelection();
return;
}
if (!confirmSelectionDrag(event.clientX, event.clientY)) return;
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
if (options.shouldUpdateDraggedRowsImmediately?.()) updateRowSelectionFromPointer();
if (!selectionAutoScrollFrame) selectionAutoScrollFrame = requestAnimationFrame(runSelectionAutoScroll);
}
@@ -459,10 +496,12 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
document.removeEventListener("mouseup", finishRowSelection);
document.removeEventListener("mousemove", handleRowSelectionPointerMove);
stopSelectionAutoScroll();
detachSelectionInterruptionListenersIfIdle();
}
function beginRowSelection(rowIndex: number, rowId: number, event: MouseEvent) {
if (event.button !== 0) return;
finishSelection();
event.preventDefault();
focusGridWithoutScrolling();
clearCellSelection();
@@ -484,6 +523,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
selectionDragConfirmed = false;
document.addEventListener("mouseup", finishRowSelection);
document.addEventListener("mousemove", handleRowSelectionPointerMove);
attachSelectionInterruptionListeners();
}
function finishCellSelection() {
@@ -491,6 +531,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
document.removeEventListener("mouseup", finishCellSelection);
document.removeEventListener("mousemove", handleSelectionPointerMove);
stopSelectionAutoScroll();
detachSelectionInterruptionListenersIfIdle();
}
function restoreCellSelectionState(state: RestoredCellSelectionState) {
@@ -550,6 +591,10 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
function handleSelectionPointerMove(event: MouseEvent) {
if (!isSelectingCells.value) return;
if (primaryMouseButtonReleased(event)) {
finishCellSelection();
return;
}
if (!confirmSelectionDrag(event.clientX, event.clientY)) return;
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
@@ -564,6 +609,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
function beginCellSelection(rowIndex: number, colIndex: number, event: MouseEvent) {
if (event.button !== 0) return;
if (editingCell.value) return;
finishSelection();
event.preventDefault();
focusGridWithoutScrolling();
clearCellSelection();
@@ -578,6 +624,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
if (showTranspose.value) transposeRowIndex.value = rowIndex;
document.addEventListener("mouseup", finishCellSelection);
document.addEventListener("mousemove", handleSelectionPointerMove);
attachSelectionInterruptionListeners();
}
function finishSelection() {