Files
aida_front/src/component/Canvas/CanvasEditor/components/LayersPanel/ContextMenu.vue
2025-07-14 01:00:23 +08:00

346 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>