1.静态结构
我们在手写右键菜单时,这个右键菜单最好是给指定的元素或者指定区域生效,因此我们将右键菜单封装成组件。这里我们可以参考element-plus中popover组件的方式,将需要生成右键菜单的内容区域当做一个插槽传入到组件中,为整个组件注册右键菜单事件。
<ContextMenu>
<!-- 内容 -->
</ContextMenu>
然后再ContextMenu中就可以创建一个菜单,然后绑定右键菜单事件。
ContextMenu静态:
<template>
<div class="content-menu">
<slot />
<div class="menu">
<ul>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.content-menu {
width: 100%;
height: 100%;
}
.menu {
position: fixed;
background-color: #fff;
width: 150px;
color: #000;
border-radius: 20px;
padding: 0;
box-shadow: 0 1px 3px #ccc;
border: 1px solid #ccc;
transition: 0.5s ease-in;
overflow: hidden;
padding: 0 10px;
ul {
width: 100%;
margin: 10px 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: center;
li {
margin: 0 5px;
height: 30px;
width: 100%;
line-height: 30px;
text-align: center;
border-bottom: 1px solid #efefef;
cursor: pointer;
transition: all 0.3s ease-in;
&:hover {
background-color: #efefef;
transform: translateY(-10px);
box-shadow: 0 0 10px #ccc;
border: 1px solid #fff;
color: #46bff0;
border-radius: 10px;
}
&:last-child {
border-bottom: none;
}
}
}
}
</style>
上面代码中,我们还需要解决一个问题,就是右键菜单出现的位置是根据鼠标位置定位的,其定位一般是按照视口固定定位,因此我们将menu的定位设置为固定定位。但这里存在的问题就是我们设置为fixed后,其并不一定按照视口绝对定位,就比如说这个组件外层页面嵌套了许多层,一但父元素使用了transfrom变换了位置,我们的fixed就无法固定在页面视口,因此我们需要将这个menu元素抛出到整个页面的最外层。这里我们就想到了Vue3的一个内置组件Teleport正有这个功能。
<Teleport to="body">
<div class="menu" >
...
</div>
</Teleport>
2.位置与显示
我们创建一个hooks函数来为菜单提供x,y位置坐标和菜单显示变量。
并为组件元素绑定右键菜单事件,当点击页面其他位置时隐藏页面菜单。
import { ref, onUnmounted } from "vue"
export const useContextMenu = (container: HTMLElement) => {
const x = ref(0)
const y = ref(0)
const visible = ref(false)
// 代开菜单
const openMenu = (e: MouseEvent) => {
// 阻止默认行为
e.preventDefault()
// 阻止冒泡
e.stopPropagation()
// 显示菜单
visible.value = true
// 获取鼠标位置
x.value = e.clientX
y.value = e.clientY
}
// 关闭菜单
const closeMenu = () => {
console.log("xaxa")
visible.value = false
}
container.addEventListener("contextmenu", openMenu)
window.addEventListener("click", closeMenu)
window.addEventListener("contextmenu", closeMenu)
onUnmounted(() => {
// 移除事件
container.removeEventListener("contextmenu", openMenu)
window.removeEventListener("click", closeMenu)
window.removeEventListener("contextmenu", closeMenu)
})
return {
x,
y,
visible
}
}
在组件中使用useContextMenu函数,并且使用函数必须在onmounted中,因为我们需要获取到当前组件实例。
import { ref, onMounted, watch } from "vue"
import { useContextMenu } from "./hooks/useContextMenu"
const contextMenuRef = ref<any>()
const menuRef = ref<any>()
const contextInfo = ref({
x: 0,
y: 0,
visible: false
})
onMounted(() => {
const { x, y, visible } = useContextMenu(contextMenuRef.value)
contextInfo.value.x = x.value
contextInfo.value.y = y.value
contextInfo.value.visible = visible.value
// 监听显示隐藏
watch(visible, () => {
contextInfo.value.visible = visible.value
const position = isOut(x.value, y.value, {
clientWidth: 150,
clientHeight: menuHeight.value || 150
})
contextInfo.value.x = position.x
contextInfo.value.y = position.y
})
// 监听位置变动
watch(x, () => {
contextInfo.value.visible = false
contextInfo.value.x = position.x
contextInfo.value.y = position.y
}, 0)
})
})
<div class="content-menu" ref="contextMenuRef">
<slot />
<Teleport to="body">
<div class="menu" ref="menuRef" v-if="contextInfo.visible" :style="{ top: contextInfo.y + 'px', left: contextInfo.x + 'px' }">
<ul>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
</ul>
</div>
</Teleport>
</div>
4. 动画设置
我们将每次点击右键菜单,让菜单高度从0开始变化,例如展开一般。我们可以使用Vue3提供的一个内置组件Transition 但是这里又涉及到了一个问题,如果纯用Transition的话我们那就必须要知道menu的高度,这样才能实现动画变化(因为css帧动画只能是css某个属性的数值变化),在我们不强制设置menu的高度情况下,其高度值为auto,因此无法触发动画。所以我们首先需要在menu渲染出来的第一时间获取到它的高度,然后再通过js设置高度,使用requestAnimationFrame,使动画在下一帧的时候生效。获取高度我们就可以在transition组件的enter方法调用时。
<Transition @enter="handleEnter">
<div class="menu" ref="menuRef" v-if="contextInfo.visible" :style="{ top: contextInfo.y + 'px', left: contextInfo.x + 'px' }">
<ul>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
<li>个人信息</li>
</ul>
</div>
</Transition>
const menuHeight = ref(0)
const handleEnter = (el: any) => {
el.style.height = "auto"
menuHeight.value = el.clientHeight
el.style.height = "0"
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.height = menuHeight.value + "px"
})
})
}
这样还存在一个问题就是当菜单显示状态未变化只改变其位置时,transition的enter就不会触发,因此在只改变位置时就无法触发动画效果,因此我们需要主动在只改变鼠标位置时将显示关闭,然后再一步开启。
watch(x, () => {
contextInfo.value.visible = false
setTimeout(() => {
contextInfo.value.visible = visible.value
contextInfo.value.x = position.x
contextInfo.value.y = position.y
}, 0)
5.计算是否越界
// 计算鼠标是否越界
const isOut = (x: number, y: number, menu: any) => {
const position = {
x,
y
}
if (x + menu.clientWidth > window.innerWidth) {
position.x = window.innerWidth - 150
}
if (y + menu.clientHeight > window.innerHeight) {
position.y = window.innerHeight - 150
}
return position
}
在鼠标位置改变时我们依然需要判断
watch(x, () => {
contextInfo.value.visible = false
setTimeout(() => {
contextInfo.value.visible = visible.value
const position = isOut(x.value, y.value, {
clientWidth: 150,
clientHeight: menuHeight.value || 150
})
contextInfo.value.x = position.x
contextInfo.value.y = position.y
}, 0)
})
6. 表单项配置
我们定义一个props变量,来接收父组件传递的菜单项配置。
const props = defineProps<{
options: {
label: string
handle: () => void
}[]
}>()
<li v-for="item in props.options" :key="item.label" @click="item.handle">{{ item.label }}</li>
<ContextMenu :options="options">
<el-card style="height: 800px" />
</ContextMenu>
const options = [
{
label: "新建",
handle: () => {}
},
{
label: "编辑",
handle: () => {}
},
{
label: "删除",
handle: () => {}
},
{
label: "复制",
handle: () => {}
}
]
如过需要可以在按照自己的想法与需求进行进一步改造。