添加右键菜单和clickoutside指令

This commit is contained in:
pipipi-pikachu 2020-12-12 16:56:34 +08:00
parent 39d5b1fc20
commit 32552d03a5
11 changed files with 487 additions and 6 deletions

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

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

View File

@ -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>

View File

@ -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')

View File

@ -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,
}

View File

@ -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)
}
}

View File

@ -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,
}
},
})