From fd412684320725d4b1bea5bfa90f49def4b3a978 Mon Sep 17 00:00:00 2001 From: hanxuanyu <2252193204@qq.com> Date: Wed, 19 Aug 2026 00:30:40 +0800 Subject: [PATCH] fix(grid): stabilize row drag selection gestures --- apps/desktop/src/components/grid/DataGrid.vue | 26 +++- .../DataGridSelectionGesture.spec.ts | 16 ++ .../__tests__/useDataGridSelection.spec.ts | 143 +++++++++++++++++- .../src/composables/useDataGridSelection.ts | 47 ++++++ 4 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridSelectionGesture.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index da4e5acb7e..e6b8208f03 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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" diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSelectionGesture.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSelectionGesture.spec.ts new file mode 100644 index 0000000000..1e9ddcfa9f --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridSelectionGesture.spec.ts @@ -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);"); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts index c6a9f32de2..a19b60dcc5 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts @@ -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>(); + const windowListeners = new Map>(); 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; diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index b26163229c..6e954ef988 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -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(); @@ -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() {