Added context menu for easier editing
This commit is contained in:
102
src/App.vue
102
src/App.vue
@@ -103,7 +103,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" />
|
||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,25 +122,11 @@
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md mx-4 shadow-xl border border-gray-600">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">💬</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Help us improve!
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||
We'd love to hear your thoughts about the spritesheet generator. Would you like to share your feedback?
|
||||
</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Help us improve!</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">We'd love to hear your thoughts about the spritesheet generator. Would you like to share your feedback?</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
@click="handleFeedbackPopupResponse(false)"
|
||||
class="px-4 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Maybe later
|
||||
</button>
|
||||
<button
|
||||
@click="handleFeedbackPopupResponse(true)"
|
||||
class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
<button @click="handleFeedbackPopupResponse(false)" class="px-4 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors">Maybe later</button>
|
||||
<button @click="handleFeedbackPopupResponse(true)" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors">Share feedback</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -624,6 +610,84 @@
|
||||
sprites.value = newSprites;
|
||||
};
|
||||
|
||||
const removeSprite = (id: string) => {
|
||||
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
if (spriteIndex !== -1) {
|
||||
const sprite = sprites.value[spriteIndex];
|
||||
// Revoke the blob URL to prevent memory leaks
|
||||
if (sprite.url && sprite.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(sprite.url);
|
||||
} catch {}
|
||||
}
|
||||
// Remove the sprite from the array
|
||||
sprites.value.splice(spriteIndex, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const replaceSprite = (id: string, file: File) => {
|
||||
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
|
||||
if (spriteIndex !== -1) {
|
||||
const oldSprite = sprites.value[spriteIndex];
|
||||
|
||||
// Revoke the old blob URL to prevent memory leaks
|
||||
if (oldSprite.url && oldSprite.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(oldSprite.url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Create new sprite from the replacement file
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const newSprite: Sprite = {
|
||||
id: oldSprite.id, // Keep the same ID
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: oldSprite.x, // Keep the same position
|
||||
y: oldSprite.y,
|
||||
};
|
||||
// Create a new array to trigger Vue's reactivity
|
||||
const newSprites = [...sprites.value];
|
||||
newSprites[spriteIndex] = newSprite;
|
||||
sprites.value = newSprites;
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load replacement image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const newSprite: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
sprites.value = [...sprites.value, newSprite];
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
// Download as GIF with specified FPS
|
||||
const downloadAsGif = (fps: number) => {
|
||||
if (sprites.value.length === 0) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<div class="p-2 bg-cyan-600 rounded w-full my-4">
|
||||
<p>Developer's tip: Right click a sprite to open the context menu and add, replace or remove sprites.</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
||||
@@ -33,9 +36,40 @@
|
||||
</div>
|
||||
|
||||
<div class="canvas-container touch-manipulation" :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }">
|
||||
<canvas ref="canvasRef" @mousedown="startDrag" @mousemove="drag" @mouseup="stopDrag" @mouseleave="stopDrag" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="stopDrag" class="w-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"></canvas>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
@contextmenu.prevent
|
||||
class="w-full"
|
||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50 py-1" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||
<button @click="addSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
Add sprite
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
Replace sprite
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
<i class="fas fa-trash"></i>
|
||||
Remove sprite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input for replace functionality -->
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,6 +100,9 @@
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
(e: 'updateSpriteCell', id: string, newIndex: number): void;
|
||||
(e: 'removeSprite', id: string): void;
|
||||
(e: 'replaceSprite', id: string, file: File): void;
|
||||
(e: 'addSprite', file: File): void;
|
||||
}>();
|
||||
|
||||
// Get settings from store
|
||||
@@ -90,6 +127,12 @@
|
||||
const highlightCell = ref<CellPosition | null>(null);
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
const contextMenuSpriteId = ref<string | null>(null);
|
||||
const replacingSpriteId = ref<string | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const spritePositions = computed(() => {
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
@@ -146,6 +189,27 @@
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
|
||||
// Hide context menu if open
|
||||
showContextMenu.value = false;
|
||||
|
||||
// Handle right-click for context menu
|
||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||
event.preventDefault();
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||
contextMenuX.value = event.clientX;
|
||||
contextMenuY.value = event.clientY;
|
||||
showContextMenu.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||
|
||||
@@ -312,6 +376,60 @@
|
||||
}
|
||||
};
|
||||
|
||||
const removeSprite = () => {
|
||||
if (contextMenuSpriteId.value) {
|
||||
emit('removeSprite', contextMenuSpriteId.value);
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const replaceSprite = () => {
|
||||
if (contextMenuSpriteId.value && fileInput.value) {
|
||||
// Store the sprite ID separately so it persists after context menu closes
|
||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||
fileInput.value.click();
|
||||
// Hide context menu immediately since we've stored the ID
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const addSprite = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
// Hide context menu immediately
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
if (input.files && input.files.length > 0) {
|
||||
const file = input.files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
if (replacingSpriteId.value) {
|
||||
emit('replaceSprite', replacingSpriteId.value, file);
|
||||
} else {
|
||||
// Adding new sprite
|
||||
emit('addSprite', file);
|
||||
}
|
||||
} else {
|
||||
alert('Please select an image file.');
|
||||
}
|
||||
}
|
||||
// Clean up after file selection
|
||||
replacingSpriteId.value = null;
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const hideContextMenu = () => {
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
// Only prevent default when we're actually dragging
|
||||
if (isDragging.value) {
|
||||
@@ -466,10 +584,14 @@
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
|
||||
// Hide context menu when clicking elsewhere
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
document.removeEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
|
||||
Reference in New Issue
Block a user