fix(grid): stabilize row drag selection gestures
This commit is contained in:
1 parent
b7338518d1
commit
fd41268432
4 files changed
+228
-4
No files matched your search
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user