Compare commits

...

4 Commits

Author SHA1 Message Date
d571cb51cb Clean 2025-11-18 19:49:25 +01:00
c1620d6bbb Reused logic from earlier made composables 2025-11-18 19:45:53 +01:00
6afbd42794 [FEAT] Began working on cleaning code 2025-11-18 19:40:44 +01:00
f97879b642 [FEAT] Improved modals 2025-11-18 19:28:32 +01:00
11 changed files with 676 additions and 866 deletions

View File

@@ -1,6 +1,6 @@
# spritesheetgen
# Spritesheet generator
This template should help get you started developing with Vue 3 in Vite.
This repository contains the source code of [Spritesheetgenerator](https://spritesheetgenerator.online)
## Recommended IDE Setup

68
package-lock.json generated
View File

@@ -1949,9 +1949,9 @@
}
},
"node_modules/@tsconfig/node22": {
"version": "22.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.4.tgz",
"integrity": "sha512-GI0gtqW+8Bz9UMOsOO1z3w1ebspOkVjSyeJ18r96LW82OOZenpzWXti/+f/MgPy1hqXTIRXn8QpGIOYHgKkTnQ==",
"version": "22.0.5",
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz",
"integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==",
"dev": true,
"license": "MIT"
},
@@ -2148,23 +2148,23 @@
}
},
"node_modules/@vue/devtools-api": {
"version": "7.7.8",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.8.tgz",
"integrity": "sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw==",
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.8"
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-core": {
"version": "7.7.8",
"resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.7.8.tgz",
"integrity": "sha512-EVLQTYML/v77JFA3Q8zvVANCvEv1WtG0TMo+HQR5eZ7PpEzSmVbEcBp2C1/OXyn8EJO4mHEeParMLpp43prUOw==",
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.7.9.tgz",
"integrity": "sha512-48jrBSwG4GVQRvVeeXn9p9+dlx+ISgasM7SxZZKczseohB0cBz+ITKr4YbLWjmJdy45UHL7UMPlR4Y0CWTRcSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.8",
"@vue/devtools-shared": "^7.7.8",
"@vue/devtools-kit": "^7.7.9",
"@vue/devtools-shared": "^7.7.9",
"mitt": "^3.0.1",
"nanoid": "^5.1.0",
"pathe": "^2.0.3",
@@ -2194,12 +2194,12 @@
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.8",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.8.tgz",
"integrity": "sha512-4Y8op+AoxOJhB9fpcEF6d5vcJXWKgHxC3B0ytUB8zz15KbP9g9WgVzral05xluxi2fOeAy6t140rdQ943GcLRQ==",
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.8",
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
@@ -2209,9 +2209,9 @@
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.8",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.8.tgz",
"integrity": "sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA==",
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
@@ -2377,9 +2377,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.28",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz",
"integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==",
"version": "2.8.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz",
"integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2587,9 +2587,9 @@
}
},
"node_modules/csstype": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz",
"integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/de-indent": {
@@ -2670,9 +2670,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.254",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz",
"integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==",
"version": "1.5.255",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.255.tgz",
"integrity": "sha512-Z9oIp4HrFF/cZkDPMpz2XSuVpc1THDpT4dlmATFlJUIBVCy9Vap5/rIXsASP1CscBacBqhabwh8vLctqBwEerQ==",
"dev": true,
"license": "ISC"
},
@@ -4817,15 +4817,15 @@
}
},
"node_modules/vite-plugin-vue-devtools": {
"version": "7.7.8",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.8.tgz",
"integrity": "sha512-04jowFsal5f9Gbso0X5Ff/mtvik7VP/PBYcKDCQHnTLH0x+juWSj7v1QJfDtXnWrrxU7/yrljEP8KZTm4skvkg==",
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.9.tgz",
"integrity": "sha512-08DvePf663SxqLFJeMVNW537zzVyakp9KIrI2K7lwgaTqA5R/ydN/N2K8dgZO34tg/Qmw0ch84fOKoBtCEdcGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-core": "^7.7.8",
"@vue/devtools-kit": "^7.7.8",
"@vue/devtools-shared": "^7.7.8",
"@vue/devtools-core": "^7.7.9",
"@vue/devtools-kit": "^7.7.9",
"@vue/devtools-shared": "^7.7.9",
"execa": "^9.5.2",
"sirv": "^3.0.1",
"vite-plugin-inspect": "0.8.9",

View File

@@ -1,5 +1,8 @@
All notable changes to this project will be documented in this file.
## [1.6.0] - 2025-11-18
- Improved animation preview modal
## [1.5.0] - 2025-11-17
- Show offset values in sprite cells and in preview modal
- Modal stays on correct position

View File

@@ -135,7 +135,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { ref, onMounted } from 'vue';
import FileUploader from './components/FileUploader.vue';
import SpriteCanvas from './components/SpriteCanvas.vue';
import Modal from './components/utilities/Modal.vue';
@@ -145,37 +145,13 @@
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
import GifFpsModal from './components/GifFpsModal.vue';
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
import GIF from 'gif.js';
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
import JSZip from 'jszip';
import { useSprites } from './composables/useSprites';
import { useExport } from './composables/useExport';
import type { SpriteFile } from './types/sprites';
interface Sprite {
id: string;
file: File;
img: HTMLImageElement;
url: string;
width: number;
height: number;
x: number;
y: number;
}
const { sprites, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites } = useSprites();
interface SpriteFile {
file: File;
x: number;
y: number;
width: number;
height: number;
}
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 { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExport(sprites, columns);
const isPreviewModalOpen = ref(false);
const isHelpModalOpen = ref(false);
const isFeedbackModalOpen = ref(false);
@@ -186,126 +162,41 @@
const spritesheetImageFile = ref<File | null>(null);
const showFeedbackPopup = ref(false);
const handleSpritesUpload = (files: File[]) => {
// Check if any of the files is a JSON file
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
// If it's a JSON file, try to import it
importSpritesheetJSON(jsonFile);
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
return;
}
// Check if it's a single image file that might be a spritesheet
if (files.length === 1 && files[0].type.startsWith('image/')) {
const file = files[0];
const url = URL.createObjectURL(file);
// Load the image to check its dimensions
const img = new Image();
img.onload = () => {
// Ask the user if they want to split the spritesheet
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
// Open the spritesheet splitter
spritesheetImageUrl.value = url;
spritesheetImageFile.value = file;
isSpritesheetSplitterOpen.value = true;
return;
}
// If the user doesn't want to split or it's not large enough, process as a single sprite
processImageFiles([file]);
};
img.src = url;
return;
}
// Process multiple image files normally
processImageFiles(files);
};
// Extract the image processing logic to a separate function for reuse
const processImageFiles = (files: File[]) => {
Promise.all(
files.map(file => {
return new Promise<Sprite>(resolve => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
resolve({
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
});
};
img.src = url;
});
})
).then(newSprites => {
sprites.value = [...sprites.value, ...newSprites];
});
};
const updateSpritePosition = (id: string, x: number, y: number) => {
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
if (spriteIndex !== -1) {
// Ensure integer positions for pixel-perfect rendering
sprites.value[spriteIndex].x = Math.floor(x);
sprites.value[spriteIndex].y = Math.floor(y);
}
};
const downloadSpritesheet = () => {
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');
if (!ctx) return;
// Find max dimensions
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
if (sprite.width > maxWidth) maxWidth = sprite.width;
if (sprite.height > maxHeight) maxHeight = sprite.height;
});
// Set canvas size
const rows = Math.ceil(sprites.value.length / columns.value);
canvas.width = maxWidth * columns.value;
canvas.height = maxHeight * rows;
// Disable image smoothing for pixel-perfect rendering
ctx.imageSmoothingEnabled = false;
// Draw sprites with integer positions
sprites.value.forEach((sprite, index) => {
const col = index % columns.value;
const row = Math.floor(index / columns.value);
const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight);
ctx.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
});
// Create download link with PNG format
const link = document.createElement('a');
link.download = 'spritesheet.png';
link.href = canvas.toDataURL('image/png', 1.0); // Use maximum quality
link.click();
};
// Preview modal control
const openPreviewModal = () => {
if (sprites.value.length === 0) {
alert('Please upload or import sprites to preview an animation.');
@@ -318,7 +209,6 @@
isPreviewModalOpen.value = false;
};
// Help modal control
const openHelpModal = () => {
isHelpModalOpen.value = true;
};
@@ -327,7 +217,6 @@
isHelpModalOpen.value = false;
};
// Feedback modal control
const openFeedbackModal = () => {
isFeedbackModalOpen.value = true;
};
@@ -336,10 +225,8 @@
isFeedbackModalOpen.value = false;
};
// Spritesheet splitter modal control
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
// Clean up the URL object to prevent memory leaks
if (spritesheetImageUrl.value) {
URL.revokeObjectURL(spritesheetImageUrl.value);
spritesheetImageUrl.value = '';
@@ -347,7 +234,6 @@
spritesheetImageFile.value = null;
};
// GIF FPS modal control
const openGifFpsModal = () => {
if (sprites.value.length === 0) {
alert('Please upload or import sprites before generating a GIF.');
@@ -360,488 +246,37 @@
isGifFpsModalOpen.value = false;
};
// Handle the split spritesheet result
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
// Process sprite files with their positions
Promise.all(
spriteFiles.map(spriteFile => {
return new Promise<Sprite>(resolve => {
const url = URL.createObjectURL(spriteFile.file);
const img = new Image();
img.onload = () => {
resolve({
id: crypto.randomUUID(),
file: spriteFile.file,
img,
url,
width: img.width,
height: img.height,
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;
});
})
).then(newSprites => {
sprites.value = [...sprites.value, ...newSprites];
});
processImageFiles(spriteFiles.map(s => s.file));
};
// Export spritesheet as JSON with base64 images
const exportSpritesheetJSON = async () => {
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(
sprites.value.map(async (sprite, index) => {
// Create a canvas for each sprite to get its base64 data
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Set canvas size to match the sprite
canvas.width = sprite.width;
canvas.height = sprite.height;
// Draw the sprite
ctx.drawImage(sprite.img, 0, 0);
// Get base64 data
const base64Data = canvas.toDataURL('image/png');
return {
id: sprite.id,
width: sprite.width,
height: sprite.height,
x: sprite.x,
y: sprite.y,
base64: base64Data,
name: sprite.file.name,
};
})
);
// Create JSON object with all necessary data
const jsonData = {
columns: columns.value,
sprites: spritesData.filter(Boolean), // Remove any null values
};
// Convert to JSON string
const jsonString = JSON.stringify(jsonData, null, 2);
// Create download link
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'spritesheet.json';
link.href = url;
link.click();
// Clean up
URL.revokeObjectURL(url);
};
// Open file dialog for JSON import
const openJSONImportDialog = () => {
jsonFileInput.value?.click();
};
// Handle JSON file selection
const handleJSONFileChange = (event: Event) => {
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
const jsonFile = input.files[0];
importSpritesheetJSON(jsonFile);
// Reset input value so uploading the same file again will trigger the event
if (jsonFileInput.value) jsonFileInput.value.value = '';
}
};
// Import spritesheet from JSON
const importSpritesheetJSON = async (jsonFile: File) => {
try {
const jsonText = await jsonFile.text();
const jsonData = JSON.parse(jsonText);
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) {
throw new Error('Invalid JSON format: missing sprites array');
}
// Set columns if available
if (jsonData.columns && typeof jsonData.columns === 'number') {
columns.value = jsonData.columns;
}
// 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 => {
// Create image from base64
const img = new Image();
img.onload = () => {
// Create a file from the base64 data
const byteString = atob(spriteData.base64.split(',')[1]);
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeType });
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
const file = new File([blob], fileName, { type: mimeType });
resolve({
id: spriteData.id || crypto.randomUUID(),
file,
img,
url: spriteData.base64,
width: spriteData.width,
height: spriteData.height,
x: spriteData.x || 0,
y: spriteData.y || 0,
});
};
img.src = spriteData.base64;
});
})
);
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
// Add new alignment function
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
if (sprites.value.length === 0) return;
// Find max dimensions for the current column layout
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
});
sprites.value = sprites.value.map((sprite, index) => {
let x = sprite.x;
let y = sprite.y;
switch (position) {
case 'left':
x = 0;
break;
case 'center':
x = Math.floor((maxWidth - sprite.width) / 2);
break;
case 'right':
x = Math.floor(maxWidth - sprite.width);
break;
case 'top':
y = 0;
break;
case 'middle':
y = Math.floor((maxHeight - sprite.height) / 2);
break;
case 'bottom':
y = Math.floor(maxHeight - sprite.height);
break;
}
// Ensure integer positions for pixel-perfect rendering
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
});
// Force redraw of the preview canvas
setTimeout(() => {
const event = new Event('forceRedraw');
window.dispatchEvent(event);
}, 0);
};
const updateSpriteCell = (id: string, newIndex: number) => {
// Find the current index of the sprite
const currentIndex = sprites.value.findIndex(sprite => sprite.id === id);
if (currentIndex === -1) return;
// If we're trying to move to the same position, do nothing
if (currentIndex === newIndex) return;
// Create a copy of the sprites array
const newSprites = [...sprites.value];
// Perform a swap between the two positions
if (newIndex < sprites.value.length) {
// Get references to both sprites
const movingSprite = { ...newSprites[currentIndex] };
const targetSprite = { ...newSprites[newIndex] };
// Swap them
newSprites[currentIndex] = targetSprite;
newSprites[newIndex] = movingSprite;
} else {
// If dragging to an empty cell (beyond the array length)
// Use the original reordering logic
const [movedSprite] = newSprites.splice(currentIndex, 1);
newSprites.splice(newIndex, 0, movedSprite);
}
// Update the sprites array
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);
if (jsonFileInput.value) jsonFileInput.value.value = '';
}
};
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;
};
const addSpriteWithResize = (file: File) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
// Find current max dimensions
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
});
// Create new sprite
const newSprite: Sprite = {
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
};
// Calculate new max dimensions after adding the new sprite
const newMaxWidth = Math.max(maxWidth, img.width);
const newMaxHeight = Math.max(maxHeight, img.height);
// Resize existing sprites if the new image is larger
if (img.width > maxWidth || img.height > maxHeight) {
// Update all existing sprites to center them in the new larger cells
sprites.value = sprites.value.map(sprite => {
let newX = sprite.x;
let newY = sprite.y;
// Adjust x position if width increased
if (img.width > maxWidth) {
const widthDiff = newMaxWidth - maxWidth;
// Try to keep the sprite in the same relative position
const relativeX = maxWidth > 0 ? sprite.x / maxWidth : 0;
newX = Math.floor(relativeX * newMaxWidth);
// Make sure it doesn't go out of bounds
newX = Math.max(0, Math.min(newX, newMaxWidth - sprite.width));
}
// Adjust y position if height increased
if (img.height > maxHeight) {
const heightDiff = newMaxHeight - maxHeight;
const relativeY = maxHeight > 0 ? sprite.y / maxHeight : 0;
newY = Math.floor(relativeY * newMaxHeight);
newY = Math.max(0, Math.min(newY, newMaxHeight - sprite.height));
}
return { ...sprite, x: newX, y: newY };
});
}
// Add the new sprite
sprites.value = [...sprites.value, newSprite];
// Force redraw of the canvas
setTimeout(() => {
const event = new Event('forceRedraw');
window.dispatchEvent(event);
}, 0);
};
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) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
// Find max dimensions
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
if (sprite.width > maxWidth) maxWidth = sprite.width;
if (sprite.height > maxHeight) maxHeight = sprite.height;
});
// Create a canvas for rendering frames
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to just fit one sprite cell
canvas.width = maxWidth;
canvas.height = maxHeight;
// Disable image smoothing for pixel-perfect rendering
ctx.imageSmoothingEnabled = false;
// Create GIF encoder
const gif = new GIF({
workers: 2,
quality: 10,
width: maxWidth,
height: maxHeight,
workerScript: gifWorkerUrl,
});
// Add each sprite as a frame
sprites.value.forEach(sprite => {
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw grid background (cell)
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, maxWidth, maxHeight);
// Draw sprite
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
// Add frame to GIF
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
});
// Generate GIF
gif.on('finished', (blob: Blob) => {
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'animation.gif';
link.href = url;
link.click();
// Clean up
URL.revokeObjectURL(url);
});
gif.render();
};
// Check for one-time feedback popup on mount
onMounted(() => {
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
if (!hasShownFeedbackPopup) {
// Show popup after a short delay to let the page load
setTimeout(() => {
showFeedbackPopup.value = true;
}, 3000);
}
});
// Handle feedback popup response
const handleFeedbackPopupResponse = (showModal: boolean) => {
showFeedbackPopup.value = false;
localStorage.setItem('hasShownFeedbackPopup', 'true');
@@ -850,90 +285,4 @@
openFeedbackModal();
}
};
// 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) {
alert('Please upload or import sprites before downloading a ZIP.');
return;
}
// Create a new ZIP file
const zip = new JSZip();
// Find max dimensions
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
if (sprite.width > maxWidth) maxWidth = sprite.width;
if (sprite.height > maxHeight) maxHeight = sprite.height;
});
// Create a canvas for rendering individual sprites
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to just fit one sprite cell
canvas.width = maxWidth;
canvas.height = maxHeight;
// Disable image smoothing for pixel-perfect rendering
ctx.imageSmoothingEnabled = false;
// Add each sprite as an individual file
sprites.value.forEach((sprite, index) => {
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw grid background (cell)
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, maxWidth, maxHeight);
// Draw sprite
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
// Convert to PNG data URL
const dataURL = canvas.toDataURL('image/png');
// Convert data URL to binary data
const binaryData = atob(dataURL.split(',')[1]);
const arrayBuffer = new ArrayBuffer(binaryData.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < binaryData.length; i++) {
uint8Array[i] = binaryData.charCodeAt(i);
}
// 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
const content = await zip.generateAsync({ type: 'blob' });
// Create download link
const url = URL.createObjectURL(content);
const link = document.createElement('a');
link.download = 'sprites.zip';
link.href = url;
link.click();
// Clean up
URL.revokeObjectURL(url);
};
</script>

