动手做一个 vue 右键菜单

时间:2022-10-13 21:51:28

有一个vue的右键菜单的需求,先上网查了一下是否有插件,比如下面这个

1分钟Vue实现右键菜单

https://www.jb51.net/article/226761.htm

一顿操作之后,页面白屏,控制台报错,后来分析,大概应该是不适用vue3? 

动手做一个 vue 右键菜单

vue-contextmenu

关于这个插件在网上找了很多用法,都以失败告终。

还是自己动手造轮胎吧,正好也没做过这种东西。

先上效果图:

(仿windows桌面右键菜单,当然,没做快捷键功能)

动手做一个 vue 右键菜单

还有个夜间主题: 

动手做一个 vue 右键菜单

思路:

内容大致分为两部分:

1、菜单列表

(1)数组数据,展示菜单项

(2)坐标控制显示

(3)显示开关

(4)子菜单

(5)定制主题

(6)下级菜单展示位置 处理

2、菜单项

(1)显示图标,文字,是否存在下级菜单(箭头)

(2)点击或禁用

(3)点击函数

(4)点击菜单项,关闭整个菜单后,执行对应函数

。。。。。。。。

代码如下:

RightMenu.vue

定义一个组件入口,规范并处理入参。

<template>
  <div class="full" v-show="modelValue.status" style="position: fixed;top:0;left:0;user-select: none;" @contextmenu.prevent="">
    <div class="full" @click="handle_click" @contextmenu.prevent.stop="handle_click"></div>
    <RightMenuList :setting="childInfo" :data="data" :theme="theme" :item-size="itemSize"></RightMenuList>
  </div>
</template>
<script>
import RightMenuList from "@/view/rightmenu/RightMenuList";

export default {
  name: "RightMenu",
  components: {RightMenuList},
  props: {
    data: Array,//菜单数据
    modelValue: Object,//设置入口
    theme: {//主题
      type: String,
      default: 'light',
    },
  },
  data() {
    return {
      itemSize: {
        width: 220,
        height: 30,
      },
      childInfo: {
        status: false,
        x: 0,
        y: 0,
      },
    }
  },
  watch: {
    modelValue(n) {
      if (n.status) {
        this.calculatePosition();
      }
    },
  },
  methods: {
    /**
     * 计算菜单生成位置
     */
    calculatePosition() {
      let x = 0;
      let y = 0;
      let screen = this.getScreen();
      let childHeight = this.data.length * this.itemSize.height;
      if (screen.width - this.modelValue.x <= this.itemSize.width) {
        x = screen.width - this.itemSize.width;
      } else {
        x = this.modelValue.x;
      }
      if (screen.height - this.modelValue.y <= childHeight) {
        y = screen.height - childHeight;
      } else {
        y = this.modelValue.y;
      }
      this.childInfo = {
        status: true,
        x: x,
        y: y,
      }
    },
    /**
     * 获取窗口大小
     */
    getScreen() {
      return {
        width: document.body.clientWidth,
        height: document.body.clientHeight,
      }
    },
    /**
     * 统一关闭菜单入口
     */
    close() {
      this.childInfo = {
        status: false,
        x: 0,
        y: 0,
      }
      this.$event.$emit("RightMenuListClose");
      this.$emit('update:modelValue', {status: false, x: 0, y: 0});
    },
    /**
     * 单击空白地方,左右键通用
     */
    handle_click(event) {
      this.close();
      setTimeout(() => {
        document.elementFromPoint(event.clientX, event.clientY).dispatchEvent(event);
      }, 10);
    },
  }

}
</script>
<style scoped>
</style>

RightMenuList.vue

将主菜单列表,子菜单列表 抽象出来,作为一个菜单列表组件,该组件只负责根据指定坐标进行显示列表,隐藏。

<template>
  <div  :class="'right_menu right_menu_'+theme" :style="{width:itemSize.width+'px',top:setting.y+'px',left:setting.x+'px'}" v-show="setting.status" @mouseenter="handle_enter" @mouseleave="handle_leave">
    <template v-for="(item,index) in data" :key="'a'+index">
      <RightMenuItem  :data="item" :theme="theme" :top="setting.y+(index)*itemSize.height" :left="setting.x" :item-size="itemSize"></RightMenuItem>
      <div v-if="item.outline" :class="'right_menu_outline_'+theme"></div>
    </template>
  </div>
</template>

