generated from corrad-software/corrad-af-2024
356 lines
8.0 KiB
Vue
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> |