1
+ import {
2
+ useFloating ,
3
+ offset ,
4
+ flip ,
5
+ shift ,
6
+ autoUpdate ,
7
+ useDismiss ,
8
+ useInteractions ,
9
+ FloatingPortal ,
10
+ } from "@floating-ui/react" ;
1
11
import type { Editor } from "@tiptap/react" ;
2
12
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" ;
5
14
// constants
15
+ import { cn } from "@plane/utils" ;
6
16
import { CORE_EXTENSIONS } from "@/constants/extension" ;
7
17
import { IEditorProps } from "@/types" ;
8
18
@@ -14,62 +24,73 @@ type Props = {
14
24
15
25
export const BlockMenu = ( props : Props ) => {
16
26
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
+ ) ;
65
84
66
85
useEffect ( ( ) => {
67
- const handleKeyDown = ( ) => {
68
- popup . current ?. hide ( ) ;
86
+ const handleKeyDown = ( event : KeyboardEvent ) => {
87
+ if ( event . key === "Escape" ) {
88
+ setIsOpen ( false ) ;
89
+ }
69
90
} ;
70
91
71
92
const handleScroll = ( ) => {
72
- popup . current ?. hide ( ) ;
93
+ setIsOpen ( false ) ;
73
94
} ;
74
95
document . addEventListener ( "click" , handleClickDragHandle ) ;
75
96
document . addEventListener ( "contextmenu" , handleClickDragHandle ) ;
@@ -84,6 +105,23 @@ export const BlockMenu = (props: Props) => {
84
105
} ;
85
106
} , [ handleClickDragHandle ] ) ;
86
107
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
+
87
125
const MENU_ITEMS : {
88
126
icon : LucideIcon ;
89
127
key : string ;
@@ -96,10 +134,13 @@ export const BlockMenu = (props: Props) => {
96
134
key : "delete" ,
97
135
label : "Delete" ,
98
136
onClick : ( e ) => {
99
- editor . chain ( ) . deleteSelection ( ) . focus ( ) . run ( ) ;
100
- popup . current ?. hide ( ) ;
101
137
e . preventDefault ( ) ;
102
138
e . stopPropagation ( ) ;
139
+
140
+ // Execute the delete action
141
+ editor . chain ( ) . deleteSelection ( ) . focus ( ) . run ( ) ;
142
+
143
+ setIsOpen ( false ) ;
103
144
} ,
104
145
} ,
105
146
{
@@ -146,36 +187,53 @@ export const BlockMenu = (props: Props) => {
146
187
console . error ( error . message ) ;
147
188
}
148
189
}
149
-
150
- popup . current ?. hide ( ) ;
190
+ setIsOpen ( false ) ;
151
191
} ,
152
192
} ,
153
193
] ;
154
194
195
+ if ( ! isOpen ) {
196
+ return null ;
197
+ }
155
198
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 >
180
238
) ;
181
239
} ;
0 commit comments