Files
aida_front/src/component/Canvas/CanvasEditor/components/LayersPanel/ContextMenu.vue

346 lines
9.4 KiB
Vue
Raw Normal View History

2025-06-18 11:05:23 +08:00
<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) {
2025-06-18 11:05:23 +08:00
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>
2025-06-18 11:05:23 +08:00
<!-- 菜单项 -->
<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">
2025-06-18 11:05:23 +08:00
<SvgIcon name="CRight" size="12" />
</span>
<!-- 子菜单 -->
<transition name="context-submenu">
<div
v-if="item.children && item.children.length > 0 && hoveredItem === index"
2025-06-18 11:05:23 +08:00
class="context-submenu"
:class="{
'submenu-left': submenuPositions.get(index)?.direction === 'left',
2025-06-18 11:05:23 +08:00
}"
@mouseenter="handleSubmenuMouseEnter(index)"
@mouseleave="handleSubmenuMouseLeave"
>
<template v-for="(subItem, subIndex) in item.children" :key="subIndex">
2025-06-18 11:05:23 +08:00
<!-- 子菜单分隔线 -->
<div v-if="subItem.type === 'divider'" class="context-menu-divider"></div>
2025-06-18 11:05:23 +08:00
<!-- 子菜单项 -->
<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',
2025-06-18 11:05:23 +08:00
}"
/>
</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>