[FEAT] UI context menu streamline

This commit is contained in:
2026-01-07 16:13:21 +01:00
parent ad28f6a607
commit f9635ba044
6 changed files with 442 additions and 298 deletions

View File

@@ -1,40 +1,19 @@
<template>
<Teleport to="body">
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
<button @click="addSprite" class="w-full px-3 py-1.5 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-plus text-blue-600 dark:text-blue-400 text-xs w-4"></i>
<span>Add sprite</span>
</button>
<button v-if="contextMenuSpriteId" @click="rotateSpriteInMenu(90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
<span>Rotate +90°</span>
</button>
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
<span>Flip horizontal</span>
</button>
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
<span>Flip vertical</span>
</button>
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
<span>Replace sprite</span>
</button>
<button v-if="contextMenuSpriteId" @click="openCopyToFrameModal" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
<span>Copy to frame...</span>
</button>
<button v-if="contextMenuSpriteId" @click="openPixelEditor" class="w-full px-3 py-1.5 text-left hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-paint-brush text-indigo-600 dark:text-indigo-400 text-xs w-4"></i>
<span>Edit in Pixel Editor</span>
</button>
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-3 py-1.5 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-trash text-xs w-4"></i>
<span>{{ selectedSpriteIds.size > 1 ? `Remove ${selectedSpriteIds.size} sprites` : 'Remove sprite' }}</span>
</button>
</div>
<SpriteContextMenu
:is-open="isContextMenuOpen"
:position="contextMenuPosition"
:has-sprite="!!contextMenuSpriteId"
:selected-count="selectedSpriteIds.size"
@add="addSpriteRefined"
@rotate="rotateSpriteInMenu"
@flip="flipSpriteInMenu"
@replace="replaceSprite"
@copy-to-frame="openCopyToFrameModal"
@edit-in-pixel-editor="openPixelEditor"
@remove="removeSprite"
@close="closeContextMenu"
/>
<!-- Copy to Frame Modal -->
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
@@ -60,7 +39,7 @@
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="stopDrag"
@contextmenu.prevent
@contextmenu.prevent.stop
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="onDragLeave"
@@ -187,7 +166,7 @@
</div>
</div>
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChangeRefined" />
</div>
</template>
@@ -199,6 +178,8 @@
import { useFileDrop } from '@/composables/useFileDrop';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import { useContextMenu } from '@/composables/useContextMenu';
import SpriteContextMenu from '@/components/shared/SpriteContextMenu.vue';
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
import type { Layer } from '@/types/sprites';
@@ -289,11 +270,12 @@
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
});
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const contextMenuIndex = ref<number | null>(null);
const contextMenuSpriteId = ref<string | null>(null);
const { isOpen: isContextMenuOpen, position: contextMenuPosition, contextData: contextMenuData, open: openContextMenuBase, close: closeContextMenu } = useContextMenu<{ spriteId?: string; layerId?: string; index?: number }>();
// Computed properties to access context data safely
const contextMenuSpriteId = computed(() => contextMenuData.value?.spriteId || null);
const contextMenuIndex = computed(() => contextMenuData.value?.index ?? null); // Use ?? to allow 0 index
const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
@@ -359,7 +341,7 @@
}
if (!gridContainerRef.value) return;
showContextMenu.value = false;
closeContextMenu();
if ('button' in event && (event as MouseEvent).button === 2) {
event.preventDefault();
@@ -367,8 +349,10 @@
if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
contextMenuIndex.value = findCellAtPosition(pos.x, pos.y)?.index ?? null;
contextMenuSpriteId.value = clickedSprite?.id || null;
const clickedCell = findCellAtPosition(pos.x, pos.y);
const cellIndex = clickedCell?.index ?? null;
const spriteId = clickedSprite?.id || undefined;
if (clickedSprite) {
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
@@ -379,10 +363,10 @@
selectedSpriteIds.value.clear();
}
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
showContextMenu.value = true;
openContextMenuBase(event, {
spriteId: spriteId,
index: cellIndex !== null ? cellIndex : undefined,
});
return;
}
@@ -431,13 +415,12 @@
if (selectedSpriteIds.value.size > 0) {
emit('removeSprites', Array.from(selectedSpriteIds.value));
selectedSpriteIds.value.clear();
showContextMenu.value = false;
contextMenuSpriteId.value = null;
} else if (contextMenuSpriteId.value) {
emit('removeSprite', contextMenuSpriteId.value);
showContextMenu.value = false;
contextMenuSpriteId.value = null;
}
closeContextMenu();
// Note: SpriteContextMenu component will also emit 'close' after action if using emitAndClose,
// but duplicate close calls are harmless.
};
const rotateSpriteInMenu = (angle: number) => {
@@ -448,7 +431,7 @@
} else if (contextMenuSpriteId.value) {
emit('rotateSprite', contextMenuSpriteId.value, angle);
}
showContextMenu.value = false;
closeContextMenu();
};
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
@@ -459,7 +442,7 @@
} else if (contextMenuSpriteId.value) {
emit('flipSprite', contextMenuSpriteId.value, direction);
}
showContextMenu.value = false;
closeContextMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
@@ -477,20 +460,29 @@
if (contextMenuSpriteId.value && fileInput.value) {
replacingSpriteId.value = contextMenuSpriteId.value;
fileInput.value.click();
showContextMenu.value = false;
contextMenuSpriteId.value = null;
closeContextMenu();
}
};
const addSprite = () => {
// Refined addSprite with index caching logic
const pendingAddIndex = ref<number | null>(null);
const addSpriteRefined = () => {
// Capture index
if (contextMenuIndex.value !== null) {
pendingAddIndex.value = contextMenuIndex.value;
} else {
pendingAddIndex.value = null;
}
if (fileInput.value) {
fileInput.value.click();
showContextMenu.value = false;
contextMenuSpriteId.value = null;
closeContextMenu();
}
};
const handleFileChange = (event: Event) => {
// Refined handleFileChange
const handleFileChangeRefined = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
@@ -499,8 +491,8 @@
if (replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file);
} else {
if (contextMenuIndex.value !== null) {
emit('addSprite', file, contextMenuIndex.value);
if (pendingAddIndex.value !== null) {
emit('addSprite', file, pendingAddIndex.value);
} else {
emit('addSprite', file);
}
@@ -510,21 +502,16 @@
}
}
replacingSpriteId.value = null;
contextMenuIndex.value = null;
pendingAddIndex.value = null; // Clear it
input.value = '';
};
const hideContextMenu = () => {
showContextMenu.value = false;
contextMenuSpriteId.value = null;
};
const openCopyToFrameModal = () => {
if (contextMenuSpriteId.value) {
copySpriteId.value = contextMenuSpriteId.value;
copyTargetLayerId.value = props.activeLayerId;
showCopyToFrameModal.value = true;
showContextMenu.value = false;
closeContextMenu();
}
};
@@ -550,8 +537,7 @@
emit('openPixelEditor', props.activeLayerId, frameIndex);
}
}
showContextMenu.value = false;
contextMenuSpriteId.value = null;
closeContextMenu();
}
};

