EDMS/components/dms/ui/ContextMenu.vue
2025-06-05 14:57:08 +08:00

356 lines
8.0 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
x: {
type: Number,
default: 0
},
y: {
type: Number,
default: 0
},
items: {
type: Array,
default: () => []
},
target: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'item-click']);
// Menu state
const menuRef = ref(null);
const menuWidth = ref(220);
const menuHeight = ref(0);
// Computed position to keep menu within viewport
const menuPosition = computed(() => {
if (!props.visible) return { x: 0, y: 0 };
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = props.x;
let y = props.y;
// Adjust x position if menu would overflow right edge
if (x + menuWidth.value > viewportWidth) {
x = viewportWidth - menuWidth.value - 10;
}
// Adjust y position if menu would overflow bottom edge
if (y + menuHeight.value > viewportHeight) {
y = viewportHeight - menuHeight.value - 10;
}
return { x, y };
});
// Menu items with icons and keyboard shortcuts
const getMenuItems = computed(() => {
if (!props.target) return [];
const baseItems = [
{
id: 'open',
label: 'Open',
icon: 'mdi:folder-open',
shortcut: 'Enter',
enabled: true,
default: true
},
{
id: 'open-new-window',
label: 'Open in new window',
icon: 'mdi:open-in-new',
shortcut: 'Ctrl+Enter',
enabled: true
},
{ type: 'separator' },
{
id: 'cut',
label: 'Cut',
icon: 'mdi:content-cut',
shortcut: 'Ctrl+X',
enabled: true
},
{
id: 'copy',
label: 'Copy',
icon: 'mdi:content-copy',
shortcut: 'Ctrl+C',
enabled: true
},
{
id: 'paste',
label: 'Paste',
icon: 'mdi:content-paste',
shortcut: 'Ctrl+V',
enabled: false // Would be enabled if clipboard has content
},
{ type: 'separator' },
{
id: 'rename',
label: 'Rename',
icon: 'mdi:pencil',
shortcut: 'F2',
enabled: true
},
{
id: 'delete',
label: 'Delete',
icon: 'mdi:delete',
shortcut: 'Del',
enabled: true,
destructive: true
},
{ type: 'separator' },
{
id: 'share',
label: 'Share',
icon: 'mdi:share',
enabled: true,
submenu: [
{
id: 'share-link',
label: 'Copy link',
icon: 'mdi:link'
},
{
id: 'share-email',
label: 'Send via email',
icon: 'mdi:email'
},
{
id: 'share-teams',
label: 'Share with teams',
icon: 'mdi:account-group'
}
]
},
{
id: 'download',
label: 'Download',
icon: 'mdi:download',
shortcut: 'Ctrl+S',
enabled: props.target?.type === 'file'
},
{ type: 'separator' },
{
id: 'properties',
label: 'Properties',
icon: 'mdi:information',
shortcut: 'Alt+Enter',
enabled: true
}
];
// Filter items based on target type and context
return baseItems.filter(item => {
if (item.type === 'separator') return true;
// Hide download for folders
if (item.id === 'download' && props.target?.type === 'folder') {
return false;
}
return true;
});
});
// Handle item click
const handleItemClick = (item, event) => {
if (!item.enabled || item.type === 'separator') return;
event.stopPropagation();
emit('item-click', { item, target: props.target });
closeMenu();
};
// Close menu
const closeMenu = () => {
emit('close');
};
// Handle clicks outside menu
const handleClickOutside = (event) => {
if (menuRef.value && !menuRef.value.contains(event.target)) {
closeMenu();
}
};
// Handle keyboard navigation
const handleKeydown = (event) => {
if (!props.visible) return;
switch (event.key) {
case 'Escape':
closeMenu();
break;
case 'ArrowDown':
// Focus next item
event.preventDefault();
break;
case 'ArrowUp':
// Focus previous item
event.preventDefault();
break;
case 'Enter':
// Activate focused item
event.preventDefault();
break;
}
};
// Calculate menu height
const calculateMenuHeight = () => {
nextTick(() => {
if (menuRef.value) {
menuHeight.value = menuRef.value.offsetHeight;
}
});
};
// Lifecycle hooks
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeydown);
calculateMenuHeight();
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeydown);
});
// Watch for visibility changes
watch(() => props.visible, (visible) => {
if (visible) {
calculateMenuHeight();
}
});
</script>
<template>
<Teleport to="body">
<div
v-if="visible"
ref="menuRef"
:style="{
position: 'fixed',
left: menuPosition.x + 'px',
top: menuPosition.y + 'px',
zIndex: 9999
}"
class="context-menu bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600
rounded-lg shadow-lg min-w-[220px] max-w-[280px] py-1
animate-in fade-in zoom-in-95 duration-150"
@click.stop
>
<template v-for="(item, index) in getMenuItems" :key="index">
<!-- Separator -->
<div
v-if="item.type === 'separator'"
class="h-px bg-gray-200 dark:bg-gray-600 my-1"
></div>
<!-- Menu Item -->
<div
v-else
@click="handleItemClick(item, $event)"
:class="[
'flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors',
item.enabled
? item.destructive
? 'text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
: item.default
? 'text-gray-900 dark:text-gray-100 hover:bg-blue-50 dark:hover:bg-blue-900/20 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
]"
>
<div class="flex items-center space-x-3">
<Icon
:name="item.icon"
:class="[
'w-4 h-4',
item.enabled
? item.destructive
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-gray-400'
: 'text-gray-300 dark:text-gray-600'
]"
/>
<span>{{ item.label }}</span>
</div>
<!-- Keyboard shortcut -->
<span
v-if="item.shortcut"
class="text-xs text-gray-400 dark:text-gray-500 ml-8"
>
{{ item.shortcut }}
</span>
<!-- Submenu indicator -->
<Icon
v-if="item.submenu"
name="mdi:chevron-right"
class="w-4 h-4 text-gray-400 ml-2"
/>
</div>
</template>
</div>
</Teleport>
</template>
<style scoped>
.context-menu {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
backdrop-filter: blur(10px);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.05);
}
/* Animation classes for smooth entry */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes zoom-in-95 {
from { transform: scale(0.95); }
to { transform: scale(1); }
}
.animate-in {
animation-fill-mode: both;
}
.fade-in {
animation-name: fade-in;
}
.zoom-in-95 {
animation-name: zoom-in-95;
}
.duration-150 {
animation-duration: 150ms;
}
/* Hover effects */
.context-menu > div:not(.h-px):hover {
transform: translateX(1px);
}
</style>