Просмотр исходного кода

添加右键菜单和clickoutside指令

pipipi-pikachu 5 лет назад
Родитель
Сommit
32552d03a5

+ 0 - 1
package.json

@@ -9,7 +9,6 @@
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
-    "@ant-design/icons-vue": "^5.1.7",
     "ant-design-vue": "^2.0.0-rc.3",
     "clipboard": "^2.0.6",
     "core-js": "^3.6.5",

+ 2 - 0
public/index.html

@@ -14,5 +14,7 @@
     </noscript>
     <div id="app"></div>
     <!-- built files will be auto injected -->
+    
+    <script src="//at.alicdn.com/t/font_1667193_8v2yoxguspq.js"></script>
   </body>
 </html>

+ 179 - 0
src/components/Contextmenu/ContextmenuContent.vue

@@ -0,0 +1,179 @@
+<template>
+  <ul :class="['contextmenu-content', { 'dark': isDark }]">
+    <template v-for="(menu, index) in menus">
+      <li
+        v-if="!menu.hide"
+        class="contextmenu-item"
+        :key="menu.text || index"
+        @click.stop="handleClickMenuItem(menu)"
+        :class="{'divider': menu.divider, 'disable': menu.disable}"
+      >
+        <div class="contextmenu-item-content" :class="{'has-sub-menu': menu.children}" v-if="!menu.divider">
+          <span class="text">
+            <IconFont class="icon" v-if="menu.icon" :type="menu.icon" />
+            <div v-else-if="menu.iconPlacehoder" class="icon-placehoder"></div>
+            <span>{{menu.text}}</span>
+          </span>
+          <span class="sub-text" v-if="menu.subText && !menu.children">{{menu.subText}}</span>
+
+          <contextmenu-content 
+            class="sub-menu" 
+            :style="{
+              [subMenuPosition]: '112.5%',
+            }"
+            :menus="menu.children" 
+            v-if="menu.children && menu.children.length"
+            :handleClickMenuItem="handleClickMenuItem" 
+          />
+        </div>
+      </li>
+    </template>
+  </ul>
+</template>
+
+<script lang="ts">
+import { PropType } from 'vue'
+import { ContextmenuItem } from './types'
+
+import IconFont from '@/components/IconFont.vue'
+
+export default {
+  name: 'contextmenu-content',
+  components: {
+    IconFont,
+  },
+  props: {
+    menus: {
+      type: Array as PropType<ContextmenuItem[]>,
+      required: true,
+    },
+    isDark: {
+      type: Boolean,
+      default: false,
+    },
+    subMenuPosition: {
+      type: String,
+      default: 'left',
+    },
+    handleClickMenuItem: {
+      type: Function,
+      required: true,
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+$menuWidth: 160px;
+$menuHeight: 32px;
+$subMenuWidth: 120px;
+
+.contextmenu-content {
+  width: $menuWidth;
+  padding: 5px 0;
+  background: #fff;
+  box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.1);
+  border-radius: 2px;
+  list-style: none;
+  margin: 0;
+
+  &.dark {
+    background-color: #393939;
+    box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.25);
+    .contextmenu-content {
+      background-color: #393939;
+      box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.25);
+    }
+    .contextmenu-item {
+      color: #f1f1f1;
+      background-color: #393939;
+      &:hover:not(.disable) {
+        background-color: #555;
+      }
+      &.divider {
+        background-color: #999;
+      }
+      &.disable {
+        color: #999;
+      }
+    }
+  }
+}
+.contextmenu-item {
+  padding: 0 20px;
+  color: #666;
+  font-size: 12px;
+  transition: all 0.3s;
+  white-space: nowrap;
+  height: $menuHeight;
+  line-height: $menuHeight;
+  border-radius: 4px;
+  background-color: #fff;
+  cursor: pointer;
+
+  &:not(.disable):hover > .contextmenu-item-content > .sub-menu {
+    display: block;
+  }
+
+  &:hover:not(.disable) {
+    background-color: #f7f7f7;
+  }
+
+  &.divider {
+    height: 1px;
+    overflow: hidden;
+    margin: 5px 15px;
+    background-color: #e5e5e5;
+    line-height: 0;
+    padding: 0;
+  }
+
+  &.disable {
+    color: #b1b1b1;
+    cursor: no-drop;
+  }
+}
+.contextmenu-item-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  position: relative;
+
+  &.has-sub-menu::before {
+    content: '';
+    display: inline-block;
+    width: 0;
+    height: 0;
+    border-top: 4px solid transparent;
+    border-left: 4px solid #676b6f;
+    border-bottom: 4px solid transparent;
+    position: absolute;
+    right: 0;
+    top: 50%;
+    transform: translateY(-50%);
+  }
+
+  .icon {
+    margin-right: 7px;
+    vertical-align: middle;
+  }
+  .text span {
+    vertical-align: middle;
+  }
+  .icon-placehoder {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    margin-right: 7px;
+  }
+  .sub-text {
+    opacity: 0.3;
+  }
+  .sub-menu {
+    position: absolute;
+    top: -5px;
+    display: none;
+    width: $subMenuWidth;
+  }
+}
+</style>

