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; +}