feat(grid): auto-scroll while dragging cell selection

This commit is contained in:
t8y2 2026-07-10 23:35:54 +08:00
parent 43f52463de
commit 8339dec428
3 changed files with 152 additions and 3 deletions

View File

@ -4599,6 +4599,8 @@ const selection = useDataGridSelection({
showTranspose,
transposeRowIndex,
gridRef,
getScrollElement: dataGridSelectionScroller,
cellFromClientPoint: dataGridCellFromClientPoint,
});
const {
@ -5616,6 +5618,35 @@ function canvasScrollerElement(): HTMLElement | null {
return null;
}
function dataGridSelectionScroller(): HTMLElement | null {
if (showTranspose.value) return null;
return canvasScrollerElement();
}
function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowIndex: number; colIndex: number } | null {
const scroller = dataGridSelectionScroller();
if (!scroller) return null;
const rect = scroller.getBoundingClientRect();
const clampedX = Math.min(rect.right - 1, Math.max(rect.left + DATA_GRID_ROW_NUM_WIDTH + 1, clientX));
const clampedY = Math.min(rect.bottom - 1, Math.max(rect.top + 1, clientY));
if (useCanvasGridRows.value) {
const rowIndex = Math.floor((scroller.scrollTop + clampedY - rect.top) / CANVAS_DATA_GRID_ROW_HEIGHT);
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - DATA_GRID_ROW_NUM_WIDTH);
if (rowIndex < 0 || rowIndex >= displayRowCount.value || visibleColIdx < 0) return null;
const item = displayItemAt(rowIndex);
return item ? { rowIndex: item.displayIndex, colIndex: visibleColIdx } : null;
}
const target = document.elementFromPoint(clampedX, clampedY);
const cell = target instanceof Element ? target.closest<HTMLElement>("[data-row-index] [data-visible-col-index]") : null;
const row = cell?.closest<HTMLElement>("[data-row-index]");
const rowIndex = Number(row?.dataset.rowIndex);
const colIndex = Number(cell?.dataset.visibleColIndex);
if (!Number.isInteger(rowIndex) || !Number.isInteger(colIndex)) return null;
return { rowIndex, colIndex };
}
function syncCanvasViewport() {
if (!dataGridIsActive) return;
const scroller = canvasScrollerElement();

View File

@ -2,7 +2,7 @@ import { computed, ref } from "vue";
import { describe, expect, it } from "vitest";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
function createSelection() {
function createSelection(options?: { getScrollElement?: () => HTMLElement | null; cellFromClientPoint?: (clientX: number, clientY: number) => { rowIndex: number; colIndex: number } | null }) {
const columns = computed(() => ["id", "name", "email"]);
const displayItems = computed(() =>
[1, 2, 3, 4].map((id, index) => ({
@ -24,6 +24,8 @@ function createSelection() {
showTranspose: ref(false),
transposeRowIndex: ref(null),
gridRef: ref(undefined),
getScrollElement: options?.getScrollElement,
cellFromClientPoint: options?.cellFromClientPoint,
});
}
@ -63,4 +65,55 @@ describe("useDataGridSelection", () => {
expect(selection.selectedRange.value).toBeNull();
expect(selection.hasCellSelection.value).toBe(false);
});
it("scrolls and extends the selection while dragging near an edge", () => {
const animationFrames: FrameRequestCallback[] = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const originalDocument = globalThis.document;
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
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;
const scroller = { scrollLeft: 0, scrollTop: 0 } as HTMLElement;
scroller.getBoundingClientRect = () => ({ left: 0, top: 0, right: 300, bottom: 200, width: 300, height: 200, x: 0, y: 0, toJSON: () => ({}) });
const selection = createSelection({
getScrollElement: () => scroller,
cellFromClientPoint: () => ({ rowIndex: scroller.scrollTop > 0 ? 3 : 0, colIndex: 2 }),
});
const event = { button: 0, clientX: 100, clientY: 100, preventDefault() {} } as MouseEvent;
try {
selection.beginCellSelection(0, 0, event);
const moveEvent = { clientX: 295, clientY: 195 } as MouseEvent;
listeners.get("mousemove")?.forEach((listener) => {
if (typeof listener === "function") listener(moveEvent);
else listener.handleEvent(moveEvent);
});
animationFrames.shift()?.(0);
expect(scroller.scrollLeft).toBeGreaterThan(0);
expect(scroller.scrollTop).toBeGreaterThan(0);
expect(selection.selectedRange.value).toEqual({ startRow: 0, endRow: 3, startCol: 0, endCol: 2 });
} finally {
selection.finishCellSelection();
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
}
});
});

View File

@ -1,4 +1,4 @@
import { ref, computed, type ComputedRef, type Ref } from "vue";
import { ref, computed, getCurrentScope, onScopeDispose, type ComputedRef, type Ref } from "vue";
import { allCellsSelectionRange, extractColumnsSelection, extractSelection, isCellInSelection, normalizeSelectionRange, normalizeSelectedColumnIndexes, rowSelectionRange, type CellPosition, type CellSelectionRange, type SelectionData } from "@/lib/dataGrid/gridSelection";
type CellValue = string | number | boolean | null;
@ -22,14 +22,22 @@ export interface UseDataGridSelectionOptions {
showTranspose: Ref<boolean>;
transposeRowIndex: Ref<number | null>;
gridRef: Ref<HTMLDivElement | undefined>;
getScrollElement?: () => HTMLElement | null;
cellFromClientPoint?: (clientX: number, clientY: number) => CellPosition | null;
}
const AUTO_SCROLL_EDGE_SIZE = 40;
const AUTO_SCROLL_MAX_SPEED = 28;
export function useDataGridSelection(options: UseDataGridSelectionOptions) {
const { columns, displayItems, editingCell, showTranspose, transposeRowIndex, gridRef } = options;
const { columns, displayItems, editingCell, showTranspose, transposeRowIndex, gridRef, getScrollElement, cellFromClientPoint } = options;
const selectionAnchor = ref<CellPosition | null>(null);
const selectionFocus = ref<CellPosition | null>(null);
const isSelectingCells = ref(false);
let selectionPointerClientX = 0;
let selectionPointerClientY = 0;
let selectionAutoScrollFrame = 0;
const isSelectingAll = ref(false);
const selectedCellKeys = ref<Set<string>>(new Set());
@ -259,6 +267,58 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
function finishCellSelection() {
isSelectingCells.value = false;
document.removeEventListener("mouseup", finishCellSelection);
document.removeEventListener("mousemove", handleSelectionPointerMove);
stopSelectionAutoScroll();
}
function stopSelectionAutoScroll() {
if (!selectionAutoScrollFrame) return;
cancelAnimationFrame(selectionAutoScrollFrame);
selectionAutoScrollFrame = 0;
}
function selectionScrollVelocity(pointer: number, start: number, end: number): number {
if (pointer < start + AUTO_SCROLL_EDGE_SIZE) {
return -AUTO_SCROLL_MAX_SPEED * Math.min(1, (start + AUTO_SCROLL_EDGE_SIZE - pointer) / AUTO_SCROLL_EDGE_SIZE);
}
if (pointer > end - AUTO_SCROLL_EDGE_SIZE) {
return AUTO_SCROLL_MAX_SPEED * Math.min(1, (pointer - (end - AUTO_SCROLL_EDGE_SIZE)) / AUTO_SCROLL_EDGE_SIZE);
}
return 0;
}
function updateSelectionFromPointer() {
const cell = cellFromClientPoint?.(selectionPointerClientX, selectionPointerClientY);
if (cell) extendCellSelection(cell.rowIndex, cell.colIndex);
}
function runSelectionAutoScroll() {
selectionAutoScrollFrame = 0;
if (!isSelectingCells.value) return;
const scroller = getScrollElement?.();
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const deltaX = selectionScrollVelocity(selectionPointerClientX, rect.left, rect.right);
const deltaY = selectionScrollVelocity(selectionPointerClientY, rect.top, rect.bottom);
const previousLeft = scroller.scrollLeft;
const previousTop = scroller.scrollTop;
scroller.scrollLeft += deltaX;
scroller.scrollTop += deltaY;
updateSelectionFromPointer();
if (scroller.scrollLeft !== previousLeft || scroller.scrollTop !== previousTop) {
selectionAutoScrollFrame = requestAnimationFrame(runSelectionAutoScroll);
}
}
function handleSelectionPointerMove(event: MouseEvent) {
if (!isSelectingCells.value) return;
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
updateSelectionFromPointer();
if (!selectionAutoScrollFrame) selectionAutoScrollFrame = requestAnimationFrame(runSelectionAutoScroll);
}
function focusGridWithoutScrolling() {
@ -273,11 +333,16 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
clearCellSelection();
selectSingleCell(rowIndex, colIndex);
isSelectingCells.value = true;
selectionPointerClientX = event.clientX;
selectionPointerClientY = event.clientY;
lastClickedColumnIndex.value = colIndex;
if (showTranspose.value) transposeRowIndex.value = rowIndex;
document.addEventListener("mouseup", finishCellSelection);
document.addEventListener("mousemove", handleSelectionPointerMove);
}
if (getCurrentScope()) onScopeDispose(finishCellSelection);
function extendCellSelection(rowIndex: number, colIndex: number) {
if (!isSelectingCells.value || !selectionAnchor.value) return;
selectionFocus.value = { rowIndex, colIndex };