自定义右键菜单 React 实现

5,931 阅读4分钟

这是我参与11月更文挑战的第15天,活动详情查看:2021最后一次更文挑战

鼠标右击网页时会弹出默认的浏览器菜单,但是在某些业务场景下,我们需要自定义右键菜单(比如:右键复制图片信息、定制视频播放器等),现在,我们就借助 contextmenu 事件,快速实现一个自定义右键菜单。

contextmenu 事件监听

contextmenu 事件会在用户尝试打开上下文菜单时被触发。该事件通常在鼠标点击右键或者按下键盘上的菜单键时被触发,如果使用菜单键,该上下文菜单会被展示 到所聚焦元素的左下角,但是如果该元素是一棵DOM树的话,上下文菜单便会展示在当前这一行的左下角。 (MDN)

首先,我们需要禁用浏览器弹出默认菜单的行为,通过 preventDefault() 阻止 contextmenu 事件的默认行为:

document.addEventListener('contextmenu', handleContextMenu);

const handleContextMenu = (event: MouseEvent) => {
  event.preventDefault()
}

在组件卸载时,需要移除监听的事件:

document.removeEventListener('contextmenu', handleContextMenu);

当鼠标点到其它地方,或者滚动的时候,不需要显示菜单,我们还需要监听 click 事件和 scroll 事件,并在组件卸载的时候移除监听的事件:

  useEffect(() => {

		// 组件初次渲染时监听事件
    document.addEventListener('contextmenu', handleContextMenu);
    document.addEventListener('click', (e) => handleClick(e, menuVisible));
    document.addEventListener('scroll', handleScroll);

		// 组件卸载时移除监听的事件
    return () => {
      document.removeEventListener('contextmenu', handleContextMenu);
      document.removeEventListener('click', handleClick);
      document.removeEventListener('scroll', handleScroll);
    }
  }, [])

右键菜单事件

当我们使用鼠标右击页面的时候,获取到鼠标当前的坐标,设置菜单为固定定位(position: fixed),并将菜单左上角位置设置为鼠标当前的坐标,以实现菜单在鼠标点击的位置弹出:

  const handleContextMenu = (event: MouseEvent) => {
    
    // const nodeName = event.target.nodeName
    const className = (event.target as any)?.className;
    // const id = event.target.id;

    if (!(targetElementClassName && className && targetElementClassName === className)) {
      return
    }

    event.preventDefault()

    setVisible(true)

    //获取可视区宽度
    var winWidth = function () {
      return document.documentElement.clientWidth || document.body.clientWidth;
    }
    //获取可视区高度
    var winHeight = function () {
      return document.documentElement.clientHeight || document.body.clientHeight;
    }

    const menu = menuRef.current
    let l = event.clientX;
    let t = event.clientY;

    if (l >= (winWidth() - menu?.offsetWidth)) {
      l = winWidth() - menu?.offsetWidth;
    } else {
      l = l
    }
    if (t > winHeight() - menu?.offsetHeight) {
      t = winHeight() - menu?.offsetHeight;
    } else {
      t = t;
    }

    if (menu) {
      menu.style.left = l + 'px';
      menu.style.top = t + 'px';
    }

    return false;
  }

在上面的代码中,我们使用 setVisible() 方法将菜单设置为显示状态,因此需要先使用 useState 方法定义一个用于控制菜单显隐的变量 visible 和更新 visible 的函数 setVisible:

const [visible, setVisible] = useState<boolean>(false);

当鼠标点击其它位置或者滚动的时候,我们需要将 visible 设置为 false,从而隐藏菜单:

  const handleClick = (event: any, visible?: boolean) => {
    setVisible(false)
  }
  
  const handleScroll = (event: any) => {
    setVisible(false)
  }

右键菜单的内容

在一个项目中,自定义右键菜单的功能可能会在多个地方中使用,但是其菜单内容可能会不一样,因此,我们将其封装成一个组件,菜单的内容在组件调用的时候再传进来,从而实现组件的可复用:

return (
  (visible) ? <div className={styles.menu} id="context-menu" ref={menuRef} style={style ? style : {}}>
    {props.children || menu}
  </div> : <></>
);

对外暴露事件

为了便于外部其它元素可以控制菜单的显隐,可以对外暴露一些菜单组件的属性方法,在这里,我们使用 forwardRef + useImperativeHandle 的方式将组件的属性方法暴露出去:

  useImperativeHandle(ref, () => ({
    menuRef, // 当前的元素引用
    event: currentEvent, // 当前的事件对象
    handleContextMenu, // 触发 contextmenu 事件时的处理方法
    closeMenu, // 隐藏菜单的处理方法
    openMenu, // 显示菜单的处理方法
  }))

完整代码

效果:codesandbox.io/s/custom-co…

index.tsx

