前端通用右键菜单解决方案(文末贴了源码)
前端经常有用到右键菜单扩展交互能力的需求,不同场景下菜单功能不同,且通常需要根据右键目标的不同显示不同的菜单操作,本文就记录了一种通用的右键菜单解决方案。
简介
demo演示链接: 右键菜单演示demo
- 每个菜单项都可以设置是否禁用、是否隐藏
- 支持子菜单
- 支持显示icon图标、提示语、快捷键等
- 与业务完全解耦,通过简单配置即可定制出功能各异的菜单
样图演示:
用法
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>;
转载自:https://juejin.cn/post/7188882827776098360