dbeaver/pro#8355 throttle hover and menu showing

This commit is contained in:
naumov
2026-02-25 19:31:58 +01:00
parent 091160a59a
commit a88f7aee8b
7 changed files with 180 additions and 7 deletions
+4 -1
View File
@@ -262,4 +262,7 @@ export * from './Alert.js';
export * from './ObjectPropertyInfo/evaluate.js';
export * from './ObjectPropertyInfo/getObjectPropertyDefaults.js';
export * from './useVisible.js';
export * from './SAVED_VALUE_INDICATOR.js';
export * from './SAVED_VALUE_INDICATOR.js';
export * from './useDebounceValue.js';
export * from './useThrottleValue.js';
export * from './useThrottleCallback.js';
@@ -0,0 +1,51 @@
/*
* 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.
*/
/* eslint-disable react-hooks/refs */
import { useMemo, useRef } from 'react';
type TDebouncedCallback<Params extends unknown[]> = ((...args: Params) => void) & {
cancel: () => void;
};
export function useDebounceCallback<Params extends unknown[], Return>(
callback: (...args: Params) => Return,
delay: number,
): TDebouncedCallback<Params> {
const internalCallbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const delayRef = useRef(delay);
internalCallbackRef.current = callback;
delayRef.current = delay;
const debounced = useMemo(() => {
const cancel = () => {
if (!timerRef.current) {
return;
}
clearTimeout(timerRef.current);
timerRef.current = null;
};
const debouncedCallback = (...args: Params) => {
cancel();
timerRef.current = setTimeout(() => {
internalCallbackRef.current(...args);
}, delayRef.current);
};
debouncedCallback.cancel = cancel;
return debouncedCallback;
}, []);
return debounced;
}
@@ -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 { useEffect, useRef, useState } from 'react';
import { useDebounceCallback } from './useDebounceCallback.js';
export function useDebounceValue<Value>(value: Value, delay: number): Value {
const previousValueRef = useRef(value);
const [debouncedValue, setDebounceValue] = useState(value);
const setState = useDebounceCallback(setDebounceValue, delay);
useEffect(() => {
if (previousValueRef.current === value) {
return;
}
setState(value);
previousValueRef.current = value;
}, [value, setState]);
return debouncedValue;
}
+10 -5
View File
@@ -1,11 +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 { useCallback, useRef, useState } from 'react';
import { useThrottleCallback } from './useThrottleCallback.js';
interface UseHoverResult<T extends HTMLElement> {
ref: React.RefCallback<T>;
@@ -16,13 +17,17 @@ export function useHover<T extends HTMLElement = HTMLElement>(): UseHoverResult<
const [isHovered, setIsHovered] = useState(false);
const nodeRef = useRef<T | null>(null);
const handleMove = useThrottleCallback((value: boolean) => {
setIsHovered(value);
}, 100);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
handleMove(true);
}, [handleMove]);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
handleMove(false);
}, [handleMove]);
const ref = useCallback(
(node: T | null) => {
@@ -0,0 +1,61 @@
import { useMemo, useRef } from 'react';
type TThrottledCallback<Params extends unknown[]> = ((...args: Params) => void) & {
cancel: () => void;
};
export const useThrottleCallback = <Params extends unknown[], Return>(
callback: (...args: Params) => Return,
delay: number,
): TThrottledCallback<Params> => {
const internalCallbackRef = useRef(callback);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isCalledRef = useRef(false);
const delayRef = useRef(delay);
const lastArgsRef = useRef<Params | null>(null);
internalCallbackRef.current = callback;
delayRef.current = delay;
const throttled = useMemo(() => {
const timer = () => {
isCalledRef.current = false;
if (!lastArgsRef.current) {
return;
}
internalCallbackRef.current.apply(this, lastArgsRef.current);
lastArgsRef.current = null;
setTimeout(timer, delayRef.current);
};
const cancel = () => {
if (!timeoutRef.current) {
return;
}
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
isCalledRef.current = false;
};
const throttledCallback = (...args: Params) => {
lastArgsRef.current = args;
if (isCalledRef.current) {
return;
}
internalCallbackRef.current.apply(this, args);
isCalledRef.current = true;
timeoutRef.current = setTimeout(timer, delayRef.current);
};
throttledCallback.cancel = cancel;
cancel();
return throttledCallback;
}, [delay]);
return throttled;
};
@@ -0,0 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useThrottleCallback } from './useThrottleCallback.js';
export function useThrottleValue<Value>(value: Value, delay: number) {
const previousValueRef = useRef(value);
const [throttledValue, setThrottledValue] = useState(value);
const throttledSetState = useThrottleCallback(setThrottledValue, delay);
useEffect(() => {
if (previousValueRef.current === value) {
return;
}
throttledSetState(value);
previousValueRef.current = value;
}, [value]);
return throttledValue;
}
@@ -18,6 +18,7 @@ import {
TreeNodeIcon,
TreeNodeName,
useContextMenuPosition,
useDebounceValue,
useHover,
useMergeRefs,
useS,
@@ -69,6 +70,7 @@ export const ConnectionNavNodeControl: NavTreeControlComponent = observer<NavTre
const temporary = node.objectFeatures.includes(EObjectFeature.dataSourceTemporary);
const mountMenu = (selected || hoverHook.isHovered) && !dndPlaceholder;
const mountMenuDebounced = useDebounceValue(mountMenu, mountMenu ? 0 : 200);
if (temporary) {
tooltip += `\n${translate('ui_type')}: ${translate('core_connections_connection_temporary')}`;
@@ -90,7 +92,7 @@ export const ConnectionNavNodeControl: NavTreeControlComponent = observer<NavTre
<div className={s(styles, { nameBox: true })}>{name}</div>
</Loader>
</TreeNodeName>
{mountMenu && (
{mountMenuDebounced && (
<div className={s(styles, { portal: true })} onClick={handlePortalClick}>
<TreeNodeMenuLoader contextMenuPosition={contextMenuPosition} node={node} selected={selected} />
</div>