346 lines
9.4 KiB
Vue
346 lines
9.4 KiB
Vue
<script setup>
|
||
import { ref, watch, nextTick, onMounted, onUnmounted } from "vue";
|
||
|
||
const props = defineProps({
|
||
visible: Boolean,
|
||
position: {
|
||
type: Object,
|
||
default: () => ({ x: 0, y: 0 }),
|
||
},
|
||
items: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(["close", "select"]);
|
||
|
||
const menuRef = ref(null);
|
||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||
const hoveredItem = ref(null);
|
||
const submenuPositions = ref(new Map());
|
||
const hideTimer = ref(null); // 添加隐藏定时器
|
||
|
||
// 计算菜单位置,处理边界问题
|
||
const calculatePosition = () => {
|
||
if (!menuRef.value || !props.visible) return;
|
||
|
||
const menu = menuRef.value;
|
||
const menuRect = menu.getBoundingClientRect();
|
||
const windowWidth = window.innerWidth;
|
||
const windowHeight = window.innerHeight;
|
||
|
||
let x = props.position.x;
|
||
let y = props.position.y;
|
||
|
||
// 右边界检测
|
||
if (x + menuRect.width > windowWidth - 10) {
|
||
x = x - menuRect.width;
|
||
}
|
||
|
||
// 底边界检测
|
||
if (y + menuRect.height > windowHeight - 10) {
|
||
y = windowHeight - menuRect.height - 10;
|
||
}
|
||
|
||
// 左边界检测
|
||
if (x < 10) {
|
||
x = 10;
|
||
}
|
||
|
||
// 顶边界检测
|
||
if (y < 10) {
|
||
y = 10;
|
||
}
|
||
|
||
adjustedPosition.value = { x, y };
|
||
};
|
||
|
||
// 计算子菜单位置
|
||
const calculateSubmenuPosition = (itemElement, itemIndex) => {
|
||
if (!itemElement || !menuRef.value) return { x: 0, y: 0, direction: "right" };
|
||
|
||
const itemRect = itemElement.getBoundingClientRect();
|
||
const menuRect = menuRef.value.getBoundingClientRect();
|
||
const windowWidth = window.innerWidth;
|
||
const windowHeight = window.innerHeight;
|
||
|
||
// 预估子菜单宽度(可以根据实际情况调整)
|
||
const submenuWidth = 200;
|
||
const submenuHeight = 300; // 预估高度
|
||
|
||
let x = itemRect.right + 4;
|
||
// 直接使用菜单项相对于主菜单容器的偏移量
|
||
let y = itemElement.offsetTop;
|
||
let direction = "right";
|
||
|
||
// 右边界检测,如果右侧空间不足,显示在左侧
|
||
if (x + submenuWidth > windowWidth - 10) {
|
||
x = itemRect.left - submenuWidth - 4;
|
||
direction = "left";
|
||
}
|
||
|
||
// 底边界检测 - 基于子菜单的绝对位置检查
|
||
const absoluteSubmenuBottom = itemRect.top + submenuHeight;
|
||
if (absoluteSubmenuBottom > windowHeight - 10) {
|
||
// 计算可用的最大Y位置(相对于主菜单)
|
||
const maxAbsoluteY = windowHeight - submenuHeight - 10;
|
||
const maxRelativeY = maxAbsoluteY - menuRect.top;
|
||
y = Math.max(0, maxRelativeY);
|
||
}
|
||
|
||
// 左边界检测
|
||
if (x < 10) {
|
||
x = 10;
|
||
direction = "right";
|
||
}
|
||
|
||
// 确保 y 不为负数
|
||
if (y < 0) {
|
||
y = 0;
|
||
}
|
||
|
||
y = 0;
|
||
|
||
const position = { x, y, direction };
|
||
submenuPositions.value.set(itemIndex, position);
|
||
return position;
|
||
};
|
||
|
||
// 清除隐藏定时器
|
||
const clearHideTimer = () => {
|
||
if (hideTimer.value) {
|
||
clearTimeout(hideTimer.value);
|
||
hideTimer.value = null;
|
||
}
|
||
};
|
||
|
||
// 显示子菜单
|
||
const showSubmenu = (item, index, element) => {
|
||
if (item.children && item.children.length > 0) {
|
||
clearHideTimer();
|
||
hoveredItem.value = index;
|
||
nextTick(() => {
|
||
calculateSubmenuPosition(element, index);
|
||
});
|
||
}
|
||
};
|
||
|
||
// 隐藏子菜单(延迟)
|
||
const hideSubmenu = (index) => {
|
||
clearHideTimer();
|
||
hideTimer.value = setTimeout(() => {
|
||
if (hoveredItem.value === index) {
|
||
hoveredItem.value = null;
|
||
}
|
||
}, 150);
|
||
};
|
||
|
||
// 处理鼠标进入菜单项
|
||
const handleItemMouseEnter = (item, index, event) => {
|
||
const element = event.target.closest(".context-menu-item");
|
||
showSubmenu(item, index, element);
|
||
};
|
||
|
||
// 处理鼠标在菜单项内移动
|
||
const handleItemMouseMove = (item, index, event) => {
|
||
// 如果当前菜单项有子菜单但子菜单未显示,则显示子菜单
|
||
if (item.children && item.children.length > 0 && hoveredItem.value !== index) {
|
||
const element = event.target.closest(".context-menu-item");
|
||
showSubmenu(item, index, element);
|
||
}
|
||
};
|
||
|
||
// 处理鼠标离开菜单项
|
||
const handleItemMouseLeave = (item, index) => {
|
||
// 只有当有子菜单时才延迟隐藏
|
||
if (item.children && item.children.length > 0) {
|
||
hideSubmenu(index);
|
||
} else {
|
||
hoveredItem.value = null;
|
||
}
|
||
};
|
||
|
||
// 处理鼠标进入子菜单
|
||
const handleSubmenuMouseEnter = (index) => {
|
||
clearHideTimer();
|
||
hoveredItem.value = index;
|
||
};
|
||
|
||
// 处理鼠标离开子菜单
|
||
const handleSubmenuMouseLeave = (index) => {
|
||
hideSubmenu(index);
|
||
};
|
||
|
||
// 监听可见性和位置变化
|
||
watch([() => props.visible, () => props.position], () => {
|
||
if (props.visible) {
|
||
nextTick(() => {
|
||
calculatePosition();
|
||
});
|
||
} else {
|
||
hoveredItem.value = null;
|
||
submenuPositions.value.clear();
|
||
}
|
||
});
|
||
|
||
// 处理菜单项点击
|
||
const handleItemClick = (item, index) => {
|
||
if (item.disabled || item.type === "divider") return;
|
||
|
||
// 如果有子菜单,不关闭菜单
|
||
if (item.children && item.children.length > 0) {
|
||
return;
|
||
}
|
||
|
||
emit("select", item, index);
|
||
|
||
if (item.action) {
|
||
item.action();
|
||
}
|
||
|
||
emit("close");
|
||
};
|
||
|
||
// 处理子菜单项点击
|
||
const handleSubItemClick = (subItem, parentIndex, subIndex) => {
|
||
if (subItem.disabled || subItem.type === "divider") return;
|
||
|
||
emit("select", subItem, `${parentIndex}-${subIndex}`);
|
||
|
||
if (subItem.action) {
|
||
subItem.action();
|
||
}
|
||
|
||
emit("close");
|
||
};
|
||
|
||
// 处理外部点击关闭
|
||
const handleOutsideClick = (event) => {
|
||
if (menuRef.value && !menuRef.value.contains(event.target)) {
|
||
emit("close");
|
||
}
|
||
};
|
||
|
||
// 处理 ESC 键关闭
|
||
const handleEscKey = (event) => {
|
||
if (event.key === "Escape") {
|
||
emit("close");
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
document.addEventListener("click", handleOutsideClick, true);
|
||
document.addEventListener("contextmenu", handleOutsideClick, true);
|
||
document.addEventListener("keydown", handleEscKey);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener("click", handleOutsideClick, true);
|
||
document.removeEventListener("contextmenu", handleOutsideClick, true);
|
||
document.removeEventListener("keydown", handleEscKey);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<teleport to="body">
|
||
<transition name="context-menu">
|
||
<div
|
||
v-if="visible"
|
||
ref="menuRef"
|
||
class="context-menu"
|
||
:style="{
|
||
top: `${adjustedPosition.y}px`,
|
||
left: `${adjustedPosition.x}px`,
|
||
}"
|
||
@contextmenu.prevent
|
||
>
|
||
<template v-for="(item, index) in items" :key="index">
|
||
<!-- 分隔线 -->
|
||
<div v-if="item.type === 'divider'" class="context-menu-divider"></div>
|
||
|
||
<!-- 菜单项 -->
|
||
<div
|
||
v-else
|
||
class="context-menu-item"
|
||
:class="{
|
||
disabled: item.disabled,
|
||
danger: item.danger,
|
||
'has-children': item.children && item.children.length > 0,
|
||
hovered: hoveredItem === index,
|
||
}"
|
||
@click="handleItemClick(item, index)"
|
||
@mouseenter="handleItemMouseEnter(item, index, $event)"
|
||
@mousemove="handleItemMouseMove(item, index, $event)"
|
||
@mouseleave="handleItemMouseLeave(item, index)"
|
||
>
|
||
<span class="context-menu-icon" v-if="item.icon">
|
||
<SvgIcon
|
||
:name="item.icon"
|
||
size="14"
|
||
:style="{
|
||
transform: item.inverIcon ? `rotate(90deg)` : 'none',
|
||
}"
|
||
/>
|
||
</span>
|
||
<span class="context-menu-label">{{ item.label }}</span>
|
||
<span class="context-menu-shortcut" v-if="item.shortcut">
|
||
{{ item.shortcut }}
|
||
</span>
|
||
<span class="context-menu-arrow" v-if="item.children && item.children.length > 0">
|
||
<SvgIcon name="CRight" size="12" />
|
||
</span>
|
||
|
||
<!-- 子菜单 -->
|
||
<transition name="context-submenu">
|
||
<div
|
||
v-if="item.children && item.children.length > 0 && hoveredItem === index"
|
||
class="context-submenu"
|
||
:class="{
|
||
'submenu-left': submenuPositions.get(index)?.direction === 'left',
|
||
}"
|
||
@mouseenter="handleSubmenuMouseEnter(index)"
|
||
@mouseleave="handleSubmenuMouseLeave"
|
||
>
|
||
<template v-for="(subItem, subIndex) in item.children" :key="subIndex">
|
||
<!-- 子菜单分隔线 -->
|
||
<div v-if="subItem.type === 'divider'" class="context-menu-divider"></div>
|
||
|
||
<!-- 子菜单项 -->
|
||
<div
|
||
v-else
|
||
class="context-menu-item"
|
||
:class="{
|
||
disabled: subItem.disabled,
|
||
danger: subItem.danger,
|
||
}"
|
||
@click="handleSubItemClick(subItem, index, subIndex)"
|
||
>
|
||
<span class="context-menu-icon" v-if="subItem.icon">
|
||
<SvgIcon
|
||
:name="subItem.icon"
|
||
size="14"
|
||
:style="{
|
||
transform: subItem.inverIcon ? `rotate(90deg)` : 'none',
|
||
}"
|
||
/>
|
||
</span>
|
||
<span class="context-menu-label">{{ subItem.label }}</span>
|
||
<span class="context-menu-shortcut" v-if="subItem.shortcut">
|
||
{{ subItem.shortcut }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
</template>
|
||
|
||
<style scoped lang="less">
|
||
@import "./contextMenu.less";
|
||
</style>
|