View File

@@ -97,15 +97,8 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed, onUnmounted } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
interface Sprite {
id: string;
img: HTMLImageElement;
width: number;
height: number;
x: number;
y: number;
}
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from '@/composables/useSprites';
interface CellPosition {
col: number;
@@ -188,24 +181,12 @@
const lastMaxHeight = ref(1);
const calculateMaxDimensions = () => {
let maxWidth = 0;
let maxHeight = 0;
props.sprites.forEach(sprite => {
const img = sprite.img as HTMLImageElement | undefined;
const w = Math.max(0, sprite.width || (img ? img.naturalWidth || img.width || 0 : 0));
const h = Math.max(0, sprite.height || (img ? img.naturalHeight || img.height || 0 : 0));
maxWidth = Math.max(maxWidth, w);
maxHeight = Math.max(maxHeight, h);
});
// Keep dimensions at least as large as last known to prevent temporary collapse during loading
maxWidth = Math.max(1, maxWidth, lastMaxWidth.value);
maxHeight = Math.max(1, maxHeight, lastMaxHeight.value);
// Use shared utility for max dimensions, then apply local caching to avoid visual collapse
const base = getMaxDimensions(props.sprites);
const maxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
const maxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
lastMaxWidth.value = maxWidth;
lastMaxHeight.value = maxHeight;
return { maxWidth, maxHeight };
};

View File

@@ -1,113 +1,10 @@
<template>
<div class="spritesheet-preview w-full">
<!-- Controls Container -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
<!-- Playback Controls -->
<div class="flex items-center gap-2">
<div class="space-y-2">
<button @click="togglePlayback" class="flex items-center gap-1.5 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors w-full cursor-pointer">
<span v-if="isPlaying" class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
</svg>
<span class="hidden sm:inline">Pause</span>
</span>
<span v-else class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
</svg>
<span class="hidden sm:inline">Play</span>
</span>
</button>
<div class="flex items-center gap-1">
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 w-full cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 mx-auto dark:text-gray-200">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(90 12 12)" />
</svg>
</button>
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 w-full cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 mx-auto dark:text-gray-200">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(-90 12 12)" />
</svg>
</button>
</div>
<!-- Checkboxes -->
<div class="flex flex-wrap gap-4 items-center">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="isDraggable" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm whitespace-nowrap dark:text-gray-200">Reposition</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm whitespace-nowrap dark:text-gray-200">Compare sprites</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
<span class="text-sm whitespace-nowrap dark:text-gray-200">Pixel perfect</span>
</label>
</div>
</div>
</div>
<!-- Frame Controls -->
<div class="flex-1 min-w-[200px] space-y-6">
<!-- Frame Navigation -->
<div class="flex items-center gap-2">
<span class="text-sm font-medium w-30 dark:text-gray-200">Frame {{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
</div>
<!-- FPS Control -->
<div class="flex items-center gap-2">
<span class="text-sm font-medium w-30 dark:text-gray-200">FPS: {{ fps }}</span>
<input type="range" min="1" max="60" v-model.number="fps" class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
</div>
<!-- Zoom Control -->
<div class="flex items-center gap-2">
<span class="text-sm font-medium w-30 dark:text-gray-200">{{ Math.round(zoom * 100) }}%</span>
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
</div>
</div>
</div>
<div v-if="showAllSprites" class="w-full mt-3">
<div class="flex items-center mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-200">Select visible frames:</label>
<div class="ml-auto flex gap-2">
<button @click="showAllFrames" class="px-2 py-1 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors">Show All</button>
<button @click="hideAllFrames" class="px-2 py-1 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors">Hide All</button>
</div>
</div>
<div class="w-full rounded-md border border-gray-300 dark:border-gray-600 shadow-sm focus-within:ring-1 focus-within:ring-blue-500 focus-within:border-blue-500 dark:bg-gray-800">
<div class="max-h-[200px] overflow-y-auto">
<div class="grid grid-cols-2 gap-2">
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer" @click="toggleHiddenFrame(index)">
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden">
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
<span class="text-sm dark:text-gray-200">Frame {{ index + 1 }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Current frame offset display -->
<div v-if="props.sprites[currentFrameIndex]" class="mt-3 p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Current Frame Offset:</span>
<span class="text-sm font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ props.sprites[currentFrameIndex].x }}, y: {{ props.sprites[currentFrameIndex].y }}</span>
</div>
</div>
</div>
<div class="mt-3 relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg mb-4 sm:mb-6 overflow-auto min-h-[300px] sm:min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
<!-- Main Layout: Canvas Left, Controls Right -->
<div class="flex flex-col lg:flex-row gap-4">
<!-- Canvas Area (Left/Main) -->
<div class="flex-1 min-w-0">
<div class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto min-h-[300px] sm:min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
<canvas
ref="previewCanvasRef"
@mousedown="startDrag"
@@ -124,7 +21,7 @@
</canvas>
<!-- Mobile zoom controls -->
<div class="absolute bottom-3 right-3 flex space-x-2 sm:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
<button @click="increaseZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
<i class="fas fa-plus"></i>
</button>
@@ -134,20 +31,136 @@
</div>
</div>
</div>
<!-- Controls Sidebar (Right) -->
<div class="lg:w-80 xl:w-96 flex-shrink-0">
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-4">
<!-- Playback Controls -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Playback</h3>
<div class="flex items-center gap-2">
<button @click="togglePlayback" class="flex items-center justify-center gap-1.5 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors cursor-pointer flex-1">
<span v-if="isPlaying" class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
</svg>
Pause
</span>
<span v-else class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
</svg>
Play
</span>
</button>
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(90 12 12)" />
</svg>
</button>
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(-90 12 12)" />
</svg>
</button>
</div>
</div>
<!-- Sliders -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Controls</h3>
<!-- Frame Navigation -->
<div class="space-y-1">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Frame</span>
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
</div>
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
</div>
<!-- FPS Control -->
<div class="space-y-1">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">FPS</span>
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ fps }}</span>
</div>
<input type="range" min="1" max="60" v-model.number="fps" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
</div>
<!-- Zoom Control -->
<div class="space-y-1">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Zoom</span>
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ Math.round(zoom * 100) }}%</span>
</div>
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
</div>
</div>
<!-- Options -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Options</h3>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="isDraggable" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm dark:text-gray-200">Reposition</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm dark:text-gray-200">Compare sprites</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
</label>
</div>
</div>
<!-- Current frame offset display -->
<div v-if="props.sprites[currentFrameIndex]" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Offset</span>
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ props.sprites[currentFrameIndex].x }}, y: {{ props.sprites[currentFrameIndex].y }}</span>
</div>
</div>
<!-- Frame Selection (when Compare sprites is enabled) -->
<div v-if="showAllSprites" class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Frames</h3>
<div class="flex gap-1">
<button @click="showAllFrames" class="px-2 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors">All</button>
<button @click="hideAllFrames" class="px-2 py-1 text-xs bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">None</button>
</div>
</div>
<div class="rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800">
<div class="max-h-[180px] overflow-y-auto">
<div class="space-y-0.5 p-1">
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-3.5 h-3.5 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden flex-shrink-0">
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
<span class="text-xs dark:text-gray-200">{{ index + 1 }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
interface Sprite {
id: string;
img: HTMLImageElement;
width: number;
height: number;
x: number;
y: number;
}
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from '@/composables/useSprites';
const props = defineProps<{
sprites: Sprite[];
@@ -193,17 +206,6 @@
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Canvas drawing
const calculateMaxDimensions = () => {
let maxWidth = 0;
let maxHeight = 0;
props.sprites.forEach(sprite => {
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
});
return { maxWidth, maxHeight };
};
const drawPreviewCanvas = () => {
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
@@ -211,7 +213,7 @@
const currentSprite = props.sprites[currentFrameIndex.value];
if (!currentSprite) return;
const { maxWidth, maxHeight } = calculateMaxDimensions();
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
// Apply pixel art optimization consistently from store
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
@@ -351,7 +353,7 @@
const sprite = props.sprites[currentFrameIndex.value];
if (!sprite || sprite.id !== activeSpriteId.value) return;
const { maxWidth, maxHeight } = calculateMaxDimensions();
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
// Calculate new position with constraints and round to integers
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);

View File

@@ -96,6 +96,7 @@
import { ref, watch, onUnmounted } from 'vue';
import Modal from './utilities/Modal.vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { SpriteFile } from '@/types/sprites';
interface SpritePreview {
url: string;
@@ -117,14 +118,6 @@
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
}>();
interface SpriteFile {
file: File;
x: number;
y: number;
width: number;
height: number;
}
// Get settings from store
const settingsStore = useSettingsStore();

View File

@@ -32,7 +32,11 @@
</div>
<!-- Resize handle -->
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize"></div>
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize">
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22Z" />
</svg>
</div>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,205 @@
import type { Ref } from 'vue';
import GIF from 'gif.js';
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
import JSZip from 'jszip';
import type { Sprite } from '../types/sprites';
import { getMaxDimensions } from './useSprites';
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>) => {
const downloadSpritesheet = () => {
if (!sprites.value.length) {
alert('Please upload or import sprites before downloading the spritesheet.');
return;
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const rows = Math.ceil(sprites.value.length / columns.value);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = maxWidth * columns.value;
canvas.height = maxHeight * rows;
ctx.imageSmoothingEnabled = false;
sprites.value.forEach((sprite, index) => {
const col = index % columns.value;
const row = Math.floor(index / columns.value);
const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight);
ctx.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
});
const link = document.createElement('a');
link.download = 'spritesheet.png';
link.href = canvas.toDataURL('image/png', 1.0);
link.click();
};
const exportSpritesheetJSON = async () => {
if (!sprites.value.length) {
alert('Nothing to export. Please add sprites first.');
return;
}
const spritesData = await Promise.all(
sprites.value.map(async sprite => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
canvas.width = sprite.width;
canvas.height = sprite.height;
ctx.drawImage(sprite.img, 0, 0);
const base64Data = canvas.toDataURL('image/png');
return {
id: sprite.id,
width: sprite.width,
height: sprite.height,
x: sprite.x,
y: sprite.y,
base64: base64Data,
name: sprite.file.name,
};
})
);
const jsonData = { columns: columns.value, sprites: spritesData.filter(Boolean) };
const jsonString = JSON.stringify(jsonData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'spritesheet.json';
link.href = url;
link.click();
URL.revokeObjectURL(url);
};
const importSpritesheetJSON = async (jsonFile: File) => {
const jsonText = await jsonFile.text();
const jsonData = JSON.parse(jsonText);
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) throw new Error('Invalid JSON format: missing sprites array');
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
// revoke existing blob urls
if (sprites.value.length) {
sprites.value.forEach(s => {
if (s.url && s.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(s.url);
} catch {}
}
});
}
const imported = await Promise.all(
jsonData.sprites.map((spriteData: any) => {
return new Promise<Sprite>(resolve => {
const img = new Image();
img.onload = () => {
const byteString = atob(spriteData.base64.split(',')[1]);
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
const blob = new Blob([ab], { type: mimeType });
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
const file = new File([blob], fileName, { type: mimeType });
resolve({
id: spriteData.id || crypto.randomUUID(),
file,
img,
url: spriteData.base64,
width: spriteData.width,
height: spriteData.height,
x: spriteData.x || 0,
y: spriteData.y || 0,
});
};
img.src = spriteData.base64;
});
})
);
sprites.value = imported;
};
const downloadAsGif = (fps: number) => {
if (!sprites.value.length) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = maxWidth;
canvas.height = maxHeight;
ctx.imageSmoothingEnabled = false;
const gif = new GIF({ workers: 2, quality: 10, width: maxWidth, height: maxHeight, workerScript: gifWorkerUrl });
sprites.value.forEach(sprite => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, maxWidth, maxHeight);
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
});
gif.on('finished', (blob: Blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'animation.gif';
link.href = url;
link.click();
URL.revokeObjectURL(url);
});
gif.render();
};
const downloadAsZip = async () => {
if (!sprites.value.length) {
alert('Please upload or import sprites before downloading a ZIP.');
return;
}
const zip = new JSZip();
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = maxWidth;
canvas.height = maxHeight;
ctx.imageSmoothingEnabled = false;
sprites.value.forEach((sprite, index) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, maxWidth, maxHeight);
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
const dataURL = canvas.toDataURL('image/png');
const binary = atob(dataURL.split(',')[1]);
const buf = new ArrayBuffer(binary.length);
const view = new Uint8Array(buf);
for (let i = 0; i < binary.length; i++) view[i] = binary.charCodeAt(i);
const baseName = sprite.file?.name ? sprite.file.name.replace(/\s+/g, '_') : `sprite_${index + 1}.png`;
const name = `${index + 1}_${baseName}`;
zip.file(name, view);
});
const content = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(content);
const link = document.createElement('a');
link.download = 'sprites.zip';
link.href = url;
link.click();
URL.revokeObjectURL(url);
};
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
};

