369 lines
9.8 KiB
Vue
369 lines
9.8 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>
|