diff --git a/src/App.vue b/src/App.vue
index 3e78d5e..db2fcf8 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -135,7 +135,7 @@
diff --git a/src/composables/useExport.ts b/src/composables/useExport.ts
new file mode 100644
index 0000000..9c59d46
--- /dev/null
+++ b/src/composables/useExport.ts
@@ -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, columns: Ref) => {
+ 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(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 };
+};
diff --git a/src/composables/useSprites.ts b/src/composables/useSprites.ts
new file mode 100644
index 0000000..edd912c
--- /dev/null
+++ b/src/composables/useSprites.ts
@@ -0,0 +1,255 @@
+import { ref, watch, onUnmounted } from 'vue';
+import type { Sprite } from '../types/sprites';
+
+export const useSprites = () => {
+ const sprites = ref([]);
+ 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(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): { 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);
+};
diff --git a/src/types/sprites.ts b/src/types/sprites.ts
new file mode 100644
index 0000000..e65597a
--- /dev/null
+++ b/src/types/sprites.ts
@@ -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;
+}