Added context menu for easier editing

This commit is contained in:
2025-09-15 21:02:15 +02:00
parent d1ddf5c256
commit 6d4622e109
4 changed files with 315 additions and 126 deletions

View File

@@ -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) {

View File

@@ -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