From ee4832fc0e331a447b4697717b02204cb34b16b4 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 12:06:42 +0200 Subject: [PATCH 1/7] dbeaver/pro#8684 refactor: move clamp utility function to js helpers plugin --- .../@dbeaver/js-helpers/src}/clamp.ts | 6 +++--- webapp/common-typescript/@dbeaver/js-helpers/src/index.ts | 1 + .../plugin-data-viewer-conditional-formatting/package.json | 1 + .../src/GridConditionalFormattingAction.ts | 4 ++-- .../src/utils/getColorMix.ts | 4 ++-- .../src/utils/normalize.ts | 4 ++-- .../plugin-data-viewer-conditional-formatting/tsconfig.json | 3 +++ webapp/yarn.lock | 1 + 8 files changed, 15 insertions(+), 9 deletions(-) rename webapp/{packages/plugin-data-viewer-conditional-formatting/src/utils => common-typescript/@dbeaver/js-helpers/src}/clamp.ts (50%) diff --git a/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/clamp.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/clamp.ts similarity index 50% rename from webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/clamp.ts rename to webapp/common-typescript/@dbeaver/js-helpers/src/clamp.ts index 9daf5908aca..36dc09676c8 100644 --- a/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/clamp.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/clamp.ts @@ -1,11 +1,11 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -export function clamp(x: number, a: number, b: number): number { - return Math.max(a, Math.min(b, x)); +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); } diff --git a/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts b/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts index 8a8f1be87e6..98a1737c6a6 100644 --- a/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts +++ b/webapp/common-typescript/@dbeaver/js-helpers/src/index.ts @@ -17,3 +17,4 @@ export * from './mutex.js'; export * from './reorderArray.js'; export * from './getLocalizedDisplayName.js'; export * from './formatNumber.js'; +export * from './clamp.js'; diff --git a/webapp/packages/plugin-data-viewer-conditional-formatting/package.json b/webapp/packages/plugin-data-viewer-conditional-formatting/package.json index fd3cd07bc22..6cfe6675777 100644 --- a/webapp/packages/plugin-data-viewer-conditional-formatting/package.json +++ b/webapp/packages/plugin-data-viewer-conditional-formatting/package.json @@ -30,6 +30,7 @@ "@cloudbeaver/core-ui": "workspace:*", "@cloudbeaver/core-utils": "workspace:*", "@cloudbeaver/plugin-data-viewer": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", "@dbeaver/ui-kit": "workspace:^", "mobx": "^6", "mobx-react-lite": "^4", diff --git a/webapp/packages/plugin-data-viewer-conditional-formatting/src/GridConditionalFormattingAction.ts b/webapp/packages/plugin-data-viewer-conditional-formatting/src/GridConditionalFormattingAction.ts index 477579a2a6e..379df8db1e8 100644 --- a/webapp/packages/plugin-data-viewer-conditional-formatting/src/GridConditionalFormattingAction.ts +++ b/webapp/packages/plugin-data-viewer-conditional-formatting/src/GridConditionalFormattingAction.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import { IDatabaseDataViewAction, type IGridDataKey, } from '@cloudbeaver/plugin-data-viewer'; +import { clamp } from '@dbeaver/js-helpers'; import type { IFormatRuleState } from './formatting/IFormatRuleState.js'; import { makeObservable, observable } from 'mobx'; import { COLOR_SCALE_RULE, DEFAULT_FORMAT_RULES } from './formatting/DEFAULT_FORMAT_RULES.js'; @@ -31,7 +32,6 @@ import { getSurfaceColor } from './getSurfaceColor.js'; import { ThemeService } from '@cloudbeaver/core-theming'; import { normalize } from './utils/normalize.js'; import { resolveStopValue } from './utils/resolveStopValue.js'; -import { clamp } from './utils/clamp.js'; @injectable(() => [ IDatabaseDataSource, diff --git a/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/getColorMix.ts b/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/getColorMix.ts index 3221719240c..40497632ff0 100644 --- a/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/getColorMix.ts +++ b/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/getColorMix.ts @@ -1,12 +1,12 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { clamp } from './clamp.js'; +import { clamp } from '@dbeaver/js-helpers'; export function getColorMix(color1: string, color2: string, ratio: number): string { const percent = clamp(ratio * 100, 0, 100); diff --git a/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/normalize.ts b/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/normalize.ts index 51e5d2ab33e..43e9609bff9 100644 --- a/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/normalize.ts +++ b/webapp/packages/plugin-data-viewer-conditional-formatting/src/utils/normalize.ts @@ -1,12 +1,12 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { clamp } from './clamp.js'; +import { clamp } from '@dbeaver/js-helpers'; export function normalize(x: number, a: number, b: number): number { if (a === b) { diff --git a/webapp/packages/plugin-data-viewer-conditional-formatting/tsconfig.json b/webapp/packages/plugin-data-viewer-conditional-formatting/tsconfig.json index 9c2af289be7..f318af305e7 100644 --- a/webapp/packages/plugin-data-viewer-conditional-formatting/tsconfig.json +++ b/webapp/packages/plugin-data-viewer-conditional-formatting/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../../common-react/@dbeaver/ui-kit" }, + { + "path": "../../common-typescript/@dbeaver/js-helpers" + }, { "path": "../core-blocks" }, diff --git a/webapp/yarn.lock b/webapp/yarn.lock index b3d07b4a4f1..925f5b518b0 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -3005,6 +3005,7 @@ __metadata: "@cloudbeaver/core-utils": "workspace:*" "@cloudbeaver/plugin-data-viewer": "workspace:*" "@cloudbeaver/tsconfig": "workspace:*" + "@dbeaver/js-helpers": "workspace:*" "@dbeaver/ui-kit": "workspace:^" "@types/react": "npm:^19" mobx: "npm:^6" From 2a10608a6527451f8c22fce131cb61c05c016c29 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 15:34:11 +0200 Subject: [PATCH 2/7] dbeaver/pro#8684 feat: implement auto-scrolling in data grid --- .../src/DataGrid/helpers/getEdgeSpeed.test.ts | 60 ++++++ .../src/DataGrid/helpers/getEdgeSpeed.ts | 30 +++ .../src/DataGrid/useGridAutoScroll.ts | 101 +++++++++ .../src/DataGrid/useGridDragging.ts | 196 ++++++++++-------- 4 files changed, 295 insertions(+), 92 deletions(-) create mode 100644 webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.test.ts create mode 100644 webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.ts create mode 100644 webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.test.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.test.ts new file mode 100644 index 00000000000..47f720d697d --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.test.ts @@ -0,0 +1,60 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { describe, expect, test } from 'vitest'; + +import { EDGE_SIZE, getEdgeSpeed, MAX_SCROLL_SPEED, MIN_SCROLL_SPEED } from './getEdgeSpeed.js'; + +const START = 0; +const END = 1000; + +describe('getEdgeSpeed', () => { + test('returns 0 while the cursor stays in the inner area', () => { + expect(getEdgeSpeed((START + END) / 2, START, END)).toBe(0); + expect(getEdgeSpeed(START + EDGE_SIZE, START, END)).toBe(0); + expect(getEdgeSpeed(END - EDGE_SIZE, START, END)).toBe(0); + }); + + test('scrolls toward the start edge with a negative speed', () => { + expect(getEdgeSpeed(START + EDGE_SIZE / 2, START, END)).toBeLessThan(0); + expect(getEdgeSpeed(START + 1, START, END)).toBeLessThan(0); + }); + + test('scrolls toward the end edge with a positive speed', () => { + expect(getEdgeSpeed(END - EDGE_SIZE / 2, START, END)).toBeGreaterThan(0); + expect(getEdgeSpeed(END - 1, START, END)).toBeGreaterThan(0); + }); + + test('reaches MAX_SCROLL_SPEED right at the edge', () => { + expect(getEdgeSpeed(START, START, END)).toBe(-MAX_SCROLL_SPEED); + expect(getEdgeSpeed(END, START, END)).toBe(MAX_SCROLL_SPEED); + }); + + test('stays at MAX_SCROLL_SPEED when the cursor is dragged past the edge', () => { + expect(getEdgeSpeed(START - 500, START, END)).toBe(-MAX_SCROLL_SPEED); + expect(getEdgeSpeed(END + 500, START, END)).toBe(MAX_SCROLL_SPEED); + }); + + test('speeds up the closer the cursor gets to the edge', () => { + const farther = getEdgeSpeed(END - EDGE_SIZE + 5, START, END); + const closer = getEdgeSpeed(END - 5, START, END); + const atEdge = getEdgeSpeed(END, START, END); + + expect(farther).toBeGreaterThan(0); + expect(closer).toBeGreaterThan(farther); + expect(atEdge).toBeGreaterThan(closer); + }); + + test('keeps the non-zero speed within the [MIN, MAX] range', () => { + for (let depth = 1; depth <= EDGE_SIZE; depth++) { + const speed = Math.abs(getEdgeSpeed(END - EDGE_SIZE + depth, START, END)); + + expect(speed).toBeGreaterThanOrEqual(MIN_SCROLL_SPEED); + expect(speed).toBeLessThanOrEqual(MAX_SCROLL_SPEED); + } + }); +}); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.ts new file mode 100644 index 00000000000..9f92228fade --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.ts @@ -0,0 +1,30 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { clamp } from '@dbeaver/js-helpers'; + +export const EDGE_SIZE = 48; +export const MIN_SCROLL_SPEED = 1; +export const MAX_SCROLL_SPEED = 128; + +function depthToSpeed(depth: number): number { + const ratio = clamp(depth / EDGE_SIZE, 0, 1); + return Math.round(MIN_SCROLL_SPEED + ratio * (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED)); +} + +export function getEdgeSpeed(position: number, start: number, end: number): number { + const depthAtStart = start + EDGE_SIZE - position; + const depthAtEnd = position - (end - EDGE_SIZE); + + if (depthAtStart > 0) { + return -depthToSpeed(depthAtStart); + } + if (depthAtEnd > 0) { + return depthToSpeed(depthAtEnd); + } + return 0; +} diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts new file mode 100644 index 00000000000..4eeea099786 --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts @@ -0,0 +1,101 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { useEffect, useRef, useState } from 'react'; +import { clamp } from '@dbeaver/js-helpers'; +import { getEdgeSpeed } from './helpers/getEdgeSpeed.js'; + +export interface IMousePosition { + x: number; + y: number; +} + +export interface IGridAutoScroll { + update: (container: HTMLElement, cursor: IMousePosition) => void; + stop: () => void; +} + +const LOOKUP_INSET = 16; + +export function useGridAutoScroll(onScroll: (cellLookupPoint: IMousePosition) => void): IGridAutoScroll { + const onScrollRef = useRef(onScroll); + useEffect(() => { + onScrollRef.current = onScroll; + }, [onScroll]); + + const [controller] = useState(() => { + let container: HTMLElement | null = null; + let headerRow: Element | null = null; + let cursor: IMousePosition | null = null; + let frameId: number | null = null; + + function step(): void { + frameId = null; + + if (!container || !cursor) { + return; + } + + const rect = container.getBoundingClientRect(); + const speedX = getEdgeSpeed(cursor.x, rect.left, rect.right); + const speedY = getEdgeSpeed(cursor.y, rect.top, rect.bottom); + + // cursor is back inside the inner area — pause until the next update() + if (speedX === 0 && speedY === 0) { + return; + } + + const scrollLeftBefore = container.scrollLeft; + const scrollTopBefore = container.scrollTop; + + container.scrollLeft += speedX; + container.scrollTop += speedY; + + if (container.scrollLeft === scrollLeftBefore && container.scrollTop === scrollTopBefore) { + return; + } + + const bodyTop = headerRow ? headerRow.getBoundingClientRect().bottom : rect.top; + + onScrollRef.current({ + x: clamp(cursor.x, rect.left + LOOKUP_INSET, rect.right - LOOKUP_INSET), + y: clamp(cursor.y, bodyTop + LOOKUP_INSET, rect.bottom - LOOKUP_INSET), + }); + + frameId = requestAnimationFrame(step); + } + + function stop(): void { + container = null; + headerRow = null; + cursor = null; + + if (frameId !== null) { + cancelAnimationFrame(frameId); + frameId = null; + } + } + + function update(nextContainer: HTMLElement, nextCursor: IMousePosition): void { + if (nextContainer !== container) { + container = nextContainer; + headerRow = nextContainer.querySelector('.rdg-header-row'); + } + cursor = nextCursor; + + if (frameId === null) { + frameId = requestAnimationFrame(step); + } + } + + return { update, stop }; + }); + + useEffect(() => controller.stop, [controller]); + + return controller; +} diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts index 9c4039c296b..486a0864f87 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts @@ -1,44 +1,47 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useObjectRef } from '@cloudbeaver/core-blocks'; +import { type IMousePosition, useGridAutoScroll } from './useGridAutoScroll.js'; + export interface IDraggingPosition { rowIdx: number; colIdx: number; } -interface IMousePosition { - x: number; - y: number; -} +type DraggingMouseEvent = React.MouseEvent | MouseEvent; -type DraggingCallback = ( - startPosition: IDraggingPosition, - currentPosition: IDraggingPosition, - event: React.MouseEvent | MouseEvent, -) => void; +type DraggingCallback = (startPosition: IDraggingPosition, currentPosition: IDraggingPosition, event: DraggingMouseEvent) => void; interface IDraggingState { startDraggingCell: IDraggingPosition | null; currentDraggingCell: IDraggingPosition | null; startMousePosition: IMousePosition | null; + /** Last mouse event, reused to keep modifier keys (ctrl/meta) available while auto-scrolling */ + lastEvent: DraggingMouseEvent | null; + scrollContainer: HTMLElement | null; dragging: boolean; mouseDown: boolean; } interface IDraggingCallbacks { - onDragStart?: (startPosition: IDraggingPosition, event: React.MouseEvent | MouseEvent) => void; + onDragStart?: (startPosition: IDraggingPosition, event: DraggingMouseEvent) => void; onDragOver?: DraggingCallback; onDragEnd?: DraggingCallback; } +interface IGridDragging { + onMouseDownHandler: (event: React.MouseEvent) => void; + onMouseMoveHandler: (event: React.MouseEvent) => void; +} + const THRESHOLD = 10; function getDelta(startPosition: IMousePosition | null, currentPosition: IMousePosition | null) { @@ -52,19 +55,18 @@ function getDelta(startPosition: IMousePosition | null, currentPosition: IMouseP return Math.max(xDelta, yDelta); } -function getCellPositionFromEvent(event: React.MouseEvent) { - const target = event.target as HTMLElement; - const cell = target.closest('[role="gridcell"]') as HTMLElement | null; +function getCellPositionFromElement(element: Element | null): IDraggingPosition | undefined { + const cell = element?.closest('[role="gridcell"]'); if (!cell) { - return; + return undefined; } const rowIdx = cell.getAttribute('data-row-index'); const columnIdx = cell.getAttribute('data-column-index'); if (!rowIdx || !columnIdx) { - return; + return undefined; } return { @@ -81,105 +83,115 @@ function isDraggingStarted(delta: number | null, threshold: number) { return delta > threshold; } -export function useGridDragging(props: IDraggingCallbacks) { +export function useGridDragging(props: IDraggingCallbacks): IGridDragging { const callbacks = useObjectRef(props); - const state = useObjectRef( - () => ({ - startDraggingCell: null, - currentDraggingCell: null, - startMousePosition: null, - dragging: false, - mouseDown: false, - }), - false, + const state = useRef({ + startDraggingCell: null, + currentDraggingCell: null, + startMousePosition: null, + lastEvent: null, + scrollContainer: null, + dragging: false, + mouseDown: false, + }); + + const dragOver = useCallback( + (position: IDraggingPosition, event: DraggingMouseEvent): void => { + const { startDraggingCell, currentDraggingCell } = state.current; + + if (!startDraggingCell || (position.rowIdx === currentDraggingCell?.rowIdx && position.colIdx === currentDraggingCell.colIdx)) { + return; + } + + state.current.currentDraggingCell = position; + callbacks.onDragOver?.(startDraggingCell, position, event); + }, + [callbacks], + ); + + const autoScroll = useGridAutoScroll( + useCallback( + (cellLookupPoint: IMousePosition): void => { + const position = getCellPositionFromElement(document.elementFromPoint(cellLookupPoint.x, cellLookupPoint.y)); + + if (position && state.current.lastEvent) { + dragOver(position, state.current.lastEvent); + } + }, + [dragOver], + ), ); - const onMouseDownHandler = useCallback((event: React.MouseEvent) => { - const position = getCellPositionFromEvent(event); + const onMouseDownHandler = useCallback((event: React.MouseEvent): void => { + const position = getCellPositionFromElement(event.target as Element); if (!position) { return; } - state.mouseDown = true; - state.startMousePosition = { x: event.pageX, y: event.pageY }; - state.startDraggingCell = { colIdx: position.colIdx, rowIdx: position.rowIdx }; + state.current.mouseDown = true; + state.current.startMousePosition = { x: event.pageX, y: event.pageY }; + state.current.startDraggingCell = position; + state.current.scrollContainer = event.currentTarget.querySelector('[role="grid"]'); }, []); - const onMouseMoveHandler = useCallback((event: React.MouseEvent) => { - if (!state.mouseDown) { - return; - } + const onMouseMoveHandler = useCallback( + (event: React.MouseEvent): void => { + if (!state.current.mouseDown) { + return; + } - const position = getCellPositionFromEvent(event); + if (!state.current.dragging) { + const delta = getDelta(state.current.startMousePosition, { x: event.pageX, y: event.pageY }); - if (!position) { - return; - } + if (!isDraggingStarted(delta, THRESHOLD)) { + return; + } + + if (state.current.startDraggingCell) { + callbacks.onDragStart?.(state.current.startDraggingCell, event); + } - if (!state.dragging) { - const delta = getDelta(state.startMousePosition, { x: event.pageX, y: event.pageY }); - if (!isDraggingStarted(delta, THRESHOLD)) { + state.current.dragging = true; return; } - if (callbacks.onDragStart && state.startDraggingCell) { - callbacks.onDragStart(state.startDraggingCell, event); - } + state.current.lastEvent = event; - state.dragging = true; - return; - } + const position = getCellPositionFromElement(event.target as Element); - // check if the new cell is equal to the previous cell - if (position.rowIdx === state.currentDraggingCell?.rowIdx && position.colIdx === state.currentDraggingCell.colIdx) { - return; - } + if (position) { + dragOver(position, event); + } - state.currentDraggingCell = { colIdx: position.colIdx, rowIdx: position.rowIdx }; - - if (callbacks.onDragOver) { - callbacks.onDragOver( - { - colIdx: state.startDraggingCell!.colIdx, - rowIdx: state.startDraggingCell!.rowIdx, - }, - { - colIdx: position.colIdx, - rowIdx: position.rowIdx, - }, - event, - ); - } - }, []); + if (state.current.scrollContainer) { + autoScroll.update(state.current.scrollContainer, { x: event.clientX, y: event.clientY }); + } + }, + [callbacks, dragOver, autoScroll], + ); - const onMouseUpHandler = useCallback((event: React.MouseEvent | MouseEvent) => { - state.mouseDown = false; - state.startMousePosition = null; + const onMouseUpHandler = useCallback( + (event: DraggingMouseEvent): void => { + autoScroll.stop(); - if (!state.dragging || !state.startDraggingCell || !state.currentDraggingCell) { - return; - } + const { dragging, startDraggingCell, currentDraggingCell } = state.current; - if (callbacks.onDragEnd) { - callbacks.onDragEnd( - { - colIdx: state.startDraggingCell.colIdx, - rowIdx: state.startDraggingCell.rowIdx, - }, - { - colIdx: state.currentDraggingCell.colIdx, - rowIdx: state.currentDraggingCell.rowIdx, - }, - event, - ); - } + if (dragging && startDraggingCell && currentDraggingCell) { + callbacks.onDragEnd?.(startDraggingCell, currentDraggingCell, event); + } - state.dragging = false; - state.startMousePosition = null; - state.currentDraggingCell = null; - }, []); + state.current.mouseDown = false; + state.current.dragging = false; + state.current.startMousePosition = null; + state.current.startDraggingCell = null; + state.current.currentDraggingCell = null; + state.current.lastEvent = null; + state.current.scrollContainer = null; + }, + [callbacks, autoScroll], + ); useEffect(() => { document.addEventListener('mouseup', onMouseUpHandler); From 1fc975367443f0be1ecdb53d517e30953eaa051a Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 16:00:05 +0200 Subject: [PATCH 3/7] dbeaver/pro#8684 refactor: add getCellLookupPoint function --- .../helpers/getCellLookupPoint.test.ts | 60 +++++++++++++++++++ .../DataGrid/helpers/getCellLookupPoint.ts | 30 ++++++++++ .../src/DataGrid/useGridAutoScroll.ts | 16 ++--- 3 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.test.ts create mode 100644 webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.test.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.test.ts new file mode 100644 index 00000000000..31ca97a4dd5 --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.test.ts @@ -0,0 +1,60 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { describe, expect, test } from 'vitest'; + +import { getCellLookupPoint, type IRect, LOOKUP_INSET } from './getCellLookupPoint.js'; + +const BODY: IRect = { left: 100, top: 50, right: 900, bottom: 600 }; + +describe('getCellLookupPoint', () => { + test('returns the cursor unchanged when it is well inside the body', () => { + expect(getCellLookupPoint({ x: 500, y: 300 }, BODY)).toEqual({ x: 500, y: 300 }); + }); + + test('clamps the x coordinate between the left and right edges', () => { + expect(getCellLookupPoint({ x: -1000, y: 300 }, BODY).x).toBe(BODY.left + LOOKUP_INSET); + expect(getCellLookupPoint({ x: 5000, y: 300 }, BODY).x).toBe(BODY.right - LOOKUP_INSET); + }); + + test('clamps the y coordinate between the top and bottom edges', () => { + expect(getCellLookupPoint({ x: 500, y: -1000 }, BODY).y).toBe(BODY.top + LOOKUP_INSET); + expect(getCellLookupPoint({ x: 500, y: 5000 }, BODY).y).toBe(BODY.bottom - LOOKUP_INSET); + }); + + test('clamps both axes when the cursor is dragged past a corner', () => { + expect(getCellLookupPoint({ x: 5000, y: 5000 }, BODY)).toEqual({ + x: BODY.right - LOOKUP_INSET, + y: BODY.bottom - LOOKUP_INSET, + }); + }); + + test('insets a cursor sitting exactly on the edge', () => { + expect(getCellLookupPoint({ x: BODY.left, y: BODY.top }, BODY)).toEqual({ + x: BODY.left + LOOKUP_INSET, + y: BODY.top + LOOKUP_INSET, + }); + }); + + test('always returns a point within the inset bounds', () => { + const cursors = [ + { x: -9999, y: -9999 }, + { x: 9999, y: 9999 }, + { x: 500, y: 300 }, + { x: BODY.left, y: BODY.bottom }, + ]; + + for (const cursor of cursors) { + const point = getCellLookupPoint(cursor, BODY); + + expect(point.x).toBeGreaterThanOrEqual(BODY.left + LOOKUP_INSET); + expect(point.x).toBeLessThanOrEqual(BODY.right - LOOKUP_INSET); + expect(point.y).toBeGreaterThanOrEqual(BODY.top + LOOKUP_INSET); + expect(point.y).toBeLessThanOrEqual(BODY.bottom - LOOKUP_INSET); + } + }); +}); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts new file mode 100644 index 00000000000..a270425a556 --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts @@ -0,0 +1,30 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { clamp } from '@dbeaver/js-helpers'; + +import type { IMousePosition } from '../useGridAutoScroll.js'; + +export interface IRect { + left: number; + top: number; + right: number; + bottom: number; +} + +// clear of scrollbars +export const LOOKUP_INSET = 18; + +/** + Lets the caller resolve a cell even when the cursor is dragged off-grid. + */ +export function getCellLookupPoint(cursor: IMousePosition, body: IRect): IMousePosition { + return { + x: clamp(cursor.x, body.left + LOOKUP_INSET, body.right - LOOKUP_INSET), + y: clamp(cursor.y, body.top + LOOKUP_INSET, body.bottom - LOOKUP_INSET), + }; +} diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts index 4eeea099786..086320de504 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ import { useEffect, useRef, useState } from 'react'; -import { clamp } from '@dbeaver/js-helpers'; +import { getCellLookupPoint } from './helpers/getCellLookupPoint.js'; import { getEdgeSpeed } from './helpers/getEdgeSpeed.js'; export interface IMousePosition { @@ -19,8 +19,6 @@ export interface IGridAutoScroll { stop: () => void; } -const LOOKUP_INSET = 16; - export function useGridAutoScroll(onScroll: (cellLookupPoint: IMousePosition) => void): IGridAutoScroll { const onScrollRef = useRef(onScroll); useEffect(() => { @@ -59,12 +57,14 @@ export function useGridAutoScroll(onScroll: (cellLookupPoint: IMousePosition) => return; } - const bodyTop = headerRow ? headerRow.getBoundingClientRect().bottom : rect.top; + const body = { + left: rect.left, + top: headerRow ? headerRow.getBoundingClientRect().bottom : rect.top, + right: rect.right, + bottom: rect.bottom, + }; - onScrollRef.current({ - x: clamp(cursor.x, rect.left + LOOKUP_INSET, rect.right - LOOKUP_INSET), - y: clamp(cursor.y, bodyTop + LOOKUP_INSET, rect.bottom - LOOKUP_INSET), - }); + onScrollRef.current(getCellLookupPoint(cursor, body)); frameId = requestAnimationFrame(step); } From b8c6f2afc60d5299f30dac40210602dcb8785c6b Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 16:12:37 +0200 Subject: [PATCH 4/7] dbeaver/pro#8684 refactor: improve grid auto-scroll controller --- .../src/DataGrid/useGridAutoScroll.ts | 126 ++++++++++-------- 1 file changed, 68 insertions(+), 58 deletions(-) diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts index 086320de504..8fc96f73fe3 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts @@ -5,7 +5,7 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { getCellLookupPoint } from './helpers/getCellLookupPoint.js'; import { getEdgeSpeed } from './helpers/getEdgeSpeed.js'; @@ -19,81 +19,91 @@ export interface IGridAutoScroll { stop: () => void; } -export function useGridAutoScroll(onScroll: (cellLookupPoint: IMousePosition) => void): IGridAutoScroll { - const onScrollRef = useRef(onScroll); - useEffect(() => { - onScrollRef.current = onScroll; - }, [onScroll]); +interface IGridAutoScrollController extends IGridAutoScroll { + setOnScroll: (onScroll: (cellLookupPoint: IMousePosition) => void) => void; +} - const [controller] = useState(() => { - let container: HTMLElement | null = null; - let headerRow: Element | null = null; - let cursor: IMousePosition | null = null; - let frameId: number | null = null; +function createGridAutoScrollController(): IGridAutoScrollController { + let onScroll: ((cellLookupPoint: IMousePosition) => void) | null = null; + let container: HTMLElement | null = null; + let headerRow: Element | null = null; + let cursor: IMousePosition | null = null; + let frameId: number | null = null; - function step(): void { - frameId = null; + function step(): void { + frameId = null; - if (!container || !cursor) { - return; - } + if (!container || !cursor) { + return; + } - const rect = container.getBoundingClientRect(); - const speedX = getEdgeSpeed(cursor.x, rect.left, rect.right); - const speedY = getEdgeSpeed(cursor.y, rect.top, rect.bottom); + const rect = container.getBoundingClientRect(); + const speedX = getEdgeSpeed(cursor.x, rect.left, rect.right); + const speedY = getEdgeSpeed(cursor.y, rect.top, rect.bottom); - // cursor is back inside the inner area — pause until the next update() - if (speedX === 0 && speedY === 0) { - return; - } + if (speedX === 0 && speedY === 0) { + return; + } - const scrollLeftBefore = container.scrollLeft; - const scrollTopBefore = container.scrollTop; + const scrollLeftBefore = container.scrollLeft; + const scrollTopBefore = container.scrollTop; - container.scrollLeft += speedX; - container.scrollTop += speedY; + container.scrollLeft += speedX; + container.scrollTop += speedY; - if (container.scrollLeft === scrollLeftBefore && container.scrollTop === scrollTopBefore) { - return; - } + if (container.scrollLeft === scrollLeftBefore && container.scrollTop === scrollTopBefore) { + return; + } - const body = { - left: rect.left, - top: headerRow ? headerRow.getBoundingClientRect().bottom : rect.top, - right: rect.right, - bottom: rect.bottom, - }; + const body = { + left: rect.left, + top: headerRow ? headerRow.getBoundingClientRect().bottom : rect.top, + right: rect.right, + bottom: rect.bottom, + }; - onScrollRef.current(getCellLookupPoint(cursor, body)); + onScroll?.(getCellLookupPoint(cursor, body)); - frameId = requestAnimationFrame(step); - } + frameId = requestAnimationFrame(step); + } - function stop(): void { - container = null; - headerRow = null; - cursor = null; + function stop(): void { + container = null; + headerRow = null; + cursor = null; - if (frameId !== null) { - cancelAnimationFrame(frameId); - frameId = null; - } + if (frameId !== null) { + cancelAnimationFrame(frameId); + frameId = null; } + } - function update(nextContainer: HTMLElement, nextCursor: IMousePosition): void { - if (nextContainer !== container) { - container = nextContainer; - headerRow = nextContainer.querySelector('.rdg-header-row'); - } - cursor = nextCursor; + function update(nextContainer: HTMLElement, nextCursor: IMousePosition): void { + if (nextContainer !== container) { + container = nextContainer; + headerRow = nextContainer.querySelector('.rdg-header-row'); + } + cursor = nextCursor; - if (frameId === null) { - frameId = requestAnimationFrame(step); - } + if (frameId === null) { + frameId = requestAnimationFrame(step); } + } - return { update, stop }; - }); + function setOnScroll(nextOnScroll: (cellLookupPoint: IMousePosition) => void): void { + onScroll = nextOnScroll; + } + + return { update, stop, setOnScroll }; +} + +export function useGridAutoScroll(onScroll: (cellLookupPoint: IMousePosition) => void): IGridAutoScroll { + const [controller] = useState(createGridAutoScrollController); + + // the scroll loop runs outside React (requestAnimationFrame), so keep its callback in sync from an effect + useEffect(() => { + controller.setOnScroll(onScroll); + }, [controller, onScroll]); useEffect(() => controller.stop, [controller]); From 601743ffd8d368e0e47528e7cb1cee90c9030a1e Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 16:20:12 +0200 Subject: [PATCH 5/7] dbeaver/pro#8684 refactor: improve comment --- .../src/DataGrid/helpers/getCellLookupPoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts index a270425a556..58613e11fff 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts @@ -20,7 +20,7 @@ export interface IRect { export const LOOKUP_INSET = 18; /** - Lets the caller resolve a cell even when the cursor is dragged off-grid. + * Clamps the cursor into the grid body so a cell can be resolved via elementFromPoint even when the cursor is dragged off-grid. */ export function getCellLookupPoint(cursor: IMousePosition, body: IRect): IMousePosition { return { From a1ba78cb385320bcdca4f72a20927b47b5263b2d Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 16:20:43 +0200 Subject: [PATCH 6/7] dbeaver/pro#8684 refactor: optimize header height caching in grid auto-scroll controller --- .../src/DataGrid/useGridAutoScroll.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts index 8fc96f73fe3..5142cee5755 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts @@ -26,7 +26,7 @@ interface IGridAutoScrollController extends IGridAutoScroll { function createGridAutoScrollController(): IGridAutoScrollController { let onScroll: ((cellLookupPoint: IMousePosition) => void) | null = null; let container: HTMLElement | null = null; - let headerRow: Element | null = null; + let headerHeight = 0; let cursor: IMousePosition | null = null; let frameId: number | null = null; @@ -57,7 +57,7 @@ function createGridAutoScrollController(): IGridAutoScrollController { const body = { left: rect.left, - top: headerRow ? headerRow.getBoundingClientRect().bottom : rect.top, + top: rect.top + headerHeight, right: rect.right, bottom: rect.bottom, }; @@ -69,7 +69,7 @@ function createGridAutoScrollController(): IGridAutoScrollController { function stop(): void { container = null; - headerRow = null; + headerHeight = 0; cursor = null; if (frameId !== null) { @@ -81,7 +81,7 @@ function createGridAutoScrollController(): IGridAutoScrollController { function update(nextContainer: HTMLElement, nextCursor: IMousePosition): void { if (nextContainer !== container) { container = nextContainer; - headerRow = nextContainer.querySelector('.rdg-header-row'); + headerHeight = nextContainer.querySelector('.rdg-header-row')?.getBoundingClientRect().height ?? 0; } cursor = nextCursor; From 1c43057d93aadd77fd538e1d85dc57340e4e7070 Mon Sep 17 00:00:00 2001 From: Sychev Andrey Date: Fri, 22 May 2026 16:33:57 +0200 Subject: [PATCH 7/7] dbeaver/pro#8684 refactor: update LOOKUP_INSET for accurate cursor clamping in grid --- .../src/DataGrid/helpers/getCellLookupPoint.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts index 58613e11fff..5c562475f91 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts @@ -16,8 +16,8 @@ export interface IRect { bottom: number; } -// clear of scrollbars -export const LOOKUP_INSET = 18; +// clear of scrollbars/header +export const LOOKUP_INSET = 32; /** * Clamps the cursor into the grid body so a cell can be resolved via elementFromPoint even when the cursor is dragged off-grid.