添加右键菜单和clickoutside指令
This commit is contained in:
parent
39d5b1fc20
commit
32552d03a5
|
@ -9,7 +9,6 @@
|
||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^5.1.7",
|
|
||||||
"ant-design-vue": "^2.0.0-rc.3",
|
"ant-design-vue": "^2.0.0-rc.3",
|
||||||
"clipboard": "^2.0.6",
|
"clipboard": "^2.0.6",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
|
|
|
@ -14,5 +14,7 @@
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<!-- built files will be auto injected -->
|
||||||
|
|
||||||
|
<script src="//at.alicdn.com/t/font_1667193_8v2yoxguspq.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 '@/assets/styles/global.scss'
|
||||||
|
|
||||||
|
import IconFont from '@/components/IconFont.vue'
|
||||||
|
import contextmenu from './plugins/contextmenu'
|
||||||
|
import clickOutside from './plugins/clickOutside'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
app.component('IconFont', IconFont)
|
||||||
|
app.use(contextmenu)
|
||||||
|
app.use(clickOutside)
|
||||||
app.use(store)
|
app.use(store)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
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"
|
class="canvas"
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
@mousedown="$event => handleClickBlankArea($event)"
|
@mousedown="$event => handleClickBlankArea($event)"
|
||||||
|
v-contextmenu="contextmenus"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="viewport"
|
class="viewport"
|
||||||
|
@ -31,6 +32,7 @@
|
||||||
import { computed, defineComponent, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { computed, defineComponent, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
import { useStore } from 'vuex'
|
import { useStore } from 'vuex'
|
||||||
import { State } from '@/store/state'
|
import { State } from '@/store/state'
|
||||||
|
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||||
import { VIEWPORT_SIZE, VIEWPORT_ASPECT_RATIO } from '@/configs/canvas'
|
import { VIEWPORT_SIZE, VIEWPORT_ASPECT_RATIO } from '@/configs/canvas'
|
||||||
|
|
||||||
import MouseSelection from './MouseSelection.vue'
|
import MouseSelection from './MouseSelection.vue'
|
||||||
|
@ -151,12 +153,45 @@ export default defineComponent({
|
||||||
updateMouseSelection(e)
|
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 {
|
return {
|
||||||
canvasRef,
|
canvasRef,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
viewportStyles,
|
viewportStyles,
|
||||||
mouseSelectionState,
|
mouseSelectionState,
|
||||||
handleClickBlankArea,
|
handleClickBlankArea,
|
||||||
|
contextmenus,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue