1
+ import {
2
+ useFloating ,
3
+ autoUpdate ,
4
+ offset ,
5
+ flip ,
6
+ shift ,
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" ;
14
+ import { cn } from "@plane/utils " ;
5
15
// constants
6
16
import { CORE_EXTENSIONS } from "@/constants/extension" ;
7
17
@@ -11,67 +21,71 @@ type Props = {
11
21
12
22
export const BlockMenu = ( props : Props ) => {
13
23
const { editor } = props ;
14
- const menuRef = useRef < HTMLDivElement > ( null ) ;
15
- const popup = useRef < Instance | null > ( null ) ;
16
-
17
- const handleClickDragHandle = useCallback ( ( event : MouseEvent ) => {
18
- const target = event . target as HTMLElement ;
19
- if ( target . matches ( "#drag-handle" ) ) {
20
- event . preventDefault ( ) ;
21
-
22
- popup . current ?. setProps ( {
23
- getReferenceClientRect : ( ) => target . getBoundingClientRect ( ) ,
24
- } ) ;
25
-
26
- popup . current ?. show ( ) ;
27
- return ;
28
- }
29
-
30
- popup . current ?. hide ( ) ;
31
- return ;
32
- } , [ ] ) ;
33
-
34
- useEffect ( ( ) => {
35
- if ( menuRef . current ) {
36
- menuRef . current . remove ( ) ;
37
- menuRef . current . style . visibility = "visible" ;
38
-
39
- // @ts -expect-error - Tippy types are incorrect
40
- popup . current = tippy ( document . body , {
41
- getReferenceClientRect : null ,
42
- content : menuRef . current ,
43
- appendTo : ( ) => document . querySelector ( ".frame-renderer" ) ,
44
- trigger : "manual" ,
45
- interactive : true ,
46
- arrow : false ,
47
- placement : "left-start" ,
48
- animation : "shift-away" ,
49
- maxWidth : 500 ,
50
- hideOnClick : true ,
51
- onShown : ( ) => {
52
- menuRef . current ?. focus ( ) ;
53
- } ,
54
- } ) ;
55
- }
56
-
57
- return ( ) => {
58
- popup . current ?. destroy ( ) ;
59
- popup . current = null ;
60
- } ;
61
- } , [ ] ) ;
24
+ const [ isOpen , setIsOpen ] = useState ( false ) ;
25
+ const [ isAnimatedIn , setIsAnimatedIn ] = useState ( false ) ;
26
+ const menuRef = useRef < HTMLDivElement | null > ( null ) ;
27
+ const virtualReferenceRef = useRef < { getBoundingClientRect : ( ) => DOMRect } > ( {
28
+ getBoundingClientRect : ( ) => new DOMRect ( ) ,
29
+ } ) ;
30
+
31
+ // Set up Floating UI with virtual reference element
32
+ const { refs , floatingStyles , context } = useFloating ( {
33
+ open : isOpen ,
34
+ onOpenChange : setIsOpen ,
35
+ middleware : [ offset ( { crossAxis : - 10 } ) , flip ( ) , shift ( ) ] ,
36
+ whileElementsMounted : autoUpdate ,
37
+ placement : "left-start" ,
38
+ } ) ;
39
+
40
+ const dismiss = useDismiss ( context ) ;
41
+ const { getFloatingProps } = useInteractions ( [ dismiss ] ) ;
42
+
43
+ // Handle click on drag handle
44
+ const handleClickDragHandle = useCallback (
45
+ ( event : MouseEvent ) => {
46
+ const target = event . target as HTMLElement ;
47
+ const dragHandle = target . closest ( "#drag-handle" ) ;
48
+
49
+ if ( dragHandle ) {
50
+ event . preventDefault ( ) ;
51
+
52
+ // Update virtual reference with current drag handle position
53
+ virtualReferenceRef . current = {
54
+ getBoundingClientRect : ( ) => dragHandle . getBoundingClientRect ( ) ,
55
+ } ;
56
+
57
+ // Set the virtual reference as the reference element
58
+ refs . setReference ( virtualReferenceRef . current ) ;
59
+
60
+ // Show the menu
61
+ setIsOpen ( true ) ;
62
+ return ;
63
+ }
64
+
65
+ // If clicking outside and not on a menu item, hide the menu
66
+ if ( menuRef . current && ! menuRef . current . contains ( target ) ) {
67
+ setIsOpen ( false ) ;
68
+ }
69
+ } ,
70
+ [ refs ]
71
+ ) ;
62
72
73
+ // Set up event listeners
63
74
useEffect ( ( ) => {
64
- const handleKeyDown = ( ) => {
65
- popup . current ?. hide ( ) ;
75
+ const handleKeyDown = ( event : KeyboardEvent ) => {
76
+ if ( event . key === "Escape" ) {
77
+ setIsOpen ( false ) ;
78
+ }
66
79
} ;
67
80
68
81
const handleScroll = ( ) => {
69
- popup . current ?. hide ( ) ;
82
+ setIsOpen ( false ) ;
70
83
} ;
84
+
71
85
document . addEventListener ( "click" , handleClickDragHandle ) ;
72
86
document . addEventListener ( "contextmenu" , handleClickDragHandle ) ;
73
87
document . addEventListener ( "keydown" , handleKeyDown ) ;
74
- document . addEventListener ( "scroll" , handleScroll , true ) ; // Using capture phase
88
+ document . addEventListener ( "scroll" , handleScroll , true ) ;
75
89
76
90
return ( ) => {
77
91
document . removeEventListener ( "click" , handleClickDragHandle ) ;
@@ -81,6 +95,23 @@ export const BlockMenu = (props: Props) => {
81
95
} ;
82
96
} , [ handleClickDragHandle ] ) ;
83
97
98
+ // Animation effect
99
+ useEffect ( ( ) => {
100
+ if ( isOpen ) {
101
+ setIsAnimatedIn ( false ) ;
102
+ // Add a small delay before starting the animation
103
+ const timeout = setTimeout ( ( ) => {
104
+ requestAnimationFrame ( ( ) => {
105
+ setIsAnimatedIn ( true ) ;
106
+ } ) ;
107
+ } , 50 ) ;
108
+
109
+ return ( ) => clearTimeout ( timeout ) ;
110
+ } else {
111
+ setIsAnimatedIn ( false ) ;
112
+ }
113
+ } , [ isOpen ] ) ;
114
+
84
115
const MENU_ITEMS : {
85
116
icon : LucideIcon ;
86
117
key : string ;
@@ -94,7 +125,7 @@ export const BlockMenu = (props: Props) => {
94
125
label : "Delete" ,
95
126
onClick : ( e ) => {
96
127
editor . chain ( ) . deleteSelection ( ) . focus ( ) . run ( ) ;
97
- popup . current ?. hide ( ) ;
128
+ setIsOpen ( false ) ;
98
129
e . preventDefault ( ) ;
99
130
e . stopPropagation ( ) ;
100
131
} ,
@@ -143,36 +174,51 @@ export const BlockMenu = (props: Props) => {
143
174
console . error ( error . message ) ;
144
175
}
145
176
}
146
-
147
- popup . current ?. hide ( ) ;
177
+ setIsOpen ( false ) ;
148
178
} ,
149
179
} ,
150
180
] ;
151
181
152
- return (
153
- < div
154
- ref = { menuRef }
155
- 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"
156
- >
157
- { MENU_ITEMS . map ( ( item ) => {
158
- // Skip rendering the button if it should be disabled
159
- if ( item . isDisabled && item . key === "duplicate" ) {
160
- return null ;
161
- }
182
+ if ( ! isOpen ) {
183
+ return null ;
184
+ }
162
185
163
- return (
164
- < button
165
- key = { item . key }
166
- type = "button"
167
- 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"
168
- onClick = { item . onClick }
169
- disabled = { item . isDisabled }
170
- >
171
- < item . icon className = "h-3 w-3" />
172
- { item . label }
173
- </ button >
174
- ) ;
175
- } ) }
176
- </ div >
186
+ return (
187
+ < FloatingPortal >
188
+ < div
189
+ ref = { ( node ) => {
190
+ refs . setFloating ( node ) ;
191
+ menuRef . current = node ;
192
+ } }
193
+ style = { {
194
+ ...floatingStyles ,
195
+ animationFillMode : "forwards" ,
196
+ transitionTimingFunction : "cubic-bezier(0.16, 1, 0.3, 1)" , // Expo ease out
197
+ } }
198
+ className = { cn (
199
+ "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" ,
200
+ "transition-all duration-300 transform origin-top-right" ,
201
+ isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
202
+ ) }
203
+ { ...getFloatingProps ( ) }
204
+ >
205
+ { MENU_ITEMS . map ( ( item ) => {
206
+ if ( item . isDisabled ) return null ;
207
+
208
+ return (
209
+ < button
210
+ key = { item . key }
211
+ type = "button"
212
+ 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"
213
+ onClick = { item . onClick }
214
+ disabled = { item . isDisabled }
215
+ >
216
+ < item . icon className = "h-3 w-3" />
217
+ { item . label }
218
+ </ button >
219
+ ) ;
220
+ } ) }
221
+ </ div >
222
+ </ FloatingPortal >
177
223
) ;
178
224
} ;
0 commit comments