mirror of
https://github.com/hanzoai/react.git
synced 2026-09-24 17:02:27 +00:00
## 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
118 lines
3.1 KiB
JavaScript
118 lines
3.1 KiB
JavaScript
/**
|
|
* 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 {useLayoutEffect, createRef} from 'react';
|
|
import {createPortal} from 'react-dom';
|
|
|
|
import ContextMenuItem from './ContextMenuItem';
|
|
|
|
import type {
|
|
ContextMenuItem as ContextMenuItemType,
|
|
ContextMenuPosition,
|
|
ContextMenuRef,
|
|
} from './types';
|
|
|
|
import styles from './ContextMenu.css';
|
|
|
|
function repositionToFit(element: HTMLElement, x: number, y: number) {
|
|
const ownerWindow = element.ownerDocument.defaultView;
|
|
if (y + element.offsetHeight >= ownerWindow.innerHeight) {
|
|
if (y - element.offsetHeight > 0) {
|
|
element.style.top = `${y - element.offsetHeight}px`;
|
|
} else {
|
|
element.style.top = '0px';
|
|
}
|
|
} else {
|
|
element.style.top = `${y}px`;
|
|
}
|
|
|
|
if (x + element.offsetWidth >= ownerWindow.innerWidth) {
|
|
if (x - element.offsetWidth > 0) {
|
|
element.style.left = `${x - element.offsetWidth}px`;
|
|
} else {
|
|
element.style.left = '0px';
|
|
}
|
|
} else {
|
|
element.style.left = `${x}px`;
|
|
}
|
|
}
|
|
|
|
type Props = {
|
|
anchorElementRef: {current: React.ElementRef<any> | null},
|
|
items: ContextMenuItemType[],
|
|
position: ContextMenuPosition,
|
|
hide: () => void,
|
|
ref?: ContextMenuRef,
|
|
};
|
|
|
|
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 ownerDocument = anchor.ownerDocument;
|
|
const portalContainer = ownerDocument.querySelector(
|
|
'[data-react-devtools-portal-root]',
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
const menu = ((ref.current: any): HTMLElement);
|
|
|
|
function hideUnlessContains(event: Event) {
|
|
if (!menu.contains(((event.target: any): Node))) {
|
|
hide();
|
|
}
|
|
}
|
|
|
|
ownerDocument.addEventListener('mousedown', hideUnlessContains);
|
|
ownerDocument.addEventListener('touchstart', hideUnlessContains);
|
|
ownerDocument.addEventListener('keydown', hideUnlessContains);
|
|
|
|
const ownerWindow = ownerDocument.defaultView;
|
|
ownerWindow.addEventListener('resize', hide);
|
|
|
|
repositionToFit(menu, position.x, position.y);
|
|
|
|
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(
|
|
<div className={styles.ContextMenu} ref={ref}>
|
|
{items.map(({onClick, content}, index) => (
|
|
<ContextMenuItem key={index} onClick={onClick} hide={hide}>
|
|
{content}
|
|
</ContextMenuItem>
|
|
))}
|
|
</div>,
|
|
portalContainer,
|
|
);
|
|
}
|