Skip to content

Commit c2d359a

Browse files
committed
refactor[react-devtools]: rewrite context menus
1 parent 9d76c95 commit c2d359a

File tree

17 files changed

+769
-624
lines changed

17 files changed

+769
-624
lines changed

packages/react-devtools-shared/src/backend/utils.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,15 @@ export function serializeToString(data: any): string {
140140
return 'undefined';
141141
}
142142

143+
if (typeof data === 'function') {
144+
return data.toString();
145+
}
146+
143147
const cache = new Set<mixed>();
144148
// Use a custom replacer function to protect against circular references.
145149
return JSON.stringify(
146150
data,
147-
(key, value) => {
151+
(key: string, value: any) => {
148152
if (typeof value === 'object' && value !== null) {
149153
if (cache.has(value)) {
150154
return;

packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
overflow: hidden;
77
z-index: 10000002;
88
user-select: none;
9-
}
9+
}

packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js

Lines changed: 80 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -8,141 +8,110 @@
88
*/
99

1010
import * as React from 'react';
11-
import {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
11+
import {useLayoutEffect, createRef} from 'react';
1212
import {createPortal} from 'react-dom';
13-
import {RegistryContext} from './Contexts';
1413

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';
1621

17-
import type {RegistryContextType} from './Contexts';
22+
import styles from './ContextMenu.css';
1823

19-
function repositionToFit(element: HTMLElement, pageX: number, pageY: number) {
24+
function repositionToFit(element: HTMLElement, x: number, y: number) {
2025
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`;
2829
} else {
29-
element.style.top = `${pageY}px`;
30+
element.style.top = '0px';
3031
}
32+
} else {
33+
element.style.top = `${y}px`;
34+
}
3135

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`;
3839
} else {
39-
element.style.left = `${pageX}px`;
40+
element.style.left = '0px';
4041
}
42+
} else {
43+
element.style.left = `${x}px`;
4144
}
4245
}
4346

44-
const HIDDEN_STATE = {
45-
data: null,
46-
isVisible: false,
47-
pageX: 0,
48-
pageY: 0,
49-
};
50-
5147
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,
5453
};
5554

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+
}
6170

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+
);
6575

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);
7378

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();
7882
}
7983
}
80-
}, []);
8184

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);
9788

98-
useLayoutEffect(() => {
99-
if (!state.isVisible) {
100-
return;
101-
}
89+
const ownerWindow = ownerDocument.defaultView;
90+
ownerWindow.addEventListener('resize', hide);
10291

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);
13293

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;
147105
}
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+
);
148117
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
* @flow
8+
*/
9+
10+
import * as React from 'react';
11+
import {useImperativeHandle} from 'react';
12+
13+
import ContextMenu from './ContextMenu';
14+
import useContextMenu from './useContextMenu';
15+
16+
import type {ContextMenuItem, ContextMenuRef} from './types';
17+
18+
type Props = {
19+
anchorElementRef: {
20+
current: React.ElementRef<any> | null,
21+
},
22+
items: ContextMenuItem[],
23+
closedMenuStub?: React.Node | null,
24+
ref?: ContextMenuRef,
25+
};
26+
27+
export default function ContextMenuContainer({
28+
anchorElementRef,
29+
items,
30+
closedMenuStub = null,
31+
ref,
32+
}: Props): React.Node {
33+
const {shouldShow, position, hide} = useContextMenu(anchorElementRef);
34+
35+
useImperativeHandle(
36+
ref,
37+
() => ({
38+
isShown() {
39+
return shouldShow;
40+
},
41+
hide,
42+
}),
43+
[shouldShow, hide],
44+
);
45+
46+
if (!shouldShow) {
47+
return closedMenuStub;
48+
}
49+
50+
return (
51+
<ContextMenu
52+
anchorElementRef={anchorElementRef}
53+
position={position}
54+
hide={hide}
55+
items={items}
56+
ref={ref}
57+
/>
58+
);
59+
}

packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@
88
font-family: var(--font-family-sans);
99
font-size: var(--font-size-sans-normal);
1010
}
11+
1112
.ContextMenuItem:first-of-type {
1213
border-top: none;
1314
}
15+
1416
.ContextMenuItem:hover,
1517
.ContextMenuItem:focus {
1618
outline: 0;
1719
background-color: var(--color-context-background-hover);
1820
}
21+
1922
.ContextMenuItem:active {
2023
background-color: var(--color-context-background-selected);
2124
color: var(--color-context-text-selected);
22-
}
25+
}

packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,23 @@
88
*/
99

1010
import * as React from 'react';
11-
import {useContext} from 'react';
12-
import {RegistryContext} from './Contexts';
1311

1412
import styles from './ContextMenuItem.css';
1513

16-
import type {RegistryContextType} from './Contexts';
17-
1814
type Props = {
19-
children: React$Node,
15+
children: React.Node,
2016
onClick: () => void,
21-
title: string,
17+
hide: () => void,
2218
};
2319

2420
export default function ContextMenuItem({
2521
children,
2622
onClick,
27-
title,
23+
hide,
2824
}: Props): React.Node {
29-
const {hideMenu} = useContext<RegistryContextType>(RegistryContext);
30-
31-
const handleClick = (event: any) => {
25+
const handleClick = () => {
3226
onClick();
33-
hideMenu();
27+
hide();
3428
};
3529

3630
return (

0 commit comments

Comments
 (0)