import React, { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import styles from './index.less';

interface MenuData { }

export interface MenuProps {
  targetElementClassName?: string;
  isAutoListenEvent?: boolean;
  menu?: React.ReactNode;
  menuData?: MenuData[];
  position?: { left: number; top: number };
  style?: { [propsName: string]: any }
  visibleWithClass?: string[]; // 元素的class name,点击当前元素时,菜单不隐藏
  [propsName: string]: any
}

const ContextMenu: React.FC<MenuProps> = forwardRef((props, ref) => {

  const menuRef = useRef<any>(null)

  const { targetElementClassName, isAutoListenEvent = true, menu, menuVisible, position, style, visibleWithClass = [] } = props;

  const [visible, setVisible] = useState<boolean>(false)
  const [currentEvent, setCurrentEvent] = useState<Event | null>(null)

  useImperativeHandle(ref, () => ({
    menuRef,
    visible,
    event: currentEvent,
    handleContextMenu,
    closeMenu,
    openMenu,
  }))

  useEffect(() => {

    if (isAutoListenEvent) {
      document.addEventListener('contextmenu', handleContextMenu);
      document.addEventListener('click', (e) => handleClick(e, menuVisible));
      document.addEventListener('scroll', handleScroll);
    }

    return () => {
      document.removeEventListener('contextmenu', handleContextMenu);
      document.removeEventListener('click', handleClick);
      document.removeEventListener('scroll', handleScroll);
    }
  }, [menuVisible])

  const handleContextMenu = (event: MouseEvent, position?: any) => {
    setCurrentEvent(event)

    // const nodeName = event.target.nodeName
    const className = (event.target as any)?.className;
    // const id = event.target.id;

    if (!(targetElementClassName && className && targetElementClassName === className)) {
      return
    }

    event.preventDefault()

    // event.target.nodeName   //获取事件触发元素标签名(li,p,div,img,button…)
    // event.target.id      //获取事件触发元素id
    // event.target.className  //获取事件触发元素classname
    // event.target.innerHTML  //获取事件触发元素的内容(li)

    setVisible(true)

    //获取可视区宽度
    var winWidth = function () {
      return document.documentElement.clientWidth || document.body.clientWidth;
    }
    //获取可视区高度
    var winHeight = function () {
      return document.documentElement.clientHeight || document.body.clientHeight;
    }

    const menu = menuRef.current
    let l = event.clientX;
    let t = event.clientY;

    if (position) {
      l = position.left;
      t = position.top;
    }

    if (l >= (winWidth() - menu?.offsetWidth)) {
      l = winWidth() - menu?.offsetWidth;
    } else {
      l = l
    }
    if (t > winHeight() - menu?.offsetHeight) {
      t = winHeight() - menu?.offsetHeight;
    } else {
      t = t;
    }

    if (menu) {
      menu.style.left = l + 'px';
      menu.style.top = t + 'px';
    }

    return false;
  }

  const closeMenu = () => {
    setVisible(false)
  }
  const openMenu = () => {
    setVisible(true)
  }

  const handleClick = (event: any, visible?: boolean) => {

    const className = event.target?.className;
    const parentNode = event.target?.parentNode;

    for (let i = 0; i < visibleWithClass.length; i++) {
      const name = visibleWithClass[i]

      if (className && className?.startsWith(name) || parentNode?.className?.startsWith(name)) {
        return
      }
    }

    setVisible(false)
  }
  const handleScroll = (event: any) => {
    setVisible(false)
  }


  return (
    (visible) ? <div className={styles.menu} id="context-menu" ref={menuRef} style={style ? style : {}}>
      {props.children || menu}
    </div> : <></>
  );
});

export default ContextMenu;

index.less

.menu {
  min-width: 200px;
  // border: 1px solid #ccc;
  background-color: #fff;
  position: fixed;
  z-index: 99999;
  box-shadow: 0 0 5px rgba(0,0,0,.2);
  transition: all .1s ease;   
  border-radius: 10px;
  padding: 0 10px;
  // overflow: hidden;

  ul {
    padding: 0;
    margin: 0;
    li {
      list-style: none;
      width: 100%;
      border-bottom: 1px solid #d9d9d9;
      cursor: pointer;
      text-align: center;
      padding: 5px 0;
      color: #555;

      // &:first-of-type{
      //   border-radius: 5px 5px 0 0;
      // }
      &:last-child{
        border-bottom: none;
        position: relative;
      }
      // &:hover, &:active {
      //   background-color: #e6f7ff;
        
      // }
    
      & > a, & > span {
        display: inline-block;
        text-decoration: none;
        color: #555;
        width: 100%;
        padding: 0;
        text-align: center;

        &:hover, &:active {
          // background: #eee;
          background-color: #e6f7ff;
          border-radius: 5px;
          // color: #0AAF88;
        }
      }
    }
  }
}