[FEAT] Began working on cleaning code
This commit is contained in:
685
src/App.vue
685
src/App.vue
@@ -135,7 +135,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import FileUploader from './components/FileUploader.vue';
|
import FileUploader from './components/FileUploader.vue';
|
||||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||||
import Modal from './components/utilities/Modal.vue';
|
import Modal from './components/utilities/Modal.vue';
|
||||||
@@ -145,37 +145,24 @@
|
|||||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||||
import GifFpsModal from './components/GifFpsModal.vue';
|
import GifFpsModal from './components/GifFpsModal.vue';
|
||||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||||
import GIF from 'gif.js';
|
import { useSprites } from './composables/useSprites';
|
||||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
import { useExport } from './composables/useExport';
|
||||||
import JSZip from 'jszip';
|
import type { SpriteFile } from './types/sprites';
|
||||||
|
|
||||||
interface Sprite {
|
const {
|
||||||
id: string;
|
sprites,
|
||||||
file: File;
|
columns,
|
||||||
img: HTMLImageElement;
|
updateSpritePosition,
|
||||||
url: string;
|
updateSpriteCell,
|
||||||
width: number;
|
removeSprite,
|
||||||
height: number;
|
replaceSprite,
|
||||||
x: number;
|
addSprite,
|
||||||
y: number;
|
addSpriteWithResize,
|
||||||
}
|
processImageFiles,
|
||||||
|
alignSprites,
|
||||||
|
} = useSprites();
|
||||||
|
|
||||||
interface SpriteFile {
|
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExport(sprites, columns);
|
||||||
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 isPreviewModalOpen = ref(false);
|
const isPreviewModalOpen = ref(false);
|
||||||
const isHelpModalOpen = ref(false);
|
const isHelpModalOpen = ref(false);
|
||||||
const isFeedbackModalOpen = ref(false);
|
const isFeedbackModalOpen = ref(false);
|
||||||
@@ -186,13 +173,17 @@
|
|||||||
const spritesheetImageFile = ref<File | null>(null);
|
const spritesheetImageFile = ref<File | null>(null);
|
||||||
const showFeedbackPopup = ref(false);
|
const showFeedbackPopup = ref(false);
|
||||||
|
|
||||||
const handleSpritesUpload = (files: File[]) => {
|
const handleSpritesUpload = async (files: File[]) => {
|
||||||
// Check if any of the files is a JSON file
|
// Check if any of the files is a JSON file
|
||||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||||
|
|
||||||
if (jsonFile) {
|
if (jsonFile) {
|
||||||
// If it's a JSON file, try to import it
|
try {
|
||||||
importSpritesheetJSON(jsonFile);
|
await importSpritesheetJSON(jsonFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing JSON:', error);
|
||||||
|
alert('Failed to import JSON file. Please check the file format.');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,86 +215,7 @@
|
|||||||
processImageFiles(files);
|
processImageFiles(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract the image processing logic to a separate function for reuse
|
// sprite position and export/import/dl handled by composables
|
||||||
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
|
// Preview modal control
|
||||||
const openPreviewModal = () => {
|
const openPreviewModal = () => {
|
||||||
@@ -362,89 +274,11 @@
|
|||||||
|
|
||||||
// Handle the split spritesheet result
|
// Handle the split spritesheet result
|
||||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||||
// Process sprite files with their positions
|
// For now ignore bounding box offsets; add all files as new sprites
|
||||||
Promise.all(
|
processImageFiles(spriteFiles.map(s => s.file));
|
||||||
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];
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export spritesheet as JSON with base64 images
|
// export handled by composable
|
||||||
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
|
// Open file dialog for JSON import
|
||||||
const openJSONImportDialog = () => {
|
const openJSONImportDialog = () => {
|
||||||
@@ -452,383 +286,36 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle JSON file selection
|
// Handle JSON file selection
|
||||||
const handleJSONFileChange = (event: Event) => {
|
const handleJSONFileChange = async (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
const jsonFile = input.files[0];
|
const jsonFile = input.files[0];
|
||||||
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.');
|
||||||
|
}
|
||||||
// Reset input value so uploading the same file again will trigger the event
|
// Reset input value so uploading the same file again will trigger the event
|
||||||
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import spritesheet from JSON
|
// import handled by composable (used in handleSpritesUpload/handleJSONFileChange)
|
||||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
|
||||||
try {
|
|
||||||
const jsonText = await jsonFile.text();
|
|
||||||
const jsonData = JSON.parse(jsonText);
|
|
||||||
|
|
||||||
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) {
|
// alignment handled by composable (exposed as alignSprites)
|
||||||
throw new Error('Invalid JSON format: missing sprites array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set columns if available
|
// cell update handled by composable (exposed as updateSpriteCell)
|
||||||
if (jsonData.columns && typeof jsonData.columns === 'number') {
|
|
||||||
columns.value = jsonData.columns;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each sprite
|
// remove handled by composable
|
||||||
// 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(
|
// replace handled by composable
|
||||||
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++) {
|
// add handled by composable
|
||||||
ia[i] = byteString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([ab], { type: mimeType });
|
// add with resize handled by composable
|
||||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
|
||||||
const file = new File([blob], fileName, { type: mimeType });
|
|
||||||
|
|
||||||
resolve({
|
// GIF handled by composable
|
||||||
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;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
// Check for one-time feedback popup on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -851,89 +338,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Revoke blob URLs on unmount to avoid memory leaks
|
// unmount cleanup is handled inside useSprites
|
||||||
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
|
// ZIP handled by composable
|
||||||
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>
|
</script>
|
||||||
|
|||||||
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);
|
||||||
|
};
|
||||||
18
src/types/sprites.ts
Normal file
18
src/types/sprites.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user