View File

@@ -1,37 +1,24 @@
<template>
<Teleport to="body">
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
<button @click="rotateSpriteInMenu(90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
<span>Rotate +90°</span>
</button>
<button @click="flipSpriteInMenu('horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
<span>Flip Horizontal</span>
</button>
<button @click="flipSpriteInMenu('vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
<span>Flip Vertical</span>
</button>
<button @click="replaceSprite" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
<span>Replace Sprite</span>
</button>
<button @click="openCopyToFrameModal" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
<span>Copy to Frame...</span>
</button>
<button @click="openPixelEditor" class="w-full px-3 py-1.5 text-left hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-paint-brush text-indigo-600 dark:text-indigo-400 text-xs w-4"></i>
<span>Edit in Pixel Editor</span>
</button>
</div>
<SpriteContextMenu
:is-open="isContextMenuOpen"
:position="contextMenuPosition"
:has-sprite="!!contextMenuSpriteId"
@add="addSprite"
@rotate="rotateSpriteInMenu"
@flip="flipSpriteInMenu"
@replace="replaceSprite"
@copy-to-frame="openCopyToFrameModal"
@edit-in-pixel-editor="openPixelEditor"
@remove="removeSprite"
@close="closeContextMenu"
/>
<!-- Copy to Frame Modal -->
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
</Teleport>
<div class="spritesheet-preview w-full h-full" @click="hideContextMenu">
<div class="spritesheet-preview w-full h-full" @click="closeContextMenu">
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
<div class="flex-1 min-w-0 flex flex-col min-h-0">
<div
@@ -99,7 +86,7 @@
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
transform: `rotate(${layer.sprites[currentFrameIndex].rotation || 0}deg) scale(${layer.sprites[currentFrameIndex].flipX ? -1 : 1}, ${layer.sprites[currentFrameIndex].flipY ? -1 : 1})`,
}"
@contextmenu.prevent="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
@contextmenu.prevent.stop="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
draggable="false"
/>
</template>
@@ -303,6 +290,8 @@
import { useAnimationFrames } from '@/composables/useAnimationFrames';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import { useContextMenu } from '@/composables/useContextMenu';
import SpriteContextMenu from '@/components/shared/SpriteContextMenu.vue';
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
const props = defineProps<{
@@ -319,6 +308,7 @@
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
(e: 'replaceSprite', id: string, file: File): void;
(e: 'removeSprite', id: string): void;
(e: 'openPixelEditor', layerId: string, frameIndex: number): void;
}>();
@@ -368,11 +358,6 @@
const showAllSprites = ref(false);
const isDragOver = ref(false);
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const contextMenuSpriteId = ref<string | null>(null);
const contextMenuLayerId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const replacingSpriteId = ref<string | null>(null);
@@ -654,40 +639,34 @@
);
watch(currentFrameIndex, () => {});
/* Context Menu */
const { isOpen: isContextMenuOpen, position: contextMenuPosition, contextData: contextMenuData, open: openContextMenuBase, close: closeContextMenu } = useContextMenu<{ spriteId: string; layerId: string }>();
const openContextMenu = (event: MouseEvent, sprite: Sprite, layerId: string) => {
event.preventDefault();
contextMenuSpriteId.value = sprite.id;
contextMenuLayerId.value = layerId;
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
showContextMenu.value = true;
openContextMenuBase(event, { spriteId: sprite.id, layerId });
};
const hideContextMenu = () => {
showContextMenu.value = false;
contextMenuSpriteId.value = null;
contextMenuLayerId.value = null;
};
const contextMenuSpriteId = computed(() => contextMenuData.value?.spriteId || null);
const contextMenuLayerId = computed(() => contextMenuData.value?.layerId || null);
const rotateSpriteInMenu = (angle: number) => {
if (contextMenuSpriteId.value) {
emit('rotateSprite', contextMenuSpriteId.value, angle);
}
hideContextMenu();
// Context menu closes automatically via component emit
};
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
if (contextMenuSpriteId.value) {
emit('flipSprite', contextMenuSpriteId.value, direction);
}
hideContextMenu();
};
const openCopyToFrameModal = () => {
if (contextMenuSpriteId.value) {
copyTargetLayerId.value = contextMenuLayerId.value || props.activeLayerId;
showCopyToFrameModal.value = true;
showContextMenu.value = false;
closeContextMenu();
}
};
@@ -699,15 +678,15 @@
if (contextMenuSpriteId.value) {
emit('copySpriteToFrame', contextMenuSpriteId.value, targetLayerId, targetFrameIndex);
closeCopyToFrameModal();
contextMenuSpriteId.value = null;
}
// We don't null contextMenuSpriteId here since it's computed now, but closing the menu does the trick effectively for the user flow.
};
const replaceSprite = () => {
if (contextMenuSpriteId.value && fileInput.value) {
replacingSpriteId.value = contextMenuSpriteId.value;
fileInput.value.click();
hideContextMenu();
closeContextMenu();
}
};
@@ -716,8 +695,13 @@
if (input.files && input.files.length > 0) {
const file = input.files[0];
if (file.type.startsWith('image/') && replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file);
if (file.type.startsWith('image/')) {
if (replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file);
} else {
// Add sprite case - use dropSprite emit as it handles adding files to layer/frame
emit('dropSprite', props.activeLayerId, currentFrameIndex.value, [file]);
}
}
}
replacingSpriteId.value = null;
@@ -727,7 +711,21 @@
const openPixelEditor = () => {
if (contextMenuSpriteId.value && contextMenuLayerId.value) {
emit('openPixelEditor', contextMenuLayerId.value, currentFrameIndex.value);
hideContextMenu();
closeContextMenu();
}
};
const addSprite = () => {
if (fileInput.value) {
replacingSpriteId.value = null;
fileInput.value.click();
closeContextMenu();
}
};
const removeSprite = () => {
if (contextMenuSpriteId.value) {
(emit as any)('removeSprite', contextMenuSpriteId.value);
closeContextMenu();
}
};
</script>

