diff --git a/fluxer_app/src/features/ui/tooltip/Tooltip.tsx b/fluxer_app/src/features/ui/tooltip/Tooltip.tsx index 04b5ee513..2aad749e3 100644 --- a/fluxer_app/src/features/ui/tooltip/Tooltip.tsx +++ b/fluxer_app/src/features/ui/tooltip/Tooltip.tsx @@ -34,6 +34,7 @@ import {AnimatePresence, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useExclusiveTooltip} from './TooltipExclusivity'; +import {isTooltipHandoffWarm, markTooltipOpen} from './TooltipHandoff'; import {getTooltipScrollSuppressRemainingMs, subscribeTooltipScrollHide} from './TooltipScrollCoordinator'; const logger = new Logger('Tooltip'); @@ -466,9 +467,10 @@ export const Tooltip = observer( }, [updatePositionNow, cancelRaf, getTooltipOwnerWindow]); const getVisibilityDriverDelayMs = useCallback(() => { const scrollSuppressRemainingMs = getTooltipScrollSuppressRemainingMs(); + const openDelay = isTooltipHandoffWarm() ? 0 : delay; switch (true) { - case delay > 0 || scrollSuppressRemainingMs > 0: - return Math.max(delay, scrollSuppressRemainingMs > 0 ? scrollSuppressRemainingMs + 1 : 0); + case openDelay > 0 || scrollSuppressRemainingMs > 0: + return Math.max(openDelay, scrollSuppressRemainingMs > 0 ? scrollSuppressRemainingMs + 1 : 0); default: return 0; } @@ -602,6 +604,10 @@ export const Tooltip = observer( cancelRaf(); }; }, [cancelRaf, clearDelayTimer, clearPointerFocusTimeout]); + useEffect(() => { + if (!shouldRenderTooltip) return; + return markTooltipOpen(); + }, [shouldRenderTooltip]); useEffect(() => { if (!shouldRenderTooltip) return; return subscribeTooltipScrollHide(dismissTooltip); diff --git a/fluxer_app/src/features/ui/tooltip/TooltipHandoff.ts b/fluxer_app/src/features/ui/tooltip/TooltipHandoff.ts new file mode 100644 index 000000000..9580621c4 --- /dev/null +++ b/fluxer_app/src/features/ui/tooltip/TooltipHandoff.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const TOOLTIP_HANDOFF_GRACE_MS = 300; + +let openCount = 0; +let warmUntil = 0; +let graceTimer: ReturnType | null = null; + +function now(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +function clearGraceTimer(): void { + if (graceTimer == null) return; + clearTimeout(graceTimer); + graceTimer = null; +} + +function armGraceTimer(): void { + clearGraceTimer(); + const remaining = warmUntil - now(); + if (remaining <= 0) return; + graceTimer = setTimeout(() => { + graceTimer = null; + warmUntil = 0; + }, remaining); +} + +export function markTooltipOpen(): () => void { + openCount += 1; + warmUntil = 0; + clearGraceTimer(); + let released = false; + return () => { + if (released) return; + released = true; + openCount = Math.max(0, openCount - 1); + if (openCount === 0) { + warmUntil = now() + TOOLTIP_HANDOFF_GRACE_MS; + armGraceTimer(); + } + }; +} + +export function isTooltipHandoffWarm(): boolean { + return openCount > 0 || now() < warmUntil; +} diff --git a/fluxer_app/src/features/ui/tooltip/useHoverFloatingTooltip.ts b/fluxer_app/src/features/ui/tooltip/useHoverFloatingTooltip.ts index 8f46d3c7c..5b65a66fc 100644 --- a/fluxer_app/src/features/ui/tooltip/useHoverFloatingTooltip.ts +++ b/fluxer_app/src/features/ui/tooltip/useHoverFloatingTooltip.ts @@ -17,6 +17,7 @@ import { import type React from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react'; import {useExclusiveTooltip} from './TooltipExclusivity'; +import {isTooltipHandoffWarm, markTooltipOpen} from './TooltipHandoff'; import {subscribeTooltipScrollHide} from './TooltipScrollCoordinator'; export interface HoverFloatingTooltipState { @@ -67,7 +68,10 @@ export function useHoverFloatingTooltip(hoverDelay = 500, placement: Placement = middleware, whileElementsMounted: hoverFloatingAutoUpdate, }); - const hoverDelayConfig = useMemo(() => ({open: hoverDelay, close: CLOSE_DELAY_MS}), [hoverDelay]); + const hoverDelayConfig = useCallback( + () => ({open: isTooltipHandoffWarm() ? 0 : hoverDelay, close: CLOSE_DELAY_MS}), + [hoverDelay], + ); const hoverSafePolygon = useMemo(() => safePolygon({buffer: SAFE_POLYGON_BUFFER_PX, requireIntent: false}), []); const hover = useHover(context, { delay: hoverDelayConfig, @@ -95,6 +99,10 @@ export function useHoverFloatingTooltip(hoverDelay = 500, placement: Placement = setIsOpen(false); }, []); useExclusiveTooltip(isOpen, hide); + useEffect(() => { + if (!isOpen) return; + return markTooltipOpen(); + }, [isOpen]); useEffect(() => { if (!isOpen) return; return subscribeTooltipScrollHide(hide);