Skip to content

Commit 14e3aac

Browse files
[WIKI-623] fix: add block menu to rich text editor (#7813)
* fix : block menu for rich editor * chore: remove comments * chore : update selection logic
1 parent 36d3284 commit 14e3aac

File tree

2 files changed

+146
-83
lines changed

2 files changed

+146
-83
lines changed

packages/editor/src/core/components/editors/rich-text/editor.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { forwardRef, useCallback } from "react";
22
// components
33
import { EditorWrapper } from "@/components/editors";
4-
import { EditorBubbleMenu } from "@/components/menus";
4+
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
55
// extensions
66
import { SideMenuExtension } from "@/extensions";
77
// plane editor imports
@@ -40,7 +40,12 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
4040

4141
return (
4242
<EditorWrapper {...props} extensions={getExtensions()}>
43-
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
43+
{(editor) => (
44+
<>
45+
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
46+
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
47+
</>
48+
)}
4449
</EditorWrapper>
4550
);
4651
};
Lines changed: 139 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
import {
2+
useFloating,
3+
offset,
4+
flip,
5+
shift,
6+
autoUpdate,
7+
useDismiss,
8+
useInteractions,
9+
FloatingPortal,
10+
} from "@floating-ui/react";
111
import type { Editor } from "@tiptap/react";
212
import { Copy, LucideIcon, Trash2 } from "lucide-react";
3-
import { useCallback, useEffect, useRef } from "react";
4-
import tippy, { Instance } from "tippy.js";
13+
import { useCallback, useEffect, useRef, useState } from "react";
514
// constants
15+
import { cn } from "@plane/utils";
616
import { CORE_EXTENSIONS } from "@/constants/extension";
717
import { IEditorProps } from "@/types";
818

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