+ 124 - 0
src/components/Contextmenu/index.vue

@@ -0,0 +1,124 @@
+<template>
+  <div class="contextmenu" 
+    ref="contextmenuRef"
+    v-show="visible" 
+    :style="{
+      left: style.left,
+      top: style.top,
+    }"
+    @contextmenu.prevent
+    v-click-outside="removeContextMenu"
+  >
+    <ContextmenuContent 
+      :menus="menus" 
+      :isDark="isDark"
+      :subMenuPosition="style.subMenuPosition" 
+      :handleClickMenuItem="handleClickMenuItem" 
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, nextTick, onMounted, onUnmounted, ref, PropType } from 'vue'
+import { ContextmenuItem, Axis } from './types'
+
+import ContextmenuContent from './ContextmenuContent.vue'
+import clickOutside from '@/plugins/clickOutside'
+
+const MENU_WIDTH = 160
+const MENU_HEIGHT = 32
+const DIVIDER_HEIGHT = 11
+const SUB_MENU_WIDTH = 120
+
+export default defineComponent({
+  name: 'contextmenu',
+  components: {
+    ContextmenuContent,
+  },
+  directives: {
+    'click-outside': clickOutside.directive,
+  },
+  props: {
+    axis: {
+      type: Object as PropType<Axis>,
+      required: true,
+    },
+    el: {
+      type: Object as PropType<HTMLElement>,
+      required: true,
+    },
+    menus: {
+      type: Array as PropType<ContextmenuItem[]>,
+      required: true,
+    },
+    isDark: {
+      type: Boolean,
+      default: false,
+    },
+    removeContextMenu: {
+      type: Function,
+      required: true,
+    },
+  },
+  setup(props) {
+    const contextmenuRef = ref<Element | null>(null)
+    const visible = ref(false)
+
+    const style = computed(() => {
+      const { x, y } = props.axis
+      const normalMenuCount = props.menus.filter(menu => !menu.divider && !menu.hide).length
+      const dividerMenuCount = props.menus.filter(menu => menu.divider).length
+
+      const menuWidth = MENU_WIDTH
+      const menuHeight = normalMenuCount * MENU_HEIGHT + dividerMenuCount * DIVIDER_HEIGHT
+
+      const maxMenuWidth = MENU_WIDTH + SUB_MENU_WIDTH - 10
+
+      const screenWidth = document.body.clientWidth
+      const screenHeight = document.body.clientHeight
+
+      const left = (screenWidth <= x + menuWidth ? x - menuWidth : x)
+      const top = (screenHeight <= y + menuHeight ? y - menuHeight : y)
+
+      const subMenuPosition = screenWidth <= left + maxMenuWidth ? 'right' : 'left'
+
+      return {
+        left: left + 'px',
+        top: top + 'px',
+        subMenuPosition,
+      }
+    })
+
+    const handleClickMenuItem = (item: ContextmenuItem) => {
+      if(item.disable || item.children) return
+
+      visible.value = false
+      item.action && item.action(props.el)
+
+      props.removeContextMenu()
+    }
+
+    onMounted(() => {
+      nextTick(() => visible.value = true)
+    })
+    onUnmounted(() => {
+      if(contextmenuRef.value) document.body.removeChild(contextmenuRef.value)
+    })
+
+    return {
+      visible,
+      style,
+      contextmenuRef,
+      handleClickMenuItem,
+    }
+  },
+})
+</script>
+
+<style lang="scss">
+.contextmenu {
+  position: fixed;
+  z-index: 9999;
+  user-select: none;
+}
+</style>

