Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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));
}
1 change: 1 addition & 0 deletions webapp/common-typescript/@dbeaver/js-helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from './mutex.js';
export * from './reorderArray.js';
export * from './getLocalizedDisplayName.js';
export * from './formatNumber.js';
export * from './clamp.js';
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);
}
});
});
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),
};
}
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);
}
});
});
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;
}
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 {

Copy link
Copy Markdown
Member

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

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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;
}
Loading
Loading