前端通用右键菜单解决方案(文末贴了源码)

2,816 阅读1分钟

前端经常有用到右键菜单扩展交互能力的需求,不同场景下菜单功能不同,且通常需要根据右键目标的不同显示不同的菜单操作,本文就记录了一种通用的右键菜单解决方案。

简介

demo演示链接: 右键菜单演示demo

  • 每个菜单项都可以设置是否禁用、是否隐藏
  • 支持子菜单
  • 支持显示icon图标、提示语、快捷键等
  • 与业务完全解耦,通过简单配置即可定制出功能各异的菜单

样图演示: image.png

用法

1. 最简单的用法

import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
  { text: '复制', key: 'copy' },
  { text: '粘贴', key: 'paste', shortcutKeyDesc: `${cmd}+V` },
  {
    text: '对齐',
    key: 'align',
    children: [
      { text: '水平垂直居中', key: 'horizontalVerticalAlign' },
      { text: '水平居中', key: 'horizontalAlign' },
    ],
 },
];

export () => {
  const containerDomRef = React.useRef();
  // 菜单点击触发
  const handleMenuTrigger = (menu: IContextMenuItem) => {
      console.log(menu); // { text: '复制', key: 'copy' }
      // 这里处理触发菜单后的逻辑....

  };
  return (
    <div
      ref={containerDomRef}
      style={{ position: 'relative' }}>
      <ContextMenu
        getContainerDom={() => containerDomRef.current}
        menuList={menuList}
        onTrigger={handleMenuTrigger}
      />
    </div>
  );
};

2. 根据右键目标动态控制是否禁用和隐藏

import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
  {
    text: '复制',
    key: 'copy',
    // 动态判断是否禁用
    disable: (ctx) => ctx.event.target?.getAttribute('id') === 'button',
  },
  {
    text: '粘贴',
    key: 'paste',
    // 动态判断是否隐藏
    hide: (ctx) => ctx.event.target?.getAttribute('id') === 'text',
  },
];

3. 为判断函数注入更多上下文数据

  • mergeContextFromEvent 函数会在每次唤起右键菜单前运行一次,其返回值会融合进入disable hide等菜单属性函数的参数,作为ctx上下文用来辅助判断
import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
  {
    text: '复制',
    key: 'copy',
    // 动态判断是否禁用
    disable: (ctx) => ctx.event.target?.getAttribute('id') === 'button',
  },
];
export () => {
  const containerDomRef = React.useRef();
  const selectedNodeRef = useRef(null);
  // 菜单点击触发
  const handleMenuTrigger = (menu: IContextMenuItem) => {
      console.log(menu); // { text: '复制', key: 'copy' }
      // 这里处理触发菜单后的逻辑....

  };
  const mergeContext = () => {
    const id = selectedNodeRef.current?.getAttribute('id');
    return {
      id,
      selectedNode: selectedNodeRef.current, // 也可以传ref等值
    };
  };
  return (
    <div
      ref={containerDomRef}
      style={{ position: 'relative' }}>
      {/* 这里随便渲染一些节点 */}
      {[1,2,3].map(e => <div id={e}>node:{e}</div>)}
      <ContextMenu
        getContainerDom={() => containerDomRef.current}
        menuList={menuList}
        onTrigger={handleMenuTrigger}
        mergeContextFromEvent={mergeContext}
      />
    </div>
  );
};

4. text、childen等字段都可以设为动态值以满足复杂场景


