419 lines
10 KiB
Vue
419 lines
10 KiB
Vue
<!-- 右键菜单 -->
|
|
<template>
|
|
<div class="menu-right">
|
|
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
|
<div
|
|
v-show="visible"
|
|
:style="menuStyle"
|
|
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
|
|
>
|
|
<ul class="menu-list m-0 list-none" :style="menuListStyle">
|
|
<template v-for="item in menuItems" :key="item.key">
|
|
<!-- 普通菜单项 -->
|
|
<li
|
|
v-if="!item.children"
|
|
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
|
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
|
:style="menuItemStyle"
|
|
@click="handleMenuClick(item)"
|
|
>
|
|
<ArtSvgIcon
|
|
v-if="item.icon"
|
|
class="mr-2 shrink-0 text-base text-g-800"
|
|
:icon="item.icon"
|
|
/>
|
|
<span
|
|
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
|
>{{ item.label }}</span
|
|
>
|
|
</li>
|
|
|
|
<!-- 子菜单 -->
|
|
<li
|
|
v-else
|
|
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
|
:style="menuItemStyle"
|
|
>
|
|
<div class="submenu-title flex-c w-full">
|
|
<ArtSvgIcon
|
|
v-if="item.icon"
|
|
class="mr-2 shrink-0 text-base text-g-800"
|
|
:icon="item.icon"
|
|
/>
|
|
<span
|
|
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
|
>{{ item.label }}</span
|
|
>
|
|
<ArtSvgIcon
|
|
icon="ri:arrow-right-s-line"
|
|
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
|
|
/>
|
|
</div>
|
|
<ul
|
|
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
|
|
:style="submenuListStyle"
|
|
>
|
|
<li
|
|
v-for="child in item.children"
|
|
:key="child.key"
|
|
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
|
:class="{
|
|
'is-disabled': child.disabled,
|
|
'has-line': child.showLine
|
|
}"
|
|
:style="menuItemStyle"
|
|
@click="handleMenuClick(child)"
|
|
>
|
|
<ArtSvgIcon
|
|
v-if="child.icon"
|
|
class="r-2 shrink-0 text-base text-g-800 mr-1"
|
|
:icon="child.icon"
|
|
/>
|
|
<span
|
|
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
|
>{{ child.label }}</span
|
|
>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { CSSProperties } from 'vue'
|
|
|
|
defineOptions({ name: 'ArtMenuRight' })
|
|
|
|
export interface MenuItemType {
|
|
/** 菜单项唯一标识 */
|
|
key: string
|
|
/** 菜单项标签 */
|
|
label: string
|
|
/** 菜单项图标 */
|
|
icon?: string
|
|
/** 菜单项是否禁用 */
|
|
disabled?: boolean
|
|
/** 菜单项是否显示分割线 */
|
|
showLine?: boolean
|
|
/** 子菜单 */
|
|
children?: MenuItemType[]
|
|
[key: string]: any
|
|
}
|
|
|
|
interface Props {
|
|
menuItems: MenuItemType[]
|
|
/** 菜单宽度 */
|
|
menuWidth?: number
|
|
/** 子菜单宽度 */
|
|
submenuWidth?: number
|
|
/** 菜单项高度 */
|
|
itemHeight?: number
|
|
/** 边界距离 */
|
|
boundaryDistance?: number
|
|
/** 菜单内边距 */
|
|
menuPadding?: number
|
|
/** 菜单项水平内边距 */
|
|
itemPaddingX?: number
|
|
/** 菜单圆角 */
|
|
borderRadius?: number
|
|
/** 动画持续时间 */
|
|
animationDuration?: number
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
menuWidth: 120,
|
|
submenuWidth: 150,
|
|
itemHeight: 32,
|
|
boundaryDistance: 10,
|
|
menuPadding: 5,
|
|
itemPaddingX: 6,
|
|
borderRadius: 6,
|
|
animationDuration: 100
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'select', item: MenuItemType): void
|
|
(e: 'show'): void
|
|
(e: 'hide'): void
|
|
}>()
|
|
|
|
const visible = ref(false)
|
|
const position = ref({ x: 0, y: 0 })
|
|
|
|
// 用于清理定时器和事件监听器
|
|
let showTimer: number | null = null
|
|
let eventListenersAdded = false
|
|
|
|
// 计算菜单样式
|
|
const menuStyle = computed(
|
|
(): CSSProperties => ({
|
|
position: 'fixed' as const,
|
|
left: `${position.value.x}px`,
|
|
top: `${position.value.y}px`,
|
|
zIndex: 2000,
|
|
width: `${props.menuWidth}px`
|
|
})
|
|
)
|
|
|
|
// 计算菜单列表样式
|
|
const menuListStyle = computed(
|
|
(): CSSProperties => ({
|
|
padding: `${props.menuPadding}px`
|
|
})
|
|
)
|
|
|
|
// 计算菜单项样式
|
|
const menuItemStyle = computed(
|
|
(): CSSProperties => ({
|
|
height: `${props.itemHeight}px`,
|
|
padding: `0 ${props.itemPaddingX}px`,
|
|
borderRadius: '4px'
|
|
})
|
|
)
|
|
|
|
// 计算子菜单列表样式
|
|
const submenuListStyle = computed(
|
|
(): CSSProperties => ({
|
|
minWidth: `${props.submenuWidth}px`,
|
|
padding: `${props.menuPadding}px 0`,
|
|
borderRadius: `${props.borderRadius}px`
|
|
})
|
|
)
|
|
|
|
// 计算菜单高度(用于边界检测)
|
|
const calculateMenuHeight = (): number => {
|
|
let totalHeight = props.menuPadding * 2 // 上下内边距
|
|
|
|
props.menuItems.forEach((item) => {
|
|
totalHeight += props.itemHeight
|
|
if (item.showLine) {
|
|
totalHeight += 10 // 分割线额外高度
|
|
}
|
|
})
|
|
|
|
return totalHeight
|
|
}
|
|
|
|
// 优化的位置计算函数
|
|
const calculatePosition = (e: MouseEvent) => {
|
|
const screenWidth = window.innerWidth
|
|
const screenHeight = window.innerHeight
|
|
const menuHeight = calculateMenuHeight()
|
|
|
|
let x = e.clientX
|
|
let y = e.clientY
|
|
|
|
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
|
|
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
|
|
x = Math.max(props.boundaryDistance, x - props.menuWidth)
|
|
}
|
|
|
|
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
|
|
if (y + menuHeight > screenHeight - props.boundaryDistance) {
|
|
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
|
|
}
|
|
|
|
// 确保不会超出边界
|
|
x = Math.max(
|
|
props.boundaryDistance,
|
|
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
|
|
)
|
|
y = Math.max(
|
|
props.boundaryDistance,
|
|
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
|
|
)
|
|
|
|
return { x, y }
|
|
}
|
|
|
|
// 添加事件监听器
|
|
const addEventListeners = () => {
|
|
if (eventListenersAdded) return
|
|
|
|
document.addEventListener('click', handleDocumentClick)
|
|
document.addEventListener('contextmenu', handleDocumentContextmenu)
|
|
document.addEventListener('keydown', handleKeydown)
|
|
eventListenersAdded = true
|
|
}
|
|
|
|
// 移除事件监听器
|
|
const removeEventListeners = () => {
|
|
if (!eventListenersAdded) return
|
|
|
|
document.removeEventListener('click', handleDocumentClick)
|
|
document.removeEventListener('contextmenu', handleDocumentContextmenu)
|
|
document.removeEventListener('keydown', handleKeydown)
|
|
eventListenersAdded = false
|
|
}
|
|
|
|
// 处理文档点击事件
|
|
const handleDocumentClick = (e: Event) => {
|
|
// 检查点击是否在菜单内部
|
|
const target = e.target as Element
|
|
const menuElement = document.querySelector('.context-menu')
|
|
if (menuElement && menuElement.contains(target)) {
|
|
return
|
|
}
|
|
hide()
|
|
}
|
|
|
|
// 处理文档右键事件
|
|
const handleDocumentContextmenu = () => {
|
|
hide()
|
|
}
|
|
|
|
// 处理键盘事件
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
hide()
|
|
}
|
|
}
|
|
|
|
const show = (e: MouseEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
// 清理之前的定时器
|
|
if (showTimer) {
|
|
window.clearTimeout(showTimer)
|
|
showTimer = null
|
|
}
|
|
|
|
// 计算位置
|
|
position.value = calculatePosition(e)
|
|
visible.value = true
|
|
|
|
emit('show')
|
|
|
|
// 延迟添加事件监听器,避免立即触发关闭
|
|
showTimer = window.setTimeout(() => {
|
|
if (visible.value) {
|
|
addEventListeners()
|
|
}
|
|
showTimer = null
|
|
}, 50) // 减少延迟时间,提升响应性
|
|
}
|
|
|
|
const hide = () => {
|
|
if (!visible.value) return
|
|
|
|
visible.value = false
|
|
emit('hide')
|
|
|
|
// 清理定时器
|
|
if (showTimer) {
|
|
window.clearTimeout(showTimer)
|
|
showTimer = null
|
|
}
|
|
|
|
// 移除事件监听器
|
|
removeEventListeners()
|
|
}
|
|
|
|
const handleMenuClick = (item: MenuItemType) => {
|
|
if (item.disabled) return
|
|
emit('select', item)
|
|
hide()
|
|
}
|
|
|
|
// 动画钩子函数
|
|
const onBeforeEnter = (el: Element) => {
|
|
const element = el as HTMLElement
|
|
element.style.transformOrigin = 'top left'
|
|
}
|
|
|
|
const onAfterLeave = () => {
|
|
// 确保清理所有资源
|
|
removeEventListeners()
|
|
if (showTimer) {
|
|
window.clearTimeout(showTimer)
|
|
showTimer = null
|
|
}
|
|
}
|
|
|
|
// 组件卸载时清理资源
|
|
onUnmounted(() => {
|
|
removeEventListeners()
|
|
if (showTimer) {
|
|
window.clearTimeout(showTimer)
|
|
showTimer = null
|
|
}
|
|
})
|
|
|
|
// 导出方法供父组件调用
|
|
defineExpose({
|
|
show,
|
|
hide,
|
|
visible: computed(() => visible.value)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.menu-right {
|
|
--menu-width: v-bind('props.menuWidth + "px"');
|
|
--border-radius: v-bind('props.borderRadius + "px"');
|
|
}
|
|
|
|
.menu-item.has-line {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.menu-item.has-line::after {
|
|
position: absolute;
|
|
right: 0;
|
|
bottom: -5px;
|
|
left: 0;
|
|
height: 1px;
|
|
content: '';
|
|
background-color: var(--art-gray-300);
|
|
}
|
|
|
|
.menu-item.is-disabled {
|
|
color: var(--el-text-color-disabled);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.menu-item.is-disabled:hover {
|
|
background-color: transparent !important;
|
|
}
|
|
|
|
.menu-item.is-disabled i:not(.submenu-arrow),
|
|
.menu-item.is-disabled :deep(.art-svg-icon) {
|
|
color: var(--el-text-color-disabled) !important;
|
|
}
|
|
|
|
.menu-item.is-disabled .menu-label {
|
|
color: var(--el-text-color-disabled) !important;
|
|
}
|
|
|
|
.menu-item.submenu:hover .submenu-list {
|
|
display: block;
|
|
}
|
|
|
|
.menu-item.submenu:hover .submenu-title .submenu-arrow {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
/* 动画样式 */
|
|
.context-menu-enter-active,
|
|
.context-menu-leave-active {
|
|
transition: all v-bind('props.animationDuration + "ms"') ease-out;
|
|
}
|
|
|
|
.context-menu-enter-from,
|
|
.context-menu-leave-to {
|
|
opacity: 0;
|
|
transform: scale(0.9);
|
|
}
|
|
|
|
.context-menu-enter-to,
|
|
.context-menu-leave-from {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
</style>
|