252 lines
9.5 KiB
TypeScript
252 lines
9.5 KiB
TypeScript
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';
|
|
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
|
|
|
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
|
const getCellDimensions = () => {
|
|
// If manual cell size is enabled, use manual values
|
|
if (manualCellSizeEnabled?.value) {
|
|
return {
|
|
cellWidth: manualCellWidth?.value ?? 64,
|
|
cellHeight: manualCellHeight?.value ?? 64,
|
|
negativeSpacing: 0,
|
|
};
|
|
}
|
|
|
|
// Otherwise, calculate from sprite dimensions
|
|
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
|
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
|
return {
|
|
cellWidth: maxWidth + negativeSpacing,
|
|
cellHeight: maxHeight + negativeSpacing,
|
|
negativeSpacing,
|
|
};
|
|
};
|
|
|
|
const downloadSpritesheet = () => {
|
|
if (!sprites.value.length) {
|
|
alert('Please upload or import sprites before downloading the spritesheet.');
|
|
return;
|
|
}
|
|
|
|
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
|
const rows = Math.ceil(sprites.value.length / columns.value);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
canvas.width = cellWidth * columns.value;
|
|
canvas.height = cellHeight * rows;
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
// Apply background color if not transparent
|
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
|
ctx.fillStyle = backgroundColor.value;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
sprites.value.forEach((sprite, index) => {
|
|
const col = index % columns.value;
|
|
const row = Math.floor(index / columns.value);
|
|
const cellX = Math.floor(col * cellWidth);
|
|
const cellY = Math.floor(row * cellHeight);
|
|
ctx.drawImage(sprite.img, Math.floor(cellX + negativeSpacing + sprite.x), Math.floor(cellY + negativeSpacing + 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,
|
|
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
|
backgroundColor: backgroundColor?.value || 'transparent',
|
|
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
|
manualCellWidth: manualCellWidth?.value || 64,
|
|
manualCellHeight: manualCellHeight?.value || 64,
|
|
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;
|
|
if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled;
|
|
if (typeof jsonData.backgroundColor === 'string' && backgroundColor) backgroundColor.value = jsonData.backgroundColor;
|
|
if (typeof jsonData.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = jsonData.manualCellSizeEnabled;
|
|
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
|
|
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
|
|
|
|
// 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 { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
canvas.width = cellWidth;
|
|
canvas.height = cellHeight;
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
const gif = new GIF({ workers: 2, quality: 10, width: cellWidth, height: cellHeight, workerScript: gifWorkerUrl });
|
|
|
|
sprites.value.forEach(sprite => {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
// Apply background color if not transparent
|
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
|
ctx.fillStyle = backgroundColor.value;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + 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 { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
canvas.width = cellWidth;
|
|
canvas.height = cellHeight;
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
sprites.value.forEach((sprite, index) => {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
// Apply background color if not transparent
|
|
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
|
ctx.fillStyle = backgroundColor.value;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + 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 };
|
|
};
|