Skip to content

Commit

Permalink
refactor[react-devtools]: rewrite context menus
Browse files Browse the repository at this point in the history
  • Loading branch information
hoxyq committed May 13, 2024
1 parent 9d76c95 commit c2d359a
Show file tree
Hide file tree
Showing 17 changed files with 769 additions and 624 deletions.
6 changes: 5 additions & 1 deletion packages/react-devtools-shared/src/backend/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,15 @@ export function serializeToString(data: any): string {
return 'undefined';
}

if (typeof data === 'function') {
return data.toString();
}

const cache = new Set<mixed>();
// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
overflow: hidden;
z-index: 10000002;
user-select: none;
}
}
191 changes: 80 additions & 111 deletions packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> | null},
items: ContextMenuItemType[],
position: ContextMenuPosition,
hide: () => void,
ref?: ContextMenuRef,
};

export default function ContextMenu({children, id}: Props): React.Node {
const {hideMenu, registerMenu} =
useContext<RegistryContextType>(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 <div ref={bodyAccessorRef} />;
} else {
const container = containerRef.current;
if (container !== null) {
return createPortal(
<div ref={menuRef} className={styles.ContextMenu}>
{children(state.data)}
</div>,
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(
<div className={styles.ContextMenu} ref={ref}>
{items.map(({onClick, content}, index) => (
<ContextMenuItem key={index} onClick={onClick} hide={hide}>
{content}
</ContextMenuItem>
))}
</div>,
portalContainer,
);
}
Original file line number Diff line number Diff line change
@@ -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<any> | 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 (
<ContextMenu
anchorElementRef={anchorElementRef}
position={position}
hide={hide}
items={items}
ref={ref}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegistryContextType>(RegistryContext);

const handleClick = (event: any) => {
const handleClick = () => {
onClick();
hideMenu();
hide();
};

return (
Expand Down
Loading

0 comments on commit c2d359a

Please sign in to comment.