<script>
import RightMenuItem from "@/view/rightmenu/RightMenuItem";
export default {
  name: "RightMenuList",
  components: {RightMenuItem},
  props:{
    data:Array,
    theme:String,
    setting:Object,
    itemSize:Object,
  },
  mounted() {
    /**
     * 统一关闭入口
     */
    this.$event.$on("RightMenuListClose",()=>{
       if(this.$parent.closeChild)this.$parent.closeChild();
    });
  },
  methods:{
    close() {
      this.$parent.close();
    },
    /**
     * 由父节点控制当前菜单列表是否展示,关闭
     */
    handle_enter(){
      if(this.$parent.noCloseChild)this.$parent.noCloseChild();
    },
    handle_leave(){
      if(this.$parent.closeChild)this.$parent.closeChild();
    },
  },
}
</script>

<style scoped>
.right_menu{
  box-shadow: 1px 1px 8px 2px rgba(0, 0, 0, 0.3);
  position: fixed;
  padding: 4px 2px;
}
.right_menu_light{
  background: #f3f3f3;
}
.right_menu_dark{
  border: 1px solid #bbbbbb;
  background: #282828;
}
.right_menu_outline_light{
  width: 90%;
  height: 1px;
  margin:3px 0 3px 5%;
  background: #aaaaaa;
}
.right_menu_outline_dark{
  width: 90%;
  height: 1px;
  margin:3px 0 3px 5%;
  background: #bbbbbb;
}

</style>

RightMenuItem.vue

将菜单项抽象为一个组件,主要负责展示图片文字,点击事件,是否禁用等功能,

如果该菜单项下存在子菜单项,则要负责计算子菜单显示的坐标,也需要控制子菜单的显示和隐藏

<template>
  <button ref="item" v-if="data.child&&data.child.length>0"
          :class="`empty_button right_item right_item_${theme} ${!isEnable()?'right_item_enable_'+theme:''}`"
          @mouseenter="handle_enter"
          @mouseleave="handle_leave"
          :style="{height:itemSize.height+'px' }">
    <RightMenuItemIcon :icon="data.icon" :theme="theme"></RightMenuItemIcon>
    {{ data.name }}
    <b-icon v-if="theme==='light'" class="right_item_arrow" local="arrow_thick_right" style="color: #3b3b3b;"></b-icon>
    <b-icon v-else class="right_item_arrow" local="arrow_thick_right" style="color: #adadad;"></b-icon>
    <RightMenuList v-if="data.child&&data.child.length>0"   :setting="childInfo" :data="data.child" :theme="theme" :item-size="itemSize"></RightMenuList>
  </button>
  <button v-else
          :class="`empty_button right_item right_item_${theme}  ${!isEnable()?'right_item_enable_'+theme:''}`"
          @click="handle_click"
          :style="{height:itemSize.height+'px'}">
    <RightMenuItemIcon :icon="data.icon" :theme="theme"></RightMenuItemIcon>
    {{ data.name }}
  </button>
</template>

<script>
import RightMenuItemIcon from "@/view/rightmenu/RightMenuItemIcon";

export default {
  name: "RightMenuItem",
  components: {
    RightMenuItemIcon
  },
  beforeCreate() {
    this.$options.components.RightMenuList = require('@/view/rightmenu/RightMenuList').default
  },
  props: {
    data: Object,
    theme: String,
    itemSize:Object,
    top:Number,
    left:Number,
  },
  data() {
    return {
      childPosition: "",
      childInfo: {
        status: false,
        x: 0,
        y: 0,
      },
      cancelTimer: null,
    }
  },
  methods: {
    /**
     * 鼠标进入菜单项时,计算子菜单展示的位置
     */
    handle_enter() {
      let x = 0;
      let y = 0;
      let screen = this.getScreen();
      let item = this.$refs.item;
      let itemX = this.left;//当前菜单项的x坐标
      let itemY = this.top;//当前菜单项的y坐标
      let childHeight = this.data.child.length * item.clientHeight;
      //计算坐标x
      if ((screen.width - itemX - item.clientWidth) > item.clientWidth) {
        x = itemX + item.clientWidth;
        this.childPosition = "right";
      } else if (itemX > item.clientWidth) {
        x = itemX - item.clientWidth;
      }
      if (this.childPosition === "") this.childPosition = "left";
      //计算坐标y
      if ((screen.height - itemY) > childHeight) {
        y = itemY;
      } else if (screen.height > childHeight) {
        y = screen.height - childHeight;
      }
      this.noCloseChild();
      this.childInfo = {
        status: true,
        x: x,
        y: y,
      }
    },
    /**
     * 鼠标离开时,判断从哪个方向离开
     * @param e
     */
    handle_leave(e) {
      let item = this.$refs.item;
      let itemX = this.left;//当前菜单项的x坐标
      if (this.childPosition === "right") {
        if (Math.abs(item.clientWidth + itemX - e.clientX) < 5) {
          return;
        }
      } else if (this.childPosition === "left") {
        if (Math.abs(itemX - e.clientX) < 5) {
          return;
        }
      }
      this.noCloseChild();
      this.cancelTimer = setTimeout(() => {
        this.closeChild();
      }, 100);
    },
    /**
     * 获取窗口大小
     */
    getScreen() {
      return {
        width: document.body.clientWidth,
        height: document.body.clientHeight,
      }
    },
    isEnable(){
      return this.data.enable!==false;
    },
    /**
     * 处理点击事件,先关闭按钮,在处理点击事件
     */
    handle_click() {
      if(!this.isEnable())return;
      this.close();
      setTimeout(() => {
        if(this.data.click)this.data.click();
      }, 10);
    },
    /**
     * 通知整个菜单关闭
     */
    close() {
      this.$parent.close();
    },
    /**
     * 关闭子菜单
     */
    closeChild() {
      this.childInfo = {
        status: false,
        x: 0,
        y: 0,
      }
      this.childPosition = "";
    },
    /**
     * 取消关闭子菜单
     */
    noCloseChild() {
      clearTimeout(this.cancelTimer);
      this.cancelTimer = null;
    },
  }
}
</script>

