[FEAT] UI context menu streamline
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
85
src/components/shared/SpriteContextMenu.vue
Normal file
85
src/components/shared/SpriteContextMenu.vue
Normal 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>
|
||||
32
src/composables/useContextMenu.ts
Normal file
32
src/composables/useContextMenu.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user