Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,7 +40,12 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {

return (
<EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
{(editor) => (
<>
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: BlockMenu Renders Before Editor Initialization

The BlockMenu component renders unconditionally, unlike EditorBubbleMenu, even though the editor prop can be null during initialization. This leads to runtime errors when BlockMenu attempts to access properties or methods of a null editor object.

Fix in Cursor Fix in Web

</>
)}
</EditorWrapper>
);
};
Expand Down
220 changes: 139 additions & 81 deletions packages/editor/src/core/components/menus/block-menu.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,62 +24,73 @@ type Props = {

export const BlockMenu = (props: Props) => {
const { editor } = props;
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(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<HTMLDivElement | null>(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);
Expand All @@ -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);
Comment on lines +113 to +117
Copy link

Copilot AI Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 50 for the animation delay should be extracted to a named constant to improve code maintainability and make the timing configurable.

Copilot uses AI. Check for mistakes.

return () => clearTimeout(timeout);
} else {
setIsAnimatedIn(false);
}
}, [isOpen]);

const MENU_ITEMS: {
icon: LucideIcon;
key: string;
Expand All @@ -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);
},
},
{
Expand Down Expand Up @@ -146,36 +187,53 @@ export const BlockMenu = (props: Props) => {
console.error(error.message);
}
}

popup.current?.hide();
setIsOpen(false);
},
},
];

if (!isOpen) {
return null;
}
return (
<div
ref={menuRef}
className="z-10 max-h-60 min-w-[7rem] overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
>
{MENU_ITEMS.map((item) => {
// Skip rendering the button if it should be disabled
if (item.isDisabled && item.key === "duplicate") {
return null;
}

return (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80"
onClick={item.onClick}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
</button>
);
})}
</div>
<FloatingPortal>
<div
ref={(node) => {
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 (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-1.5 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-90"
onClick={item.onClick}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
</button>
);
})}
</div>
</FloatingPortal>
);
};
Loading