<style scoped>
.right_item{
  display: block;
  width: 100%;
  text-align: left;
  padding-left: 5px;
  font-size: 15px;
  white-space: nowrap;
  text-overflow:ellipsis;
  overflow: hidden;
}

.right_item_light {
  font-size: 15px;
}

.right_item_light:hover {
  background-color: #ffffff;
}

.right_item_dark {
  font-size: 13px;
  color: #e2e2e2;
}

.right_item_dark:hover {
  background-color: #444444;
}
.right_item_enable_light{
  color: #b6b6b6;
}
.right_item_enable_dark{
  color: #797979;
}

.right_item_arrow {
  width: 25px;
  height: 25px;
  float: right;
}
</style>

RightMenuItemIcon.vue

这里将菜单项的展示图标单独抽象出来,为的是兼容多模式展示。可以自行定义。如base64编码,http地址,图片文件,svg代码,空白,还有根据不同主题显示不同类型的图标等等。

<template>
    <img class="right_item_icon right_item_icon_blank" v-if="!icon||!icon.type" >
    <img class="right_item_icon" v-else-if="icon.type==='url'" :src="icon.value" >
    <b-icon class="right_item_icon" v-else-if="theme==='light'&& icon.type==='name'" :local="icon.value" style="color: black;"></b-icon>
    <b-icon class="right_item_icon" v-else-if="theme==='dark'&& icon.type==='name'" :local="icon.value" style="color: white;"></b-icon>
    <b-icon class="right_item_icon" v-else-if="theme==='light'&& icon.type==='type'" :type="icon.value" style="color: black;"></b-icon>
    <b-icon class="right_item_icon" v-else-if="theme==='dark'&& icon.type==='type'" :type="icon.value" style="color: white;"></b-icon>
    <img class="right_item_icon right_item_icon_blank" v-else >
</template>

<script>
export default {
  name: "RightMenuItemIcon",
  props:{
    icon:Object,
    theme: String,
  },
}
</script>

<style scoped>
.right_item_icon{
  width: 18px;
  height: 18px;
  margin-top: -3px;
}
.right_item_icon_blank{
  opacity: 0;
}

</style>

* b-icon是自定义的一个svg处理组件,可以删除,修改。

一共四个文件,可以直接删去最后这个文件,不使用。

测试用例:

<template>
  <div>
    <div style="height: 100px;background: #1ba3bf;"></div>
    <div class="full"  @contextmenu.prevent="showRightMenu" >
    </div>
    <RightMenu v-model="menuSetting" :data="data" theme="light"></RightMenu>
  </div>
</template>

<script>
import RightMenu from "@/view/rightmenu/RightMenu";