View File

@@ -0,0 +1,255 @@
import { ref, watch, onUnmounted } from 'vue';
import type { Sprite } from '../types/sprites';
export const useSprites = () => {
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 updateSpritePosition = (id: string, x: number, y: number) => {
const i = sprites.value.findIndex(s => s.id === id);
if (i !== -1) {
sprites.value[i].x = Math.floor(x);
sprites.value[i].y = Math.floor(y);
}
};
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
if (!sprites.value.length) return;
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
sprites.value = sprites.value.map(sprite => {
let x = sprite.x;
let y = sprite.y;
switch (position) {
case 'left':
x = 0;
break;
case 'center':
x = Math.floor((maxWidth - sprite.width) / 2);
break;
case 'right':
x = Math.floor(maxWidth - sprite.width);
break;
case 'top':
y = 0;
break;
case 'middle':
y = Math.floor((maxHeight - sprite.height) / 2);
break;
case 'bottom':
y = Math.floor(maxHeight - sprite.height);
break;
}
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
});
triggerForceRedraw();
};
const updateSpriteCell = (id: string, newIndex: number) => {
const currentIndex = sprites.value.findIndex(s => s.id === id);
if (currentIndex === -1 || currentIndex === newIndex) return;
const next = [...sprites.value];
if (newIndex < sprites.value.length) {
const moving = { ...next[currentIndex] };
const target = { ...next[newIndex] };
next[currentIndex] = target;
next[newIndex] = moving;
} else {
const [moved] = next.splice(currentIndex, 1);
next.splice(newIndex, 0, moved);
}
sprites.value = next;
};
const removeSprite = (id: string) => {
const i = sprites.value.findIndex(s => s.id === id);
if (i === -1) return;
const s = sprites.value[i];
revokeIfBlob(s.url);
sprites.value.splice(i, 1);
};
const replaceSprite = (id: string, file: File) => {
const i = sprites.value.findIndex(s => s.id === id);
if (i === -1) return;
const old = sprites.value[i];
revokeIfBlob(old.url);
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const next: Sprite = {
id: old.id,
file,
img,
url,
width: img.width,
height: img.height,
x: old.x,
y: old.y,
};
const arr = [...sprites.value];
arr[i] = next;
sprites.value = arr;
};
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 s: Sprite = {
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
};
sprites.value = [...sprites.value, s];
};
img.onerror = () => {
console.error('Failed to load new sprite image:', file.name);
URL.revokeObjectURL(url);
};
img.src = url;
};
const addSpriteWithResize = (file: File) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const newSprite: Sprite = {
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
};
const newMaxWidth = Math.max(maxWidth, img.width);
const newMaxHeight = Math.max(maxHeight, img.height);
if (img.width > maxWidth || img.height > maxHeight) {
sprites.value = sprites.value.map(sprite => {
let newX = sprite.x;
let newY = sprite.y;
if (img.width > maxWidth) {
const relativeX = maxWidth > 0 ? sprite.x / maxWidth : 0;
newX = Math.floor(relativeX * newMaxWidth);
newX = Math.max(0, Math.min(newX, newMaxWidth - sprite.width));
}
if (img.height > maxHeight) {
const relativeY = maxHeight > 0 ? sprite.y / maxHeight : 0;
newY = Math.floor(relativeY * newMaxHeight);
newY = Math.max(0, Math.min(newY, newMaxHeight - sprite.height));
}
return { ...sprite, x: newX, y: newY };
});
}
sprites.value = [...sprites.value, newSprite];
triggerForceRedraw();
};
img.onerror = () => {
console.error('Failed to load new sprite image:', file.name);
URL.revokeObjectURL(url);
};
img.src = url;
};
const processImageFiles = (files: File[]) => {
Promise.all(
files.map(
file =>
new Promise<Sprite>(resolve => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
resolve({
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
});
};
img.src = url;
})
)
).then(newSprites => {
sprites.value = [...sprites.value, ...newSprites];
});
};
onUnmounted(() => {
sprites.value.forEach(s => revokeIfBlob(s.url));
});
return {
sprites,
columns,
updateSpritePosition,
alignSprites,
updateSpriteCell,
removeSprite,
replaceSprite,
addSprite,
addSpriteWithResize,
processImageFiles,
};
};
export const getMaxDimensions = (arr: Sprite[] | Readonly<Sprite[]>): { maxWidth: number; maxHeight: number } => {
let maxWidth = 0;
let maxHeight = 0;
arr.forEach(s => {
if (s.width > maxWidth) maxWidth = s.width;
if (s.height > maxHeight) maxHeight = s.height;
});
return { maxWidth, maxHeight };
};
export const revokeIfBlob = (url?: string) => {
if (url && url.startsWith('blob:')) {
try {
URL.revokeObjectURL(url);
} catch {}
}
};
export const triggerForceRedraw = () => {
setTimeout(() => {
window.dispatchEvent(new Event('forceRedraw'));
}, 0);
};

18
src/types/sprites.ts Normal file
View File

@@ -0,0 +1,18 @@
export interface Sprite {
id: string;
file: File;
img: HTMLImageElement;
url: string;
width: number;
height: number;
x: number;
y: number;
}
export interface SpriteFile {
file: File;
x: number;
y: number;
width: number;
height: number;
}