+ 16 - 0
src/components/Contextmenu/types.ts

@@ -0,0 +1,16 @@
+export interface ContextmenuItem {
+  text?: string;
+  subText?: string;
+  icon?: string;
+  divider?: boolean;
+  disable?: boolean;
+  hide?: boolean;
+  iconPlacehoder?: boolean;
+  children?: ContextmenuItem[];
+  action?: (el: HTMLElement) => void;
+}
+
+export interface Axis {
+  x: number;
+  y: number;
+}

+ 0 - 5
src/components/IconFont.ts

@@ -1,5 +0,0 @@
-import { createFromIconfontCN } from '@ant-design/icons-vue'
-
-export default createFromIconfontCN({
-  scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
-})

+ 28 - 0
src/components/IconFont.vue

@@ -0,0 +1,28 @@
+<template>
+  <svg class="icon-font" aria-hidden="true">
+    <use :xlink:href="`#${type}`"></use>
+  </svg>
+</template>
+
+<script lang="ts">
+export default {
+  name: 'icon-font',
+  props: {
+    type: {
+      type: String,
+      required: true,
+    },
+  }
+}
+</script>
+
+<style lang="scss">
+.icon-font {
+  width: 1em;
+  height: 1em;
+  vertical-align: -0.15em;
+  fill: currentColor;
+  overflow: hidden;
+  outline: none;
+}
+</style>

+ 7 - 0
src/main.ts

@@ -5,7 +5,14 @@ import store from './store'
 
 import '@/assets/styles/global.scss'
 
+import IconFont from '@/components/IconFont.vue'
+import contextmenu from './plugins/contextmenu'
+import clickOutside from './plugins/clickOutside'
+
 const app = createApp(App)
+app.component('IconFont', IconFont)
+app.use(contextmenu)
+app.use(clickOutside)
 app.use(store)
 app.use(router)
 app.mount('#app')

+ 34 - 0
src/plugins/clickOutside.ts

@@ -0,0 +1,34 @@
+import { Directive, App, DirectiveBinding } from 'vue'
+
+const CTX_CLICK_OUTSIDE_HANDLER = 'CTX_CLICK_OUTSIDE_HANDLER'
+
+const clickListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => {
+  const handler = binding.value
+
+  const path = event.composedPath()
+  const isClickOutside = path ? path.indexOf(el) < 0 : !el.contains(event.target as HTMLElement)
+
+  if(!isClickOutside) return
+  handler(event)
+}
+
+const ClickOutsideDirective: Directive = {
+  mounted(el: HTMLElement, binding) {
+    el[CTX_CLICK_OUTSIDE_HANDLER] = (event: MouseEvent) => clickListener(el, event, binding)
+    document.addEventListener('mousedown', el[CTX_CLICK_OUTSIDE_HANDLER])
+  },
+  
+  unmounted(el: HTMLElement) {
+    if(el && el[CTX_CLICK_OUTSIDE_HANDLER]) {
+      document.removeEventListener('mousedown', el[CTX_CLICK_OUTSIDE_HANDLER])
+      delete el[CTX_CLICK_OUTSIDE_HANDLER]
+    }
+  },
+}
+
+export default {
+  install(app: App) {
+    app.directive('click-outside', ClickOutsideDirective)
+  },
+  directive: ClickOutsideDirective,
+}