export default {
  name: "RightMenuTestPane",
  components: {RightMenu},
  data(){
    return{
      menuSetting:{
        status:false,
        x:0,
        y:0,
      },
      data:[{
        name:'查看(V)',
        click:()=>{
          alert("查看(V)");
        }
      },{
        name:'排序方式(O)',
        click:()=>{
          alert("排序方式(O)");
        },
      },{
        name:'刷新(E)',
        outline:true,
        click:()=>{
          alert("刷新(E)");
        }
      },{
        name:'粘贴(P)',
        enable:false,
        click:()=>{
          alert("刷新(E)");
        }
      },{
        name:'粘贴快捷方式(S)',
        enable:false,
        outline:true,
        click:()=>{
          alert("刷新(E)");
        }
      },{
        name:'新建(W)',
        outline:true,
        child:[{
          name:'文件夹(F)',
          icon:{
            type:'url',
            value:require("@/assets/file/dir.png"),
          },
        },{
          name:'快捷方式(S)',
          icon:{
            type:'url',
            value:require("@/assets/rightmenu/shortcut.png"),
          },
          outline:true,
        },{
          name:'Microsoft Word 文档',
          icon:{
            type:'url',
            value:'https://docs.idqqimg.com/tim/docs/docs-design-resources/pc/png@2x/file_web_doc_64@2x-77242f419d.png',
          },
        },{
          name:'Microsoft PowerPrint 演示文稿',
          icon:{
            type:'url',
            value:'data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIwJSIgeDI9IjEwMCUiIHkxPSIwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iI2Y1ODQ2YSIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI2U2NWUyZSIvPjwvbGluZWFyR3JhZGllbnQ+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBmaWxsPSJ1cmwoI2EpIiBoZWlnaHQ9IjI0IiByeD0iMiIgd2lkdGg9IjI0Ii8+PGcgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExIDYuMDE5VjEyaDYuOTgxYTYuNSA2LjUgMCAxMS02Ljk4LTUuOTgyeiIvPjxwYXRoIGQ9Ik0xMyA1LjAxOWE2LjUwNCA2LjUwNCAwIDAxNS44MjYgNC45OEwxMyAxMHoiIG9wYWNpdHk9Ii42Ii8+PC9nPjwvZz48L3N2Zz4=',
          },
        },{
          name:'文本文档',
          icon:{
            type:'url',
            value:'data:image/svg+xml;base64,PHN2ZyAgc3R5bGU9Im92ZXJmbG93OiBoaWRkZW47IiB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgcC1pZD0iMjIzMSI+PHBhdGggZD0iTTcyNi42MjQgNjRMODY0IDIwMS4zNzZWOTYwSDE2MFY2NGg1NjYuNjI0eiBtLTI0LjI1NiAzMkgxOTJ2ODMyaDY0MFYyMjkuOTJoLTY1LjZhNjQgNjQgMCAwIDEtNjQtNjRMNzAyLjM2OCA5NnoiIGZpbGw9IiM2NDZFN0YiIHAtaWQ9IjIyMzIiPjwvcGF0aD48cGF0aCBkPSJNMzUyIDM4NHYtNjRoMzIwdjY0aC0xMjh2MzIxLjc2aC02NFYzODRoLTEyOHoiIGZpbGw9IiMxNkIyQkMiIHAtaWQ9IjIyMzMiPjwvcGF0aD48L3N2Zz4=',
          },
        },{
          name:'Microsoft Excel 工作表',
          icon:{
            type:'url',
            value:'https://pub.idqqimg.com/pc/misc/files/20200904/2eb030216d9362bbc6c0df045857b718.png',
          },
        },],
      },{
        name:'显示设置(D)',
        icon:{
          type:'url',
          value:require("@/assets/rightmenu/viewsetting.png"),
        },
        click:()=>{
          alert("显示设置(D)");
        }
      },{
        name:'个性化(R)',
        icon:{
          type:'url',
          value:require("@/assets/rightmenu/individuation.png"),
        },
        click:()=>{
          alert("个性化(R)");
        }
      }],
    }
  },
  mounted() {

  },
  methods:{
    showRightMenu(e){
      this.menuSetting={
        status:true,
        x:e.clientX,
        y:e.clientY,
      }
    },

  }
}
</script>

<style scoped>

</style>

 API

 入参

使用方式(属性名) 解释 类型
v-model 显示状态,坐标 Object
:data 菜单数据 Array
theme 主题名 String

 v-model  菜单设置

参数名 解释 类型
status 显示状态 Boolean
x 横坐标 Number
y 竖坐标 Number

:data    数组类型,数组项内容如下

参数名 解释 类型
name 菜单名称 String
icon type   图标类型 String
value   值 String
click 点击事件 function
outline

该菜单项下面是否显示分割线,默认true

Boolean
enable 是否可点击,默认true Boolean
child 子菜单数据数组 Array

theme  主题

枚举 解释
light 亮色主题
dark 暗色主题

自定义主题,可以在代码中仿照已有的两个主题样式 新增自定义css样式即可。

遇到问题请提问