Vue3 通用右键菜单

目录

1.静态结构

2.位置与显示

3.使用useContextMenu

4. 动画设置

5.计算是否越界

6. 表单项配置


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
  }
}

3.使用useContextMenu

在组件中使用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: () => {}
  }
]

如过需要可以在按照自己的想法与需求进行进一步改造。

  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值