// 菜单配置数据
const menuList: IContextMenuItem[] = [
  {
    // 显示动态文本
    text: (ctx) => ctx.event.target?.getAttribute('id') === 'button' ? '复制按钮' : '复制',
    key: 'copy',
  },
  {
    text: '对齐',
    key: 'align',
    children: (ctx) => {
      const arr =[
        { text: '水平垂直居中', key: 'horizontalVerticalAlign' },
        { text: '水平居中', key: 'horizontalAlign' },
      ];
      // 某个判断逻辑可以控制子节点是否展示等
      if (ctx..event.target?.getAttribute('id') === 'button') {
	arr = [];
      }
      return arr;
   },
];

源码

1. 入口组件文件

此文件中主要包含了绑定右键菜单事件、将disable等动态值计算为静态值等功能

// 右键菜单
import React from 'react';
import styled from 'styled-components';
import Menu from './menu-base';
import { IContextMenuItem, IContextMenuUIItem, ITriggerContext } from './type';

const ContextMenuContainer = styled.div``;

export function typeOf(param: any) {
  return Object.prototype.toString.call(param).slice(8, -1).toLowerCase();
}

// TODO
const cloneDeep = (e: any) => {
  const dfs = (node: any) => {
    let children: any = null;
    if (node?.children?.length > 1) {
      children = node.children.map(dfs);
    }
    if (typeOf(node) === 'object') {
      if (children) {
        return { ...node, children };
      }
      return { ...node };
    } else if (typeOf(node) === 'array') {
      return [...node];
    }
    return node;
  };
  return e.map(dfs);
};

/**
 * 处理属性,
 *  1. 计算函数类型变为数值,例如disable等
 *  2. 删除不应该存在的变量
 *
 * @param menuList - 菜单列表
 * @param ctx - 上下文
 * @returns 菜单列表UI数据
 */
const computeMenuState = (
  menuList: IContextMenuItem[],
  ctx: ITriggerContext,
) => {
  const computeState = (menuItem: IContextMenuItem) => {
    // 除此之外的属性会别删除
    const arrowKeyMap = {
      text: true,
      key: true,
      tips: true,
      shortcutKeyDesc: true,
      disable: true,
      hide: true,
      icon: true,
      // 用户用来透传自定义数据的属性
      customData: true,
      children: true,
    };
    // 以下属性如果是函数会被运行处理成值
    const funcKeyMap: Record<string, boolean> = {
      text: true,
      disable: true,
      hide: true,
      icon: true,
      tips: true,
      children: true,
    };
    Object.keys(menuItem).forEach((key: string) => {
      if (!arrowKeyMap[key]) {
        delete menuItem[key];
        return;
      }
      if (funcKeyMap[key] && typeof menuItem?.[key] === 'function') {
        menuItem[key] = menuItem[key](ctx);
      }
    });
  };
  const dfs = (menuItem: IContextMenuItem) => {
    computeState(menuItem);
    if (menuItem?.children?.length > 0) {
      (menuItem?.children as IContextMenuItem[]).forEach(child => dfs(child));
    }
  };
  const newMenuList: IContextMenuUIItem[] = cloneDeep(menuList) as any;
  newMenuList.forEach(dfs);
  return newMenuList;
};

interface Point {
  x: number;
  y: number;
}

interface IProps {
  className?: string;
  style?: React.CSSProperties;
  menuList: IContextMenuItem[];
  getContainerDom: () => any;
  onTrigger?: (
    menuItem: IContextMenuUIItem,
    triggerContext: ITriggerContext,
  ) => void;
  onContextMenu?: (
    e: PointerEvent,
    triggerContext: ITriggerContext,
  ) => boolean | any;
  mergeContextFromEvent?: (params: {
    event: PointerEvent;
  }) => Record<string, any>;
  [key: string]: any;
}

interface IState {
  menuData: IContextMenuUIItem[];
  contextMenuPoint: Point;
  visiblePopover: boolean;
  triggerContext: ITriggerContext;
}
export class ContextMenu extends React.Component<IProps, IState> {
  menuContainerRef: any = React.createRef();

  containerDom: any = null;

  contextMenuEvent: PointerEvent;

  readonly state: IState = {
    contextMenuPoint: { x: 0, y: 0 },
    visiblePopover: false,
    triggerContext: { event: null as any },
    menuData: [],
  };

  componentDidMount() {
    setTimeout(() => {
      const { getContainerDom } = this.props;
      if (getContainerDom) {
        this.containerDom = getContainerDom();
      } else {
        this.containerDom = this.menuContainerRef.current?.parentNode;
      }
      this.containerDom?.addEventListener(
        'contextmenu',
        this.handleContextMenu,
      );
    }, 500);
  }

  componentWillUnmount() {
    this.containerDom?.removeEventListener(
      'contextmenu',
      this.handleContextMenu,
    );
  }

  handleContextMenu = (e: PointerEvent) => {
    const { menuList, onContextMenu, mergeContextFromEvent } = this.props;
    if (!this.containerDom) {
      return;
    }
    const moreContext = mergeContextFromEvent?.({ event: e }) || {};
    const newContext = {
      ...moreContext,
      event: e,
    };
    if (onContextMenu && onContextMenu(e, newContext) === false) {
      return;
    }
    this.hidePopover();
    // e.stopPropagation();
    e.preventDefault();
    const { x, y } = this.containerDom?.getBoundingClientRect();
    const point = { x: e.pageX - x, y: e.pageY - y };
    this.contextMenuEvent = e;
    // 计算状态
    const newMenuData = computeMenuState(menuList, newContext);
    this.showPopover(point);
    this.setState({
      triggerContext: newContext,
      menuData: newMenuData,
    });
  };

  handleTrigger = (menuItem: IContextMenuUIItem) => {
    const { onTrigger } = this.props;
    const { triggerContext } = this.state;
    onTrigger?.({ ...menuItem }, triggerContext);
  };

  showPopover = (point: Point) => {
    this.setState({ contextMenuPoint: point, visiblePopover: true });
  };

  hidePopover = () => {
    this.setState({ visiblePopover: false });
  };

  render() {
    const { menuData, contextMenuPoint, visiblePopover } = this.state;
    return (
      <ContextMenuContainer
        ref={this.menuContainerRef}
        style={{
          position: 'absolute',
          left: contextMenuPoint.x,
          top: contextMenuPoint.y,
        }}>
        <Menu
          {...this.props}
          menuList={menuData}
          visible={visiblePopover}
          onTrigger={this.handleTrigger}
          onVisibleChange={(visible: boolean) => {
            if (!visible) {
              this.hidePopover();
            }
          }}
        />
      </ContextMenuContainer>
    );
  }
}


2. 纯ui右键菜单组件

不包含是否隐藏、禁用等逻辑,单纯渲染数据

  • 【menu-base.tsx】
//@ts-ignore
import React, { useRef, useEffect } from 'react';
import styled from 'styled-components';
import { IContextMenuUIItem, ITriggerContext } from './type';
import { Tooltip } from './tips';

const CaretRightOutlined = (props: any) => <div {...props}></div>;
const IconQuestionCircle = (props: any) => (
  <div
    {...props}
    style={{
      border: '1px solid #bbb',
      borderRadius: '50%',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      width: 14,
      height: 14,
      fontSize: 12,
      color: '#666',
      transform: 'scale(0.8)',
    }}>
    ?
  </div>
);

interface IProps {
  className?: string;
  style?: React.CSSProperties;
  visible?: boolean;
  menuList: IContextMenuUIItem[];
  onClick?: (e: MouseEvent) => void;
  onTrigger?: (menuItem: IContextMenuUIItem) => void;
  onVisibleChange?: (visible: boolean) => void;
}

const ContextMenuContainer = styled.div`
  box-shadow: 0 4px 10px #0001;
  .context-menu-mask {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    opacity: 0.2;
    z-index: 1;
  }
  .context-menu-list {
    position: relative;
    min-width: 150px;
    max-width: 190px;
    font-size: 12px;
    line-height: 1;
    color: rgba(0, 0, 0, 0.88);
    user-select: none;
    padding: 5px 0;
    background-color: #fff;
    border-radius: 3px;
    border: 1px solid rgb(229, 230, 235);
    z-index: 10;
    .context-menu-item {
      position: relative;
      display: flex;
      align-items: center;
      padding: 6px 8px;
      cursor: pointer;
      height: 32px;
      box-sizing: border-box;
      position: relative;
      &:hover {
        background-color: #f6f6f6;
        & > .context-menu-list-children-container {
          display: block;
        }
      }
      .context-menu-icon {
        width: 18px;
        height: 18px;
        font-size: 14px;
        /* display: none; */
        display: flex;
        align-items: center;
        justify-content: center;
        margin-right: 2px;
      }
      .context-menu-text {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      .context-menu-tips-icon {
        margin-left: 2px;
        &:hover {
          color: #000;
        }
      }
      .context-menu-shortcut-key {
        margin-left: auto;
        color: #999;
        transform: scale(0.85);
      }
      .context-menu-more {
        margin-left: auto;
        color: #bbb;
        transform: scale(0.6, 0.8);
      }
    }
    .context-menu-item-disable {
      cursor: not-allowed;
      color: rgba(0, 0, 0, 0.38);
      .context-menu-list {
        display: none;
      }
      &:hover {
        background-color: inherit;
      }
    }
    .context-menu-item-hide {
      display: none;
    }
  }
  .context-menu-list-children-container {
    display: none;
    position: absolute;
    top: 0;
    right: calc(-100% - 10px);
    padding-left: 10px;
    & > .context-menu-list {
      box-shadow: 0 4px 10px #0001;
    }
  }
`;

const ContextMenuItemList = ({ children, className = '', ...rest }: any) => (
  <div className={`context-menu-list ${className}`} {...rest}>
    {children}
  </div>
);

const ContextMenuItem = ({ children, className = '', ...rest }: any) => (
  <div className={`context-menu-item ${className}`} {...rest}>
    {children}
  </div>
);

export default class MenuBase extends React.Component<any, any> {
  componentDidMount() {

    document?.addEventListener('click', this.handleClick);
  }
  componentWillUnMount() {

    document?.removeEventListener('click', this.handleClick);
  }

  handleClick = ()=> {
    const { onVisibleChange } = this.props || {};
    onVisibleChange?.(false);
  };
  render() {

    const {
      className = '',
      style = {},
      menuList = [],
      visible = false,
      onTrigger = () => '',
      onVisibleChange = () => '',
    } = this.props || {};
    const RenderMenuItem = ({ menu, showIcon = false } : any) => {
      const {
        text,
        icon,
        tips,
        disable,
        hide = false,
        shortcutKeyDesc,
        children,
      } = menu;
      let hideChildren = false;
      if (children?.filter(e => !e.hide).length === 0) {
        hideChildren = true;
      }
      const showChildIcon = Boolean(children?.find(e => e.icon));
      if (hide) {
        return null;
      }

      const childrenMenuList =
        children && !hideChildren ? (
          <div className="context-menu-list-children-container">
            <ContextMenuItemList>
              {children.map((childMenu: IContextMenuUIItem) => <RenderMenuItem key={childMenu.key} menu={childMenu} showIcon={showChildIcon} />)}
            </ContextMenuItemList>
          </div>
        ) : null;

      return (
        <ContextMenuItem
          key={menu.key}
          onClick={() => {
            if (disable || children) {
              return;
            }
            onTrigger(menu);
            onVisibleChange?.(false);
          }}
          className={`context-menu-item ${
            disable ? 'context-menu-item-disable' : ''
          } ${children ? 'popover-parent-container' : ''}`}>
          {/* {showIcon && <div className="context-menu-icon">{icon}</div>} */}
          <div className="context-menu-text" title={text}>
            {text}
          </div>
          {tips && (
            <Tooltip content={tips}>
              <IconQuestionCircle
                title={tips}
                className="context-menu-tips-icon"
              />
            </Tooltip>
          )}
          {shortcutKeyDesc && (
            <div className="context-menu-shortcut-key">{shortcutKeyDesc}</div>
          )}

          {children && (
            <div className="context-menu-more">
              <CaretRightOutlined />
            </div>
          )}
          {childrenMenuList}
        </ContextMenuItem>
      );
    };

    return (
      <ContextMenuContainer
        className={className}
        style={{ display: visible ? undefined : 'none', ...style }}
        onClick={(e: any) => e.stopPropagation()}>
        {/* <div className="context-menu-mask" onClick={() => onVisibleChange?.(false)} /> */}
        <ContextMenuItemList>
          {menuList.map((menu: any) => <RenderMenuItem key={menu?.key} menu={menu} showIcon={true} />)}
        </ContextMenuItemList>
      </ContextMenuContainer>
    );
  }
}


3. 类型文件

export interface IContextMenuUIItem {
  text: string;
  key: string;
  tips?: React.ReactNode;
  shortcutKeyDesc?: React.ReactNode;
  disable?: boolean;
  hide?: boolean;
  icon?: React.ReactNode;
  // 用户用来透传自定义数据的属性
  customData?: any;
  children?: IContextMenuUIItem[];
}

export interface IContextMenuItem {
  key: string;
  text: string | ((ctx: ITriggerContext) => string);
  disable?: boolean | ((ctx: ITriggerContext) => boolean);
  hide?: boolean | ((ctx: ITriggerContext) => boolean);
  tips?: React.ReactNode | ((ctx: ITriggerContext) => React.ReactNode);
  icon?: React.ReactNode | ((ctx: ITriggerContext) => React.ReactNode);
  shortcutKeyDesc?: React.ReactNode;
  // 用户用来透传自定义数据的属性
  customData?: any;
  children?:
    | IContextMenuItem[]
    | ((ctx: ITriggerContext) => IContextMenuItem[]);
}

export interface Point {
  x: number;
  y: number;
}

export type ITriggerContext = Record<string, any>;