diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index a49001e20..ee9204769 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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("[data-row-index] [data-visible-col-index]") : null; + const row = cell?.closest("[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(); diff --git a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts index 3a5ebe4ea..812bcd76c 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts @@ -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>(); + 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 }); + } + }); }); diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index 2c6fa74a6..af12e3afb 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -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; transposeRowIndex: Ref; gridRef: Ref; + 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(null); const selectionFocus = ref(null); const isSelectingCells = ref(false); + let selectionPointerClientX = 0; + let selectionPointerClientY = 0; + let selectionAutoScrollFrame = 0; const isSelectingAll = ref(false); const selectedCellKeys = ref>(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 };