feat(grid): auto-scroll while dragging cell selection
This commit is contained in:
parent
43f52463de
commit
8339dec428
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue