likes
comments
collection
share

vue3自定义右键菜单组件

作者站长头像
站长
· 阅读数 22

主要逻辑

首先监听标签的右键点击事件,阻止系统的默认行为,然后创建一个自定义的右键菜单,并将其设置为默认聚焦状态,接着监听菜单失焦事件触发关闭菜单。 效果展示:

vue3自定义右键菜单组件

监听右键点击事件

在标签上监听@click.right.native事件;

views/test.vue

<script setup>
const showContextMenu = (e) => {
  e.preventDefault()
  console.log('监听右键点击')
}
</script>
<template>
  <div class="context-menu" @click.right.native="showContextMenu($event)">
    展示右键菜单
  </div>
</template>
<style scoped lang="scss">
</style>

效果

vue3自定义右键菜单组件

菜单模板

现在,我们已经知道如何触发右键点击事件,并做一些相关的操作了,接下来我们一步步的开始展示自定义菜单模板。先创建模板对应文件

文件格式如下:

vue3自定义右键菜单组件

index.js: 主要功能是创建菜单、位置计算

// 先创建一个简单index.js
import MenuContext from './menu.vue'
const contextMenu = (e, data) => {
    console.log(e, data)
}
export default contextMenu

menu.vue: 显示的菜单模板

// 先创建一个简单menu组件
<template>
  <div class="context-menu">
    显示的自定义右键菜单
  </div>
</template>
<style>
.context-menu{
    position: fixed;
}
</style>

挂载菜单

右键点击时,应该计算菜单显示的位置,以避免出现临界情况。例如,如果我们在屏幕的右边界点击右键,如果菜单仍在鼠标的右侧显示,那么我们可能无法看到菜单。因此,在这种情况下,菜单应该在鼠标的左侧显示。

前置知识:vue的h()render();

官网链接:cn.vuejs.org/api/render-… h就是createVnode,是Vue 提供了一个函数用于创建虚拟节点,完整参数签名如下

// 第一个参数既可以是一个字符串 (用于原生元素) 也可以是一个 Vue 组件定义。
// 第二个参数是要传递的 prop,
// 第三个参数是子节点。
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots
)

render:用于编程式地创建组件虚拟 DOM 树的函数。

需提供至少两个参数,第一个为需要渲染的虚拟节点,第二个为需要渲染到的容器节点

import MenuContext from './ContextMenu'
import { h, render } from 'vue'

const contextMenu = (e, data) => {
    // 创建一个临时的div,用于挂载我们的菜单  
    const container = document.createElement('div')
    // 获取body标签,用于挂载整个菜单
    const appendTo = document.body 
    // 传给组件的props
    const props = {
        ...
    }
    // 渲染虚拟节点
    const vnode = h(
        MenuContext,
        props
    )
    // vnode为需要渲染的虚拟节点,container为渲染的容器
    render(vnode, container)
}

可以输出看看,渲染前后各个节点的情况。

vnode:就是我们menu.vue组件的相关信息

vue3自定义右键菜单组件

渲染前的container:就是一个空的div标签

vue3自定义右键菜单组件

渲染后的container:已经挂载了vnode,并能从container上获取相关信息

vue3自定义右键菜单组件

render函数执行完以后,我们就已经能获取到虚拟的dom节点了,这时候只要把它挂载到我们想要显示的位置上就好了。

位置计算

我们需要获取的数据有:渲染出来的菜单高度和宽度、当前可视区域的高度和宽度、当前点击时相对于浏览器的坐标。

// 首先需要先把菜单真正渲染到页面,才能拿到它的宽度和高度
appendTo.appendChild(container.firstElementChild)
// 当前真正的菜单节点,上面输出的vnode中可以看到,el就是我们的菜单节点
const curMenu = vnode.el
// 获取curMenu的高度和宽度,用于临界的计算
const { offsetWidth, offsetHeight } = curMenu 
// 获取body的可视区域的宽度
const { clientWidth } = appendTo 
// 取出右键点击时的坐标,clientX是距离左侧的位置,clientY是距离顶部的位置
const { clientX, clientY } = e

// 当前可视区域的宽度 - 当前鼠标距离浏览器左边的距离 
// 如果 大于菜单的宽度,说明正常设置菜单距离左边界的距离,即设置style.left
// 否则菜单需要在鼠标左侧展示,即需要设置style.right组件距离可视区域右侧的距离
const leftOrRight = clientWidth - clientX > offsetWidth ? "left" : "right"

// 当前浏览器的高度(不包含滚动条) - 当前鼠标距离浏览器上边的距离 
// 如果 大于菜单的高度,说明可以正常设置菜单距离上边界的距离,即设置style.top
// 否则需要设置菜单距离底部边界的位置,即style.bottom
const topOrBottom = window.innerHeight - clientY > offsetHeight ? "top" : "bottom" 

// 设置top或者bottom的style
 curMenu.style[leftOrRight] = leftOrRight === "left" ? `${clientX + 20}px` : `2px`
 
// 设置left或者right的style
 curMenu.style[topOrBottom] = topOrBottom === 'bottom' ? '2px' : `${clientY}px`

结果展示

vue3自定义右键菜单组件

关闭菜单

可以发现上面的结果中,我们展示了三个菜单,实际情况中我们应该在下一次右键时,关闭并卸载上一个菜单。

这里我们可以维护一个菜单实例,在创建菜单前,判断实例是否已经存在了,如果已经存在,先把它卸载。

index.js的完整代码:

import MenuContext from './menu.vue'
import { h, render } from 'vue'

// 维护一个菜单实例
let curInstance = null
let seed = 1
const contextMenu = (e, data) => {
  if (curInstance) {
    curInstance.destroy()
  }
  curInstance = null
  let id = seed++
  // 创建一个临时的div,用于挂载我们的菜单  
  const container = document.createElement('div')
  // 获取body标签,用于挂载整个菜单
  const appendTo = document.body
  // 传给menu组件的props
  const props = {
    data,
    onClose: () => {
      curInstance.destroy()
    },
  }
  // 渲染虚拟节点
  const vnode = h(
    MenuContext,
    props
  )
  // vnode为需要渲染的虚拟节点,container为渲染的容器
  render(vnode, container)
  // 首先需要先把菜单真正渲染到页面,才能拿到它的宽度和高度
  appendTo.appendChild(container.firstElementChild)
  // 当前真正的菜单节点,上面输出的vnode中可以看到,el就是我们的菜单节点
  const curMenu = vnode.el
  // 获取curMenu的高度和宽度,用于临界的计算
  const { offsetWidth, offsetHeight } = curMenu
  // 获取body的可视区域的宽度
  const { clientWidth } = appendTo
  // 取出右键点击时的坐标,clientX是距离左侧的位置,clientY是距离顶部的位置
  const { clientX, clientY } = e

  // 当前可视区域的宽度 - 当前鼠标距离浏览器左边的距离 
  // 如果 大于菜单的宽度,说明正常设置菜单距离左边界的距离,即设置style.left
  // 否则菜单需要在鼠标左侧展示,即需要设置style.right组件距离可视区域右侧的距离
  const leftOrRight = clientWidth - clientX > offsetWidth ? "left" : "right"

  // 当前浏览器的高度(不包含滚动条) - 当前鼠标距离浏览器上边的距离 
  // 如果 大于菜单的高度,说明可以正常设置菜单距离上边界的距离,即设置style.top
  // 否则需要设置菜单距离底部边界的位置,即style.bottom
  const topOrBottom = window.innerHeight - clientY > offsetHeight ? "top" : "bottom"
  const offsetLeft = Math.abs(clientWidth - clientX)
  // 设置left或者right的style
  curMenu.style[leftOrRight] = leftOrRight === "left" ? `${clientX + 20}px` : `${offsetLeft}px`
  // 设置top或者bottom的style
  curMenu.style[topOrBottom] = topOrBottom === 'bottom' ? '2px' : `${clientY}px`

  const instance = {
    id,
    destroy: () => {
      render(null, container)
    },
  }
  curInstance = instance
  return instance

}

export default contextMenu

效果:

vue3自定义右键菜单组件

效果实现了,但是这时又发现另一个问题,当切换页面时,菜单没有正常关闭。

focus和blur

