/* global: window */ import React, { Component, ReactNode } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import { cloneItem } from './cloneItem'; import { Portal } from './Portal'; import { HIDE_ALL, DISPLAY_MENU } from '../utils/actions'; import { styles } from '../utils/styles'; import { eventManager } from '../utils/eventManager'; import { TriggerEvent, StyleProps, MenuId } from '../types'; const KEY = { ENTER: 13, ESC: 27, ARROW_UP: 38, ARROW_DOWN: 40, ARROW_LEFT: 37, ARROW_RIGHT: 39 }; export interface MenuProps extends StyleProps { /** * Unique id to identify the menu. Use to Trigger the corresponding menu */ id: MenuId; /** * Any valid node that can be rendered */ children: ReactNode; /** * Theme is appended to `react-contexify__theme--${given theme}`. * * Built-in theme are `light` and `dark` */ theme?: string; /** * Animation is appended to `.react-contexify__will-enter--${given animation}` * * Built-in animations are fade, flip, pop, zoom */ animation?: string; /** * Invoked when the menu is shown. */ onShown?: () => void; /** * Invoked when the menu is hidden. */ onHidden?: () => void; } interface MenuState { x: number; y: number; visible: boolean; nativeEvent: TriggerEvent; propsFromTrigger: object; } class Menu extends Component { static propTypes = { id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, children: PropTypes.node.isRequired, theme: PropTypes.string, animation: PropTypes.string, className: PropTypes.string, style: PropTypes.object }; state = { x: 0, y: 0, visible: false, nativeEvent: {} as any, propsFromTrigger: {}, onShown: null, onHidden: null }; menuRef!: HTMLDivElement; unsub: (() => boolean)[] = []; componentDidMount() { this.unsub.push(eventManager.on(DISPLAY_MENU(this.props.id), this.show)); this.unsub.push(eventManager.on(HIDE_ALL, this.hide)); } componentWillUnmount() { this.unsub.forEach(cb => cb()); this.unBindWindowEvent(); } componentDidUpdate(_: Readonly, prevState: Readonly) { if (this.state.visible !== prevState.visible) { if (this.state.visible && this.props.onShown) { this.props.onShown(); } else if (!this.state.visible && this.props.onHidden) { this.props.onHidden(); } } } bindWindowEvent = () => { window.addEventListener('resize', this.hide); window.addEventListener('contextmenu', this.hide); window.addEventListener('mousedown', this.hide); window.addEventListener('click', this.hide); window.addEventListener('scroll', this.hide); window.addEventListener('keydown', this.handleKeyboard); }; unBindWindowEvent = () => { window.removeEventListener('resize', this.hide); window.removeEventListener('contextmenu', this.hide); window.removeEventListener('mousedown', this.hide); window.removeEventListener('click', this.hide); window.removeEventListener('scroll', this.hide); window.removeEventListener('keydown', this.handleKeyboard); }; onMouseEnter = () => window.removeEventListener('mousedown', this.hide); onMouseLeave = () => window.addEventListener('mousedown', this.hide); hide = (event?: Event) => { // Safari trigger a click event when you ctrl + trackpad // Firefox: trigger a click event when right click occur const e = event as KeyboardEvent & MouseEvent; if ( typeof e !== 'undefined' && (e.button === 2 || e.ctrlKey === true) && e.type !== 'contextmenu' ) { return; } this.unBindWindowEvent(); this.setState({ visible: false }); }; handleKeyboard = (e: KeyboardEvent) => { if (e.keyCode === KEY.ENTER || e.keyCode === KEY.ESC) { this.unBindWindowEvent(); this.setState({ visible: false }); } }; setRef = (ref: HTMLDivElement) => { this.menuRef = ref; }; setMenuPosition() { const { innerWidth: windowWidth, innerHeight: windowHeight, scrollY: scrollHeight } = window; const { offsetWidth: menuWidth, offsetHeight: menuHeight } = this.menuRef; let { x, y } = this.state; x -= menuWidth + 5; if (x < 0) { x += menuWidth; } if (x + menuWidth > windowWidth) { x -= menuWidth; } if (y + menuHeight > windowHeight + scrollHeight) { y -= menuHeight; } this.setState( { x, y }, this.bindWindowEvent ); } getMousePosition(e: TriggerEvent) { const pos = { x: e.clientX, y: e.clientY }; if ( e.type === 'touchend' && (!pos.x || !pos.y) && e.changedTouches && e.changedTouches.length > 0 ) { pos.x = e.changedTouches[0].clientX; pos.y = e.changedTouches[0].clientY; } if (!pos.x || pos.x < 0) { pos.x = 0; } if (!pos.y || pos.y < 0) { pos.y = 0; } return pos; } show = ( e: TriggerEvent, props: { x?: number; y?: number; [key: string]: any } ) => { e.stopPropagation(); eventManager.emit(HIDE_ALL); const { x, y } = this.getMousePosition(e); this.setState( { visible: true, x: props.x || x, y: props.y || y, nativeEvent: e, propsFromTrigger: props }, this.setMenuPosition ); }; render() { const { theme, animation, style, className, children } = this.props; const { visible, nativeEvent, propsFromTrigger, x, y } = this.state; const cssClasses = cx(styles.menu, className, { [styles.theme + theme]: theme, [styles.animationWillEnter + animation]: animation }); const menuStyle = { ...style, left: x, top: y + 1, opacity: 1 }; return ( {visible && (
{cloneItem(children, { nativeEvent, propsFromTrigger })}
)}
); } } export { Menu };