[FEAT] Began working on cleaning code
This commit is contained in:
205
src/composables/useExport.ts
Normal file
205
src/composables/useExport.ts
Normal 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 };
|
||||
};
|
||||
255
src/composables/useSprites.ts
Normal file
255
src/composables/useSprites.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user