+ 62 - 0
src/plugins/contextmenu.ts

@@ -0,0 +1,62 @@
+import { Directive, App, createVNode, render, DirectiveBinding } from 'vue'
+import ContextmenuComponent from '@/components/Contextmenu/index.vue'
+
+const CTX_CONTEXTMENU_HANDLER = 'CTX_CONTEXTMENU_HANDLER'
+
+const contextmenuListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => {
+  event.stopPropagation()
+  event.preventDefault()
+
+  const menus = binding.value(el)
+  if(!menus) return
+  const isDark = binding.modifiers.dark
+
+  let container: HTMLDivElement | null = null
+
+  const removeContextMenu = () => {
+    if(container) {
+      document.body.removeChild(container)
+      container = null
+    }
+    el.classList.remove('contextmenu-active')
+    document.body.removeEventListener('scroll', removeContextMenu)  
+    window.removeEventListener('resize', removeContextMenu)
+  }
+
+  const options = {
+    axis: { x: event.x, y: event.y },
+    el,
+    menus,
+    isDark,
+    removeContextMenu,
+  }
+  container = document.createElement('div')
+  const vm = createVNode(ContextmenuComponent, options, null)
+  render(vm, container)
+  document.body.appendChild(container)
+
+  el.classList.add('contextmenu-active')
+
+  document.body.addEventListener('scroll', removeContextMenu)
+  window.addEventListener('resize', removeContextMenu)
+}
+
+const ContextmenuDirective: Directive = {
+  mounted(el: HTMLElement, binding) {
+    el[CTX_CONTEXTMENU_HANDLER] = (event: MouseEvent) => contextmenuListener(el, event, binding)
+    el.addEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
+  },
+
+  unmounted(el: HTMLElement) {
+    if(el && el[CTX_CONTEXTMENU_HANDLER]) {
+      el.removeEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
+      delete el[CTX_CONTEXTMENU_HANDLER]
+    }
+  },
+}
+
+export default {
+  install(app: App) {
+    app.directive('contextmenu', ContextmenuDirective)
+  }
+}

+ 35 - 0
src/views/Editor/Canvas/index.vue

@@ -3,6 +3,7 @@
     class="canvas" 
     ref="canvasRef"
     @mousedown="$event => handleClickBlankArea($event)"
+    v-contextmenu="contextmenus"
   >
     <div 
       class="viewport" 
@@ -31,6 +32,7 @@
 import { computed, defineComponent, onMounted, onUnmounted, reactive, ref } from 'vue'
 import { useStore } from 'vuex'
 import { State } from '@/store/state'
+import { ContextmenuItem } from '@/components/Contextmenu/types'
 import { VIEWPORT_SIZE, VIEWPORT_ASPECT_RATIO } from '@/configs/canvas'
 
 import MouseSelection from './MouseSelection.vue'
@@ -151,12 +153,45 @@ export default defineComponent({
       updateMouseSelection(e)
     }
 
+    const contextmenus = (): ContextmenuItem[] => {
+      return [
+        {
+          text: '全选',
+          subText: 'Ctrl + A',
+        },
+        {
+          text: '粘贴',
+          subText: 'Ctrl + V',
+        },
+        { divider: true },
+        {
+          text: '参考线',
+          children: [
+            {
+              text: '打开',
+            },
+            {
+              text: '关闭',
+            },
+          ],
+        },
+        {
+          text: '背景设置',
+        },
+        { divider: true },
+        {
+          text: '清空页面',
+        },
+      ]
+    }
+
     return {
       canvasRef,
       viewportRef,
       viewportStyles,
       mouseSelectionState,
       handleClickBlankArea,
+      contextmenus,
     }
   },
 })