UI and application enhancements

This commit is contained in:
2025-08-09 15:46:41 +02:00
parent 6953a41ca4
commit f8d9d2ae83
7 changed files with 371 additions and 89 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="min-h-screen p-3 sm:p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 transition-colors duration-300">
<div class="max-w-6xl mx-auto">
<div class="max-w-7xl mx-auto">
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-8 gap-2">
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight text-center sm:text-left">Spritesheet generator</h1>
<dark-mode-toggle />
@@ -33,7 +33,7 @@
<input
id="columns"
type="number"
v-model="columns"
v-model.number="columns"
min="1"
max="10"
class="w-20 px-3 py-2 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all text-base"
@@ -108,7 +108,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch, onUnmounted } from 'vue';
import FileUploader from './components/FileUploader.vue';
import SpriteCanvas from './components/SpriteCanvas.vue';
import Modal from './components/utilities/Modal.vue';
@@ -117,8 +117,8 @@
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
import GifFpsModal from './components/GifFpsModal.vue';
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
import { useSettingsStore } from './stores/useSettingsStore';
import GIF from 'gif.js';
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
import JSZip from 'jszip';
interface Sprite {
@@ -142,6 +142,12 @@
const sprites = ref<Sprite[]>([]);
const columns = ref(4);
// Clamp and coerce columns to a safe range [1..10]
watch(columns, val => {
const num = typeof val === 'number' ? val : parseInt(String(val));
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
if (safe !== columns.value) columns.value = safe;
});
const isPreviewModalOpen = ref(false);
const isHelpModalOpen = ref(false);
const isSpritesheetSplitterOpen = ref(false);
@@ -225,7 +231,10 @@
};
const downloadSpritesheet = () => {
if (sprites.value.length === 0) return;
if (sprites.value.length === 0) {
alert('Please upload or import sprites before downloading the spritesheet.');
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@@ -268,9 +277,8 @@
// Preview modal control
const openPreviewModal = () => {
console.log('Opening preview modal');
if (sprites.value.length === 0) {
console.log('No sprites to preview');
alert('Please upload or import sprites to preview an animation.');
return;
}
isPreviewModalOpen.value = true;
@@ -302,7 +310,10 @@
// GIF FPS modal control
const openGifFpsModal = () => {
if (sprites.value.length === 0) return;
if (sprites.value.length === 0) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
isGifFpsModalOpen.value = true;
};
@@ -326,8 +337,8 @@
url,
width: img.width,
height: img.height,
x: spriteFile.x, // Use the position from the splitter
y: spriteFile.y, // Use the position from the splitter
x: 0, // Start at top-left of cell; ignore splitter bounding-box offset for display
y: 0, // Start at top-left of cell; ignore splitter bounding-box offset for display
});
};
img.src = url;
@@ -340,7 +351,10 @@
// Export spritesheet as JSON with base64 images
const exportSpritesheetJSON = async () => {
if (sprites.value.length === 0) return;
if (sprites.value.length === 0) {
alert('Nothing to export. Please add sprites first.');
return;
}
// Create an array to store sprite data with base64 images
const spritesData = await Promise.all(
@@ -426,6 +440,17 @@
// Process each sprite
// Replace current sprites with imported ones
// Revoke existing blob: URLs to avoid memory leaks
if (sprites.value.length) {
sprites.value.forEach(s => {
if (s.url && s.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(s.url);
} catch {}
}
});
}
sprites.value = await Promise.all(
jsonData.sprites.map(async (spriteData: any) => {
return new Promise<Sprite>(resolve => {
@@ -548,7 +573,10 @@
// Download as GIF with specified FPS
const downloadAsGif = (fps: number) => {
if (sprites.value.length === 0) return;
if (sprites.value.length === 0) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
// Find max dimensions
let maxWidth = 0;
@@ -577,7 +605,7 @@
quality: 10,
width: maxWidth,
height: maxHeight,
workerScript: '/gif.worker.js',
workerScript: gifWorkerUrl,
});
// Add each sprite as a frame
@@ -612,9 +640,23 @@
gif.render();
};
// Revoke blob URLs on unmount to avoid memory leaks
onUnmounted(() => {
sprites.value.forEach(s => {
if (s.url && s.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(s.url);
} catch {}
}
});
});
// Download as ZIP with each cell individually
const downloadAsZip = async () => {
if (sprites.value.length === 0) return;
if (sprites.value.length === 0) {
alert('Please upload or import sprites before downloading a ZIP.');
return;
}
// Create a new ZIP file
const zip = new JSZip();
@@ -664,8 +706,10 @@
uint8Array[i] = binaryData.charCodeAt(i);
}
// Add to ZIP file
zip.file(`sprite_${index + 1}.png`, uint8Array);
// Add to ZIP file with clearer naming
const baseName = sprite.file?.name ? sprite.file.name.replace(/\s+/g, '_') : `sprite_${index + 1}.png`;
const name = `${index + 1}_${baseName}`;
zip.file(name, uint8Array);
});
// Generate ZIP file