diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index 3dd770a133852..ace070d8b6dd8 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -140,11 +140,15 @@ export function serializeToString(data: any): string { return 'undefined'; } + if (typeof data === 'function') { + return data.toString(); + } + const cache = new Set(); // Use a custom replacer function to protect against circular references. return JSON.stringify( data, - (key, value) => { + (key: string, value: any) => { if (typeof value === 'object' && value !== null) { if (cache.has(value)) { return; diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css index 4a8bca7073390..f6e9fa5dd7a55 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css @@ -6,4 +6,4 @@ overflow: hidden; z-index: 10000002; user-select: none; -} \ No newline at end of file +} diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js index afc7ada8d7aa5..a96634fbe46a0 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js @@ -8,141 +8,110 @@ */ import * as React from 'react'; -import {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {useLayoutEffect, createRef} from 'react'; import {createPortal} from 'react-dom'; -import {RegistryContext} from './Contexts'; -import styles from './ContextMenu.css'; +import ContextMenuItem from './ContextMenuItem'; + +import type { + ContextMenuItem as ContextMenuItemType, + ContextMenuPosition, + ContextMenuRef, +} from './types'; -import type {RegistryContextType} from './Contexts'; +import styles from './ContextMenu.css'; -function repositionToFit(element: HTMLElement, pageX: number, pageY: number) { +function repositionToFit(element: HTMLElement, x: number, y: number) { const ownerWindow = element.ownerDocument.defaultView; - if (element !== null) { - if (pageY + element.offsetHeight >= ownerWindow.innerHeight) { - if (pageY - element.offsetHeight > 0) { - element.style.top = `${pageY - element.offsetHeight}px`; - } else { - element.style.top = '0px'; - } + if (y + element.offsetHeight >= ownerWindow.innerHeight) { + if (y - element.offsetHeight > 0) { + element.style.top = `${y - element.offsetHeight}px`; } else { - element.style.top = `${pageY}px`; + element.style.top = '0px'; } + } else { + element.style.top = `${y}px`; + } - if (pageX + element.offsetWidth >= ownerWindow.innerWidth) { - if (pageX - element.offsetWidth > 0) { - element.style.left = `${pageX - element.offsetWidth}px`; - } else { - element.style.left = '0px'; - } + if (x + element.offsetWidth >= ownerWindow.innerWidth) { + if (x - element.offsetWidth > 0) { + element.style.left = `${x - element.offsetWidth}px`; } else { - element.style.left = `${pageX}px`; + element.style.left = '0px'; } + } else { + element.style.left = `${x}px`; } } -const HIDDEN_STATE = { - data: null, - isVisible: false, - pageX: 0, - pageY: 0, -}; - type Props = { - children: (data: Object) => React$Node, - id: string, + anchorElementRef: {current: React.ElementRef | null}, + items: ContextMenuItemType[], + position: ContextMenuPosition, + hide: () => void, + ref?: ContextMenuRef, }; -export default function ContextMenu({children, id}: Props): React.Node { - const {hideMenu, registerMenu} = - useContext(RegistryContext); - - const [state, setState] = useState(HIDDEN_STATE); +export default function ContextMenu({ + anchorElementRef, + position, + items, + hide, + ref = createRef(), +}: Props): React.Node { + // This works on the assumption that ContextMenu component is only rendered when it should be shown + const anchor = anchorElementRef.current; + + if (anchor == null) { + throw new Error( + 'Attempted to open a context menu for an element, which is not mounted', + ); + } - const bodyAccessorRef = useRef(null); - const containerRef = useRef(null); - const menuRef = useRef(null); + const ownerDocument = anchor.ownerDocument; + const portalContainer = ownerDocument.querySelector( + '[data-react-devtools-portal-root]', + ); - useEffect(() => { - const element = bodyAccessorRef.current; - if (element !== null) { - const ownerDocument = element.ownerDocument; - containerRef.current = ownerDocument.querySelector( - '[data-react-devtools-portal-root]', - ); + useLayoutEffect(() => { + const menu = ((ref.current: any): HTMLElement); - if (containerRef.current == null) { - console.warn( - 'DevTools tooltip root node not found; context menus will be disabled.', - ); + function hideUnlessContains(event: Event) { + if (!menu.contains(((event.target: any): Node))) { + hide(); } } - }, []); - useEffect(() => { - const showMenuFn = ({ - data, - pageX, - pageY, - }: { - data: any, - pageX: number, - pageY: number, - }) => { - setState({data, isVisible: true, pageX, pageY}); - }; - const hideMenuFn = () => setState(HIDDEN_STATE); - return registerMenu(id, showMenuFn, hideMenuFn); - }, [id]); + ownerDocument.addEventListener('mousedown', hideUnlessContains); + ownerDocument.addEventListener('touchstart', hideUnlessContains); + ownerDocument.addEventListener('keydown', hideUnlessContains); - useLayoutEffect(() => { - if (!state.isVisible) { - return; - } + const ownerWindow = ownerDocument.defaultView; + ownerWindow.addEventListener('resize', hide); - const menu = ((menuRef.current: any): HTMLElement); - const container = containerRef.current; - if (container !== null) { - // $FlowFixMe[missing-local-annot] - const hideUnlessContains = event => { - if (!menu.contains(event.target)) { - hideMenu(); - } - }; - - const ownerDocument = container.ownerDocument; - ownerDocument.addEventListener('mousedown', hideUnlessContains); - ownerDocument.addEventListener('touchstart', hideUnlessContains); - ownerDocument.addEventListener('keydown', hideUnlessContains); - - const ownerWindow = ownerDocument.defaultView; - ownerWindow.addEventListener('resize', hideMenu); - - repositionToFit(menu, state.pageX, state.pageY); - - return () => { - ownerDocument.removeEventListener('mousedown', hideUnlessContains); - ownerDocument.removeEventListener('touchstart', hideUnlessContains); - ownerDocument.removeEventListener('keydown', hideUnlessContains); - - ownerWindow.removeEventListener('resize', hideMenu); - }; - } - }, [state]); + repositionToFit(menu, position.x, position.y); - if (!state.isVisible) { - return
; - } else { - const container = containerRef.current; - if (container !== null) { - return createPortal( -
- {children(state.data)} -
, - container, - ); - } else { - return null; - } + return () => { + ownerDocument.removeEventListener('mousedown', hideUnlessContains); + ownerDocument.removeEventListener('touchstart', hideUnlessContains); + ownerDocument.removeEventListener('keydown', hideUnlessContains); + + ownerWindow.removeEventListener('resize', hide); + }; + }, []); + + if (portalContainer == null || items.length === 0) { + return null; } + + return createPortal( +
+ {items.map(({onClick, content}, index) => ( + + {content} + + ))} +
, + portalContainer, + ); } diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer.js b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer.js new file mode 100644 index 0000000000000..14e1985b2022a --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer.js @@ -0,0 +1,59 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import {useImperativeHandle} from 'react'; + +import ContextMenu from './ContextMenu'; +import useContextMenu from './useContextMenu'; + +import type {ContextMenuItem, ContextMenuRef} from './types'; + +type Props = { + anchorElementRef: { + current: React.ElementRef | null, + }, + items: ContextMenuItem[], + closedMenuStub?: React.Node | null, + ref?: ContextMenuRef, +}; + +export default function ContextMenuContainer({ + anchorElementRef, + items, + closedMenuStub = null, + ref, +}: Props): React.Node { + const {shouldShow, position, hide} = useContextMenu(anchorElementRef); + + useImperativeHandle( + ref, + () => ({ + isShown() { + return shouldShow; + }, + hide, + }), + [shouldShow, hide], + ); + + if (!shouldShow) { + return closedMenuStub; + } + + return ( + + ); +} diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css index 1b36ea76c0142..aab558860bc61 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css @@ -8,15 +8,18 @@ font-family: var(--font-family-sans); font-size: var(--font-size-sans-normal); } + .ContextMenuItem:first-of-type { border-top: none; } + .ContextMenuItem:hover, .ContextMenuItem:focus { outline: 0; background-color: var(--color-context-background-hover); } + .ContextMenuItem:active { background-color: var(--color-context-background-selected); color: var(--color-context-text-selected); -} \ No newline at end of file +} diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js index d634f5eeef286..4d15364b002d5 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js @@ -8,29 +8,23 @@ */ import * as React from 'react'; -import {useContext} from 'react'; -import {RegistryContext} from './Contexts'; import styles from './ContextMenuItem.css'; -import type {RegistryContextType} from './Contexts'; - type Props = { - children: React$Node, + children: React.Node, onClick: () => void, - title: string, + hide: () => void, }; export default function ContextMenuItem({ children, onClick, - title, + hide, }: Props): React.Node { - const {hideMenu} = useContext(RegistryContext); - - const handleClick = (event: any) => { + const handleClick = () => { onClick(); - hideMenu(); + hide(); }; return ( diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js b/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js deleted file mode 100644 index 9d8b49e7d9250..0000000000000 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import type {ReactContext} from 'shared/ReactTypes'; - -import {createContext} from 'react'; - -export type ShowFn = ({data: Object, pageX: number, pageY: number}) => void; -export type HideFn = () => void; -export type OnChangeFn = boolean => void; - -const idToShowFnMap = new Map(); -const idToHideFnMap = new Map(); - -let currentHide: ?HideFn = null; -let currentOnChange: ?OnChangeFn = null; - -function hideMenu() { - if (typeof currentHide === 'function') { - currentHide(); - - if (typeof currentOnChange === 'function') { - currentOnChange(false); - } - } - - currentHide = null; - currentOnChange = null; -} - -function showMenu({ - data, - id, - onChange, - pageX, - pageY, -}: { - data: Object, - id: string, - onChange?: OnChangeFn, - pageX: number, - pageY: number, -}) { - const showFn = idToShowFnMap.get(id); - if (typeof showFn === 'function') { - // Prevent open menus from being left hanging. - hideMenu(); - - currentHide = idToHideFnMap.get(id); - - showFn({data, pageX, pageY}); - - if (typeof onChange === 'function') { - currentOnChange = onChange; - onChange(true); - } - } -} - -function registerMenu(id: string, showFn: ShowFn, hideFn: HideFn): () => void { - if (idToShowFnMap.has(id)) { - throw Error(`Context menu with id "${id}" already registered.`); - } - - idToShowFnMap.set(id, showFn); - idToHideFnMap.set(id, hideFn); - - return function unregisterMenu() { - idToShowFnMap.delete(id); - idToHideFnMap.delete(id); - }; -} - -export type RegistryContextType = { - hideMenu: typeof hideMenu, - showMenu: typeof showMenu, - registerMenu: typeof registerMenu, -}; - -export const RegistryContext: ReactContext = - createContext({ - hideMenu, - showMenu, - registerMenu, - }); diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/types.js b/packages/react-devtools-shared/src/devtools/ContextMenu/types.js new file mode 100644 index 0000000000000..2436fcc2d1b80 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/types.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Node as ReactNode, AbstractComponent, ElementRef} from 'react'; + +export type ContextMenuItem = { + onClick: () => void, + content: ReactNode, +}; + +// Relative to [data-react-devtools-portal-root] +export type ContextMenuPosition = { + x: number, + y: number, +}; + +export type ContextMenuHandle = { + isShown(): boolean, + hide(): void, +}; + +export type ContextMenuComponent = AbstractComponent<{}, ContextMenuHandle>; +export type ContextMenuRef = {current: ElementRef | null}; diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js b/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js index 092725eacae48..016012f7346ce 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js @@ -7,47 +7,67 @@ * @flow */ -import {useContext, useEffect} from 'react'; -import {RegistryContext} from './Contexts'; - -import type {OnChangeFn, RegistryContextType} from './Contexts'; -import type {ElementRef} from 'react'; - -export default function useContextMenu({ - data, - id, - onChange, - ref, -}: { - data: Object, - id: string, - onChange?: OnChangeFn, - ref: {current: ElementRef | null}, -}) { - const {showMenu} = useContext(RegistryContext); +import * as React from 'react'; +import {useState, useEffect, useCallback} from 'react'; + +import type {ContextMenuPosition} from './types'; + +type Payload = { + shouldShow: boolean, + position: ContextMenuPosition | null, + hide: () => void, +}; + +export default function useContextMenu(anchorElementRef: { + current: React.ElementRef | null, +}): Payload { + const [shouldShow, setShouldShow] = useState(false); + const [position, setPosition] = React.useState( + null, + ); + + const hide = useCallback(() => { + setShouldShow(false); + setPosition(null); + }, []); useEffect(() => { - if (ref.current !== null) { - const handleContextMenu = (event: MouseEvent | TouchEvent) => { - event.preventDefault(); - event.stopPropagation(); - - const pageX = - (event: any).pageX || - (event.touches && (event: any).touches[0].pageX); - const pageY = - (event: any).pageY || - (event.touches && (event: any).touches[0].pageY); - - showMenu({data, id, onChange, pageX, pageY}); - }; - - const trigger = ref.current; - trigger.addEventListener('contextmenu', handleContextMenu); - - return () => { - trigger.removeEventListener('contextmenu', handleContextMenu); - }; + const anchor = anchorElementRef.current; + if (anchor == null) return; + + function handleAnchorContextMenu(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + + const {pageX, pageY} = e; + + const ownerDocument = anchor?.ownerDocument; + const portalContainer = ownerDocument?.querySelector( + '[data-react-devtools-portal-root]', + ); + + if (portalContainer == null) { + throw new Error( + "DevTools tooltip root node not found: can't display the context menu", + ); + } + + // `x` and `y` should be relative to the container, to which these context menus will be portaled + // we can't use just `pageX` or `pageY` for Fusebox integration, because RDT frontend is inlined with the whole document + // meaning that `pageY` will have an offset of 27, which is the tab bar height + // for the browser extension, these will equal to 0 + const {top: containerTop, left: containerLeft} = + portalContainer.getBoundingClientRect(); + + setShouldShow(true); + setPosition({x: pageX - containerLeft, y: pageY - containerTop}); } - }, [data, id, showMenu]); + + anchor.addEventListener('contextmenu', handleAnchorContextMenu); + return () => { + anchor.removeEventListener('contextmenu', handleAnchorContextMenu); + }; + }, [anchorElementRef]); + + return {shouldShow, position, hide}; } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js index b04fe44737581..5fd1a0a1ee779 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js @@ -9,7 +9,7 @@ import {copy} from 'clipboard-js'; import * as React from 'react'; -import {useCallback, useContext, useRef, useState} from 'react'; +import {useCallback, useContext, useState} from 'react'; import {BridgeContext, StoreContext} from '../context'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; @@ -19,7 +19,6 @@ import KeyValue from './KeyValue'; import {getMetaValueLabel, serializeHooksForCopy} from '../utils'; import Store from '../../store'; import styles from './InspectedElementHooksTree.css'; -import useContextMenu from '../../ContextMenu/useContextMenu'; import {meta} from '../../../hydration'; import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookNamesCache'; import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; @@ -182,22 +181,6 @@ function HookView({ [], ); - const contextMenuTriggerRef = useRef(null); - - useContextMenu({ - data: { - path: ['hooks', ...path], - type: - hook !== null && - typeof hook === 'object' && - hook.hasOwnProperty(meta.type) - ? hook[(meta.type: any)] - : typeof value, - }, - id: 'InspectedElement', - ref: contextMenuTriggerRef, - }); - if (hook.hasOwnProperty(meta.inspected)) { // This Hook is too deep and hasn't been hydrated. if (__DEV__) { @@ -301,7 +284,7 @@ function HookView({ if (isComplexDisplayValue) { return (
-
+
-
+
(ContextMenuContext); - const rendererLabel = rendererPackageName !== null && rendererVersion !== null ? `${rendererPackageName}@${rendererVersion}` @@ -182,65 +168,6 @@ export default function InspectedElementView({ /> )}
- - {isContextMenuEnabledForInspectedElement && ( - - {({path, type: pathType}) => { - const copyInspectedElementPath = () => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID !== null) { - copyInspectedElementPathAPI({ - bridge, - id, - path, - rendererID, - }); - } - }; - - const storeAsGlobal = () => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID !== null) { - storeAsGlobalAPI({ - bridge, - id, - path, - rendererID, - }); - } - }; - - return ( - - - Copy - value to clipboard - - - {' '} - Store as global variable - - {viewAttributeSourceFunction !== null && - pathType === 'function' && ( - viewAttributeSourceFunction(id, path)} - title="Go to definition"> - Go - to definition - - )} - - ); - }} - - )} ); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js index 72684cf5946bc..fbd3645e36116 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js @@ -8,7 +8,7 @@ */ import * as React from 'react'; -import {useTransition, useContext, useRef, useState} from 'react'; +import {useTransition, useContext, useRef, useState, useMemo} from 'react'; import {OptionsContext} from '../context'; import EditableName from './EditableName'; import EditableValue from './EditableValue'; @@ -18,7 +18,6 @@ import LoadingAnimation from './LoadingAnimation'; import ExpandCollapseToggle from './ExpandCollapseToggle'; import {alphaSortEntries, getMetaValueLabel} from '../utils'; import {meta} from '../../../hydration'; -import useContextMenu from '../../ContextMenu/useContextMenu'; import Store from '../../store'; import {parseHookPathForEdit} from './utils'; import styles from './KeyValue.css'; @@ -27,6 +26,7 @@ import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon'; import isArray from 'react-devtools-shared/src/isArray'; import {InspectedElementContext} from './InspectedElementContext'; import {PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE} from './constants'; +import KeyValueContextMenuContainer from './KeyValueContextMenuContainer'; import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; import type {Element} from 'react-devtools-shared/src/frontend/types'; @@ -85,6 +85,7 @@ export default function KeyValue({ canRenamePaths = !readOnlyGlobalFlag && canRenamePaths; const {id} = inspectedElement; + const fullPath = useMemo(() => [pathRoot, ...path], [pathRoot, path]); const [isOpen, setIsOpen] = useState(false); const contextMenuTriggerRef = useRef(null); @@ -113,20 +114,6 @@ export default function KeyValue({ } }; - useContextMenu({ - data: { - path: [pathRoot, ...path], - type: - value !== null && - typeof value === 'object' && - hasOwnProperty.call(value, meta.type) - ? value[meta.type] - : typeof value, - }, - id: 'InspectedElement', - ref: contextMenuTriggerRef, - }); - const dataType = typeof value; const isSimpleType = dataType === 'number' || @@ -134,6 +121,14 @@ export default function KeyValue({ dataType === 'boolean' || value == null; + const pathType = + value !== null && + typeof value === 'object' && + hasOwnProperty.call(value, meta.type) + ? value[meta.type] + : typeof value; + const pathIsFunction = pathType === 'function'; + const style = { paddingLeft: `${(depth - 1) * 0.75}rem`, }; @@ -270,60 +265,78 @@ export default function KeyValue({ } children = ( -