diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index f110c789b2b..40a2b0c0be4 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -1,7 +1,7 @@ import { forwardRef, useCallback } from "react"; // components import { EditorWrapper } from "@/components/editors"; -import { EditorBubbleMenu } from "@/components/menus"; +import { BlockMenu, EditorBubbleMenu } from "@/components/menus"; // extensions import { SideMenuExtension } from "@/extensions"; // plane editor imports @@ -40,7 +40,12 @@ const RichTextEditor: React.FC = (props) => { return ( - {(editor) => <>{editor && bubbleMenuEnabled && }} + {(editor) => ( + <> + {editor && bubbleMenuEnabled && } + + + )} ); }; diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index 43c0193c715..e8a644c1fa7 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -1,8 +1,18 @@ +import { + useFloating, + offset, + flip, + shift, + autoUpdate, + useDismiss, + useInteractions, + FloatingPortal, +} from "@floating-ui/react"; import type { Editor } from "@tiptap/react"; import { Copy, LucideIcon, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useRef } from "react"; -import tippy, { Instance } from "tippy.js"; +import { useCallback, useEffect, useRef, useState } from "react"; // constants +import { cn } from "@plane/utils"; import { CORE_EXTENSIONS } from "@/constants/extension"; import { IEditorProps } from "@/types"; @@ -14,62 +24,73 @@ type Props = { export const BlockMenu = (props: Props) => { const { editor } = props; - const menuRef = useRef(null); - const popup = useRef(null); - - const handleClickDragHandle = useCallback((event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.matches("#drag-handle")) { - event.preventDefault(); - - popup.current?.setProps({ - getReferenceClientRect: () => target.getBoundingClientRect(), - }); - - popup.current?.show(); - return; - } - - popup.current?.hide(); - return; - }, []); - - useEffect(() => { - if (menuRef.current) { - menuRef.current.remove(); - menuRef.current.style.visibility = "visible"; - - // @ts-expect-error - Tippy types are incorrect - popup.current = tippy(document.body, { - getReferenceClientRect: null, - content: menuRef.current, - appendTo: () => document.querySelector(".frame-renderer"), - trigger: "manual", - interactive: true, - arrow: false, - placement: "left-start", - animation: "shift-away", - maxWidth: 500, - hideOnClick: true, - onShown: () => { - menuRef.current?.focus(); - }, - }); - } - - return () => { - popup.current?.destroy(); - popup.current = null; - }; - }, []); + const [isOpen, setIsOpen] = useState(false); + const [isAnimatedIn, setIsAnimatedIn] = useState(false); + const menuRef = useRef(null); + const virtualReferenceRef = useRef<{ getBoundingClientRect: () => DOMRect }>({ + getBoundingClientRect: () => new DOMRect(), + }); + + // Set up Floating UI with virtual reference element + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [offset({ crossAxis: -10 }), flip(), shift()], + whileElementsMounted: autoUpdate, + placement: "left-start", + }); + + const dismiss = useDismiss(context); + const { getFloatingProps } = useInteractions([dismiss]); + + // Handle click on drag handle + const handleClickDragHandle = useCallback( + (event: MouseEvent) => { + const target = event.target as HTMLElement; + const dragHandle = target.closest("#drag-handle"); + + if (dragHandle) { + event.preventDefault(); + + // Update virtual reference with current drag handle position + virtualReferenceRef.current = { + getBoundingClientRect: () => dragHandle.getBoundingClientRect(), + }; + + // Set the virtual reference as the reference element + refs.setReference(virtualReferenceRef.current); + + // Ensure the targeted block is selected + const rect = dragHandle.getBoundingClientRect(); + const coords = { left: rect.left + rect.width / 2, top: rect.top + rect.height / 2 }; + const posAtCoords = editor.view.posAtCoords(coords); + if (posAtCoords) { + const $pos = editor.state.doc.resolve(posAtCoords.pos); + const nodePos = $pos.before($pos.depth); + editor.chain().setNodeSelection(nodePos).run(); + } + // Show the menu + setIsOpen(true); + return; + } + + // If clicking outside and not on a menu item, hide the menu + if (menuRef.current && !menuRef.current.contains(target)) { + setIsOpen(false); + } + }, + [refs] + ); useEffect(() => { - const handleKeyDown = () => { - popup.current?.hide(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } }; const handleScroll = () => { - popup.current?.hide(); + setIsOpen(false); }; document.addEventListener("click", handleClickDragHandle); document.addEventListener("contextmenu", handleClickDragHandle); @@ -84,6 +105,23 @@ export const BlockMenu = (props: Props) => { }; }, [handleClickDragHandle]); + // Animation effect + useEffect(() => { + if (isOpen) { + setIsAnimatedIn(false); + // Add a small delay before starting the animation + const timeout = setTimeout(() => { + requestAnimationFrame(() => { + setIsAnimatedIn(true); + }); + }, 50); + + return () => clearTimeout(timeout); + } else { + setIsAnimatedIn(false); + } + }, [isOpen]); + const MENU_ITEMS: { icon: LucideIcon; key: string; @@ -96,10 +134,13 @@ export const BlockMenu = (props: Props) => { key: "delete", label: "Delete", onClick: (e) => { - editor.chain().deleteSelection().focus().run(); - popup.current?.hide(); e.preventDefault(); e.stopPropagation(); + + // Execute the delete action + editor.chain().deleteSelection().focus().run(); + + setIsOpen(false); }, }, { @@ -146,36 +187,53 @@ export const BlockMenu = (props: Props) => { console.error(error.message); } } - - popup.current?.hide(); + setIsOpen(false); }, }, ]; + if (!isOpen) { + return null; + } return ( -
- {MENU_ITEMS.map((item) => { - // Skip rendering the button if it should be disabled - if (item.isDisabled && item.key === "duplicate") { - return null; - } - - return ( - - ); - })} -
+ +
{ + refs.setFloating(node); + menuRef.current = node; + }} + style={{ + ...floatingStyles, + zIndex: 99, + animationFillMode: "forwards", + transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out + }} + className={cn( + "z-20 max-h-60 min-w-[7rem] overflow-y-scroll rounded-lg border border-custom-border-200 bg-custom-background-100 p-1.5 shadow-custom-shadow-rg", + "transition-all duration-300 transform origin-top-right", + isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75" + )} + data-prevent-outside-click + {...getFloatingProps()} + > + {MENU_ITEMS.map((item) => { + if (item.isDisabled) { + return null; + } + return ( + + ); + })} +
+
); };