有一个vue的右键菜单的需求,先上网查了一下是否有插件,比如下面这个
1分钟Vue实现右键菜单
一顿操作之后,页面白屏,控制台报错,后来分析,大概应该是不适用vue3?
vue-contextmenu
关于这个插件在网上找了很多用法,都以失败告终。
还是自己动手造轮胎吧,正好也没做过这种东西。
先上效果图:
(仿windows桌面右键菜单,当然,没做快捷键功能)
还有个夜间主题:
思路:
内容大致分为两部分:
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样式即可。
遇到问题请提问