这是我参与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;
}
}
}
}
}