添加右键菜单和clickoutside指令
This commit is contained in:
parent
39d5b1fc20
commit
32552d03a5
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { createFromIconfontCN } from '@ant-design/icons-vue'
|
||||
|
||||
export default createFromIconfontCN({
|
||||
scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
|
||||
})
|
|
@ -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>
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue