7.7 KB255 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import {Icon} from 'isl-components/Icon';
9import {findParentWithClassName} from 'isl-components/utils';
10import {getZoomLevel} from 'isl-components/zoom';
11import {atom, useAtom, useSetAtom} from 'jotai';
12import React, {useEffect, useRef, useState} from 'react';
13
14import './ContextMenu.css';
15
16/**
17 * Hook to create a context menu in HTML.
18 * Pass in a function that returns the list of context menu items.
19 * Then use the result in onContextMenu:
20 * ```
21 * function MyComponent() {
22 * const menu = useContextMenu(() => [
23 * {label: 'Choice 1', onClick: () => console.log('clicked!')}
24 * ]);
25 * return <div onContextMenu={menu}>...</div>
26 * }
27 * ```
28 */
29export function useContextMenu<T>(
30 creator: () => Array<ContextMenuItem>,
31): React.MouseEventHandler<T> {
32 const setState = useSetAtom(contextMenuState);
33 return e => {
34 const zoom = getZoomLevel();
35 const items = creator();
36 if (items.length === 0) {
37 return;
38 }
39 setState({x: e.clientX / zoom, y: e.clientY / zoom, items});
40
41 e.preventDefault();
42 e.stopPropagation();
43 };
44}
45
46type ContextMenuData = {x: number; y: number; items: Array<ContextMenuItem>};
47export type ContextMenuItem =
48 | {type?: undefined; label: string | React.ReactNode; onClick?: () => void; tooltip?: string}
49 | {
50 type: 'submenu';
51 label: string | React.ReactNode;
52 children: Array<ContextMenuItem>;
53 }
54 | {type: 'divider'};
55
56export const contextMenuState = atom<null | ContextMenuData>(null);
57
58export function ContextMenus() {
59 const [state, setState] = useAtom(contextMenuState);
60
61 const ref = useRef<HTMLDivElement>(null);
62
63 useEffect(() => {
64 if (state != null) {
65 const hide = (e: Event) => {
66 if (e.type === 'keyup') {
67 if ((e as KeyboardEvent).key === 'Escape') {
68 setState(null);
69 }
70 return;
71 } else if (e.type === 'click' || e.type === 'scroll') {
72 // if click or scroll inside the context menu, don't dismiss
73 if (findParentWithClassName(e.target as HTMLElement, 'context-menu-container')) {
74 return;
75 }
76 }
77 setState(null);
78 };
79 window.addEventListener('click', hide, true);
80 window.addEventListener('scroll', hide, true);
81 window.addEventListener('resize', hide, true);
82 window.addEventListener('keyup', hide, true);
83 return () => {
84 window.removeEventListener('click', hide, true);
85 window.removeEventListener('scroll', hide, true);
86 window.removeEventListener('resize', hide, true);
87 window.removeEventListener('keyup', hide, true);
88 };
89 }
90 }, [state, setState]);
91
92 if (state == null) {
93 return null;
94 }
95
96 const zoom = getZoomLevel();
97 const topOrBottom = state.y > window.innerHeight / zoom / 2 ? 'bottom' : 'top';
98 const leftOrRight = state.x > window.innerWidth / zoom / 2 ? 'right' : 'left';
99 const yOffset = 10;
100 const xOffset = -10; // var(--pad)
101 let position: React.CSSProperties;
102 if (topOrBottom === 'top') {
103 if (leftOrRight === 'left') {
104 position = {top: state.y + yOffset, left: state.x + xOffset};
105 } else {
106 position = {top: state.y + yOffset, right: window.innerWidth / zoom - (state.x - xOffset)};
107 }
108 } else {
109 if (leftOrRight === 'left') {
110 position = {bottom: window.innerHeight / zoom - (state.y - yOffset), left: state.x + xOffset};
111 } else {
112 position = {
113 bottom: window.innerHeight / zoom - (state.y - yOffset),
114 right: window.innerWidth / zoom - (state.x - xOffset),
115 };
116 }
117 }
118 position.maxHeight =
119 window.innerHeight / zoom -
120 ((position.top as number | null) ?? 0) -
121 ((position.bottom as number | null) ?? 0);
122
123 return (
124 <div
125 ref={ref}
126 className={'context-menu-container'}
127 data-testid="context-menu-container"
128 style={position}>
129 {topOrBottom === 'top' ? (
130 <div
131 className={`context-menu-arrow context-menu-arrow-top context-menu-arrow-${leftOrRight}`}
132 />
133 ) : null}
134 <ContextMenuList
135 items={state.items}
136 clickItem={item => {
137 if (item.type != null) {
138 return;
139 }
140 // don't allow double-clicking to run the action twice
141 if (state != null) {
142 item.onClick?.();
143 setState(null);
144 }
145 }}
146 />
147
148 {topOrBottom === 'bottom' ? (
149 <div
150 className={`context-menu-arrow context-menu-arrow-bottom context-menu-arrow-${leftOrRight}`}
151 />
152 ) : null}
153 </div>
154 );
155}
156
157function ContextMenuList({
158 items,
159 clickItem,
160}: {
161 items: Array<ContextMenuItem>;
162 clickItem: (item: ContextMenuItem) => void;
163}) {
164 // Each ContextMenuList renders one additional layer of submenu
165 const [submenuNavigation, setSubmenuNavigation] = useState<
166 {x: number; y: number; children: Array<ContextMenuItem>} | undefined
167 >(undefined);
168 const [tooltip, setTooltip] = useState<{x: number; y: number; tooltip: string} | undefined>(
169 undefined,
170 );
171 const ref = useRef<HTMLDivElement | null>(null);
172
173 function getCoordinatesForSubElement(e: React.PointerEvent) {
174 const target = e.currentTarget as HTMLElement;
175 const parent = ref.current;
176 if (!parent) {
177 return;
178 }
179 const parentRect = parent?.getBoundingClientRect();
180 const rect = target.getBoundingClientRect();
181 // attach to top right corner
182 const x = -1 * parentRect.left + rect.right;
183 const y = -1 * parentRect.top + rect.top;
184 return {x, y};
185 }
186
187 return (
188 <>
189 <div className="context-menu" ref={ref}>
190 {items.map((item, i) =>
191 item.type === 'divider' ? (
192 <div className="context-menu-divider" key={i} />
193 ) : item.type === 'submenu' ? (
194 <div
195 key={i}
196 className={'context-menu-item context-menu-submenu'}
197 onPointerEnter={e => {
198 const coordinates = getCoordinatesForSubElement(e);
199 if (!coordinates) {
200 return;
201 }
202 const {x, y} = coordinates;
203 setSubmenuNavigation({
204 x,
205 y,
206 children: item.children,
207 });
208 setTooltip(undefined);
209 }}>
210 <span>{item.label}</span>
211 <Icon icon="chevron-right" />
212 </div>
213 ) : (
214 <div
215 key={i}
216 onPointerEnter={e => {
217 if (item.tooltip) {
218 const coordinates = getCoordinatesForSubElement(e);
219 if (!coordinates) {
220 return;
221 }
222 const {x, y} = coordinates;
223 setTooltip({x, y, tooltip: item.tooltip});
224 } else {
225 setTooltip(undefined);
226 }
227 setSubmenuNavigation(undefined);
228 }}
229 onClick={() => {
230 clickItem(item);
231 }}
232 className={'context-menu-item'}>
233 {item.label}
234 </div>
235 ),
236 )}
237 </div>
238 {submenuNavigation != null && (
239 <div
240 className="context-menu-submenu-navigation"
241 style={{position: 'absolute', top: submenuNavigation.y, left: submenuNavigation.x}}>
242 <ContextMenuList items={submenuNavigation.children} clickItem={clickItem} />
243 </div>
244 )}
245 {tooltip != null && (
246 <div
247 className="context-menu-tooltip"
248 style={{position: 'absolute', top: tooltip.y, left: tooltip.x}}>
249 {tooltip.tooltip}
250 </div>
251 )}
252 </>
253 );
254}
255