-
Notifications
You must be signed in to change notification settings - Fork 536
dbeaver/pro#8684 [CB] feat: add autoscroll when dragging selection #4359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
SychevAndrey
wants to merge
7
commits into
devel
from
8684-cb-cannot-scroll-while-extending-selection-to-rows-outside-the-visible-area-in-data-editor
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
ee4832f
dbeaver/pro#8684 refactor: move clamp utility function to js helpers …
SychevAndrey 2a10608
dbeaver/pro#8684 feat: implement auto-scrolling in data grid
SychevAndrey 1fc9753
dbeaver/pro#8684 refactor: add getCellLookupPoint function
SychevAndrey b8c6f2a
dbeaver/pro#8684 refactor: improve grid auto-scroll controller
SychevAndrey 601743f
dbeaver/pro#8684 refactor: improve comment
SychevAndrey a1ba78c
dbeaver/pro#8684 refactor: optimize header height caching in grid aut…
SychevAndrey 1c43057
dbeaver/pro#8684 refactor: update LOOKUP_INSET for accurate cursor cl…
SychevAndrey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
6 changes: 3 additions & 3 deletions
6
...conditional-formatting/src/utils/clamp.ts → ...pescript/@dbeaver/js-helpers/src/clamp.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| }); | ||
| }); |
30 changes: 30 additions & 0 deletions
30
webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getCellLookupPoint.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/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. | ||
| */ | ||
| 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), | ||
| }; | ||
| } |
60 changes: 60 additions & 0 deletions
60
webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| }); | ||
| }); |
30 changes: 30 additions & 0 deletions
30
webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/helpers/getEdgeSpeed.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
111 changes: 111 additions & 0 deletions
111
webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridAutoScroll.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| /* | ||
| * 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, useState } from 'react'; | ||
| import { getCellLookupPoint } from './helpers/getCellLookupPoint.js'; | ||
| import { getEdgeSpeed } from './helpers/getEdgeSpeed.js'; | ||
|
|
||
| export interface IMousePosition { | ||
| x: number; | ||
| y: number; | ||
| } | ||
|
|
||
| export interface IGridAutoScroll { | ||
| update: (container: HTMLElement, cursor: IMousePosition) => void; | ||
| stop: () => void; | ||
| } | ||
|
|
||
| interface IGridAutoScrollController extends IGridAutoScroll { | ||
| setOnScroll: (onScroll: (cellLookupPoint: IMousePosition) => void) => void; | ||
| } | ||
|
|
||
| function createGridAutoScrollController(): IGridAutoScrollController { | ||
| let onScroll: ((cellLookupPoint: IMousePosition) => void) | null = null; | ||
| let container: HTMLElement | null = null; | ||
| let headerHeight = 0; | ||
| 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); | ||
|
|
||
| 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 body = { | ||
| left: rect.left, | ||
| top: rect.top + headerHeight, | ||
| right: rect.right, | ||
| bottom: rect.bottom, | ||
| }; | ||
|
|
||
| onScroll?.(getCellLookupPoint(cursor, body)); | ||
|
|
||
| frameId = requestAnimationFrame(step); | ||
| } | ||
|
|
||
| function stop(): void { | ||
| container = null; | ||
| headerHeight = 0; | ||
| cursor = null; | ||
|
|
||
| if (frameId !== null) { | ||
| cancelAnimationFrame(frameId); | ||
| frameId = null; | ||
| } | ||
| } | ||
|
|
||
| function update(nextContainer: HTMLElement, nextCursor: IMousePosition): void { | ||
| if (nextContainer !== container) { | ||
| container = nextContainer; | ||
| headerHeight = nextContainer.querySelector('.rdg-header-row')?.getBoundingClientRect().height ?? 0; | ||
| } | ||
| cursor = nextCursor; | ||
|
|
||
| if (frameId === null) { | ||
| frameId = requestAnimationFrame(step); | ||
| } | ||
| } | ||
|
|
||
| function setOnScroll(nextOnScroll: (cellLookupPoint: IMousePosition) => void): void { | ||
| onScroll = nextOnScroll; | ||
| } | ||
|
|
||
| return { update, stop, setOnScroll }; | ||
| } | ||
|
|
||
| export function useGridAutoScroll(onScroll: (cellLookupPoint: IMousePosition) => void): IGridAutoScroll { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should do the following. On cell select, run scrollToCell or scrollToRow from react data grid api, thats should be enough |
||
| 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]); | ||
|
|
||
| return controller; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems react data grid already has this functionality built in, did you check their api? It should be possible to use their logic instead of reimplementing it ourselves.
https://comcast.github.io/react-data-grid/#/AllFeatures