|
8 | 8 | */ |
9 | 9 |
|
10 | 10 | import * as React from 'react'; |
11 | | -import {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; |
| 11 | +import {useLayoutEffect, createRef} from 'react'; |
12 | 12 | import {createPortal} from 'react-dom'; |
13 | | -import {RegistryContext} from './Contexts'; |
14 | 13 |
|
15 | | -import styles from './ContextMenu.css'; |
| 14 | +import ContextMenuItem from './ContextMenuItem'; |
| 15 | + |
| 16 | +import type { |
| 17 | + ContextMenuItem as ContextMenuItemType, |
| 18 | + ContextMenuPosition, |
| 19 | + ContextMenuRef, |
| 20 | +} from './types'; |
16 | 21 |
|
17 | | -import type {RegistryContextType} from './Contexts'; |
| 22 | +import styles from './ContextMenu.css'; |
18 | 23 |
|
19 | | -function repositionToFit(element: HTMLElement, pageX: number, pageY: number) { |
| 24 | +function repositionToFit(element: HTMLElement, x: number, y: number) { |
20 | 25 | const ownerWindow = element.ownerDocument.defaultView; |
21 | | - if (element !== null) { |
22 | | - if (pageY + element.offsetHeight >= ownerWindow.innerHeight) { |
23 | | - if (pageY - element.offsetHeight > 0) { |
24 | | - element.style.top = `${pageY - element.offsetHeight}px`; |
25 | | - } else { |
26 | | - element.style.top = '0px'; |
27 | | - } |
| 26 | + if (y + element.offsetHeight >= ownerWindow.innerHeight) { |
| 27 | + if (y - element.offsetHeight > 0) { |
| 28 | + element.style.top = `${y - element.offsetHeight}px`; |
28 | 29 | } else { |
29 | | - element.style.top = `${pageY}px`; |
| 30 | + element.style.top = '0px'; |
30 | 31 | } |
| 32 | + } else { |
| 33 | + element.style.top = `${y}px`; |
| 34 | + } |
31 | 35 |
|
32 | | - if (pageX + element.offsetWidth >= ownerWindow.innerWidth) { |
33 | | - if (pageX - element.offsetWidth > 0) { |
34 | | - element.style.left = `${pageX - element.offsetWidth}px`; |
35 | | - } else { |
36 | | - element.style.left = '0px'; |
37 | | - } |
| 36 | + if (x + element.offsetWidth >= ownerWindow.innerWidth) { |
| 37 | + if (x - element.offsetWidth > 0) { |
| 38 | + element.style.left = `${x - element.offsetWidth}px`; |
38 | 39 | } else { |
39 | | - element.style.left = `${pageX}px`; |
| 40 | + element.style.left = '0px'; |
40 | 41 | } |
| 42 | + } else { |
| 43 | + element.style.left = `${x}px`; |
41 | 44 | } |
42 | 45 | } |
43 | 46 |
|
44 | | -const HIDDEN_STATE = { |
45 | | - data: null, |
46 | | - isVisible: false, |
47 | | - pageX: 0, |
48 | | - pageY: 0, |
49 | | -}; |
50 | | - |
51 | 47 | type Props = { |
52 | | - children: (data: Object) => React$Node, |
53 | | - id: string, |
| 48 | + anchorElementRef: {current: React.ElementRef<any> | null}, |
| 49 | + items: ContextMenuItemType[], |
| 50 | + position: ContextMenuPosition, |
| 51 | + hide: () => void, |
| 52 | + ref?: ContextMenuRef, |
54 | 53 | }; |
55 | 54 |
|
56 | | -export default function ContextMenu({children, id}: Props): React.Node { |
57 | | - const {hideMenu, registerMenu} = |
58 | | - useContext<RegistryContextType>(RegistryContext); |
59 | | - |
60 | | - const [state, setState] = useState(HIDDEN_STATE); |
| 55 | +export default function ContextMenu({ |
| 56 | + anchorElementRef, |
| 57 | + position, |
| 58 | + items, |
| 59 | + hide, |
| 60 | + ref = createRef(), |
| 61 | +}: Props): React.Node { |
| 62 | + // This works on the assumption that ContextMenu component is only rendered when it should be shown |
| 63 | + const anchor = anchorElementRef.current; |
| 64 | + |
| 65 | + if (anchor == null) { |
| 66 | + throw new Error( |
| 67 | + 'Attempted to open a context menu for an element, which is not mounted', |
| 68 | + ); |
| 69 | + } |
61 | 70 |
|
62 | | - const bodyAccessorRef = useRef(null); |
63 | | - const containerRef = useRef(null); |
64 | | - const menuRef = useRef(null); |
| 71 | + const ownerDocument = anchor.ownerDocument; |
| 72 | + const portalContainer = ownerDocument.querySelector( |
| 73 | + '[data-react-devtools-portal-root]', |
| 74 | + ); |
65 | 75 |
|
66 | | - useEffect(() => { |
67 | | - const element = bodyAccessorRef.current; |
68 | | - if (element !== null) { |
69 | | - const ownerDocument = element.ownerDocument; |
70 | | - containerRef.current = ownerDocument.querySelector( |
71 | | - '[data-react-devtools-portal-root]', |
72 | | - ); |
| 76 | + useLayoutEffect(() => { |
| 77 | + const menu = ((ref.current: any): HTMLElement); |
73 | 78 |
|
74 | | - if (containerRef.current == null) { |
75 | | - console.warn( |
76 | | - 'DevTools tooltip root node not found; context menus will be disabled.', |
77 | | - ); |
| 79 | + function hideUnlessContains(event: Event) { |
| 80 | + if (!menu.contains(((event.target: any): Node))) { |
| 81 | + hide(); |
78 | 82 | } |
79 | 83 | } |
80 | | - }, []); |
81 | 84 |
|
82 | | - useEffect(() => { |
83 | | - const showMenuFn = ({ |
84 | | - data, |
85 | | - pageX, |
86 | | - pageY, |
87 | | - }: { |
88 | | - data: any, |
89 | | - pageX: number, |
90 | | - pageY: number, |
91 | | - }) => { |
92 | | - setState({data, isVisible: true, pageX, pageY}); |
93 | | - }; |
94 | | - const hideMenuFn = () => setState(HIDDEN_STATE); |
95 | | - return registerMenu(id, showMenuFn, hideMenuFn); |
96 | | - }, [id]); |
| 85 | + ownerDocument.addEventListener('mousedown', hideUnlessContains); |
| 86 | + ownerDocument.addEventListener('touchstart', hideUnlessContains); |
| 87 | + ownerDocument.addEventListener('keydown', hideUnlessContains); |
97 | 88 |
|
98 | | - useLayoutEffect(() => { |
99 | | - if (!state.isVisible) { |
100 | | - return; |
101 | | - } |
| 89 | + const ownerWindow = ownerDocument.defaultView; |
| 90 | + ownerWindow.addEventListener('resize', hide); |
102 | 91 |
|
103 | | - const menu = ((menuRef.current: any): HTMLElement); |
104 | | - const container = containerRef.current; |
105 | | - if (container !== null) { |
106 | | - // $FlowFixMe[missing-local-annot] |
107 | | - const hideUnlessContains = event => { |
108 | | - if (!menu.contains(event.target)) { |
109 | | - hideMenu(); |
110 | | - } |
111 | | - }; |
112 | | - |
113 | | - const ownerDocument = container.ownerDocument; |
114 | | - ownerDocument.addEventListener('mousedown', hideUnlessContains); |
115 | | - ownerDocument.addEventListener('touchstart', hideUnlessContains); |
116 | | - ownerDocument.addEventListener('keydown', hideUnlessContains); |
117 | | - |
118 | | - const ownerWindow = ownerDocument.defaultView; |
119 | | - ownerWindow.addEventListener('resize', hideMenu); |
120 | | - |
121 | | - repositionToFit(menu, state.pageX, state.pageY); |
122 | | - |
123 | | - return () => { |
124 | | - ownerDocument.removeEventListener('mousedown', hideUnlessContains); |
125 | | - ownerDocument.removeEventListener('touchstart', hideUnlessContains); |
126 | | - ownerDocument.removeEventListener('keydown', hideUnlessContains); |
127 | | - |
128 | | - ownerWindow.removeEventListener('resize', hideMenu); |
129 | | - }; |
130 | | - } |
131 | | - }, [state]); |
| 92 | + repositionToFit(menu, position.x, position.y); |
132 | 93 |
|
133 | | - if (!state.isVisible) { |
134 | | - return <div ref={bodyAccessorRef} />; |
135 | | - } else { |
136 | | - const container = containerRef.current; |
137 | | - if (container !== null) { |
138 | | - return createPortal( |
139 | | - <div ref={menuRef} className={styles.ContextMenu}> |
140 | | - {children(state.data)} |
141 | | - </div>, |
142 | | - container, |
143 | | - ); |
144 | | - } else { |
145 | | - return null; |
146 | | - } |
| 94 | + return () => { |
| 95 | + ownerDocument.removeEventListener('mousedown', hideUnlessContains); |
| 96 | + ownerDocument.removeEventListener('touchstart', hideUnlessContains); |
| 97 | + ownerDocument.removeEventListener('keydown', hideUnlessContains); |
| 98 | + |
| 99 | + ownerWindow.removeEventListener('resize', hide); |
| 100 | + }; |
| 101 | + }, []); |
| 102 | + |
| 103 | + if (portalContainer == null || items.length === 0) { |
| 104 | + return null; |
147 | 105 | } |
| 106 | + |
| 107 | + return createPortal( |
| 108 | + <div className={styles.ContextMenu} ref={ref}> |
| 109 | + {items.map(({onClick, content}, index) => ( |
| 110 | + <ContextMenuItem key={index} onClick={onClick} hide={hide}> |
| 111 | + {content} |
| 112 | + </ContextMenuItem> |
| 113 | + ))} |
| 114 | + </div>, |
| 115 | + portalContainer, |
| 116 | + ); |
148 | 117 | } |
0 commit comments