@samitouri / QOS-React-1 / commits / d14ce51327

refactor[react-devtools]: rewrite context menus (#29049)

## Summary - While rolling out RDT 5.2.0 on Fusebox, we've discovered that context menus don't work well with this environment. The reason for it is the context menu state implementation - in a global context we define a map of registered context menus, basically what is shown at the moment (see deleted Contexts.js file). These maps are not invalidated on each re-initialization of DevTools frontend, since the bundle (react-devtools-fusebox module) is not reloaded, and this results into RDT throwing an error that some context menu was already registered. - We should not keep such data in a global state, since there is no guarantee that this will be invalidated with each re-initialization of DevTools (like with browser extension, for example). - The new implementation is based on a `ContextMenuContainer` component, which will add all required `contextmenu` event listeners to the anchor-element. This component will also receive a list of `items` that will be displayed in the shown context menu. - The `ContextMenuContainer` component is also using `useImperativeHandle` hook to extend the instance of the component, so context menus can be managed imperatively via `ref`: `contextMenu.current?.hide()`, for example. - **Changed**: The option for copying value to clipboard is now hidden for functions. The reasons for it are: - It is broken in the current implementation, because we call `JSON.stringify` on the value, see `packages/react-devtools-shared/src/backend/utils.js`. - I don't see any reasonable value in doing this for the user, since `Go to definition` option is available and you can inspect the real code and then copy it. - We already filter out fields from objects, if their value is a function, because the whole object is passed to `JSON.stringify`. ## How did you test this change? ### Works with element props and hooks: - All context menu items work reliably for props items - All context menu items work reliably or hooks items https://github.com/facebook/react/assets/28902667/5e2d58b0-92fa-4624-ad1e-2bbd7f12678f ### Works with timeline profiler: - All context menu items work reliably: copying, zooming, ... - Context menu automatically closes on the scroll event https://github.com/facebook/react/assets/28902667/de744cd0-372a-402a-9fa0-743857048d24 ### Works with Fusebox: - Produces no errors - Copy to clipboard context menu item works reliably https://github.com/facebook/react/assets/28902667/0288f5bf-0d44-435c-8842-6b57bc8a7a24

Ruslan Lesiutin committed May 20, 2024 at 15:12 UTC d14ce51327c1bd4daf78f5118ae23f8620ebad03
19 files changed +809 -642
packages/react-devtools-inline/__tests__/__e2e__/components.test.js
+35 -18
@@ -8,6 +8,7 @@ const devToolsUtils = require('./devtools-utils');
8 const {test, expect} = require('@playwright/test');
9 const config = require('../../playwright.config');
10 const semver = require('semver');
11 +
12 test.use(config);
13 test.describe('Components', () => {
14 let page;
@@ -59,41 +60,56 @@ test.describe('Components', () => {
60 const isEditableValue = semver.gte(config.use.react_version, '16.8.0');
61
62 // Then read the inspected values.
62 - const [propName, propValue] = await page.evaluate(
63 + const {
64 + name: propName,
65 + value: propValue,
66 + existingNameElementsSize,
67 + existingValueElementsSize,
68 + } = await page.evaluate(
69 isEditable => {
70 const {createTestNameSelector, findAllNodes} =
71 window.REACT_DOM_DEVTOOLS;
72 const container = document.getElementById('devtools');
73
74 // Get name of first prop
69 - const selectorName = isEditable.name
75 + const nameSelector = isEditable.name
76 ? 'EditableName'
77 : 'NonEditableName';
72 - const nameElement = findAllNodes(container, [
73 - createTestNameSelector('InspectedElementPropsTree'),
74 - createTestNameSelector(selectorName),
75 - ])[0];
76 - const name = isEditable.name
77 - ? nameElement.value
78 - : nameElement.innerText;
79 -
78 // Get value of first prop
81 - const selectorValue = isEditable.value
79 + const valueSelector = isEditable.value
80 ? 'EditableValue'
81 : 'NonEditableValue';
84 - const valueElement = findAllNodes(container, [
82 +
83 + const existingNameElements = findAllNodes(container, [
84 createTestNameSelector('InspectedElementPropsTree'),
86 - createTestNameSelector(selectorValue),
87 - ])[0];
88 - const value = isEditable.value
89 - ? valueElement.value
90 - : valueElement.innerText;
85 + createTestNameSelector('KeyValue'),
86 + createTestNameSelector(nameSelector),
87 + ]);
88 + const existingValueElements = findAllNodes(container, [
89 + createTestNameSelector('InspectedElementPropsTree'),
90 + createTestNameSelector('KeyValue'),
91 + createTestNameSelector(valueSelector),
92 + ]);
93
92 - return [name, value];
94 + const name = isEditable.name
95 + ? existingNameElements[0].value
96 + : existingNameElements[0].innerText;
97 + const value = isEditable.value
98 + ? existingValueElements[0].value
99 + : existingValueElements[0].innerText;
100 +
101 + return {
102 + name,
103 + value,
104 + existingNameElementsSize: existingNameElements.length,
105 + existingValueElementsSize: existingValueElements.length,
106 + };
107 },
108 {name: isEditableName, value: isEditableValue}
109 );
110
111 + expect(existingNameElementsSize).toBe(1);
112 + expect(existingValueElementsSize).toBe(1);
113 expect(propName).toBe('label');
114 expect(propValue).toBe('"one"');
115 });
@@ -135,6 +151,7 @@ test.describe('Components', () => {
151
152 focusWithin(container, [
153 createTestNameSelector('InspectedElementPropsTree'),
154 + createTestNameSelector('KeyValue'),
155 createTestNameSelector('EditableValue'),
156 ]);
157 });
packages/react-devtools-shared/src/backend/utils.js
+5 -1
@@ -140,11 +140,15 @@ export function serializeToString(data: any): string {
140 return 'undefined';
141 }
142
143 + if (typeof data === 'function') {
144 + return data.toString();
145 + }
146 +
147 const cache = new Set<mixed>();
148 // Use a custom replacer function to protect against circular references.
149 return JSON.stringify(
150 data,
147 - (key, value) => {
151 + (key: string, value: any) => {
152 if (typeof value === 'object' && value !== null) {
153 if (cache.has(value)) {
154 return;
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css
+1 -1
@@ -6,4 +6,4 @@
6 overflow: hidden;
7 z-index: 10000002;
8 user-select: none;
9 -}
\ No newline at end of file
9 +}
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js
+80 -111
@@ -8,141 +8,110 @@
8 */
9
10 import * as React from 'react';
11 -import {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
11 +import {useLayoutEffect, createRef} from 'react';
12 import {createPortal} from 'react-dom';
13 -import {RegistryContext} from './Contexts';
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';
21
17 -import type {RegistryContextType} from './Contexts';
22 +import styles from './ContextMenu.css';
23
19 -function repositionToFit(element: HTMLElement, pageX: number, pageY: number) {
24 +function repositionToFit(element: HTMLElement, x: number, y: number) {
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`;
29 } else {
29 - element.style.top = `${pageY}px`;
30 + element.style.top = '0px';
31 }
32 + } else {
33 + element.style.top = `${y}px`;
34 + }
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`;
39 } else {
39 - element.style.left = `${pageX}px`;
40 + element.style.left = '0px';
41 }
42 + } else {
43 + element.style.left = `${x}px`;
44 }
45 }
46
44 -const HIDDEN_STATE = {
45 - data: null,
46 - isVisible: false,
47 - pageX: 0,
48 - pageY: 0,
49 -};
50 -
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,
53 };
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 + }
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 + );
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);
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();
82 }
83 }
80 - }, []);
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);
88
98 - useLayoutEffect(() => {
99 - if (!state.isVisible) {
100 - return;
101 - }
89 + const ownerWindow = ownerDocument.defaultView;
90 + ownerWindow.addEventListener('resize', hide);
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);
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;
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 + );
117 }
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer.js new
+59
@@ -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
+4 -1
@@ -8,15 +8,18 @@
8 font-family: var(--font-family-sans);
9 font-size: var(--font-size-sans-normal);
10 }
11 +
12 .ContextMenuItem:first-of-type {
13 border-top: none;
14 }
15 +
16 .ContextMenuItem:hover,
17 .ContextMenuItem:focus {
18 outline: 0;
19 background-color: var(--color-context-background-hover);
20 }
21 +
22 .ContextMenuItem:active {
23 background-color: var(--color-context-background-selected);
24 color: var(--color-context-text-selected);
22 -}
\ No newline at end of file
25 +}
packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js
+5 -11
@@ -8,29 +8,23 @@
8 */
9
10 import * as React from 'react';
11 -import {useContext} from 'react';
12 -import {RegistryContext} from './Contexts';
11
12 import styles from './ContextMenuItem.css';
13
16 -import type {RegistryContextType} from './Contexts';
17 -
14 type Props = {
19 - children: React$Node,
15 + children: React.Node,
16 onClick: () => void,
21 - title: string,
17 + hide: () => void,
18 };
19
20 export default function ContextMenuItem({
21 children,
22 onClick,
27 - title,
23 + hide,
24 }: Props): React.Node {
29 - const {hideMenu} = useContext<RegistryContextType>(RegistryContext);
30 -
31 - const handleClick = (event: any) => {
25 + const handleClick = () => {
26 onClick();
33 - hideMenu();
27 + hide();
28 };
29
30 return (
packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js deleted
-91
@@ -1,91 +0,0 @@
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 type {ReactContext} from 'shared/ReactTypes';
11 -
12 -import {createContext} from 'react';
13 -
14 -export type ShowFn = ({data: Object, pageX: number, pageY: number}) => void;
15 -export type HideFn = () => void;
16 -export type OnChangeFn = boolean => void;
17 -
18 -const idToShowFnMap = new Map<string, ShowFn>();
19 -const idToHideFnMap = new Map<string, HideFn>();
20 -
21 -let currentHide: ?HideFn = null;
22 -let currentOnChange: ?OnChangeFn = null;
23 -
24 -function hideMenu() {
25 - if (typeof currentHide === 'function') {
26 - currentHide();
27 -
28 - if (typeof currentOnChange === 'function') {
29 - currentOnChange(false);
30 - }
31 - }
32 -
33 - currentHide = null;
34 - currentOnChange = null;
35 -}
36 -
37 -function showMenu({
38 - data,
39 - id,
40 - onChange,
41 - pageX,
42 - pageY,
43 -}: {
44 - data: Object,
45 - id: string,
46 - onChange?: OnChangeFn,
47 - pageX: number,
48 - pageY: number,
49 -}) {
50 - const showFn = idToShowFnMap.get(id);
51 - if (typeof showFn === 'function') {
52 - // Prevent open menus from being left hanging.
53 - hideMenu();
54 -
55 - currentHide = idToHideFnMap.get(id);
56 -
57 - showFn({data, pageX, pageY});
58 -
59 - if (typeof onChange === 'function') {
60 - currentOnChange = onChange;
61 - onChange(true);
62 - }
63 - }
64 -}
65 -
66 -function registerMenu(id: string, showFn: ShowFn, hideFn: HideFn): () => void {
67 - if (idToShowFnMap.has(id)) {
68 - throw Error(`Context menu with id "${id}" already registered.`);
69 - }
70 -
71 - idToShowFnMap.set(id, showFn);
72 - idToHideFnMap.set(id, hideFn);
73 -
74 - return function unregisterMenu() {
75 - idToShowFnMap.delete(id);
76 - idToHideFnMap.delete(id);
77 - };
78 -}
79 -
80 -export type RegistryContextType = {
81 - hideMenu: typeof hideMenu,
82 - showMenu: typeof showMenu,
83 - registerMenu: typeof registerMenu,
84 -};
85 -
86 -export const RegistryContext: ReactContext<RegistryContextType> =
87 - createContext<RegistryContextType>({
88 - hideMenu,
89 - showMenu,
90 - registerMenu,
91 - });
packages/react-devtools-shared/src/devtools/ContextMenu/types.js new
+29
@@ -0,0 +1,29 @@
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 type {Node as ReactNode, AbstractComponent, ElementRef} from 'react';
11 +
12 +export type ContextMenuItem = {
13 + onClick: () => void,
14 + content: ReactNode,
15 +};
16 +
17 +// Relative to [data-react-devtools-portal-root]
18 +export type ContextMenuPosition = {
19 + x: number,
20 + y: number,
21 +};
22 +
23 +export type ContextMenuHandle = {
24 + isShown(): boolean,
25 + hide(): void,
26 +};
27 +
28 +export type ContextMenuComponent = AbstractComponent<{}, ContextMenuHandle>;
29 +export type ContextMenuRef = {current: ElementRef<ContextMenuComponent> | null};
packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js
+60 -40
@@ -7,47 +7,67 @@
7 * @flow
8 */
9
10 -import {useContext, useEffect} from 'react';
11 -import {RegistryContext} from './Contexts';
12 -
13 -import type {OnChangeFn, RegistryContextType} from './Contexts';
14 -import type {ElementRef} from 'react';
15 -
16 -export default function useContextMenu({
17 - data,
18 - id,
19 - onChange,
20 - ref,
21 -}: {
22 - data: Object,
23 - id: string,
24 - onChange?: OnChangeFn,
25 - ref: {current: ElementRef<any> | null},
26 -}) {
27 - const {showMenu} = useContext<RegistryContextType>(RegistryContext);
10 +import * as React from 'react';
11 +import {useState, useEffect, useCallback} from 'react';
12 +
13 +import type {ContextMenuPosition} from './types';
14 +
15 +type Payload = {
16 + shouldShow: boolean,
17 + position: ContextMenuPosition | null,
18 + hide: () => void,
19 +};
20 +
21 +export default function useContextMenu(anchorElementRef: {
22 + current: React.ElementRef<any> | null,
23 +}): Payload {
24 + const [shouldShow, setShouldShow] = useState(false);
25 + const [position, setPosition] = React.useState<ContextMenuPosition | null>(
26 + null,
27 + );
28 +
29 + const hide = useCallback(() => {
30 + setShouldShow(false);
31 + setPosition(null);
32 + }, []);
33
34 useEffect(() => {
30 - if (ref.current !== null) {
31 - const handleContextMenu = (event: MouseEvent | TouchEvent) => {
32 - event.preventDefault();
33 - event.stopPropagation();
34 -
35 - const pageX =
36 - (event: any).pageX ||
37 - (event.touches && (event: any).touches[0].pageX);
38 - const pageY =
39 - (event: any).pageY ||
40 - (event.touches && (event: any).touches[0].pageY);
41 -
42 - showMenu({data, id, onChange, pageX, pageY});
43 - };
44 -
45 - const trigger = ref.current;
46 - trigger.addEventListener('contextmenu', handleContextMenu);
47 -
48 - return () => {
49 - trigger.removeEventListener('contextmenu', handleContextMenu);
50 - };
35 + const anchor = anchorElementRef.current;
36 + if (anchor == null) return;
37 +
38 + function handleAnchorContextMenu(e: MouseEvent) {
39 + e.preventDefault();
40 + e.stopPropagation();
41 +
42 + const {pageX, pageY} = e;
43 +
44 + const ownerDocument = anchor?.ownerDocument;
45 + const portalContainer = ownerDocument?.querySelector(
46 + '[data-react-devtools-portal-root]',
47 + );
48 +
49 + if (portalContainer == null) {
50 + throw new Error(
51 + "DevTools tooltip root node not found: can't display the context menu",
52 + );
53 + }
54 +
55 + // `x` and `y` should be relative to the container, to which these context menus will be portaled
56 + // we can't use just `pageX` or `pageY` for Fusebox integration, because RDT frontend is inlined with the whole document
57 + // meaning that `pageY` will have an offset of 27, which is the tab bar height
58 + // for the browser extension, these will equal to 0
59 + const {top: containerTop, left: containerLeft} =
60 + portalContainer.getBoundingClientRect();
61 +
62 + setShouldShow(true);
63 + setPosition({x: pageX - containerLeft, y: pageY - containerTop});
64 }
52 - }, [data, id, showMenu]);
65 +
66 + anchor.addEventListener('contextmenu', handleAnchorContextMenu);
67 + return () => {
68 + anchor.removeEventListener('contextmenu', handleAnchorContextMenu);
69 + };
70 + }, [anchorElementRef]);
71 +
72 + return {shouldShow, position, hide};
73 }
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js
+4 -21
@@ -9,7 +9,7 @@
9
10 import {copy} from 'clipboard-js';
11 import * as React from 'react';
12 -import {useCallback, useContext, useRef, useState} from 'react';
12 +import {useCallback, useContext, useState} from 'react';
13 import {BridgeContext, StoreContext} from '../context';
14 import Button from '../Button';
15 import ButtonIcon from '../ButtonIcon';
@@ -19,7 +19,6 @@ import KeyValue from './KeyValue';
19 import {getMetaValueLabel, serializeHooksForCopy} from '../utils';
20 import Store from '../../store';
21 import styles from './InspectedElementHooksTree.css';
22 -import useContextMenu from '../../ContextMenu/useContextMenu';
22 import {meta} from '../../../hydration';
23 import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache';
24 import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
@@ -182,22 +181,6 @@ function HookView({
181 [],
182 );
183
185 - const contextMenuTriggerRef = useRef(null);
186 -
187 - useContextMenu({
188 - data: {
189 - path: ['hooks', ...path],
190 - type:
191 - hook !== null &&
192 - typeof hook === 'object' &&
193 - hook.hasOwnProperty(meta.type)
194 - ? hook[(meta.type: any)]
195 - : typeof value,
196 - },
197 - id: 'InspectedElement',
198 - ref: contextMenuTriggerRef,
199 - });
200 -
184 if (hook.hasOwnProperty(meta.inspected)) {
185 // This Hook is too deep and hasn't been hydrated.
186 if (__DEV__) {
@@ -301,7 +284,7 @@ function HookView({
284 if (isComplexDisplayValue) {
285 return (
286 <div className={styles.Hook}>
304 - <div ref={contextMenuTriggerRef} className={styles.NameValueRow}>
287 + <div className={styles.NameValueRow}>
288 <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
289 <span
290 onClick={toggleIsOpen}
@@ -338,7 +321,7 @@ function HookView({
321 } else {
322 return (
323 <div className={styles.Hook}>
341 - <div ref={contextMenuTriggerRef} className={styles.NameValueRow}>
324 + <div className={styles.NameValueRow}>
325 <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
326 <span
327 onClick={toggleIsOpen}
@@ -394,7 +377,7 @@ function HookView({
377 hookName={hookName}
378 inspectedElement={inspectedElement}
379 name={name}
397 - path={[]}
380 + path={path.concat(['value'])}
381 pathRoot="hooks"
382 store={store}
383 value={value}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.css
-4
@@ -65,10 +65,6 @@
65 background-color: var(--color-background-hover);
66 }
67
68 -.ContextMenuIcon {
69 - margin-right: 0.5rem;
70 -}
71 -
68 .OwnersMetaField {
69 padding-left: 1.25rem;
70 white-space: nowrap;
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js
+1 -74
@@ -10,11 +10,8 @@
10 import * as React from 'react';
11 import {Fragment, useCallback, useContext} from 'react';
12 import {TreeDispatcherContext} from './TreeContext';
13 -import {BridgeContext, ContextMenuContext, StoreContext} from '../context';
14 -import ContextMenu from '../../ContextMenu/ContextMenu';
15 -import ContextMenuItem from '../../ContextMenu/ContextMenuItem';
13 +import {BridgeContext, StoreContext} from '../context';
14 import Button from '../Button';
17 -import Icon from '../Icon';
15 import InspectedElementBadges from './InspectedElementBadges';
16 import InspectedElementContextTree from './InspectedElementContextTree';
17 import InspectedElementErrorsAndWarningsTree from './InspectedElementErrorsAndWarningsTree';
@@ -26,17 +23,12 @@ import InspectedElementSuspenseToggle from './InspectedElementSuspenseToggle';
23 import NativeStyleEditor from './NativeStyleEditor';
24 import ElementBadges from './ElementBadges';
25 import {useHighlightNativeElement} from '../hooks';
29 -import {
30 - copyInspectedElementPath as copyInspectedElementPathAPI,
31 - storeAsGlobal as storeAsGlobalAPI,
32 -} from 'react-devtools-shared/src/backendAPI';
26 import {enableStyleXFeatures} from 'react-devtools-feature-flags';
27 import {logEvent} from 'react-devtools-shared/src/Logger';
28 import InspectedElementSourcePanel from './InspectedElementSourcePanel';
29
30 import styles from './InspectedElementView.css';
31
39 -import type {ContextMenuContextType} from '../context';
32 import type {
33 Element,
34 InspectedElement,
@@ -62,18 +54,12 @@ export default function InspectedElementView({
54 toggleParseHookNames,
55 symbolicatedSourcePromise,
56 }: Props): React.Node {
65 - const {id} = element;
57 const {owners, rendererPackageName, rendererVersion, rootType, source} =
58 inspectedElement;
59
60 const bridge = useContext(BridgeContext);
61 const store = useContext(StoreContext);
62
72 - const {
73 - isEnabledForInspectedElement: isContextMenuEnabledForInspectedElement,
74 - viewAttributeSourceFunction,
75 - } = useContext<ContextMenuContextType>(ContextMenuContext);
76 -
63 const rendererLabel =
64 rendererPackageName !== null && rendererVersion !== null
65 ? `${rendererPackageName}@${rendererVersion}`
@@ -182,65 +168,6 @@ export default function InspectedElementView({
168 />
169 )}
170 </div>
185 -
186 - {isContextMenuEnabledForInspectedElement && (
187 - <ContextMenu id="InspectedElement">
188 - {({path, type: pathType}) => {
189 - const copyInspectedElementPath = () => {
190 - const rendererID = store.getRendererIDForElement(id);
191 - if (rendererID !== null) {
192 - copyInspectedElementPathAPI({
193 - bridge,
194 - id,
195 - path,
196 - rendererID,
197 - });
198 - }
199 - };
200 -
201 - const storeAsGlobal = () => {
202 - const rendererID = store.getRendererIDForElement(id);
203 - if (rendererID !== null) {
204 - storeAsGlobalAPI({
205 - bridge,
206 - id,
207 - path,
208 - rendererID,
209 - });
210 - }
211 - };
212 -
213 - return (
214 - <Fragment>
215 - <ContextMenuItem
216 - onClick={copyInspectedElementPath}
217 - title="Copy value to clipboard">
218 - <Icon className={styles.ContextMenuIcon} type="copy" /> Copy
219 - value to clipboard
220 - </ContextMenuItem>
221 - <ContextMenuItem
222 - onClick={storeAsGlobal}
223 - title="Store as global variable">
224 - <Icon
225 - className={styles.ContextMenuIcon}
226 - type="store-as-global-variable"
227 - />{' '}
228 - Store as global variable
229 - </ContextMenuItem>
230 - {viewAttributeSourceFunction !== null &&
231 - pathType === 'function' && (
232 - <ContextMenuItem
233 - onClick={() => viewAttributeSourceFunction(id, path)}
234 - title="Go to definition">
235 - <Icon className={styles.ContextMenuIcon} type="code" /> Go
236 - to definition
237 - </ContextMenuItem>
238 - )}
239 - </Fragment>
240 - );
241 - }}
242 - </ContextMenu>
243 - )}
171 </Fragment>
172 );
173 }
packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js
+133 -98
@@ -8,7 +8,7 @@
8 */
9
10 import * as React from 'react';
11 -import {useTransition, useContext, useRef, useState} from 'react';
11 +import {useTransition, useContext, useRef, useState, useMemo} from 'react';
12 import {OptionsContext} from '../context';
13 import EditableName from './EditableName';
14 import EditableValue from './EditableValue';
@@ -18,7 +18,6 @@ import LoadingAnimation from './LoadingAnimation';
18 import ExpandCollapseToggle from './ExpandCollapseToggle';
19 import {alphaSortEntries, getMetaValueLabel} from '../utils';
20 import {meta} from '../../../hydration';
21 -import useContextMenu from '../../ContextMenu/useContextMenu';
21 import Store from '../../store';
22 import {parseHookPathForEdit} from './utils';
23 import styles from './KeyValue.css';
@@ -27,6 +26,7 @@ import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
26 import isArray from 'react-devtools-shared/src/isArray';
27 import {InspectedElementContext} from './InspectedElementContext';
28 import {PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE} from './constants';
29 +import KeyValueContextMenuContainer from './KeyValueContextMenuContainer';
30
31 import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
32 import type {Element} from 'react-devtools-shared/src/frontend/types';
@@ -85,6 +85,7 @@ export default function KeyValue({
85 canRenamePaths = !readOnlyGlobalFlag && canRenamePaths;
86
87 const {id} = inspectedElement;
88 + const fullPath = useMemo(() => [pathRoot, ...path], [pathRoot, path]);
89
90 const [isOpen, setIsOpen] = useState<boolean>(false);
91 const contextMenuTriggerRef = useRef(null);
@@ -113,20 +114,6 @@ export default function KeyValue({
114 }
115 };
116
116 - useContextMenu({
117 - data: {
118 - path: [pathRoot, ...path],
119 - type:
120 - value !== null &&
121 - typeof value === 'object' &&
122 - hasOwnProperty.call(value, meta.type)
123 - ? value[meta.type]
124 - : typeof value,
125 - },
126 - id: 'InspectedElement',
127 - ref: contextMenuTriggerRef,
128 - });
129 -
117 const dataType = typeof value;
118 const isSimpleType =
119 dataType === 'number' ||
@@ -134,6 +121,14 @@ export default function KeyValue({
121 dataType === 'boolean' ||
122 value == null;
123
124 + const pathType =
125 + value !== null &&
126 + typeof value === 'object' &&
127 + hasOwnProperty.call(value, meta.type)
128 + ? value[meta.type]
129 + : typeof value;
130 + const pathIsFunction = pathType === 'function';
131 +
132 const style = {
133 paddingLeft: `${(depth - 1) * 0.75}rem`,
134 };
@@ -270,60 +265,80 @@ export default function KeyValue({
265 }
266
267 children = (
273 - <div
268 + <KeyValueContextMenuContainer
269 key="root"
275 - className={styles.Item}
276 - hidden={hidden}
277 - ref={contextMenuTriggerRef}
278 - style={style}>
279 - <div className={styles.ExpandCollapseToggleSpacer} />
280 - {renderedName}
281 - <div className={styles.AfterName}>:</div>
282 - {canEditValues ? (
283 - <EditableValue
284 - overrideValue={overrideValue}
285 - path={path}
286 - value={value}
287 - />
288 - ) : shouldDisplayValueAsLink ? (
289 - <a
290 - className={styles.Link}
291 - href={value}
292 - target="_blank"
293 - rel="noopener noreferrer">
294 - {displayValue}
295 - </a>
296 - ) : (
297 - <span className={styles.Value} data-testname="NonEditableValue">
298 - {displayValue}
299 - </span>
300 - )}
301 - </div>
270 + anchorElementRef={contextMenuTriggerRef}
271 + attributeSourceCanBeInspected={pathIsFunction}
272 + canBeCopiedToClipboard={!pathIsFunction}
273 + store={store}
274 + bridge={bridge}
275 + id={id}
276 + path={fullPath}>
277 + <div
278 + data-testname="KeyValue"
279 + className={styles.Item}
280 + hidden={hidden}
281 + ref={contextMenuTriggerRef}
282 + style={style}>
283 + <div className={styles.ExpandCollapseToggleSpacer} />
284 + {renderedName}
285 + <div className={styles.AfterName}>:</div>
286 + {canEditValues ? (
287 + <EditableValue
288 + overrideValue={overrideValue}
289 + path={path}
290 + value={value}
291 + />
292 + ) : shouldDisplayValueAsLink ? (
293 + <a
294 + className={styles.Link}
295 + href={value}
296 + target="_blank"
297 + rel="noopener noreferrer">
298 + {displayValue}
299 + </a>
300 + ) : (
301 + <span className={styles.Value} data-testname="NonEditableValue">
302 + {displayValue}
303 + </span>
304 + )}
305 + </div>
306 + </KeyValueContextMenuContainer>
307 );
308 } else if (
309 hasOwnProperty.call(value, meta.type) &&
310 !hasOwnProperty.call(value, meta.unserializable)
311 ) {
312 children = (
308 - <div
313 + <KeyValueContextMenuContainer
314 key="root"
310 - className={styles.Item}
311 - hidden={hidden}
312 - ref={contextMenuTriggerRef}
313 - style={style}>
314 - {isInspectable ? (
315 - <ExpandCollapseToggle isOpen={isOpen} setIsOpen={toggleIsOpen} />
316 - ) : (
317 - <div className={styles.ExpandCollapseToggleSpacer} />
318 - )}
319 - {renderedName}
320 - <div className={styles.AfterName}>:</div>
321 - <span
322 - className={styles.Value}
323 - onClick={isInspectable ? toggleIsOpen : undefined}>
324 - {getMetaValueLabel(value)}
325 - </span>
326 - </div>
315 + anchorElementRef={contextMenuTriggerRef}
316 + attributeSourceCanBeInspected={pathIsFunction}
317 + canBeCopiedToClipboard={!pathIsFunction}
318 + store={store}
319 + bridge={bridge}
320 + id={id}
321 + path={fullPath}>
322 + <div
323 + data-testname="KeyValue"
324 + className={styles.Item}
325 + hidden={hidden}
326 + ref={contextMenuTriggerRef}
327 + style={style}>
328 + {isInspectable ? (
329 + <ExpandCollapseToggle isOpen={isOpen} setIsOpen={toggleIsOpen} />
330 + ) : (
331 + <div className={styles.ExpandCollapseToggleSpacer} />
332 + )}
333 + {renderedName}
334 + <div className={styles.AfterName}>:</div>
335 + <span
336 + className={styles.Value}
337 + onClick={isInspectable ? toggleIsOpen : undefined}>
338 + {getMetaValueLabel(value)}
339 + </span>
340 + </div>
341 + </KeyValueContextMenuContainer>
342 );
343
344 if (isInspectPathsPending) {
@@ -384,25 +399,35 @@ export default function KeyValue({
399 }
400
401 children.unshift(
387 - <div
402 + <KeyValueContextMenuContainer
403 key={`${depth}-root`}
389 - className={styles.Item}
390 - hidden={hidden}
391 - ref={contextMenuTriggerRef}
392 - style={style}>
393 - {hasChildren ? (
394 - <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
395 - ) : (
396 - <div className={styles.ExpandCollapseToggleSpacer} />
397 - )}
398 - {renderedName}
399 - <div className={styles.AfterName}>:</div>
400 - <span
401 - className={styles.Value}
402 - onClick={hasChildren ? toggleIsOpen : undefined}>
403 - {displayName}
404 - </span>
405 - </div>,
404 + anchorElementRef={contextMenuTriggerRef}
405 + attributeSourceCanBeInspected={pathIsFunction}
406 + canBeCopiedToClipboard={!pathIsFunction}
407 + store={store}
408 + bridge={bridge}
409 + id={id}
410 + path={fullPath}>
411 + <div
412 + data-testname="KeyValue"
413 + className={styles.Item}
414 + hidden={hidden}
415 + ref={contextMenuTriggerRef}
416 + style={style}>
417 + {hasChildren ? (
418 + <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
419 + ) : (
420 + <div className={styles.ExpandCollapseToggleSpacer} />
421 + )}
422 + {renderedName}
423 + <div className={styles.AfterName}>:</div>
424 + <span
425 + className={styles.Value}
426 + onClick={hasChildren ? toggleIsOpen : undefined}>
427 + {displayName}
428 + </span>
429 + </div>
430 + </KeyValueContextMenuContainer>,
431 );
432 } else {
433 // TRICKY
@@ -456,25 +481,35 @@ export default function KeyValue({
481 }
482
483 children.unshift(
459 - <div
484 + <KeyValueContextMenuContainer
485 key={`${depth}-root`}
461 - className={styles.Item}
462 - hidden={hidden}
463 - ref={contextMenuTriggerRef}
464 - style={style}>
465 - {hasChildren ? (
466 - <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
467 - ) : (
468 - <div className={styles.ExpandCollapseToggleSpacer} />
469 - )}
470 - {renderedName}
471 - <div className={styles.AfterName}>:</div>
472 - <span
473 - className={styles.Value}
474 - onClick={hasChildren ? toggleIsOpen : undefined}>
475 - {displayName}
476 - </span>
477 - </div>,
486 + anchorElementRef={contextMenuTriggerRef}
487 + attributeSourceCanBeInspected={pathIsFunction}
488 + canBeCopiedToClipboard={!pathIsFunction}
489 + store={store}
490 + bridge={bridge}
491 + id={id}
492 + path={fullPath}>
493 + <div
494 + data-testname="KeyValue"
495 + className={styles.Item}
496 + hidden={hidden}
497 + ref={contextMenuTriggerRef}
498 + style={style}>
499 + {hasChildren ? (
500 + <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
501 + ) : (
502 + <div className={styles.ExpandCollapseToggleSpacer} />
503 + )}
504 + {renderedName}
505 + <div className={styles.AfterName}>:</div>
506 + <span
507 + className={styles.Value}
508 + onClick={hasChildren ? toggleIsOpen : undefined}>
509 + {displayName}
510 + </span>
511 + </div>
512 + </KeyValueContextMenuContainer>,
513 );
514 }
515 }
packages/react-devtools-shared/src/devtools/views/Components/KeyValueContextMenuContainer.css new
+6
@@ -0,0 +1,6 @@
1 +.ContextMenuItemContent {
2 + display: flex;
3 + flex-direction: row;
4 + align-items: center;
5 + gap: 0.5rem;
6 +}
packages/react-devtools-shared/src/devtools/views/Components/KeyValueContextMenuContainer.js new
+135
@@ -0,0 +1,135 @@
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 {useContext} from 'react';
12 +
13 +import {ContextMenuContext} from '../context';
14 +import {
15 + copyInspectedElementPath as copyInspectedElementPathAPI,
16 + storeAsGlobal as storeAsGlobalAPI,
17 +} from '../../../backendAPI';
18 +import Icon from '../Icon';
19 +import ContextMenuContainer from '../../ContextMenu/ContextMenuContainer';
20 +
21 +import type Store from 'react-devtools-shared/src/devtools/store';
22 +import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
23 +import type {ContextMenuContextType} from '../context';
24 +
25 +import styles from './KeyValueContextMenuContainer.css';
26 +
27 +type Props = {
28 + children: React.Node,
29 + anchorElementRef: {
30 + current: React.ElementRef<any> | null,
31 + },
32 + store: Store,
33 + attributeSourceCanBeInspected: boolean,
34 + bridge: FrontendBridge,
35 + id: number,
36 + path: Array<any>,
37 + canBeCopiedToClipboard: boolean,
38 +};
39 +
40 +export default function KeyValueContextMenuContainer({
41 + children,
42 + anchorElementRef,
43 + store,
44 + attributeSourceCanBeInspected,
45 + bridge,
46 + id,
47 + path,
48 + canBeCopiedToClipboard,
49 +}: Props): React.Node {
50 + const {
51 + isEnabledForInspectedElement: isContextMenuEnabledForInspectedElement,
52 + viewAttributeSourceFunction,
53 + } = useContext<ContextMenuContextType>(ContextMenuContext);
54 +
55 + const menuItems = React.useMemo(() => {
56 + const items = [
57 + {
58 + onClick: () => {
59 + const rendererID = store.getRendererIDForElement(id);
60 + if (rendererID !== null) {
61 + storeAsGlobalAPI({
62 + bridge,
63 + id,
64 + path,
65 + rendererID,
66 + });
67 + }
68 + },
69 + content: (
70 + <span className={styles.ContextMenuItemContent}>
71 + <Icon type="store-as-global-variable" />
72 + <label>Store as global variable</label>
73 + </span>
74 + ),
75 + },
76 + ];
77 +
78 + if (canBeCopiedToClipboard) {
79 + items.unshift({
80 + onClick: () => {
81 + const rendererID = store.getRendererIDForElement(id);
82 + if (rendererID !== null) {
83 + copyInspectedElementPathAPI({
84 + bridge,
85 + id,
86 + path,
87 + rendererID,
88 + });
89 + }
90 + },
91 + content: (
92 + <span className={styles.ContextMenuItemContent}>
93 + <Icon type="copy" />
94 + <label>Copy value to clipboard</label>
95 + </span>
96 + ),
97 + });
98 + }
99 +
100 + if (viewAttributeSourceFunction != null && attributeSourceCanBeInspected) {
101 + items.push({
102 + onClick: () => viewAttributeSourceFunction(id, path),
103 + content: (
104 + <span className={styles.ContextMenuItemContent}>
105 + <Icon type="code" />
106 + <label>Go to definition</label>
107 + </span>
108 + ),
109 + });
110 + }
111 + return items;
112 + }, [
113 + store,
114 + viewAttributeSourceFunction,
115 + attributeSourceCanBeInspected,
116 + canBeCopiedToClipboard,
117 + bridge,
118 + id,
119 + path,
120 + ]);
121 +
122 + if (!isContextMenuEnabledForInspectedElement) {
123 + return children;
124 + }
125 +
126 + return (
127 + <>
128 + {children}
129 + <ContextMenuContainer
130 + anchorElementRef={anchorElementRef}
131 + items={menuItems}
132 + />
133 + </>
134 + );
135 +}
packages/react-devtools-shared/src/devtools/views/Components/NewKeyValue.js
+1
@@ -79,6 +79,7 @@ export default function NewKeyValue({
79
80 return (
81 <div
82 + data-testname="NewKeyValue"
83 key={newPropKey}
84 hidden={hidden}
85 style={{
packages/react-devtools-timeline/src/CanvasPage.js
+75 -171
@@ -8,12 +8,7 @@
8 */
9
10 import type {Interaction, Point} from './view-base';
11 -import type {
12 - ReactEventInfo,
13 - TimelineData,
14 - ReactMeasure,
15 - ViewState,
16 -} from './types';
11 +import type {ReactEventInfo, TimelineData, ViewState} from './types';
12
13 import * as React from 'react';
14 import {
@@ -26,8 +21,6 @@ import {
21 useCallback,
22 } from 'react';
23 import AutoSizer from 'react-virtualized-auto-sizer';
29 -import {copy} from 'clipboard-js';
30 -import prettyMilliseconds from 'pretty-ms';
24
25 import {
26 HorizontalPanAndZoomView,
@@ -56,18 +49,14 @@ import {
49 import {COLORS} from './content-views/constants';
50 import {clampState, moveStateToRange} from './view-base/utils/scrollState';
51 import EventTooltip from './EventTooltip';
59 -import {RegistryContext} from 'react-devtools-shared/src/devtools/ContextMenu/Contexts';
60 -import ContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenu';
61 -import ContextMenuItem from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem';
62 -import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useContextMenu';
63 -import {getBatchRange} from './utils/getBatchRange';
52 import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
53 import {TimelineSearchContext} from './TimelineSearchContext';
54 import {TimelineContext} from './TimelineContext';
55 +import CanvasPageContextMenu from './CanvasPageContextMenu';
56
68 -import styles from './CanvasPage.css';
57 +import type {ContextMenuRef} from 'react-devtools-shared/src/devtools/ContextMenu/types';
58
70 -const CONTEXT_MENU_ID = 'canvas';
59 +import styles from './CanvasPage.css';
60
61 type Props = {
62 profilerData: TimelineData,
@@ -93,45 +82,6 @@ function CanvasPage({profilerData, viewState}: Props): React.Node {
82 );
83 }
84
96 -const copySummary = (data: TimelineData, measure: ReactMeasure) => {
97 - const {batchUID, duration, timestamp, type} = measure;
98 -
99 - const [startTime, stopTime] = getBatchRange(batchUID, data);
100 -
101 - copy(
102 - JSON.stringify({
103 - type,
104 - timestamp: prettyMilliseconds(timestamp),
105 - duration: prettyMilliseconds(duration),
106 - batchDuration: prettyMilliseconds(stopTime - startTime),
107 - }),
108 - );
109 -};
110 -
111 -const zoomToBatch = (
112 - data: TimelineData,
113 - measure: ReactMeasure,
114 - viewState: ViewState,
115 - width: number,
116 -) => {
117 - const {batchUID} = measure;
118 - const [rangeStart, rangeEnd] = getBatchRange(batchUID, data);
119 -
120 - // Convert from time range to ScrollState
121 - const scrollState = moveStateToRange({
122 - state: viewState.horizontalScrollState,
123 - rangeStart,
124 - rangeEnd,
125 - contentLength: data.duration,
126 -
127 - minContentLength: data.duration * MIN_ZOOM_LEVEL,
128 - maxContentLength: data.duration * MAX_ZOOM_LEVEL,
129 - containerLength: width,
130 - });
131 -
132 - viewState.updateHorizontalScrollState(scrollState);
133 -};
134 -
85 const EMPTY_CONTEXT_INFO: ReactEventInfo = {
86 componentMeasure: null,
87 flamechartStackFrame: null,
@@ -160,14 +110,52 @@ function AutoSizedCanvas({
110 }: AutoSizedCanvasProps) {
111 const canvasRef = useRef<HTMLCanvasElement | null>(null);
112
163 - const [isContextMenuShown, setIsContextMenuShown] = useState<boolean>(false);
113 const [mouseLocation, setMouseLocation] = useState<Point>(zeroPoint); // DOM coordinates
114 const [hoveredEvent, setHoveredEvent] = useState<ReactEventInfo | null>(null);
115 + const [lastHoveredEvent, setLastHoveredEvent] =
116 + useState<ReactEventInfo | null>(null);
117 +
118 + const contextMenuRef: ContextMenuRef = useRef(null);
119
120 const resetHoveredEvent = useCallback(
121 () => setHoveredEvent(EMPTY_CONTEXT_INFO),
122 [],
123 );
124 + const updateHoveredEvent = useCallback(
125 + (event: ReactEventInfo) => {
126 + setHoveredEvent(event);
127 +
128 + // If menu is already open, don't update the hovered event data
129 + // So the same set of menu items is preserved until the current context menu is closed
130 + if (contextMenuRef.current?.isShown()) {
131 + return;
132 + }
133 +
134 + const {
135 + componentMeasure,
136 + flamechartStackFrame,
137 + measure,
138 + networkMeasure,
139 + schedulingEvent,
140 + suspenseEvent,
141 + } = event;
142 +
143 + // We have to keep track of last non-empty hovered event, since this will be the input for context menu items
144 + // We can't just pass hoveredEvent to ContextMenuContainer,
145 + // since it will be reset each time user moves mouse away from event object on the canvas
146 + if (
147 + componentMeasure != null ||
148 + flamechartStackFrame != null ||
149 + measure != null ||
150 + networkMeasure != null ||
151 + schedulingEvent != null ||
152 + suspenseEvent != null
153 + ) {
154 + setLastHoveredEvent(event);
155 + }
156 + },
157 + [contextMenuRef],
158 + );
159
160 const {searchIndex, searchRegExp, searchResults} = useContext(
161 TimelineSearchContext,
@@ -210,15 +198,13 @@ function AutoSizedCanvas({
198 const snapshotsViewRef = useRef<null | SnapshotsView>(null);
199 const thrownErrorsViewRef = useRef<null | ThrownErrorsView>(null);
200
213 - const {hideMenu: hideContextMenu} = useContext(RegistryContext);
214 -
201 useLayoutEffect(() => {
202 const surface = surfaceRef.current;
203 const defaultFrame = {origin: zeroPoint, size: {width, height}};
204
205 // Auto hide context menu when panning.
206 viewState.onHorizontalScrollStateChange(scrollState => {
221 - hideContextMenu();
207 + contextMenuRef.current?.hide();
208 });
209
210 // Initialize horizontal view state
@@ -516,16 +502,6 @@ function AutoSizedCanvas({
502
503 useCanvasInteraction(canvasRef, interactor);
504
519 - useContextMenu({
520 - data: {
521 - data,
522 - hoveredEvent,
523 - },
524 - id: CONTEXT_MENU_ID,
525 - onChange: setIsContextMenuShown,
526 - ref: canvasRef,
527 - });
528 -
505 const {selectEvent} = useContext(TimelineContext);
506
507 useEffect(() => {
@@ -533,7 +509,7 @@ function AutoSizedCanvas({
509 if (userTimingMarksView) {
510 userTimingMarksView.onHover = userTimingMark => {
511 if (!hoveredEvent || hoveredEvent.userTimingMark !== userTimingMark) {
536 - setHoveredEvent({
512 + updateHoveredEvent({
513 ...EMPTY_CONTEXT_INFO,
514 userTimingMark,
515 });
@@ -545,7 +521,7 @@ function AutoSizedCanvas({
521 if (nativeEventsView) {
522 nativeEventsView.onHover = nativeEvent => {
523 if (!hoveredEvent || hoveredEvent.nativeEvent !== nativeEvent) {
548 - setHoveredEvent({
524 + updateHoveredEvent({
525 ...EMPTY_CONTEXT_INFO,
526 nativeEvent,
527 });
@@ -557,7 +533,7 @@ function AutoSizedCanvas({
533 if (schedulingEventsView) {
534 schedulingEventsView.onHover = schedulingEvent => {
535 if (!hoveredEvent || hoveredEvent.schedulingEvent !== schedulingEvent) {
560 - setHoveredEvent({
536 + updateHoveredEvent({
537 ...EMPTY_CONTEXT_INFO,
538 schedulingEvent,
539 });
@@ -575,7 +551,7 @@ function AutoSizedCanvas({
551 if (suspenseEventsView) {
552 suspenseEventsView.onHover = suspenseEvent => {
553 if (!hoveredEvent || hoveredEvent.suspenseEvent !== suspenseEvent) {
578 - setHoveredEvent({
554 + updateHoveredEvent({
555 ...EMPTY_CONTEXT_INFO,
556 suspenseEvent,
557 });
@@ -587,7 +563,7 @@ function AutoSizedCanvas({
563 if (reactMeasuresView) {
564 reactMeasuresView.onHover = measure => {
565 if (!hoveredEvent || hoveredEvent.measure !== measure) {
590 - setHoveredEvent({
566 + updateHoveredEvent({
567 ...EMPTY_CONTEXT_INFO,
568 measure,
569 });
@@ -602,7 +578,7 @@ function AutoSizedCanvas({
578 !hoveredEvent ||
579 hoveredEvent.componentMeasure !== componentMeasure
580 ) {
605 - setHoveredEvent({
581 + updateHoveredEvent({
582 ...EMPTY_CONTEXT_INFO,
583 componentMeasure,
584 });
@@ -614,7 +590,7 @@ function AutoSizedCanvas({
590 if (snapshotsView) {
591 snapshotsView.onHover = snapshot => {
592 if (!hoveredEvent || hoveredEvent.snapshot !== snapshot) {
617 - setHoveredEvent({
593 + updateHoveredEvent({
594 ...EMPTY_CONTEXT_INFO,
595 snapshot,
596 });
@@ -629,7 +605,7 @@ function AutoSizedCanvas({
605 !hoveredEvent ||
606 hoveredEvent.flamechartStackFrame !== flamechartStackFrame
607 ) {
632 - setHoveredEvent({
608 + updateHoveredEvent({
609 ...EMPTY_CONTEXT_INFO,
610 flamechartStackFrame,
611 });
@@ -641,7 +617,7 @@ function AutoSizedCanvas({
617 if (networkMeasuresView) {
618 networkMeasuresView.onHover = networkMeasure => {
619 if (!hoveredEvent || hoveredEvent.networkMeasure !== networkMeasure) {
644 - setHoveredEvent({
620 + updateHoveredEvent({
621 ...EMPTY_CONTEXT_INFO,
622 networkMeasure,
623 });
@@ -653,7 +629,7 @@ function AutoSizedCanvas({
629 if (thrownErrorsView) {
630 thrownErrorsView.onHover = thrownError => {
631 if (!hoveredEvent || hoveredEvent.thrownError !== thrownError) {
656 - setHoveredEvent({
632 + updateHoveredEvent({
633 ...EMPTY_CONTEXT_INFO,
634 thrownError,
635 });
@@ -724,99 +700,27 @@ function AutoSizedCanvas({
700 return (
701 <Fragment>
702 <canvas ref={canvasRef} height={height} width={width} />
727 - <ContextMenu id={CONTEXT_MENU_ID}>
728 - {contextData => {
729 - if (contextData.hoveredEvent == null) {
730 - return null;
731 - }
732 - const {
733 - componentMeasure,
734 - flamechartStackFrame,
735 - measure,
736 - networkMeasure,
737 - schedulingEvent,
738 - suspenseEvent,
739 - } = contextData.hoveredEvent;
740 - return (
741 - <Fragment>
742 - {componentMeasure !== null && (
743 - <ContextMenuItem
744 - onClick={() => copy(componentMeasure.componentName)}
745 - title="Copy component name">
746 - Copy component name
747 - </ContextMenuItem>
748 - )}
749 - {networkMeasure !== null && (
750 - <ContextMenuItem
751 - onClick={() => copy(networkMeasure.url)}
752 - title="Copy URL">
753 - Copy URL
754 - </ContextMenuItem>
755 - )}
756 - {schedulingEvent !== null && (
757 - <ContextMenuItem
758 - onClick={() => copy(schedulingEvent.componentName)}
759 - title="Copy component name">
760 - Copy component name
761 - </ContextMenuItem>
762 - )}
763 - {suspenseEvent !== null && (
764 - <ContextMenuItem
765 - onClick={() => copy(suspenseEvent.componentName)}
766 - title="Copy component name">
767 - Copy component name
768 - </ContextMenuItem>
769 - )}
770 - {measure !== null && (
771 - <ContextMenuItem
772 - onClick={() =>
773 - zoomToBatch(contextData.data, measure, viewState, width)
774 - }
775 - title="Zoom to batch">
776 - Zoom to batch
777 - </ContextMenuItem>
778 - )}
779 - {measure !== null && (
780 - <ContextMenuItem
781 - onClick={() => copySummary(contextData.data, measure)}
782 - title="Copy summary">
783 - Copy summary
784 - </ContextMenuItem>
785 - )}
786 - {flamechartStackFrame !== null && (
787 - <ContextMenuItem
788 - onClick={() => copy(flamechartStackFrame.scriptUrl)}
789 - title="Copy file path">
790 - Copy file path
791 - </ContextMenuItem>
792 - )}
793 - {flamechartStackFrame !== null && (
794 - <ContextMenuItem
795 - onClick={() =>
796 - copy(
797 - `line ${
798 - flamechartStackFrame.locationLine ?? ''
799 - }, column ${flamechartStackFrame.locationColumn ?? ''}`,
800 - )
801 - }
802 - title="Copy location">
803 - Copy location
804 - </ContextMenuItem>
805 - )}
806 - </Fragment>
807 - );
808 - }}
809 - </ContextMenu>
810 - {!isContextMenuShown && !surfaceRef.current.hasActiveView() && (
811 - <EventTooltip
812 - canvasRef={canvasRef}
813 - data={data}
814 - height={height}
815 - hoveredEvent={hoveredEvent}
816 - origin={mouseLocation}
817 - width={width}
818 - />
819 - )}
703 +
704 + <CanvasPageContextMenu
705 + canvasRef={canvasRef}
706 + hoveredEvent={lastHoveredEvent}
707 + timelineData={data}
708 + viewState={viewState}
709 + canvasWidth={width}
710 + closedMenuStub={
711 + !surfaceRef.current.hasActiveView() ? (
712 + <EventTooltip
713 + canvasRef={canvasRef}
714 + data={data}
715 + height={height}
716 + hoveredEvent={hoveredEvent}
717 + origin={mouseLocation}
718 + width={width}
719 + />
720 + ) : null
721 + }
722 + ref={contextMenuRef}
723 + />
724 </Fragment>
725 );
726 }
packages/react-devtools-timeline/src/CanvasPageContextMenu.js new
+176
@@ -0,0 +1,176 @@
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 {useMemo} from 'react';
12 +import {copy} from 'clipboard-js';
13 +import prettyMilliseconds from 'pretty-ms';
14 +
15 +import ContextMenuContainer from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer';
16 +
17 +import {getBatchRange} from './utils/getBatchRange';
18 +import {moveStateToRange} from './view-base/utils/scrollState';
19 +import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
20 +
21 +import type {
22 + ContextMenuItem,
23 + ContextMenuRef,
24 +} from 'react-devtools-shared/src/devtools/ContextMenu/types';
25 +import type {
26 + ReactEventInfo,
27 + ReactMeasure,
28 + TimelineData,
29 + ViewState,
30 +} from './types';
31 +
32 +function zoomToBatch(
33 + data: TimelineData,
34 + measure: ReactMeasure,
35 + viewState: ViewState,
36 + width: number,
37 +) {
38 + const {batchUID} = measure;
39 + const [rangeStart, rangeEnd] = getBatchRange(batchUID, data);
40 +
41 + // Convert from time range to ScrollState
42 + const scrollState = moveStateToRange({
43 + state: viewState.horizontalScrollState,
44 + rangeStart,
45 + rangeEnd,
46 + contentLength: data.duration,
47 +
48 + minContentLength: data.duration * MIN_ZOOM_LEVEL,
49 + maxContentLength: data.duration * MAX_ZOOM_LEVEL,
50 + containerLength: width,
51 + });
52 +
53 + viewState.updateHorizontalScrollState(scrollState);
54 +}
55 +
56 +function copySummary(data: TimelineData, measure: ReactMeasure) {
57 + const {batchUID, duration, timestamp, type} = measure;
58 +
59 + const [startTime, stopTime] = getBatchRange(batchUID, data);
60 +
61 + copy(
62 + JSON.stringify({
63 + type,
64 + timestamp: prettyMilliseconds(timestamp),
65 + duration: prettyMilliseconds(duration),
66 + batchDuration: prettyMilliseconds(stopTime - startTime),
67 + }),
68 + );
69 +}
70 +
71 +type Props = {
72 + canvasRef: {current: HTMLCanvasElement | null},
73 + hoveredEvent: ReactEventInfo | null,
74 + timelineData: TimelineData,
75 + viewState: ViewState,
76 + canvasWidth: number,
77 + closedMenuStub: React.Node,
78 + ref: ContextMenuRef,
79 +};
80 +
81 +export default function CanvasPageContextMenu({
82 + canvasRef,
83 + timelineData,
84 + hoveredEvent,
85 + viewState,
86 + canvasWidth,
87 + closedMenuStub,
88 + ref,
89 +}: Props): React.Node {
90 + const menuItems = useMemo<ContextMenuItem[]>(() => {
91 + if (hoveredEvent == null) {
92 + return [];
93 + }
94 +
95 + const {
96 + componentMeasure,
97 + flamechartStackFrame,
98 + measure,
99 + networkMeasure,
100 + schedulingEvent,
101 + suspenseEvent,
102 + } = hoveredEvent;
103 + const items: ContextMenuItem[] = [];
104 +
105 + if (componentMeasure != null) {
106 + items.push({
107 + onClick: () => copy(componentMeasure.componentName),
108 + content: 'Copy component name',
109 + });
110 + }
111 +
112 + if (networkMeasure != null) {
113 + items.push({
114 + onClick: () => copy(networkMeasure.url),
115 + content: 'Copy URL',
116 + });
117 + }
118 +
119 + if (schedulingEvent != null) {
120 + items.push({
121 + onClick: () => copy(schedulingEvent.componentName),
122 + content: 'Copy component name',
123 + });
124 + }
125 +
126 + if (suspenseEvent != null) {
127 + items.push({
128 + onClick: () => copy(suspenseEvent.componentName),
129 + content: 'Copy component name',
130 + });
131 + }
132 +
133 + if (measure != null) {
134 + items.push(
135 + {
136 + onClick: () =>
137 + zoomToBatch(timelineData, measure, viewState, canvasWidth),
138 + content: 'Zoom to batch',
139 + },
140 + {
141 + onClick: () => copySummary(timelineData, measure),
142 + content: 'Copy summary',
143 + },
144 + );
145 + }
146 +
147 + if (flamechartStackFrame != null) {
148 + items.push(
149 + {
150 + onClick: () => copy(flamechartStackFrame.scriptUrl),
151 + content: 'Copy file path',
152 + },
153 + {
154 + onClick: () =>
155 + copy(
156 + `line ${flamechartStackFrame.locationLine ?? ''}, column ${
157 + flamechartStackFrame.locationColumn ?? ''
158 + }`,
159 + ),
160 + content: 'Copy location',
161 + },
162 + );
163 + }
164 +
165 + return items;
166 + }, [hoveredEvent, viewState, canvasWidth]);
167 +
168 + return (
169 + <ContextMenuContainer
170 + anchorElementRef={canvasRef}
171 + items={menuItems}
172 + closedMenuStub={closedMenuStub}
173 + ref={ref}
174 + />
175 + );
176 +}