1525
export const BlockMenu = (props: Props) => {
1626
const { editor } = props;
17-
const menuRef = useRef<HTMLDivElement>(null);
18-
const popup = useRef<Instance | null>(null);
19-
20-
const handleClickDragHandle = useCallback((event: MouseEvent) => {
21-
const target = event.target as HTMLElement;
22-
if (target.matches("#drag-handle")) {
23-
event.preventDefault();
24-
25-
popup.current?.setProps({
26-
getReferenceClientRect: () => target.getBoundingClientRect(),
27-
});
28-
29-
popup.current?.show();
30-
return;
31-
}
32-
33-
popup.current?.hide();
34-
return;
35-
}, []);
36-
37-
useEffect(() => {
38-
if (menuRef.current) {
39-
menuRef.current.remove();
40-
menuRef.current.style.visibility = "visible";
41-
42-
// @ts-expect-error - Tippy types are incorrect
43-
popup.current = tippy(document.body, {
44-
getReferenceClientRect: null,
45-
content: menuRef.current,
46-
appendTo: () => document.querySelector(".frame-renderer"),
47-
trigger: "manual",
48-
interactive: true,
49-
arrow: false,
50-
placement: "left-start",
51-
animation: "shift-away",
52-
maxWidth: 500,
53-
hideOnClick: true,
54-
onShown: () => {
55-
menuRef.current?.focus();
56-
},
57-
});
58-
}
59-
60-
return () => {
61-
popup.current?.destroy();
62-
popup.current = null;
63-
};
64-
}, []);
27+
const [isOpen, setIsOpen] = useState(false);
28+
const [isAnimatedIn, setIsAnimatedIn] = useState(false);
29+
const menuRef = useRef<HTMLDivElement | null>(null);
30+
const virtualReferenceRef = useRef<{ getBoundingClientRect: () => DOMRect }>({
31+
getBoundingClientRect: () => new DOMRect(),
32+
});
33+
34+
// Set up Floating UI with virtual reference element
35+
const { refs, floatingStyles, context } = useFloating({
36+
open: isOpen,
37+
onOpenChange: setIsOpen,
38+
middleware: [offset({ crossAxis: -10 }), flip(), shift()],
39+
whileElementsMounted: autoUpdate,
40+
placement: "left-start",
41+
});
42+
43+
const dismiss = useDismiss(context);
44+
const { getFloatingProps } = useInteractions([dismiss]);
45+
46+
// Handle click on drag handle
47+
const handleClickDragHandle = useCallback(
48+
(event: MouseEvent) => {
49+
const target = event.target as HTMLElement;
50+
const dragHandle = target.closest("#drag-handle");
51+
52+
if (dragHandle) {
53+
event.preventDefault();
54+
55+
// Update virtual reference with current drag handle position
56+
virtualReferenceRef.current = {
57+
getBoundingClientRect: () => dragHandle.getBoundingClientRect(),
58+
};
59+
60+
// Set the virtual reference as the reference element
61+
refs.setReference(virtualReferenceRef.current);
62+
63+
// Ensure the targeted block is selected
64+
const rect = dragHandle.getBoundingClientRect();
65+
const coords = { left: rect.left + rect.width / 2, top: rect.top + rect.height / 2 };
66+
const posAtCoords = editor.view.posAtCoords(coords);
67+
if (posAtCoords) {
68+
const $pos = editor.state.doc.resolve(posAtCoords.pos);
69+
const nodePos = $pos.before($pos.depth);
70+
editor.chain().setNodeSelection(nodePos).run();
71+
}
72+
// Show the menu
73+
setIsOpen(true);
74+
return;
75+
}
76+
77+
// If clicking outside and not on a menu item, hide the menu
78+
if (menuRef.current && !menuRef.current.contains(target)) {
79+
setIsOpen(false);
80+
}
81+
},
82+
[refs]
83+
);
6584

6685
useEffect(() => {
67-
const handleKeyDown = () => {
68-
popup.current?.hide();
86+
const handleKeyDown = (event: KeyboardEvent) => {
87+
if (event.key === "Escape") {
88+
setIsOpen(false);
89+
}
6990
};
7091

7192
const handleScroll = () => {
72-
popup.current?.hide();
93+
setIsOpen(false);
7394
};
7495
document.addEventListener("click", handleClickDragHandle);
7596
document.addEventListener("contextmenu", handleClickDragHandle);
@@ -84,6 +105,23 @@ export const BlockMenu = (props: Props) => {
84105
};
85106
}, [handleClickDragHandle]);
86107

108+
// Animation effect
109+
useEffect(() => {
110+
if (isOpen) {
111+
setIsAnimatedIn(false);
112+
// Add a small delay before starting the animation
113+
const timeout = setTimeout(() => {
114+
requestAnimationFrame(() => {
115+
setIsAnimatedIn(true);
116+
});
117+
}, 50);
118+
119+
return () => clearTimeout(timeout);
120+
} else {
121+
setIsAnimatedIn(false);
122+
}
123+
}, [isOpen]);
124+
87125
const MENU_ITEMS: {
88126
icon: LucideIcon;
89127
key: string;
@@ -96,10 +134,13 @@ export const BlockMenu = (props: Props) => {
96134
key: "delete",
97135
label: "Delete",
98136
onClick: (e) => {
99-
editor.chain().deleteSelection().focus().run();
100-
popup.current?.hide();
101137
e.preventDefault();
102138
e.stopPropagation();
139+
140+
// Execute the delete action
141+
editor.chain().deleteSelection().focus().run();
142+
143+
setIsOpen(false);
103144
},
104145
},
105146
{
@@ -146,36 +187,53 @@ export const BlockMenu = (props: Props) => {
146187
console.error(error.message);
147188
}
148189
}
149-
150-
popup.current?.hide();
190+
setIsOpen(false);
151191
},
152192
},
153193
];
154194

195+
if (!isOpen) {
196+
return null;
197+
}
155198
return (
156-
<div
157-
ref={menuRef}
158-
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"
159-
>
160-
{MENU_ITEMS.map((item) => {
161-
// Skip rendering the button if it should be disabled
162-
if (item.isDisabled && item.key === "duplicate") {
163-
return null;
164-
}
165-
166-
return (
167-
<button
168-
key={item.key}
169-
type="button"
170-
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"
171-
onClick={item.onClick}
172-
disabled={item.isDisabled}
173-
>
174-
<item.icon className="h-3 w-3" />
175-
{item.label}
176-
</button>
177-
);
178-
})}
179-
</div>
199+
<FloatingPortal>
200+
<div
201+
ref={(node) => {
202+
refs.setFloating(node);
203+
menuRef.current = node;
204+
}}
205+
style={{
206+
...floatingStyles,
207+
zIndex: 99,
208+
animationFillMode: "forwards",
209+
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
210+
}}
211+
className={cn(
212+
"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",
213+
"transition-all duration-300 transform origin-top-right",
214+
isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
215+
)}
216+
data-prevent-outside-click
217+
{...getFloatingProps()}
218+
>
219+
{MENU_ITEMS.map((item) => {
220+
if (item.isDisabled) {
221+
return null;
222+
}
223+
return (
224+
<button
225+
key={item.key}
226+
type="button"
227+
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"
228+
onClick={item.onClick}
229+
disabled={item.isDisabled}
230+
>
231+
<item.icon className="h-3 w-3" />
232+
{item.label}
233+
</button>
234+
);
235+
})}
236+
</div>
237+
</FloatingPortal>
180238
);
181239
};

0 commit comments

Comments
 (0)