// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { KeyboardEvent, ReactNode } from 'react'; import type { Options, VirtualElement } from '@popperjs/core'; import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; import { noop } from 'lodash'; import type { Theme } from '../util/theme'; import type { LocalizerType } from '../types/Util'; import { getClassNamesFor } from '../util/getClassNamesFor'; import { themeClassName } from '../util/theme'; import { handleOutsideClick } from '../util/handleOutsideClick'; export type ContextMenuOptionType = Readonly<{ description?: string; icon?: string; label: string; group?: string; onClick: (value?: T) => unknown; value?: T; }>; type RenderButtonProps = Readonly<{ openMenu: (ev: React.MouseEvent) => void; onKeyDown: (ev: KeyboardEvent) => void; isMenuShowing: boolean; ref: React.Ref | null; menuNode: ReactNode; }>; export type PropsType = Readonly<{ ariaLabel?: string; // contents of the button OR a function that will render the whole button children?: ReactNode | ((props: RenderButtonProps) => JSX.Element); i18n: LocalizerType; menuOptions: ReadonlyArray>; moduleClassName?: string; button?: () => JSX.Element; onClick?: (ev: React.MouseEvent) => unknown; onMenuShowingChanged?: (value: boolean) => unknown; popperOptions?: Pick; portalToRoot?: boolean; theme?: Theme; title?: string; value?: T; }>; let closeCurrentOpenContextMenu: undefined | (() => unknown); // https://popper.js.org/docs/v2/virtual-elements/ // Generating a virtual element here so that we can make the menu pop up // right under the mouse cursor. function generateVirtualElement(x: number, y: number): VirtualElement { return { getBoundingClientRect: () => new DOMRect(x, y), }; } export function ContextMenu({ ariaLabel, children, i18n, menuOptions, moduleClassName, onClick, onMenuShowingChanged, popperOptions, portalToRoot, theme, title, value, }: PropsType): JSX.Element { const [isMenuShowing, setIsMenuShowing] = useState(false); const [focusedIndex, setFocusedIndex] = useState( undefined ); const [popperElement, setPopperElement] = useState( null ); const virtualElement = useRef(generateVirtualElement(0, 0)); const [referenceElement, setReferenceElement] = useState(null); const { styles, attributes } = usePopper( virtualElement.current, popperElement, { placement: 'top-start', strategy: 'fixed', ...popperOptions, } ); // In Electron v23+, new elements added to the DOM may not trigger a recalculation of // draggable regions, so if a ContextMenu is shown on top of a draggable region, its // buttons may be unclickable. We add a class so that we can disable those draggable // regions while the context menu is shown. It has the added benefit of ensuring that // click events outside of the context menu onto an otherwise draggable region are // propagated and trigger the menu to close. useEffect(() => { document.body.classList.toggle('context-menu-open', isMenuShowing); }, [isMenuShowing]); useEffect(() => { // Remove it on unmount in case the component is unmounted when the menu is open return () => document.body.classList.remove('context-menu-open'); }, []); useEffect(() => { if (onMenuShowingChanged) { onMenuShowingChanged(isMenuShowing); } }, [isMenuShowing, onMenuShowingChanged]); useEffect(() => { if (!isMenuShowing) { return noop; } return handleOutsideClick( () => { setIsMenuShowing(false); closeCurrentOpenContextMenu = undefined; return true; }, { containerElements: [referenceElement, popperElement], name: 'ContextMenu', } ); }, [isMenuShowing, referenceElement, popperElement]); const [portalNode, setPortalNode] = React.useState(null); useEffect(() => { if (!portalToRoot || !isMenuShowing) { return noop; } const div = document.createElement('div'); document.body.appendChild(div); setPortalNode(div); return () => { document.body.removeChild(div); }; }, [portalToRoot, isMenuShowing]); const handleKeyDown = (ev: KeyboardEvent) => { if ((ev.key === 'Enter' || ev.key === 'Space') && !isMenuShowing) { closeCurrentOpenContextMenu?.(); closeCurrentOpenContextMenu = () => setIsMenuShowing(false); if (referenceElement) { const box = referenceElement.getBoundingClientRect(); virtualElement.current = generateVirtualElement(box.x, box.y); } setIsMenuShowing(true); setFocusedIndex(0); ev.preventDefault(); ev.stopPropagation(); } if (!isMenuShowing) { return; } if (ev.key === 'ArrowDown' || ev.key === 'Tab') { const currFocusedIndex = focusedIndex || 0; const nextFocusedIndex = currFocusedIndex >= menuOptions.length - 1 ? 0 : currFocusedIndex + 1; setFocusedIndex(nextFocusedIndex); ev.stopPropagation(); ev.preventDefault(); } if (ev.key === 'ArrowUp') { const currFocusedIndex = focusedIndex || 0; const nextFocusedIndex = currFocusedIndex === 0 ? menuOptions.length - 1 : currFocusedIndex - 1; setFocusedIndex(nextFocusedIndex); ev.stopPropagation(); ev.preventDefault(); } if (ev.key === 'Enter') { if (focusedIndex !== undefined) { const focusedOption = menuOptions[focusedIndex]; focusedOption.onClick(focusedOption.value); } setIsMenuShowing(false); closeCurrentOpenContextMenu = undefined; ev.stopPropagation(); ev.preventDefault(); } if (ev.key === 'Escape') { setIsMenuShowing(false); closeCurrentOpenContextMenu = undefined; ev.stopPropagation(); ev.preventDefault(); } }; const handleClick = (ev: React.MouseEvent) => { closeCurrentOpenContextMenu?.(); closeCurrentOpenContextMenu = () => setIsMenuShowing(false); virtualElement.current = generateVirtualElement(ev.clientX, ev.clientY); setIsMenuShowing(true); ev.stopPropagation(); ev.preventDefault(); }; const getClassName = getClassNamesFor('ContextMenu', moduleClassName); const optionElements = new Array(); const isAnyOptionSelected = typeof value !== 'undefined'; for (const [index, option] of menuOptions.entries()) { const previous = menuOptions[index - 1]; const needsDivider = previous && previous.group !== option.group; if (needsDivider) { optionElements.push(
); } // eslint-disable-next-line no-loop-func const onElementClick = (ev: React.MouseEvent): void => { ev.preventDefault(); ev.stopPropagation(); option.onClick(option.value); setIsMenuShowing(false); closeCurrentOpenContextMenu = undefined; }; const isOptionSelected = isAnyOptionSelected && value === option.value; optionElements.push( ); } const menuNode = isMenuShowing ? (
{title &&
{title}
} {optionElements}
) : undefined; let buttonNode: JSX.Element; if (typeof children === 'function') { buttonNode = ( <> {(children as (props: RenderButtonProps) => JSX.Element)({ openMenu: onClick || handleClick, onKeyDown: handleKeyDown, isMenuShowing, ref: setReferenceElement, menuNode, })} {portalNode ? createPortal(menuNode, portalNode) : menuNode} ); } else { buttonNode = (
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
); } return buttonNode; }