View File

@@ -0,0 +1,85 @@
<template>
<Teleport to="body">
<div v-if="isOpen" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: position.x + 'px', top: position.y + 'px' }">
<button @click="emit('add')" class="w-full px-3 py-1.5 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-plus text-blue-600 dark:text-blue-400 text-xs w-4"></i>
<span>Add sprite</span>
</button>
<template v-if="hasSprite">
<button @click="emitAndClose('rotate', 90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
<span>Rotate +90°</span>
</button>
<button @click="emitAndClose('flip', 'horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
<span>Flip horizontal</span>
</button>
<button @click="emitAndClose('flip', 'vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
<span>Flip vertical</span>
</button>
<button @click="emit('replace')" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
<span>Replace sprite</span>
</button>
<button @click="emit('copyToFrame')" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
<span>Copy to frame...</span>
</button>
<button @click="emit('editInPixelEditor')" class="w-full px-3 py-1.5 text-left hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-paint-brush text-indigo-600 dark:text-indigo-400 text-xs w-4"></i>
<span>Edit in Pixel Editor</span>
</button>
<div class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
<button @click="emitAndClose('remove')" class="w-full px-3 py-1.5 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-2 transition-colors text-sm">
<i class="fas fa-trash text-xs w-4"></i>
<span>{{ (selectedCount || 0) > 1 ? `Remove ${selectedCount} sprites` : 'Remove sprite' }}</span>
</button>
</template>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
const props = defineProps<{
isOpen: boolean;
position: { x: number; y: number };
hasSprite: boolean;
selectedCount?: number;
}>();
const emit = defineEmits<{
(e: 'add'): void;
(e: 'rotate', angle: number): void;
(e: 'flip', direction: 'horizontal' | 'vertical'): void;
(e: 'replace'): void;
(e: 'copyToFrame'): void;
(e: 'editInPixelEditor'): void;
(e: 'remove'): void;
(e: 'close'): void;
}>();
const emitAndClose = (event: 'rotate' | 'flip' | 'remove', arg?: any) => {
emit(event as any, arg);
emit('close');
};
const closeOnClickOutside = () => {
if (props.isOpen) {
emit('close');
}
};
onMounted(() => {
window.addEventListener('click', closeOnClickOutside);
window.addEventListener('contextmenu', closeOnClickOutside); // Close on right click elsewhere
});
onUnmounted(() => {
window.removeEventListener('click', closeOnClickOutside);
window.removeEventListener('contextmenu', closeOnClickOutside);
});
</script>

View File

@@ -0,0 +1,32 @@
import { ref } from 'vue';
export interface ContextMenuPosition {
x: number;
y: number;
}
export function useContextMenu<T = any>() {
const isOpen = ref(false);
const position = ref<ContextMenuPosition>({ x: 0, y: 0 });
const contextData = ref<T | null>(null);
const open = (event: MouseEvent, data: T) => {
event.preventDefault();
isOpen.value = true;
position.value = { x: event.clientX, y: event.clientY };
contextData.value = data;
};
const close = () => {
isOpen.value = false;
contextData.value = null;
};
return {
isOpen,
position,
contextData,
open,
close,
};
}

View File

@@ -322,6 +322,7 @@
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
@remove-sprite="removeSprite"
@replace-sprite="replaceSprite"
@open-pixel-editor="openPixelEditor"
/>