前言
在网页上,我们经常需要为不同的区域设置不同的右键菜单。比如,在一个文档编辑器中,你右键点击文字区域,可能会弹出“复制”、“粘贴”等选项,而在图片区域右键点击时,弹出的选项可能是“保存图片”、“查看图片属性”等。这样,每个区域的右键菜单项都不一样。
为了方便管理和维护这些右键菜单,我们可以把右键菜单功能封装成一个通用的组件。我们只需要定义一次右键菜单的逻辑,就能在多个地方重复使用,并且可以根据需要灵活地配置每个区域的菜单项。
基本思路
设计方式
首先,我们需要考虑右键菜单应该挂载到哪个 DOM 元素。我们可以通过以下几种方式告诉 ContextMenu
组件应该绑定在哪些元素上:
- 将元素类名或元素对象作为 props 传递给 ContextMenu。
<template>
<ContextMenu teleport=".container">
<!-- items -->
</ContextMenu>
</template>
这种方式会让 Vue.js 去直接操作 DOM 元素,不太适合我们想要的组件化设计。
- 将 ContextMenu 放在显示右键菜单的元素里,让 ContextMenu 读取父元素
<template>
<div>
<ContextMenu>
<!-- items -->
</ContextMenu>
</div>
</template>
这种方法虽然可行,但仍然涉及直接操作 DOM,不够优雅。
- 将 ContextMenu 作为一个独立区域,并使用命名插槽传递菜单项目和激活区域。
<div>
<!-- 作为 image 右键菜单 -->
<ContextMenu>
<template #default>
鼠标菜单项
</template>
<template #activator>
<Container>
<Image />
...
</Container>
</template>
</ContextMenu>
<!-- 另一个区域 -->
<ContextMenu>
<template #default>
鼠标菜单项
</template>
<template #activator>
激活区域
</template>
</ContextMenu>
</div>
这种方式最灵活且符合组件化的思想。
菜单的定位
右键菜单通常是跟随鼠标的位置来显示,因此我们需要将菜单定位为 fixed
。这样,菜单的位置会相对于视口进行调整。
<!-- ContextMenu.vue -->
<template>
<div class="container">
<slot />
</div>
<div class="context-menu" style="position: fixed"></div>
</template>
s
但问题来了,如果父元素使用了 transform
属性,固定定位的菜单就不再相对于视口,而是相对于父元素定位。这种情况下,菜单的位置可能会出现问题。
<!-- Parent.vue -->
<div class="container" style="transform:scale(1.1)">
<!-- 右键菜单 -->
<ContextMenu>
<template #default>
<Card>
<Image />
</Card>
</template>
<template #activator="{ item }">
items
</template>
</ContextMenu>
</div>
一旦父组件使用了 transform
属性值,那么后代的固定定位元素就不会再是相对于视口了,而是相对于父组件的位置。那是不是会产生某些样式问题。而且我们在写 ContextMenu
组件的时候,并不知道上层组件是什么样子,它套了多少层,那么这些层级之间有没有使用 transform
属性 ContextMenu
是不知道的。
所以,为了解决这个问题,我们可以使用 Vue 的 Teleport
组件,将菜单的 DOM 结构直接传送到 body
元素下,从而确保菜单相对于视口定位。
<template>
<div class="container">
<slot />
</div>
<Teleport to="body">
<div class="context-menu"></div>
</Teleport>
</template>
s
控制菜单的位置和可见度
为了让菜单在不同的位置显示,并能根据需求显示或隐藏,我们可以使用一个 Vue 的 composable
来管理菜单的状态和位置:用一个 composable 来控制菜单项:
// ContextMenu.vue
const containerRef = ref(null);
const { x, y, showMenu } = useContextMenu(containerRef);
// useContextMenu.js
export function useContextMenu(container) {
const x = ref(0);
const y = ref(0);
const showMenu = ref(false);
const openMenu = e => {
e.preventDefault(); // 阻止浏览器默认右键菜单
showMenu.value = true;
x.value = e.clientX; // 获取鼠标 X 坐标
y.value = e.clientY; // 获取鼠标 Y 坐标
};
const closeMenu = () => {
showMenu.value = false;
};
onMounted(() => {
container.addEventListener("contextmenu", openMenu);
});
onUnmounted(() => {
container.removeEventListener("contextmenu", openMenu);
});
return { x, y, showMenu };
}
现在,composable 内容已经基本有了菜单需要的状态、位置和事件。可问题来了,我们要考虑到什么时候会关闭菜单?我们要确保在点击其他地方时关闭菜单,可以给 window
添加事件监听:
window.addEventListener("click", closeMenu);
window.addEventListener("contextmenu", closeMenu);
然而,这里的逻辑还是存在着问题,考虑到事件的触发是先捕获然后再冒泡。而这样注册事件会导致,我们在容器A里打开了菜单,但是事件又冒泡到了window,而window的事件捕获到了之后会执行 closeMenu,将菜单关闭,这就相当于,打开了菜单但是又不完全打开。(逃
而我们希望的是,当我们在容器A打开了菜单时,先将其他菜单全部关闭,然后再给我们打开属于容器A的菜单。所以,我们需要让事件只在捕获执行,而不冒泡:
container.addEventListener("contextmenu", openMenu);
window.addEventListener("click", closeMenu, true);
window.addEventListener("contextmenu", closeMenu, true);
最后,让我们处理菜单的位置。在打开菜单的时候,container contextmenu 事件会将 event 对象传递给 openMenu,所以我们可以在该函数内获取鼠标位置(视口坐标):
const openMenu = e => {
e.preventDefault();
// 阻止冒泡,防止点击之后触发其他层级的菜单
showMenu.value = true;
x.value = e.clientX;
y.value = e.clientY;
};
动画
我们使用 Transiiton 来包裹菜单项,做成一个 slideY 的效果,打开菜单时高度从 0 到某一个固定高度。这时候,我们需要使用钩子函数来处理菜单的高度。
这是因为我们不确定父组件通过插槽传递过来的菜单有多少个数量,二级菜单有多少个数量。所以,整个菜单高度是未知的,需要借助js来获取。
<Transition
@beforeEnter="onBeforeEnter"
@enter="onEnter"
@afterEnter="onAfterEnter"
>
<Teleport to="body">
<div class="context-menu">
....
</div>
</Teleport>
</Transition>
// 元素进入之前
const onBeforeEnter = el => {
el.style.height = 0;
};
// 元素进入时
const onEnter = el => {
// 先让高度取决于内容
el.style.height = "auto";
// 获取高度
const height = el.clientHeight;
el.style.height = 0;
requestAnimationFrame(() => {
el.style.height = height + "px";
el.style.transition = ".5s";
});
};
// 元素进入之后
const onAfterEnter = () => {
el.style.transition = "none";
};
通过这些步骤,我们就可以封装出一个灵活的右键菜单组件,能够适应不同的使用场景。