ContextMenu 即右键菜单,当前的需求是:右键点击某些组件时,根据所点击组件的信息,展现不一样的菜单。javascript
本插件已开源,具体代码和使用可参考: vue-contextmenucss
本文采用的是 vue 技术栈,部分处理对于 react 是能够借鉴的html
其中须要注意的点有:vue
先不考虑插件形式,按平常组件开发java
|-components
|---ContextMenu.vue //菜单组件
|-views
|---Home.vue //页面组件
|---Dashbox.vue //图表组件,绝对定位于App中,有多个,右键展现自定义菜单
复制代码
其他的 vue-router 什么的,再也不赘诉react
右键菜单的内容由使用者定义(经过slot),因此咱们能够很快的编写 ContextMenu 的代码git
同时解决了 注意点2github
ContextMenu.vuevue-router
<template>
<div class="context-menu" v-show="show" :style="style">
<slot></slot>
</div>
</template>
<script> export default { name: "context-menu", props: { show: Boolean }, computed: { style() { return { left: "0px", top: "0px" }; } } }; </script>
<style lang="scss" scoped> .context-menu { z-index: 1000; display: block; position: absolute; } </style>
复制代码
先不考虑显示的位置,经过 show prop 的值来显示/隐藏该菜单,当前实现 菜单将会显示在左上角vue-cli
Dashbox.vue
<template>
<div :style="dashbox.style" class="dashbox" @contextmenu="showContextMenu">
{{ dashbox.content }}
</div>
</template>
<script> export default { props: { dashbox: Object }, methods: { showContextMenu(e) { this.$emit("show-contextmenu", e); } } }; </script>
<style> .dashbox { position: absolute; background-color: aliceblue; } </style>
复制代码
绝对定位在页面中,右键时会向上层传递事件
Home.vue
<template>
<div class="home">
<Dashbox v-for="dashbox in dashboxs" :key="dashbox.id" :dashbox="dashbox" @show-contextmenu="showContextMenu" />
<ContextMenu :show="contextMenuVisible">
<div>复制</div>
<div>粘贴</div>
<div>剪切</div>
</ContextMenu>
</div>
</template>
<script> import ContextMenu from "@/components/ContextMenu.vue"; import Dashbox from "./Dashbox.vue"; export default { name: "home", components: { ContextMenu, Dashbox }, data() { return { contextMenuVisible: false, dashboxs: [ { id: 1, style: "left:200px;top:200px;width:100px;height:100px", content: "test1" }, { id: 2, style: "left:400px;top:400px;width:100px;height:100px", content: "test2" } ] }; }, methods: { showContextMenu(e) { e.preventDefault(); this.contextMenuVisible = true; } } }; </script>
复制代码
此时能够看到页面中有2个矩形框,右键的时候,左上角能出现菜单
固然,此时并无办法将该菜单隐藏
下面,咱们将一步步进行优化
上面咱们在 showContextMenu 方法中获取到点击事件e,
其中 e.clientX/Y
是基于浏览器窗口viewport的位置,参考点随着浏览器的滚动而变化(即一直是视窗左上角)
那么,将 clientX/Y 直接传入 ContextMenu 组件修改其样式是否就能够了?
思考一下...
.
.
.
.
答案是不能够的,缘由在于 ContextMenu 的祖先节点的定位可能不是 static,
当祖先节点定位非 static 时,absolute 定位的 ContextMenu 的参考点就是以祖先节点为参考点了。
举个例子:
<body>
<div class="header" style="height:200px"/>
<div class="home" style="position: relative;">
<div class="context-menu" style="left: 200px;top: 200px;position: absolute;">
我是右键菜单
</div>
</div>
</body>
复制代码
而实际上,当右键的 clientX/Y 值为 200,200时,传入 context-menu的style后,其菜单应该显示在点击处下方 200px, 即相对 viewport 的 left,top 分别为 200,400
了解 element-ui等组件库的应该知道,在涉及 poper 显示的时候,官方默认
popper-append-to-body
,目的就是将弹窗组件插入body,脱离文档流,不与定义处的父组件产生关系,而且方便使用 event.clientX/Y
因此,将其直接插入 body 是最省事的,
mounted () {
document.body.appendChild(this.$el)
}
复制代码
ContextMenu 增长 offset 属性并修改样式
Home.vue
<template>
<div class="home">
<Dashbox v-for="dashbox in dashboxs" :key="dashbox.id" :dashbox="dashbox" @show-contextmenu="showContextMenu" />
<ContextMenu :show="contextMenuVisible" :offset="contextMenuOffset">
<div>复制</div>
<div>粘贴</div>
<div>剪切</div>
</ContextMenu>
</div>
</template>
<script> import ContextMenu from "@/components/ContextMenu.vue"; import Dashbox from "./Dashbox.vue"; export default { name: "home", components: { ContextMenu, Dashbox }, data() { return { contextMenuVisible: false, contextMenuOffset: { left: 0, top: 0 }, dashboxs: [ { id: 1, style: "left:200px;top:200px;width:100px;height:100px", content: "test1" }, { id: 2, style: "left:400px;top:400px;width:100px;height:100px", content: "test2" } ] }; }, methods: { showContextMenu(e) { e.preventDefault(); this.contextMenuVisible = true; this.contextMenuOffset = { left: e.clientX, top: e.clientY }; } } }; </script>
复制代码
ContextMenu.vue
<template>
<div class="context-menu" v-show="show" :style="style">
<slot></slot>
</div>
</template>
<script> export default { name: "context-menu", props: { offset: { type: Object, default: function() { return { left: 0, top: 0 }; } }, show: Boolean }, computed: { style() { return { left: `${this.offset.left}px`, top: `${this.offset.top}px` }; } }, mounted() { document.body.appendChild(this.$el); } }; </script>
<style lang="scss" scoped> .context-menu { z-index: 1000; display: block; position: absolute; } </style>
复制代码
到这里,咱们就能够实现菜单处于右键点击位置
的效果了,每次右键点击,context-menu 会显示在对应位置
这个也很简单
在组件销毁时,把本身从 body 中移除
beforeDestroy () {
let popperElm = this.$el
if (popperElm && popperElm.parentNode === document.body) {
document.body.removeChild(popperElm);
}
}
复制代码
这里咱们选择监听 mousedown,若事件没有中止传递,则 document 上能够监听到
固然 这里咱们须要保证 事件不会被 stopPropagation
ContextMenu.vue
<template>
<div class="context-menu" v-show="show" :style="style" @mousedown.stop @contextmenu.prevent >
<slot></slot>
</div>
</template>
<script> export default { name: "context-menu", props: { offset: { type: Object, default: function() { return { left: 0, top: 0 }; } }, show: Boolean }, computed: { style() { return { left: `${this.offset.left}px`, top: `${this.offset.top}px` }; } }, beforeDestroy() { let popperElm = this.$el; if (popperElm && popperElm.parentNode === document.body) { document.body.removeChild(popperElm); } document.removeEventListener("mousedown", this.clickDocumentHandler); }, mounted() { document.body.appendChild(this.$el); document.addEventListener("mousedown", this.clickDocumentHandler); }, methods: { clickDocumentHandler() { if (this.show) { this.$emit("update:show", false); } } } }; </script>
<style lang="scss" scoped> .context-menu { z-index: 1000; display: block; position: absolute; } </style>
复制代码
Home.vue 增长 @update:show
事件处理
<ContextMenu :show="contextMenuVisible" :offset="contextMenuOffset" @update:show="show => (contextMenuVisible = show)" >
<div>复制</div>
<div>粘贴</div>
<div>剪切</div>
</ContextMenu>
复制代码
根据点击位置,判断菜单向上显示或向下显示,即右键点击位于页面下/右侧时,菜单应该向上/左显示
页面高度:let docHeight = document.documentElement.clientHeight
菜单高度:let menuHeight = this.$el.getBoundingClientRect().height
当 e.clientY + menuHeight >= docHeight
时,菜单向下显示就会被遮挡了,须要向上显示
同理,
页面宽度:let docWidth = document.documentElement.clientWidth
菜单高度:let menuWidth = this.$el.getBoundingClientRect().width
当 e.clientX + menuWidth >= docWidth
时,菜单须要向左显示
因为菜单由外部定义,宽高不可控,因此每次都须要经过 getBoundingClientRect
获取实际宽高
这里须要注意获取 getBoundingClientRect 的时机。
一开始尝试:
computed: {
style() {
console.log(this.$el)
return {
left: `${this.offset.left}px`,
top: `${this.offset.top}px`
};
}
}
复制代码
发现此时组件处于 display:none
状态,获取到的宽高都为0
有2种解决方案,一种是将 v-show 也就是 display 样式
改成 visibility
但担忧此法不够通用(实际上是想试试 $nextTick,
另外一种就是在下一个渲染周期结束后才执行,即 v-show="true"
后计算宽高
故咱们须要监听 show prop 的值,当其为 true 时,在 $nextTick
回调中设置菜单坐标样式,此时 style 不用 computed,具体看代码。
<template>
<div class="context-menu" v-show="show" :style="style" @mousedown.stop @contextmenu.prevent >
<slot></slot>
</div>
</template>
<script> export default { name: "context-menu", data() { return { style: {} }; }, props: { offset: { type: Object, default: function() { return { left: 0, top: 0 }; } }, show: Boolean }, watch: { show(show) { if (show) { this.$nextTick(this.setPosition); } } }, beforeDestroy() { let popperElm = this.$el; if (popperElm && popperElm.parentNode === document.body) { document.body.removeChild(popperElm); } document.removeEventListener("mousedown", this.clickDocumentHandler); }, mounted() { document.body.appendChild(this.$el); document.addEventListener("mousedown", this.clickDocumentHandler); }, methods: { clickDocumentHandler() { if (this.show) { this.$emit("update:show", false); } }, setPosition() { let docHeight = document.documentElement.clientHeight; let docWidth = document.documentElement.clientWidth; let menuHeight = this.$el.getBoundingClientRect().height; let menuWidth = this.$el.getBoundingClientRect().width; // 增长点击处与菜单间间隔,较为美观 const gap = 10; let topover = this.offset.top + menuHeight + gap >= docHeight ? menuHeight + gap : -gap; let leftover = this.offset.left + menuWidth + gap >= docWidth ? menuWidth + gap : -gap; this.style = { left: `${this.offset.left - leftover}px`, top: `${this.offset.top - topover}px` }; } } }; </script>
<style lang="scss" scoped> .context-menu { z-index: 1000; display: block; position: absolute; } </style>
复制代码
固然,若是要作到(页面滚动/page resize)等菜单位置跟着变化,能够参考 element popper 的实现
右键菜单应该是没有这样的需求
这个也比较简单,采用 vue 自带的 transition
ContextMenu 中包一层 <transition name="context-menu">
style 样式 改成
<style lang="scss" scoped>
.context-menu {
z-index: 1000;
display: block;
position: absolute;
&-enter,
&-leave-to {
opacity: 0;
}
&-enter-active,
&-leave-active {
transition: opacity 0.5s;
}
}
</style>
复制代码
参考了 element-ui 的代码 和 README
以及 vue 官方文档-插件
咱们先建立一个 contextmenu.js
import ContextMenu from "@/components/ContextMenu.vue";
const plugin = {};
plugin.install = function(Vue) {
Vue.component(ContextMenu.name, ContextMenu);
};
/** * Auto install */
if (typeof window !== "undefined" && window.Vue) {
window.Vue.use(plugin);
}
export default plugin;
export { ContextMenu };
复制代码
接下来使用的话有3种方式
main.js
import ContextMenu from "./contextmenu";
// 将会调用install方法
Vue.use(ContextMenu);
// or
import { ContextMenu } from "./contextmenu";
Vue.component(ContextMenu.name, ContextMenu);
复制代码
或者在vue文件中使用(同法2,局部注册)
import { ContextMenu } from "@/contextmenu";
components: {
"context-menu": ContextMenu,
}
复制代码
须要注意的是,ContextMenu.vue
中 name 为 context-name
, 故 Home.vue 中应该相应的改成 <context-name/>
body 和 Dashbox 父容器 均可滚动的状况下,会出现菜单不在点击位置的问题,
测试页面:修改 Home.vue
<div class="home">
+ <div class="content">
...
+ </div>
</div>
//增长样式
<style lang="scss" scoped> .home { margin: 10px; overflow: scroll; height: 1500px; width: 100%; background: #eee; .content { position: relative; height: 2000px; } } </style>
复制代码
此时先滚动 home,而后右键dashbox 就会发现错位了,由于此时的 event.clientY 比 绝对定位的 top 少了一个 scrollY 值
有两种方法:
题外话
上文提到,ContextMenu 是插入 body 的,那有没有什么场景是不插入body的,另外 element-ui 中 popper-append-to-body=false
的场景是什么,这里会出现么,应该怎么解决?
当 Dashbox 组件的父节点容器是限制高度且能够 scroll 的时候,若要求右键菜单(弹框等)不能超出容器,则不该该插入body,当前,咱们右键菜单没有这样的要求
参考 antd-select 例子 codesandbox.io/s/4j168r7jw…
有用过 vue-cli 3
和 element-ui
的,应该熟悉 vue-cli-plugin-element
在咱们的项目中,使用 vue add element
命令后,会自动去下载vue-cli-plugin-element
并在 plugins 文件夹中新增 element.js 最后在 main.js 中使用,省去了上面那些手动引入的过程。
这里咱们也尝试编写一个 vue-cli-plugin-contextmenu
参考
项目结构
.
├── README.md
├── generator.js # generator (可选,这里采用 generator/index.js 的形式)
├── prompts.js # prompt 文件 (可选,本项目不使用)
├── index.js # service 插件
└── package.json
复制代码
代码的话主要是参考 vue-cli-plugin-element ,其中最主要的是 generator 的代码,以下
module.exports = (api, opts, rootOptions) => {
const utils = require('./utils')(api)
api.extendPackage({
dependencies: {
'@gahing/vcontextmenu': '^1.0.0'
}
})
api.injectImports(utils.getMain(), `import './plugins/contextmenu.js'`)
api.render({
'./src/plugins/contextmenu.js': './templates/src/plugins/contextmenu.js',
})
}
复制代码
当咱们写完后,须要进行本地测试下
# 建立测试项目(全选默认设置)
vue create test-app
cd test-app
# cd到项目文件夹并安装咱们新建立的插件
npm i file://E:/WebProjects/vue-cli-plugin-contextmenu -S
# 调用该插件
vue invoke vue-cli-plugin-contextmenu
复制代码
查看test-app项目的main.js,将会看到新增这行代码:
import './plugins/contextmenu.js'
复制代码
plugins/contextmenu.js 中内容为
import Vue from 'vue'
import ContextMenu from '@gahing/vcontextmenu'
import '@gahing/vcontextmenu/lib/vcontextmenu.css'
Vue.use(ContextMenu)
复制代码
至此,vue-cli-plugin-contextmenu 就开发完成,将其发布到 npm 上