为了解决上面的问题,我们可以在菜单正在挂载以后,使其聚焦,即主动触发focus事件,然后监听它的失焦事件blur,当失焦时触发props传进来的destroy方法。

需要注意的是,要给div标签设置tabindex属性,否则无法触发focus事件。

menu.vue完整代码:

<script setup>
import { computed, onMounted, ref, nextTick } from "vue";
const props = defineProps({
  data: { default: null },
  onClose: { type: Function, default: () => {} },
});
const showData = computed(() => {
  let data = {},
    pd = props.data;
  if (pd) {
    data.name = pd.name;
    data.id = pd.id;
    data.subname = pd.ar[0].name;
    data.picUrl = pd.al.picUrl;
  }
  return data;
});

// 创建组件ref
const contextMenu = ref(null);
onMounted(async () => {
// 确保组件已经渲染
  await nextTick();
// 触发组件focus
  contextMenu.value.focus();
});

const clickFunc = (type) => {
  props.onClose();
};
const actions = [
  "",
  {
    label: "播放",
    type: "play",
  },
  {
    label: "添加到队列",
    type: "添加到队列",
  },
  "",
  {
    label: "添加到我喜欢的音乐",
    type: "添加到我喜欢的音乐",
  },
  {
    label: "添加到歌单",
    type: "添加到歌单",
  },
  {
    label: "复制链接",
    type: "复制链接",
  },
];
</script>
<template>
  <div
    v-if="showData.name"
    class="context-menu"
    ref="contextMenu"
    @blur="onClose"
    tabindex="-1"
  >
    <div class="context-menu__info">
      <img class="context-menu__img" :src="showData.picUrl" alt="" />
      <div>
        <div class="context-menu__name">
          {{ showData.name }}
        </div>
        <div class="context-menu__subname">
          {{ showData.subname }}
        </div>
      </div>
    </div>
    <div v-for="(item, idx) in actions" :key="idx">
      <div class="divide" v-if="!item" />
      <div v-else class="context-menu__item" @click="clickFunc(item.type)">
        {{ item.label }}
      </div>
    </div>
  </div>
</template>
<style scoped lang="scss">
.context-menu {
  position: fixed;
  padding: 12px 4px;
  border-radius: 6px;
  border: 1px solid  rgba(222, 222, 222, 0.5);
  background-color:  #ffffff;
  font-size: 14px;
  font-weight: 500;
  user-select: none;
  &:focus {
    outline: none;
  }

  .divide {
    height: 1px;
    background-color:  rgba(222, 222, 222, 0.5);
    margin: 8px auto;
    width: calc(100% - 12px);
  }

  &__item {
    padding: 8px 12px;
    cursor: pointer;

    &:hover {
      color: #646cff;
      background-color: rgba(100, 108, 255, 0.1);
      border-radius: 6px;
    }
  }

  &__info {
    display: flex;
    padding: 0 12px;
  }

  &__img {
    width: 36px;
    height: 36px;
    margin-right: 6px;
  }

  &__name {
    font-size: 16px;
  }

  &__subname {
    color: #666;
    font-size: 12px;
  }
}
</style>

test.vue完整代码

<script setup>
import axios from "axios";
import ContextMenu from "../components/ContextMenu";
const showContextMenu = (e) => {
  e.preventDefault();
  axios
    .get(
      "https://www.fastmock.site/mock/6b16c722604e6f9b79e16f7ec3a768d4/vue3vite/playlist/detail"
    )
    .then((res) => {
      ContextMenu(e, res.data.playlist.tracks[0]);
    });
};
</script>
<template>  
  <div @click.right.native="showContextMenu($event)">
    展示右键菜单
  </div>
  <div style="text-align: right;" @click.right.native="showContextMenu($event)">
    展示右侧边界菜单
  </div>
  <div style="position: absolute;bottom: 0px" @click.right.native="showContextMenu($event)">
    展示底部边界菜单
  </div>
</template>
<style scoped lang="scss"></style>

最终效果

vue3自定义右键菜单组件

解语

创建组件的方式,基本大同小异,这里只要把menu.vue组件改成其他组件,props传入自定义好的数据,就能实现其他的组件,如message组件

vue3自定义右键菜单组件