From 295eb1ef725bdae5f19b67555ae421b874c0d6d8 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:29:32 +0530 Subject: [PATCH] [WIKI-506] fix: close the link view after 300ms of hovering out #7283 --- .../editors/link-view-container.tsx | 86 +++++++++++++++++-- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/core/components/editors/link-view-container.tsx b/packages/editor/src/core/components/editors/link-view-container.tsx index 68fa33dde..3d15de069 100644 --- a/packages/editor/src/core/components/editors/link-view-container.tsx +++ b/packages/editor/src/core/components/editors/link-view-container.tsx @@ -1,6 +1,6 @@ import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; import { Editor, useEditorState } from "@tiptap/react"; -import { FC, useCallback, useEffect, useState } from "react"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; // components import { LinkView, LinkViewProps } from "@/components/links"; @@ -13,6 +13,7 @@ export const LinkViewContainer: FC = ({ editor, containe const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); const [virtualElement, setVirtualElement] = useState(null); + const hoverTimeoutRef = useRef(null); const editorState = useEditorState({ editor, @@ -44,9 +45,26 @@ export const LinkViewContainer: FC = ({ editor, containe const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); + // Clear any existing timeout + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + window.clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + // Set timeout to close link view after delay + const setCloseTimeout = useCallback(() => { + clearHoverTimeout(); + hoverTimeoutRef.current = window.setTimeout(() => { + setIsOpen(false); + editorState.linkExtensionStorage.isPreviewOpen = false; + }, 400); + }, [clearHoverTimeout, editorState.linkExtensionStorage]); + const handleLinkHover = useCallback( (event: MouseEvent) => { - if (!editor || editorState.linkExtensionStorage.isBubbleMenuOpen) return; + if (!editor || editorState.linkExtensionStorage?.isBubbleMenuOpen) return; // Find the closest anchor tag from the event target const target = (event.target as HTMLElement)?.closest("a"); @@ -72,6 +90,9 @@ export const LinkViewContainer: FC = ({ editor, containe setVirtualElement(target); + // Clear any pending close timeout when hovering over a link + clearHoverTimeout(); + // Only update if not already open or if hovering over a different link if (!isOpen || (linkViewProps && (linkViewProps.from !== pos || linkViewProps.to !== pos + node.nodeSize))) { setLinkViewProps({ @@ -92,7 +113,46 @@ export const LinkViewContainer: FC = ({ editor, containe console.error("Error handling link hover:", error); } }, - [editor, editorState.linkExtensionStorage, getReferenceProps, isOpen, linkViewProps] + [editor, editorState.linkExtensionStorage, getReferenceProps, isOpen, linkViewProps, clearHoverTimeout] + ); + + // Handle mouse enter on floating element (cancel close timeout) + const handleFloatingMouseEnter = useCallback(() => { + clearHoverTimeout(); + }, [clearHoverTimeout]); + + // Handle mouse leave from floating element (start close timeout) + const handleFloatingMouseLeave = useCallback(() => { + setCloseTimeout(); + }, [setCloseTimeout]); + + const handleContainerMouseEnter = useCallback(() => { + // Cancel any pending close timeout when mouse enters container + clearHoverTimeout(); + }, [clearHoverTimeout]); + + const handleContainerMouseLeave = useCallback( + (event: MouseEvent) => { + if (!editor || !isOpen) return; + + // Check if mouse is truly leaving the container area + const relatedTarget = event.relatedTarget as HTMLElement; + const container = containerRef.current; + const floatingElement = refs.floating; + + // Only start close timeout if mouse is not moving to the floating element + // and is actually leaving the container + if ( + container && + relatedTarget && + !container.contains(relatedTarget) && + (!floatingElement || !floatingElement.current?.contains(relatedTarget)) + ) { + setCloseTimeout(); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [editor, isOpen, setCloseTimeout, refs.floating] ); // Set up event listeners @@ -101,15 +161,23 @@ export const LinkViewContainer: FC = ({ editor, containe if (!container) return; container.addEventListener("mouseover", handleLinkHover); + container.addEventListener("mouseenter", handleContainerMouseEnter); + container.addEventListener("mouseleave", handleContainerMouseLeave); return () => { container.removeEventListener("mouseover", handleLinkHover); + container.removeEventListener("mouseenter", handleContainerMouseEnter); + container.removeEventListener("mouseleave", handleContainerMouseLeave); }; - }, [handleLinkHover]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleLinkHover, handleContainerMouseEnter, handleContainerMouseLeave]); + + // Cleanup timeout on unmount + useEffect(() => () => clearHoverTimeout(), [clearHoverTimeout]); // Close link view when bubble menu opens useEffect(() => { - if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) { + if (editorState.linkExtensionStorage?.isBubbleMenuOpen && isOpen) { setIsOpen(false); } }, [editorState.linkExtensionStorage, isOpen]); @@ -117,7 +185,13 @@ export const LinkViewContainer: FC = ({ editor, containe return ( <> {isOpen && linkViewProps && virtualElement && ( -
+
)}