From a88f7aee8b5a5d328478c4a39e52dd392e7d82b9 Mon Sep 17 00:00:00 2001 From: naumov Date: Wed, 25 Feb 2026 19:31:58 +0100 Subject: [PATCH] dbeaver/pro#8355 throttle hover and menu showing --- webapp/packages/core-blocks/src/index.ts | 5 +- .../core-blocks/src/useDebounceCallback.ts | 51 ++++++++++++++++ .../core-blocks/src/useDebounceValue.ts | 30 +++++++++ webapp/packages/core-blocks/src/useHover.ts | 15 +++-- .../core-blocks/src/useThrottleCallback.ts | 61 +++++++++++++++++++ .../core-blocks/src/useThrottleValue.ts | 21 +++++++ .../ConnectionNavNodeControl.tsx | 4 +- 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 webapp/packages/core-blocks/src/useDebounceCallback.ts create mode 100644 webapp/packages/core-blocks/src/useDebounceValue.ts create mode 100644 webapp/packages/core-blocks/src/useThrottleCallback.ts create mode 100644 webapp/packages/core-blocks/src/useThrottleValue.ts diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index e7f4937a8c..26d1bbf939 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -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'; \ No newline at end of file +export * from './SAVED_VALUE_INDICATOR.js'; +export * from './useDebounceValue.js'; +export * from './useThrottleValue.js'; +export * from './useThrottleCallback.js'; diff --git a/webapp/packages/core-blocks/src/useDebounceCallback.ts b/webapp/packages/core-blocks/src/useDebounceCallback.ts new file mode 100644 index 0000000000..9720edb616 --- /dev/null +++ b/webapp/packages/core-blocks/src/useDebounceCallback.ts @@ -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 = ((...args: Params) => void) & { + cancel: () => void; +}; + +export function useDebounceCallback( + callback: (...args: Params) => Return, + delay: number, +): TDebouncedCallback { + const internalCallbackRef = useRef(callback); + const timerRef = useRef | 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; +} diff --git a/webapp/packages/core-blocks/src/useDebounceValue.ts b/webapp/packages/core-blocks/src/useDebounceValue.ts new file mode 100644 index 0000000000..9cd0995cbd --- /dev/null +++ b/webapp/packages/core-blocks/src/useDebounceValue.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 { useEffect, useRef, useState } from 'react'; + +import { useDebounceCallback } from './useDebounceCallback.js'; + +export function useDebounceValue(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; +} diff --git a/webapp/packages/core-blocks/src/useHover.ts b/webapp/packages/core-blocks/src/useHover.ts index c3c8f11084..db46181d84 100644 --- a/webapp/packages/core-blocks/src/useHover.ts +++ b/webapp/packages/core-blocks/src/useHover.ts @@ -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 { ref: React.RefCallback; @@ -16,13 +17,17 @@ export function useHover(): UseHoverResult< const [isHovered, setIsHovered] = useState(false); const nodeRef = useRef(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) => { diff --git a/webapp/packages/core-blocks/src/useThrottleCallback.ts b/webapp/packages/core-blocks/src/useThrottleCallback.ts new file mode 100644 index 0000000000..6c2a59f3a6 --- /dev/null +++ b/webapp/packages/core-blocks/src/useThrottleCallback.ts @@ -0,0 +1,61 @@ +import { useMemo, useRef } from 'react'; + +type TThrottledCallback = ((...args: Params) => void) & { + cancel: () => void; +}; + +export const useThrottleCallback = ( + callback: (...args: Params) => Return, + delay: number, +): TThrottledCallback => { + const internalCallbackRef = useRef(callback); + const timeoutRef = useRef | null>(null); + const isCalledRef = useRef(false); + const delayRef = useRef(delay); + const lastArgsRef = useRef(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; +}; diff --git a/webapp/packages/core-blocks/src/useThrottleValue.ts b/webapp/packages/core-blocks/src/useThrottleValue.ts new file mode 100644 index 0000000000..b58028d2fd --- /dev/null +++ b/webapp/packages/core-blocks/src/useThrottleValue.ts @@ -0,0 +1,21 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useThrottleCallback } from './useThrottleCallback.js'; + +export function useThrottleValue(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; +} diff --git a/webapp/packages/plugin-navigation-tree/src/NavigationTree/ConnectionsRenderer/ConnectionNavNodeControl.tsx b/webapp/packages/plugin-navigation-tree/src/NavigationTree/ConnectionsRenderer/ConnectionNavNodeControl.tsx index 3f6d7e77a7..28303f4d5c 100644 --- a/webapp/packages/plugin-navigation-tree/src/NavigationTree/ConnectionsRenderer/ConnectionNavNodeControl.tsx +++ b/webapp/packages/plugin-navigation-tree/src/NavigationTree/ConnectionsRenderer/ConnectionNavNodeControl.tsx @@ -18,6 +18,7 @@ import { TreeNodeIcon, TreeNodeName, useContextMenuPosition, + useDebounceValue, useHover, useMergeRefs, useS, @@ -69,6 +70,7 @@ export const ConnectionNavNodeControl: NavTreeControlComponent = observer{name} - {mountMenu && ( + {mountMenuDebounced && (