[WIKI-623] fix: add block menu to rich text editor (#7813)

* fix : block menu for rich editor

* chore: remove comments

* chore : update selection logic
This commit is contained in:
Vipin Chaudhary 2025-09-22 18:07:52 +05:30 committed by GitHub
parent 36d328445c
commit 14e3aace92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 142 additions and 79 deletions

View file

@ -1,7 +1,7 @@
import { forwardRef, useCallback } from "react"; import { forwardRef, useCallback } from "react";
// components // components
import { EditorWrapper } from "@/components/editors"; import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus"; import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
// extensions // extensions
import { SideMenuExtension } from "@/extensions"; import { SideMenuExtension } from "@/extensions";
// plane editor imports // plane editor imports
@ -40,7 +40,12 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
return ( return (
<EditorWrapper {...props} extensions={getExtensions()}> <EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>} {(editor) => (
<>
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
</>
)}
</EditorWrapper> </EditorWrapper>
); );
}; };

View file

@ -1,8 +1,18 @@
import {
useFloating,
offset,
flip,
shift,
autoUpdate,
useDismiss,
useInteractions,
FloatingPortal,
} from "@floating-ui/react";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { Copy, LucideIcon, Trash2 } from "lucide-react"; import { Copy, LucideIcon, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import tippy, { Instance } from "tippy.js";
// constants // constants
import { cn } from "@plane/utils";
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
import { IEditorProps } from "@/types"; import { IEditorProps } from "@/types";
@ -14,62 +24,73 @@ type Props = {
export const BlockMenu = (props: Props) => { export const BlockMenu = (props: Props) => {
const { editor } = props; const { editor } = props;
const menuRef = useRef<HTMLDivElement>(null); const [isOpen, setIsOpen] = useState(false);
const popup = useRef<Instance | null>(null); const [isAnimatedIn, setIsAnimatedIn] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const virtualReferenceRef = useRef<{ getBoundingClientRect: () => DOMRect }>({
getBoundingClientRect: () => new DOMRect(),
});
const handleClickDragHandle = useCallback((event: MouseEvent) => { // Set up Floating UI with virtual reference element
const target = event.target as HTMLElement; const { refs, floatingStyles, context } = useFloating({
if (target.matches("#drag-handle")) { open: isOpen,
event.preventDefault(); onOpenChange: setIsOpen,
middleware: [offset({ crossAxis: -10 }), flip(), shift()],
whileElementsMounted: autoUpdate,
placement: "left-start",
});
popup.current?.setProps({ const dismiss = useDismiss(context);
getReferenceClientRect: () => target.getBoundingClientRect(), const { getFloatingProps } = useInteractions([dismiss]);
});
popup.current?.show(); // Handle click on drag handle
return; const handleClickDragHandle = useCallback(
} (event: MouseEvent) => {
const target = event.target as HTMLElement;
const dragHandle = target.closest("#drag-handle");
popup.current?.hide(); if (dragHandle) {
return; 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(() => { useEffect(() => {
if (menuRef.current) { const handleKeyDown = (event: KeyboardEvent) => {
menuRef.current.remove(); if (event.key === "Escape") {
menuRef.current.style.visibility = "visible"; setIsOpen(false);
}
// @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;
};
}, []);
useEffect(() => {
const handleKeyDown = () => {
popup.current?.hide();
}; };
const handleScroll = () => { const handleScroll = () => {
popup.current?.hide(); setIsOpen(false);
}; };
document.addEventListener("click", handleClickDragHandle); document.addEventListener("click", handleClickDragHandle);
document.addEventListener("contextmenu", handleClickDragHandle); document.addEventListener("contextmenu", handleClickDragHandle);
@ -84,6 +105,23 @@ export const BlockMenu = (props: Props) => {
}; };
}, [handleClickDragHandle]); }, [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: { const MENU_ITEMS: {
icon: LucideIcon; icon: LucideIcon;
key: string; key: string;
@ -96,10 +134,13 @@ export const BlockMenu = (props: Props) => {
key: "delete", key: "delete",
label: "Delete", label: "Delete",
onClick: (e) => { onClick: (e) => {
editor.chain().deleteSelection().focus().run();
popup.current?.hide();
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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); console.error(error.message);
} }
} }
setIsOpen(false);
popup.current?.hide();
}, },
}, },
]; ];
if (!isOpen) {
return null;
}
return ( return (
<div <FloatingPortal>
ref={menuRef} <div
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" ref={(node) => {
> refs.setFloating(node);
{MENU_ITEMS.map((item) => { menuRef.current = node;
// Skip rendering the button if it should be disabled }}
if (item.isDisabled && item.key === "duplicate") { style={{
return null; ...floatingStyles,
} zIndex: 99,
animationFillMode: "forwards",
return ( transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
<button }}
key={item.key} className={cn(
type="button" "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",
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" "transition-all duration-300 transform origin-top-right",
onClick={item.onClick} isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
disabled={item.isDisabled} )}
> data-prevent-outside-click
<item.icon className="h-3 w-3" /> {...getFloatingProps()}
{item.label} >
</button> {MENU_ITEMS.map((item) => {
); if (item.isDisabled) {
})} return null;
</div> }
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>
); );
}; };