Vue之從零編寫一個ContextMenu(右鍵菜單)插件

前言

ContextMenu 即右鍵菜單,當前的需求是:右鍵點擊某些組件時,根據所點擊組件的信息,展現不一樣的菜單。javascript

本插件已開源,具體代碼和使用可參考: vue-contextmenucss

本文采用的是 vue 技術棧,部分處理對於 react 是能夠借鑑的html

其中須要注意的點有:vue

  1. 菜單徹底顯示,即右鍵點擊位於頁面下/右側時,菜單應該向上/左顯示
  2. 具體菜單由上層控制,該組件僅提供slot
  3. 該菜單dom上惟一,不須要時應該銷燬
  4. 點擊頁面其餘位置,菜單消失

先不考慮插件形式,按平常組件開發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 會顯示在對應位置

該菜單dom上惟一,不須要時應該銷燬

這個也很簡單

在組件銷燬時,把本身從 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 的實現

  1. github.com/ElemeFE/ele…
  2. github.com/ElemeFE/ele…

右鍵菜單應該是沒有這樣的需求

增長顯示/隱藏的過分動畫

這個也比較簡單,採用 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 值

有兩種方法:

  1. 將 ContextMenu 的 position 由 absolute 改成 fixed
  2. 傳入的座標採用 pageX/Y

題外話

上文提到,ContextMenu 是插入 body 的,那有沒有什麼場景是不插入body的,另外 element-ui 中 popper-append-to-body=false 的場景是什麼,這裏會出現麼,應該怎麼解決?

當 Dashbox 組件的父節點容器是限制高度且能夠 scroll 的時候,若要求右鍵菜單(彈框等)不能超出容器,則不該該插入body,當前,咱們右鍵菜單沒有這樣的要求

參考 antd-select 例子 codesandbox.io/s/4j168r7jw…

生成 vue-cli 插件

有用過 vue-cli 3element-ui 的,應該熟悉 vue-cli-plugin-element

在咱們的項目中,使用 vue add element 命令後,會自動去下載vue-cli-plugin-element 並在 plugins 文件夾中新增 element.js 最後在 main.js 中使用,省去了上面那些手動引入的過程。

這裏咱們也嘗試編寫一個 vue-cli-plugin-contextmenu

參考

  1. 插件開發指南

  2. vue-cli-plugin-element

  3. 「Vue進階」5分鐘擼一個Vue CLI 插件

項目結構

.
├── 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 上

相關文章
相關標籤/搜索