Compare commits

..

7 Commits

Author SHA1 Message Date
f7a01e6c92 Export negative offset bug fixes 2025-11-18 20:30:59 +01:00
590d76205f Finish clean, add negative spacing toggle 2025-11-18 20:27:06 +01:00
5c33e77595 npm run format 2025-11-18 20:12:32 +01:00
404ca9ce88 Continuation of separting logic into domain specific composables 2025-11-18 20:11:36 +01:00
d571cb51cb Clean 2025-11-18 19:49:25 +01:00
c1620d6bbb Reused logic from earlier made composables 2025-11-18 19:45:53 +01:00
6afbd42794 [FEAT] Began working on cleaning code 2025-11-18 19:40:44 +01:00
15 changed files with 1518 additions and 1292 deletions

View File

@@ -2,6 +2,7 @@ All notable changes to this project will be documented in this file.
## [1.6.0] - 2025-11-18 ## [1.6.0] - 2025-11-18
- Improved animation preview modal - Improved animation preview modal
- Add toggle for negative spacing in cells
## [1.5.0] - 2025-11-17 ## [1.5.0] - 2025-11-17
- Show offset values in sprite cells and in preview modal - Show offset values in sprite cells and in preview modal

View File

@@ -135,7 +135,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, toRef } 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,15 @@
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 { useSettingsStore } from './stores/useSettingsStore';
import type { SpriteFile } from './types/sprites';
interface Sprite { const settingsStore = useSettingsStore();
id: string; const { sprites, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites } = useSprites();
file: File;
img: HTMLImageElement;
url: string;
width: number;
height: number;
x: number;
y: number;
}
interface SpriteFile { const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExport(sprites, columns, toRef(settingsStore, 'negativeSpacingEnabled'));
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,126 +164,41 @@
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
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;
} }
// Check if it's a single image file that might be a spritesheet
if (files.length === 1 && files[0].type.startsWith('image/')) { if (files.length === 1 && files[0].type.startsWith('image/')) {
const file = files[0]; const file = files[0];
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
// Load the image to check its dimensions
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
// Ask the user if they want to split the spritesheet
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) { if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
// Open the spritesheet splitter
spritesheetImageUrl.value = url; spritesheetImageUrl.value = url;
spritesheetImageFile.value = file; spritesheetImageFile.value = file;
isSpritesheetSplitterOpen.value = true; isSpritesheetSplitterOpen.value = true;
return; return;
} }
// If the user doesn't want to split or it's not large enough, process as a single sprite
processImageFiles([file]); processImageFiles([file]);
}; };
img.src = url; img.src = url;
return; return;
} }
// Process multiple image files normally
processImageFiles(files); processImageFiles(files);
}; };
// Extract the image processing logic to a separate function for reuse
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
const openPreviewModal = () => { const openPreviewModal = () => {
if (sprites.value.length === 0) { if (sprites.value.length === 0) {
alert('Please upload or import sprites to preview an animation.'); alert('Please upload or import sprites to preview an animation.');
@@ -318,7 +211,6 @@
isPreviewModalOpen.value = false; isPreviewModalOpen.value = false;
}; };
// Help modal control
const openHelpModal = () => { const openHelpModal = () => {
isHelpModalOpen.value = true; isHelpModalOpen.value = true;
}; };
@@ -327,7 +219,6 @@
isHelpModalOpen.value = false; isHelpModalOpen.value = false;
}; };
// Feedback modal control
const openFeedbackModal = () => { const openFeedbackModal = () => {
isFeedbackModalOpen.value = true; isFeedbackModalOpen.value = true;
}; };
@@ -336,10 +227,8 @@
isFeedbackModalOpen.value = false; isFeedbackModalOpen.value = false;
}; };
// Spritesheet splitter modal control
const closeSpritesheetSplitter = () => { const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false; isSpritesheetSplitterOpen.value = false;
// Clean up the URL object to prevent memory leaks
if (spritesheetImageUrl.value) { if (spritesheetImageUrl.value) {
URL.revokeObjectURL(spritesheetImageUrl.value); URL.revokeObjectURL(spritesheetImageUrl.value);
spritesheetImageUrl.value = ''; spritesheetImageUrl.value = '';
@@ -347,7 +236,6 @@
spritesheetImageFile.value = null; spritesheetImageFile.value = null;
}; };
// GIF FPS modal control
const openGifFpsModal = () => { const openGifFpsModal = () => {
if (sprites.value.length === 0) { if (sprites.value.length === 0) {
alert('Please upload or import sprites before generating a GIF.'); alert('Please upload or import sprites before generating a GIF.');
@@ -360,488 +248,37 @@
isGifFpsModalOpen.value = false; isGifFpsModalOpen.value = false;
}; };
// Handle the split spritesheet result
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => { const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
// Process sprite files with their positions processImageFiles(spriteFiles.map(s => s.file));
Promise.all(
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
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
const openJSONImportDialog = () => { const openJSONImportDialog = () => {
jsonFileInput.value?.click(); jsonFileInput.value?.click();
}; };
// Handle JSON file selection const handleJSONFileChange = async (event: Event) => {
const handleJSONFileChange = (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 {
// Reset input value so uploading the same file again will trigger the event await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
if (jsonFileInput.value) jsonFileInput.value.value = ''; if (jsonFileInput.value) jsonFileInput.value.value = '';
} }
}; };
// Import spritesheet from JSON
const importSpritesheetJSON = async (jsonFile: File) => {
try {
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');
}
// Set columns if available
if (jsonData.columns && typeof jsonData.columns === 'number') {
columns.value = jsonData.columns;
}
// Process each sprite
// 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(
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++) {
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;
});
})
);
} 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
onMounted(() => { onMounted(() => {
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup'); const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
if (!hasShownFeedbackPopup) { if (!hasShownFeedbackPopup) {
// Show popup after a short delay to let the page load
setTimeout(() => { setTimeout(() => {
showFeedbackPopup.value = true; showFeedbackPopup.value = true;
}, 3000); }, 3000);
} }
}); });
// Handle feedback popup response
const handleFeedbackPopupResponse = (showModal: boolean) => { const handleFeedbackPopupResponse = (showModal: boolean) => {
showFeedbackPopup.value = false; showFeedbackPopup.value = false;
localStorage.setItem('hasShownFeedbackPopup', 'true'); localStorage.setItem('hasShownFeedbackPopup', 'true');
@@ -850,90 +287,4 @@
openFeedbackModal(); openFeedbackModal();
} }
}; };
// Revoke blob URLs on unmount to avoid memory leaks
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
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>

View File

@@ -18,6 +18,11 @@
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" /> <input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label> <label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
</div> </div>
<!-- Negative spacing control -->
<div class="flex items-center">
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
</div>
</div> </div>
</div> </div>
@@ -48,7 +53,7 @@
@contextmenu.prevent @contextmenu.prevent
@dragover="handleDragOver" @dragover="handleDragOver"
@dragenter="handleDragEnter" @dragenter="handleDragEnter"
@dragleave="handleDragLeave" @dragleave="onDragLeave"
@drop="handleDrop" @drop="handleDrop"
class="w-full transition-all" class="w-full transition-all"
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }" :class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
@@ -95,23 +100,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, computed, onUnmounted } from 'vue'; import { ref, onMounted, watch, onUnmounted, toRef } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore'; import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
interface Sprite { import { useCanvas2D } from '@/composables/useCanvas2D';
id: string; import { useZoom } from '@/composables/useZoom';
img: HTMLImageElement; import { useDragSprite } from '@/composables/useDragSprite';
width: number; import { useFileDrop } from '@/composables/useFileDrop';
height: number;
x: number;
y: number;
}
interface CellPosition {
col: number;
row: number;
index: number;
}
const props = defineProps<{ const props = defineProps<{
sprites: Sprite[]; sprites: Sprite[];
@@ -131,22 +126,54 @@
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const canvasRef = ref<HTMLCanvasElement | null>(null); const canvasRef = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
// State for tracking drag operations // Initialize composables
const isDragging = ref(false); const canvas2D = useCanvas2D(canvasRef);
const activeSpriteId = ref<string | null>(null);
const activeSpriteCellIndex = ref<number | null>(null); const {
const dragStartX = ref(0); zoom,
const dragStartY = ref(0); increase: zoomIn,
const dragOffsetX = ref(0); decrease: zoomOut,
const dragOffsetY = ref(0); reset: resetZoom,
} = useZoom({
min: 0.5,
max: 3,
step: 0.25,
initial: 1,
});
const allowCellSwap = ref(false); const allowCellSwap = ref(false);
const currentHoverCell = ref<CellPosition | null>(null);
// Visual feedback refs const {
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null); isDragging,
const highlightCell = ref<CellPosition | null>(null); activeSpriteId,
ghostSprite,
highlightCell,
spritePositions,
startDrag: dragStart,
drag: dragMove,
stopDrag,
handleTouchStart,
handleTouchMove,
findSpriteAtPosition,
calculateMaxDimensions,
} = useDragSprite({
sprites: toRef(props, 'sprites'),
columns: toRef(props, 'columns'),
zoom,
allowCellSwap,
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
onDraw: drawCanvas,
});
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
sprites: props.sprites,
onAddSprite: file => emit('addSprite', file),
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
});
const showAllSprites = ref(false); const showAllSprites = ref(false);
const showContextMenu = ref(false); const showContextMenu = ref(false);
@@ -155,59 +182,6 @@
const contextMenuSpriteId = ref<string | null>(null); const contextMenuSpriteId = ref<string | null>(null);
const replacingSpriteId = ref<string | null>(null); const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const isDragOver = ref(false);
const spritePositions = computed(() => {
const { maxWidth, maxHeight } = calculateMaxDimensions();
return props.sprites.map((sprite, index) => {
const col = index % props.columns;
const row = Math.floor(index / props.columns);
return {
id: sprite.id,
canvasX: col * maxWidth + sprite.x,
canvasY: row * maxHeight + sprite.y,
cellX: col * maxWidth,
cellY: row * maxHeight,
width: sprite.width,
height: sprite.height,
maxWidth,
maxHeight,
col,
row,
index,
x: sprite.x,
y: sprite.y,
};
});
});
// Cache last known max dimensions to avoid collapsing cells while images are loading
const lastMaxWidth = ref(1);
const lastMaxHeight = ref(1);
const calculateMaxDimensions = () => {
let maxWidth = 0;
let maxHeight = 0;
props.sprites.forEach(sprite => {
const img = sprite.img as HTMLImageElement | undefined;
const w = Math.max(0, sprite.width || (img ? img.naturalWidth || img.width || 0 : 0));
const h = Math.max(0, sprite.height || (img ? img.naturalHeight || img.height || 0 : 0));
maxWidth = Math.max(maxWidth, w);
maxHeight = Math.max(maxHeight, h);
});
// Keep dimensions at least as large as last known to prevent temporary collapse during loading
maxWidth = Math.max(1, maxWidth, lastMaxWidth.value);
maxHeight = Math.max(1, maxHeight, lastMaxHeight.value);
lastMaxWidth.value = maxWidth;
lastMaxHeight.value = maxHeight;
return { maxWidth, maxHeight };
};
const startDrag = (event: MouseEvent) => { const startDrag = (event: MouseEvent) => {
if (!canvasRef.value) return; if (!canvasRef.value) return;
@@ -218,14 +192,10 @@
// Handle right-click for context menu // Handle right-click for context menu
if ('button' in event && (event as MouseEvent).button === 2) { if ('button' in event && (event as MouseEvent).button === 2) {
event.preventDefault(); event.preventDefault();
const rect = canvasRef.value.getBoundingClientRect(); const pos = canvas2D.getMousePosition(event, zoom.value);
const scaleX = canvasRef.value.width / rect.width; if (!pos) return;
const scaleY = canvasRef.value.height / rect.height;
const mouseX = (event.clientX - rect.left) * scaleX; const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
const mouseY = (event.clientY - rect.top) * scaleY;
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
contextMenuSpriteId.value = clickedSprite?.id || null; contextMenuSpriteId.value = clickedSprite?.id || null;
contextMenuX.value = event.clientX; contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY; contextMenuY.value = event.clientY;
@@ -236,167 +206,13 @@
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop) // Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
if ('button' in event && (event as MouseEvent).button !== 0) return; if ('button' in event && (event as MouseEvent).button !== 0) return;
const rect = canvasRef.value.getBoundingClientRect(); // Delegate to composable for actual drag handling
const scaleX = canvasRef.value.width / rect.width; dragStart(event);
const scaleY = canvasRef.value.height / rect.height;
const mouseX = (event.clientX - rect.left) * scaleX;
const mouseY = (event.clientY - rect.top) * scaleY;
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
if (clickedSprite) {
isDragging.value = true;
activeSpriteId.value = clickedSprite.id;
dragStartX.value = mouseX;
dragStartY.value = mouseY;
// Find the sprite's position to calculate offset from mouse to sprite origin
const spritePosition = spritePositions.value.find(pos => pos.id === clickedSprite.id);
if (spritePosition) {
dragOffsetX.value = mouseX - spritePosition.canvasX;
dragOffsetY.value = mouseY - spritePosition.canvasY;
activeSpriteCellIndex.value = spritePosition.index;
// Store the starting cell position
const startCell = findCellAtPosition(mouseX, mouseY);
if (startCell) {
currentHoverCell.value = startCell;
highlightCell.value = null; // No highlight at the start
}
}
}
};
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
const { maxWidth, maxHeight } = calculateMaxDimensions();
const col = Math.floor(x / maxWidth);
const row = Math.floor(y / maxHeight);
// Check if the cell position is valid
const totalRows = Math.ceil(props.sprites.length / props.columns);
if (col >= 0 && col < props.columns && row >= 0 && row < totalRows) {
const index = row * props.columns + col;
if (index < props.sprites.length) {
return { col, row, index };
}
}
return null;
}; };
// Wrapper for drag move
const drag = (event: MouseEvent) => { const drag = (event: MouseEvent) => {
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value || activeSpriteCellIndex.value === null) return; dragMove(event);
const rect = canvasRef.value.getBoundingClientRect();
const scaleX = canvasRef.value.width / rect.width;
const scaleY = canvasRef.value.height / rect.height;
const mouseX = (event.clientX - rect.left) * scaleX;
const mouseY = (event.clientY - rect.top) * scaleY;
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
if (spriteIndex === -1) return;
// Find the cell the mouse is currently over
const hoverCell = findCellAtPosition(mouseX, mouseY);
currentHoverCell.value = hoverCell;
if (allowCellSwap.value && hoverCell) {
// If we're hovering over a different cell than the sprite's current cell
if (hoverCell.index !== activeSpriteCellIndex.value) {
// Show a highlight for the target cell
highlightCell.value = hoverCell;
// Create a ghost sprite that follows the mouse
ghostSprite.value = {
id: activeSpriteId.value,
x: mouseX - dragOffsetX.value,
y: mouseY - dragOffsetY.value,
};
drawCanvas();
} else {
// Same cell as the sprite's origin, just do regular movement
highlightCell.value = null;
ghostSprite.value = null;
handleInCellMovement(mouseX, mouseY, spriteIndex);
}
} else {
// Regular in-cell movement
handleInCellMovement(mouseX, mouseY, spriteIndex);
}
};
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
if (!activeSpriteId.value) return;
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
if (!position) return;
// Calculate new position based on mouse position and initial click offset
const newX = mouseX - position.cellX - dragOffsetX.value;
const newY = mouseY - position.cellY - dragOffsetY.value;
// Constrain within cell boundaries and ensure integer positions
const constrainedX = Math.floor(Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY)));
emit('updateSprite', activeSpriteId.value, constrainedX, constrainedY);
drawCanvas();
};
const stopDrag = () => {
if (isDragging.value && allowCellSwap.value && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
// We've dragged from one cell to another
// Tell parent component to update the sprite's cell index
emit('updateSpriteCell', activeSpriteId.value, currentHoverCell.value.index);
// Also reset the sprite's position within the cell to 0,0
emit('updateSprite', activeSpriteId.value, 0, 0);
}
// Reset all drag state
isDragging.value = false;
activeSpriteId.value = null;
activeSpriteCellIndex.value = null;
currentHoverCell.value = null;
highlightCell.value = null;
ghostSprite.value = null;
// Redraw without highlights
drawCanvas();
};
// Add zoom functionality for mobile
const zoom = ref(1);
const minZoom = 0.5;
const maxZoom = 3;
const zoomStep = 0.25;
const zoomIn = () => {
zoom.value = Math.min(maxZoom, zoom.value + zoomStep);
};
const zoomOut = () => {
zoom.value = Math.max(minZoom, zoom.value - zoomStep);
};
const resetZoom = () => {
zoom.value = 1;
};
// Improved touch handling
const handleTouchStart = (event: TouchEvent) => {
// Don't prevent default to allow scrolling
if (event.touches.length === 1) {
if (!canvasRef.value) return;
const touch = event.touches[0];
const mouseEvent = {
clientX: touch.clientX,
clientY: touch.clientY,
preventDefault: () => {},
} as unknown as MouseEvent;
startDrag(mouseEvent);
}
}; };
const removeSprite = () => { const removeSprite = () => {
@@ -453,132 +269,25 @@
contextMenuSpriteId.value = null; contextMenuSpriteId.value = null;
}; };
// Drag and drop handlers // Wrapper for drag leave to pass canvasRef
const handleDragOver = (event: DragEvent) => { const onDragLeave = (event: DragEvent) => {
event.preventDefault(); handleDragLeave(event, canvasRef.value);
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
isDragOver.value = true;
}; };
const handleDragEnter = (event: DragEvent) => { function drawCanvas() {
event.preventDefault(); if (!canvasRef.value || !canvas2D.ctx.value) return;
event.stopPropagation();
isDragOver.value = true;
};
const handleDragLeave = (event: DragEvent) => { const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
event.preventDefault();
event.stopPropagation();
// Only set to false if we're leaving the canvas entirely
const rect = canvasRef.value?.getBoundingClientRect();
if (rect && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) {
isDragOver.value = false;
}
};
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragOver.value = false;
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
return;
}
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
if (files.length === 0) {
alert('Please drop image files only.');
return;
}
// Process each dropped file
for (const file of files) {
await processDroppedImage(file);
}
};
const processDroppedImage = (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = e => {
if (e.target?.result) {
img.src = e.target.result as string;
}
};
img.onload = () => {
const { maxWidth, maxHeight } = calculateMaxDimensions();
// Check if the dropped image is larger than current cells
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
// Emit event with resize flag
emit('addSpriteWithResize', file);
} else {
// Normal add
emit('addSprite', file);
}
resolve();
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
reject(new Error('Failed to load image'));
};
reader.readAsDataURL(file);
});
};
const handleTouchMove = (event: TouchEvent) => {
// Only prevent default when we're actually dragging
if (isDragging.value) {
event.preventDefault();
}
if (event.touches.length === 1) {
const touch = event.touches[0];
const mouseEvent = {
clientX: touch.clientX,
clientY: touch.clientY,
preventDefault: () => {},
} as unknown as MouseEvent;
drag(mouseEvent);
}
};
const findSpriteAtPosition = (x: number, y: number) => {
// Search in reverse order to get the topmost sprite first
for (let i = spritePositions.value.length - 1; i >= 0; i--) {
const pos = spritePositions.value[i];
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
return props.sprites.find(s => s.id === pos.id) || null;
}
}
return null;
};
const drawCanvas = () => {
if (!canvasRef.value || !ctx.value) return;
const { maxWidth, maxHeight } = calculateMaxDimensions();
// Set canvas size // Set canvas size
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns)); const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
canvasRef.value.width = maxWidth * props.columns; canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
canvasRef.value.height = maxHeight * rows;
// Clear canvas // Clear canvas
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height); canvas2D.clear();
// Disable image smoothing based on pixel perfect setting // Apply pixel art optimization
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect; canvas2D.applySmoothing();
// Draw background for each cell // Draw background for each cell
for (let col = 0; col < props.columns; col++) { for (let col = 0; col < props.columns; col++) {
@@ -587,13 +296,11 @@
const cellY = Math.floor(row * maxHeight); const cellY = Math.floor(row * maxHeight);
// Draw cell background // Draw cell background
ctx.value.fillStyle = settingsStore.darkMode ? '#1F2937' : '#f9fafb'; canvas2D.fillCellBackground(cellX, cellY, maxWidth, maxHeight);
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
// Highlight the target cell if specified // Highlight the target cell if specified
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) { if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
ctx.value.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Light blue highlight canvas2D.fillRect(cellX, cellY, maxWidth, maxHeight, 'rgba(59, 130, 246, 0.2)');
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
} }
} }
} }
@@ -607,14 +314,12 @@
const cellY = Math.floor(cellRow * maxHeight); const cellY = Math.floor(cellRow * maxHeight);
// Draw all sprites with transparency in this cell // Draw all sprites with transparency in this cell
ctx.value.globalAlpha = 0.3; // Position at bottom-right with negative spacing offset
props.sprites.forEach((sprite, spriteIndex) => { props.sprites.forEach((sprite, spriteIndex) => {
if (spriteIndex !== cellIndex) { if (spriteIndex !== cellIndex) {
// Don't draw the cell's own sprite with transparency canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.3);
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
} }
}); });
ctx.value.globalAlpha = 1.0;
} }
} }
@@ -631,58 +336,38 @@
const cellX = Math.floor(col * maxWidth); const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight); const cellY = Math.floor(row * maxHeight);
// Draw sprite using integer positions for pixel-perfect rendering // Draw sprite with negative spacing offset (bottom-right positioning)
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y)); canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y);
}); });
// Draw ghost sprite if we're dragging between cells // Draw ghost sprite if we're dragging between cells
if (ghostSprite.value && activeSpriteId.value) { if (ghostSprite.value && activeSpriteId.value) {
const sprite = props.sprites.find(s => s.id === activeSpriteId.value); const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
if (sprite) { if (sprite) {
// Semi-transparent ghost canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
ctx.value.globalAlpha = 0.6;
ctx.value.drawImage(sprite.img, Math.floor(ghostSprite.value.x), Math.floor(ghostSprite.value.y));
ctx.value.globalAlpha = 1.0;
} }
} }
// Draw grid lines on top of everything // Draw grid lines on top of everything
ctx.value.strokeStyle = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
ctx.value.lineWidth = 1;
for (let col = 0; col < props.columns; col++) { for (let col = 0; col < props.columns; col++) {
for (let row = 0; row < rows; row++) { for (let row = 0; row < rows; row++) {
const cellX = Math.floor(col * maxWidth); const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight); const cellY = Math.floor(row * maxHeight);
canvas2D.strokeGridCell(cellX, cellY, maxWidth, maxHeight);
// Draw grid lines
ctx.value.strokeRect(cellX, cellY, maxWidth, maxHeight);
} }
} }
}; }
// Track which images already have listeners // Track which images already have listeners
const imagesWithListeners = new WeakSet<HTMLImageElement>(); const imagesWithListeners = new WeakSet<HTMLImageElement>();
const attachImageListeners = () => { const attachImageListeners = () => {
props.sprites.forEach(sprite => { canvas2D.attachImageListeners(props.sprites, handleForceRedraw, imagesWithListeners);
const img = sprite.img as HTMLImageElement | undefined;
if (img && !imagesWithListeners.has(img)) {
imagesWithListeners.add(img);
if (!img.complete) {
// Redraw when the image loads or errors (to reflect updated dimensions)
img.addEventListener('load', handleForceRedraw, { once: true } as AddEventListenerOptions);
img.addEventListener('error', handleForceRedraw, { once: true } as AddEventListenerOptions);
}
}
});
}; };
onMounted(() => { onMounted(() => {
if (canvasRef.value) { canvas2D.initContext();
ctx.value = canvasRef.value.getContext('2d'); drawCanvas();
drawCanvas();
}
// Attach listeners for current sprites // Attach listeners for current sprites
attachImageListeners(); attachImageListeners();
@@ -701,17 +386,9 @@
// Handler for force redraw event // Handler for force redraw event
const handleForceRedraw = () => { const handleForceRedraw = () => {
// Ensure we're using integer positions for pixel-perfect rendering canvas2D.ensureIntegerPositions(props.sprites);
props.sprites.forEach(sprite => { canvas2D.applySmoothing();
sprite.x = Math.floor(sprite.x); drawCanvas();
sprite.y = Math.floor(sprite.y);
});
// Force a redraw with the correct image smoothing settings
if (ctx.value) {
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
drawCanvas();
}
}; };
watch( watch(
@@ -725,6 +402,7 @@
watch(() => props.columns, drawCanvas); watch(() => props.columns, drawCanvas);
watch(() => settingsStore.pixelPerfect, drawCanvas); watch(() => settingsStore.pixelPerfect, drawCanvas);
watch(() => settingsStore.darkMode, drawCanvas); watch(() => settingsStore.darkMode, drawCanvas);
watch(() => settingsStore.negativeSpacingEnabled, drawCanvas);
watch(showAllSprites, drawCanvas); watch(showAllSprites, drawCanvas);
</script> </script>

View File

@@ -157,17 +157,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, computed } from 'vue'; import { ref, onMounted, watch, onUnmounted } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore'; import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
interface Sprite { import { getMaxDimensions } from '@/composables/useSprites';
id: string; import { useCanvas2D } from '@/composables/useCanvas2D';
img: HTMLImageElement; import { useZoom } from '@/composables/useZoom';
width: number; import { useAnimationFrames } from '@/composables/useAnimationFrames';
height: number;
x: number;
y: number;
}
const props = defineProps<{ const props = defineProps<{
sprites: Sprite[]; sprites: Sprite[];
@@ -179,17 +175,30 @@
}>(); }>();
const previewCanvasRef = ref<HTMLCanvasElement | null>(null); const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
// Get settings from store
const settingsStore = useSettingsStore();
// Initialize composables
const canvas2D = useCanvas2D(previewCanvasRef);
const {
zoom,
increase: increaseZoom,
decrease: decreaseZoom,
} = useZoom({
allowedValues: [0.5, 1, 2, 3, 4, 5],
initial: 1,
});
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
sprites: () => props.sprites,
onDraw: drawPreviewCanvas,
});
// Preview state // Preview state
const currentFrameIndex = ref(0);
const isPlaying = ref(false);
const fps = ref(12);
const zoom = ref(1);
const isDraggable = ref(false); const isDraggable = ref(false);
const showAllSprites = ref(false); const showAllSprites = ref(false);
const animationFrameId = ref<number | null>(null);
const lastFrameTime = ref(0);
// Dragging state // Dragging state
const isDragging = ref(false); const isDragging = ref(false);
@@ -198,139 +207,58 @@
const dragStartY = ref(0); const dragStartY = ref(0);
const spritePosBeforeDrag = ref({ x: 0, y: 0 }); const spritePosBeforeDrag = ref({ x: 0, y: 0 });
// Add this after other refs
const hiddenFrames = ref<number[]>([]);
// Get settings from store
const settingsStore = useSettingsStore();
// Add these computed properties
const visibleFrames = computed(() => props.sprites.filter((_, index) => !hiddenFrames.value.includes(index)));
const visibleFramesCount = computed(() => visibleFrames.value.length);
const visibleFrameIndex = computed(() => {
return visibleFrames.value.findIndex((_, idx) => idx === visibleFrames.value.findIndex(s => s === props.sprites[currentFrameIndex.value]));
});
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Canvas drawing // Canvas drawing
const calculateMaxDimensions = () => {
let maxWidth = 0;
let maxHeight = 0;
props.sprites.forEach(sprite => { // Calculate negative spacing based on sprite dimensions
maxWidth = Math.max(maxWidth, sprite.width); function calculateNegativeSpacing(): number {
maxHeight = Math.max(maxHeight, sprite.height); if (!settingsStore.negativeSpacingEnabled || props.sprites.length === 0) return 0;
});
return { maxWidth, maxHeight }; const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
}; const minWidth = Math.min(...props.sprites.map(s => s.width));
const minHeight = Math.min(...props.sprites.map(s => s.height));
const widthDiff = maxWidth - minWidth;
const heightDiff = maxHeight - minHeight;
return Math.max(widthDiff, heightDiff);
}
const drawPreviewCanvas = () => { function drawPreviewCanvas() {
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return; if (!previewCanvasRef.value || !canvas2D.ctx.value || props.sprites.length === 0) return;
const currentSprite = props.sprites[currentFrameIndex.value]; const currentSprite = props.sprites[currentFrameIndex.value];
if (!currentSprite) return; if (!currentSprite) return;
const { maxWidth, maxHeight } = calculateMaxDimensions(); const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
const negativeSpacing = calculateNegativeSpacing();
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
// Apply pixel art optimization consistently from store // Apply pixel art optimization
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect; canvas2D.applySmoothing();
// Set canvas size to just fit one sprite cell // Set canvas size to fit one sprite cell (expanded with negative spacing)
previewCanvasRef.value.width = maxWidth; canvas2D.setCanvasSize(cellWidth, cellHeight);
previewCanvasRef.value.height = maxHeight;
// Clear canvas // Clear canvas
ctx.value.clearRect(0, 0, previewCanvasRef.value.width, previewCanvasRef.value.height); canvas2D.clear();
// Draw grid background (cell) // Draw grid background (cell)
ctx.value.fillStyle = '#f9fafb'; canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
ctx.value.fillRect(0, 0, maxWidth, maxHeight);
// Keep pixel art optimization consistent throughout all drawing operations
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
// Draw all sprites with transparency if enabled // Draw all sprites with transparency if enabled
if (showAllSprites.value && props.sprites.length > 1) { if (showAllSprites.value && props.sprites.length > 1) {
ctx.value.globalAlpha = 0.3;
props.sprites.forEach((sprite, index) => { props.sprites.forEach((sprite, index) => {
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) { if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
// Use Math.floor for pixel-perfect positioning canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
ctx.value?.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
} }
}); });
ctx.value.globalAlpha = 1.0;
} }
// Draw current sprite with integer positions for pixel-perfect rendering // Draw current sprite with negative spacing offset
ctx.value.drawImage(currentSprite.img, Math.floor(currentSprite.x), Math.floor(currentSprite.y)); canvas2D.drawImage(currentSprite.img, negativeSpacing + currentSprite.x, negativeSpacing + currentSprite.y);
// Draw cell border // Draw cell border
ctx.value.strokeStyle = '#e5e7eb'; canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
ctx.value.lineWidth = 1; }
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
};
// Animation control
const togglePlayback = () => {
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
startAnimation();
} else {
stopAnimation();
}
};
const startAnimation = () => {
lastFrameTime.value = performance.now();
animateFrame();
};
const stopAnimation = () => {
if (animationFrameId.value !== null) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
};
const animateFrame = () => {
const now = performance.now();
const elapsed = now - lastFrameTime.value;
const frameTime = 1000 / fps.value;
if (elapsed >= frameTime) {
lastFrameTime.value = now - (elapsed % frameTime);
nextFrame();
}
animationFrameId.value = requestAnimationFrame(animateFrame);
};
const nextFrame = () => {
if (visibleFrames.value.length === 0) return;
const currentVisibleIndex = visibleFrameIndex.value;
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
drawPreviewCanvas();
};
const previousFrame = () => {
if (visibleFrames.value.length === 0) return;
const currentVisibleIndex = visibleFrameIndex.value;
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
drawPreviewCanvas();
};
// Add this method to handle slider input
const handleSliderInput = (event: Event) => {
const target = event.target as HTMLInputElement;
const index = parseInt(target.value);
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[index]);
};
// Drag functionality // Drag functionality
const startDrag = (event: MouseEvent) => { const startDrag = (event: MouseEvent) => {
@@ -344,9 +272,12 @@
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY; const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
const sprite = props.sprites[currentFrameIndex.value]; const sprite = props.sprites[currentFrameIndex.value];
const negativeSpacing = calculateNegativeSpacing();
// Check if click is on sprite // Check if click is on sprite (accounting for negative spacing offset)
if (sprite && mouseX >= sprite.x && mouseX <= sprite.x + sprite.width && mouseY >= sprite.y && mouseY <= sprite.y + sprite.height) { const spriteCanvasX = negativeSpacing + sprite.x;
const spriteCanvasY = negativeSpacing + sprite.y;
if (sprite && mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) {
isDragging.value = true; isDragging.value = true;
activeSpriteId.value = sprite.id; activeSpriteId.value = sprite.id;
dragStartX.value = mouseX; dragStartX.value = mouseX;
@@ -371,15 +302,18 @@
const sprite = props.sprites[currentFrameIndex.value]; const sprite = props.sprites[currentFrameIndex.value];
if (!sprite || sprite.id !== activeSpriteId.value) return; if (!sprite || sprite.id !== activeSpriteId.value) return;
const { maxWidth, maxHeight } = calculateMaxDimensions(); const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
const negativeSpacing = calculateNegativeSpacing();
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
// Calculate new position with constraints and round to integers // Calculate new position with constraints and round to integers
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX); let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY); let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
// Constrain movement within cell // Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
newX = Math.max(0, Math.min(maxWidth - sprite.width, newX)); newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(0, Math.min(maxHeight - sprite.height, newY)); newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
emit('updateSprite', activeSpriteId.value, newX, newY); emit('updateSprite', activeSpriteId.value, newX, newY);
drawPreviewCanvas(); drawPreviewCanvas();
@@ -390,23 +324,6 @@
activeSpriteId.value = null; activeSpriteId.value = null;
}; };
// Add helper methods for mobile zoom controls
const increaseZoom = () => {
const zoomValues = [0.5, 1, 2, 3, 4];
const currentIndex = zoomValues.indexOf(Number(zoom.value));
if (currentIndex < zoomValues.length - 1) {
zoom.value = zoomValues[currentIndex + 1];
}
};
const decreaseZoom = () => {
const zoomValues = [0.5, 1, 2, 3, 4];
const currentIndex = zoomValues.indexOf(Number(zoom.value));
if (currentIndex > 0) {
zoom.value = zoomValues[currentIndex - 1];
}
};
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
if (!isDraggable.value) return; if (!isDraggable.value) return;
@@ -439,10 +356,8 @@
// Lifecycle hooks // Lifecycle hooks
onMounted(() => { onMounted(() => {
if (previewCanvasRef.value) { canvas2D.initContext();
ctx.value = previewCanvasRef.value.getContext('2d'); drawPreviewCanvas();
drawPreviewCanvas();
}
// Listen for forceRedraw event from App.vue // Listen for forceRedraw event from App.vue
window.addEventListener('forceRedraw', handleForceRedraw); window.addEventListener('forceRedraw', handleForceRedraw);
@@ -455,17 +370,9 @@
// Handler for force redraw event // Handler for force redraw event
const handleForceRedraw = () => { const handleForceRedraw = () => {
// Ensure we're using integer positions for pixel-perfect rendering canvas2D.ensureIntegerPositions(props.sprites);
props.sprites.forEach(sprite => { canvas2D.applySmoothing();
sprite.x = Math.floor(sprite.x); drawPreviewCanvas();
sprite.y = Math.floor(sprite.y);
});
// Force a redraw with the correct image smoothing settings
if (ctx.value) {
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
drawPreviewCanvas();
}
}; };
// Watchers // Watchers
@@ -476,47 +383,12 @@
watch(showAllSprites, drawPreviewCanvas); watch(showAllSprites, drawPreviewCanvas);
watch(hiddenFrames, drawPreviewCanvas); watch(hiddenFrames, drawPreviewCanvas);
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas); watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
// Initial draw // Initial draw
if (props.sprites.length > 0) { if (props.sprites.length > 0) {
drawPreviewCanvas(); drawPreviewCanvas();
} }
const toggleHiddenFrame = (index: number) => {
const currentIndex = hiddenFrames.value.indexOf(index);
if (currentIndex === -1) {
// Adding to hidden frames
hiddenFrames.value.push(index);
// If we're hiding the current frame, switch to the next visible frame
if (index === currentFrameIndex.value) {
const nextVisibleSprite = props.sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
if (nextVisibleSprite !== -1) {
currentFrameIndex.value = nextVisibleSprite;
}
}
} else {
// Removing from hidden frames
hiddenFrames.value.splice(currentIndex, 1);
}
// Force a redraw
drawPreviewCanvas();
};
const showAllFrames = () => {
hiddenFrames.value = [];
drawPreviewCanvas();
};
const hideAllFrames = () => {
hiddenFrames.value = props.sprites.map((_, index) => index);
// Keep at least one frame visible
if (hiddenFrames.value.length > 0) {
hiddenFrames.value.splice(currentFrameIndex.value, 1);
}
drawPreviewCanvas();
};
</script> </script>
<style scoped> <style scoped>

View File

@@ -96,6 +96,7 @@
import { ref, watch, onUnmounted } from 'vue'; import { ref, watch, onUnmounted } from 'vue';
import Modal from './utilities/Modal.vue'; import Modal from './utilities/Modal.vue';
import { useSettingsStore } from '@/stores/useSettingsStore'; import { useSettingsStore } from '@/stores/useSettingsStore';
import type { SpriteFile } from '@/types/sprites';
interface SpritePreview { interface SpritePreview {
url: string; url: string;
@@ -117,14 +118,6 @@
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[] (e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
}>(); }>();
interface SpriteFile {
file: File;
x: number;
y: number;
width: number;
height: number;
}
// Get settings from store // Get settings from store
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();

View File

@@ -34,7 +34,7 @@
<!-- Resize handle --> <!-- Resize handle -->
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize"> <div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize">
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-8 h-8 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22Z"/> <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22Z" />
</svg> </svg>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,178 @@
import { ref, computed, type Ref, onUnmounted, toRef, isRef } from 'vue';
import type { Sprite } from '@/types/sprites';
export interface AnimationFramesOptions {
sprites: Ref<Sprite[]> | Sprite[] | (() => Sprite[]);
onDraw: () => void;
}
export function useAnimationFrames(options: AnimationFramesOptions) {
const { onDraw } = options;
// Convert sprites to a computed ref for reactivity
const spritesRef = computed(() => {
if (typeof options.sprites === 'function') {
return options.sprites();
}
if (isRef(options.sprites)) {
return options.sprites.value;
}
return options.sprites;
});
// Helper to get sprites array
const getSprites = () => spritesRef.value;
// State
const currentFrameIndex = ref(0);
const isPlaying = ref(false);
const fps = ref(12);
const hiddenFrames = ref<number[]>([]);
// Animation internals
const animationFrameId = ref<number | null>(null);
const lastFrameTime = ref(0);
// Computed properties for visible frames
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
const visibleFramesCount = computed(() => visibleFrames.value.length);
const visibleFrameIndex = computed(() => {
const sprites = getSprites();
const currentSprite = sprites[currentFrameIndex.value];
return visibleFrames.value.findIndex(s => s === currentSprite);
});
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Animation control
const animateFrame = () => {
const now = performance.now();
const elapsed = now - lastFrameTime.value;
const frameTime = 1000 / fps.value;
if (elapsed >= frameTime) {
lastFrameTime.value = now - (elapsed % frameTime);
nextFrame();
}
animationFrameId.value = requestAnimationFrame(animateFrame);
};
const startAnimation = () => {
lastFrameTime.value = performance.now();
animateFrame();
};
const stopAnimation = () => {
if (animationFrameId.value !== null) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
};
const togglePlayback = () => {
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
startAnimation();
} else {
stopAnimation();
}
};
const nextFrame = () => {
if (visibleFrames.value.length === 0) return;
const sprites = getSprites();
const currentVisibleIndex = visibleFrameIndex.value;
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
onDraw();
};
const previousFrame = () => {
if (visibleFrames.value.length === 0) return;
const sprites = getSprites();
const currentVisibleIndex = visibleFrameIndex.value;
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
onDraw();
};
const handleSliderInput = (event: Event) => {
const sprites = getSprites();
const target = event.target as HTMLInputElement;
const index = parseInt(target.value);
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
};
// Frame visibility management
const toggleHiddenFrame = (index: number) => {
const sprites = getSprites();
const currentIndex = hiddenFrames.value.indexOf(index);
if (currentIndex === -1) {
hiddenFrames.value.push(index);
// If hiding current frame, switch to next visible
if (index === currentFrameIndex.value) {
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
if (nextVisible !== -1) {
currentFrameIndex.value = nextVisible;
}
}
} else {
hiddenFrames.value.splice(currentIndex, 1);
}
onDraw();
};
const showAllFrames = () => {
hiddenFrames.value = [];
onDraw();
};
const hideAllFrames = () => {
const sprites = getSprites();
hiddenFrames.value = sprites.map((_, index) => index);
// Keep at least one frame visible
if (hiddenFrames.value.length > 0) {
hiddenFrames.value.splice(currentFrameIndex.value, 1);
}
onDraw();
};
// Cleanup on unmount
onUnmounted(() => {
stopAnimation();
});
return {
// State
currentFrameIndex,
isPlaying,
fps,
hiddenFrames,
// Computed
visibleFrames,
visibleFramesCount,
visibleFrameIndex,
visibleFrameNumber,
// Methods
togglePlayback,
nextFrame,
previousFrame,
handleSliderInput,
toggleHiddenFrame,
showAllFrames,
hideAllFrames,
stopAnimation,
};
}

View File

@@ -0,0 +1,150 @@
import { ref, type Ref } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
export interface Canvas2DOptions {
pixelPerfect?: Ref<boolean> | boolean;
}
export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?: Canvas2DOptions) {
const ctx = ref<CanvasRenderingContext2D | null>(null);
const settingsStore = useSettingsStore();
const initContext = () => {
if (canvasRef.value) {
ctx.value = canvasRef.value.getContext('2d');
applySmoothing();
}
return ctx.value;
};
const applySmoothing = () => {
if (ctx.value) {
const pixelPerfect = options?.pixelPerfect;
const isPixelPerfect = typeof pixelPerfect === 'boolean' ? pixelPerfect : (pixelPerfect?.value ?? settingsStore.pixelPerfect);
ctx.value.imageSmoothingEnabled = !isPixelPerfect;
}
};
const clear = () => {
if (!canvasRef.value || !ctx.value) return;
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
};
const setCanvasSize = (width: number, height: number) => {
if (canvasRef.value) {
canvasRef.value.width = width;
canvasRef.value.height = height;
}
};
const fillRect = (x: number, y: number, width: number, height: number, color: string) => {
if (!ctx.value) return;
ctx.value.fillStyle = color;
ctx.value.fillRect(Math.floor(x), Math.floor(y), width, height);
};
const strokeRect = (x: number, y: number, width: number, height: number, color: string, lineWidth = 1) => {
if (!ctx.value) return;
ctx.value.strokeStyle = color;
ctx.value.lineWidth = lineWidth;
ctx.value.strokeRect(Math.floor(x), Math.floor(y), width, height);
};
const drawImage = (img: HTMLImageElement | HTMLCanvasElement, x: number, y: number, alpha = 1) => {
if (!ctx.value) return;
const prevAlpha = ctx.value.globalAlpha;
ctx.value.globalAlpha = alpha;
ctx.value.drawImage(img, Math.floor(x), Math.floor(y));
ctx.value.globalAlpha = prevAlpha;
};
const setGlobalAlpha = (alpha: number) => {
if (ctx.value) {
ctx.value.globalAlpha = alpha;
}
};
const resetGlobalAlpha = () => {
if (ctx.value) {
ctx.value.globalAlpha = 1.0;
}
};
// Helper to ensure integer positions for pixel-perfect rendering
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
items.forEach(item => {
item.x = Math.floor(item.x);
item.y = Math.floor(item.y);
});
};
// Centralized force redraw handler
const createForceRedrawHandler = <T extends { x: number; y: number }>(items: T[], drawCallback: () => void) => {
return () => {
ensureIntegerPositions(items);
applySmoothing();
drawCallback();
};
};
// Get mouse position relative to canvas, accounting for zoom
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
if (!canvasRef.value) return null;
const rect = canvasRef.value.getBoundingClientRect();
const scaleX = canvasRef.value.width / (rect.width / zoom);
const scaleY = canvasRef.value.height / (rect.height / zoom);
return {
x: ((event.clientX - rect.left) / zoom) * scaleX,
y: ((event.clientY - rect.top) / zoom) * scaleY,
};
};
// Helper to attach load/error listeners to images that aren't yet loaded
const attachImageListeners = (sprites: Sprite[], onLoad: () => void, tracked: WeakSet<HTMLImageElement>) => {
sprites.forEach(sprite => {
const img = sprite.img as HTMLImageElement | undefined;
if (img && !tracked.has(img)) {
tracked.add(img);
if (!img.complete) {
img.addEventListener('load', onLoad, { once: true });
img.addEventListener('error', onLoad, { once: true });
}
}
});
};
// Fill cell background with theme-aware color
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
fillRect(x, y, width, height, color);
};
// Stroke grid with theme-aware color
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
strokeRect(x, y, width, height, color, 1);
};
return {
ctx,
canvasRef,
initContext,
applySmoothing,
clear,
setCanvasSize,
fillRect,
strokeRect,
drawImage,
setGlobalAlpha,
resetGlobalAlpha,
ensureIntegerPositions,
createForceRedrawHandler,
getMousePosition,
attachImageListeners,
fillCellBackground,
strokeGridCell,
};
}

View File

@@ -0,0 +1,305 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
export interface CellPosition {
col: number;
row: number;
index: number;
}
export interface SpritePosition {
id: string;
canvasX: number;
canvasY: number;
cellX: number;
cellY: number;
width: number;
height: number;
maxWidth: number;
maxHeight: number;
col: number;
row: number;
index: number;
x: number;
y: number;
}
export interface DragSpriteOptions {
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
columns: Ref<number> | number;
zoom?: Ref<number>;
allowCellSwap?: Ref<boolean>;
negativeSpacingEnabled?: Ref<boolean>;
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
onUpdateSprite: (id: string, x: number, y: number) => void;
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
onDraw: () => void;
}
export function useDragSprite(options: DragSpriteOptions) {
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
// Helper to get reactive values
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
const getZoom = () => options.zoom?.value ?? 1;
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
// Drag state
const isDragging = ref(false);
const activeSpriteId = ref<string | null>(null);
const activeSpriteCellIndex = ref<number | null>(null);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragOffsetX = ref(0);
const dragOffsetY = ref(0);
const currentHoverCell = ref<CellPosition | null>(null);
// Visual feedback
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
const highlightCell = ref<CellPosition | null>(null);
// Cache for max dimensions
const lastMaxWidth = ref(1);
const lastMaxHeight = ref(1);
const calculateMaxDimensions = () => {
const sprites = getSprites();
const negativeSpacingEnabled = getNegativeSpacingEnabled();
const base = getMaxDimensions(sprites);
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
lastMaxWidth.value = baseMaxWidth;
lastMaxHeight.value = baseMaxHeight;
// Calculate negative spacing based on sprite size differences
let negativeSpacing = 0;
if (negativeSpacingEnabled && sprites.length > 0) {
// Find the smallest sprite dimensions
const minWidth = Math.min(...sprites.map(s => s.width));
const minHeight = Math.min(...sprites.map(s => s.height));
// Negative spacing is the difference between max and min dimensions
const widthDiff = baseMaxWidth - minWidth;
const heightDiff = baseMaxHeight - minHeight;
negativeSpacing = Math.max(widthDiff, heightDiff);
}
// Add negative spacing to expand each cell
const maxWidth = baseMaxWidth + negativeSpacing;
const maxHeight = baseMaxHeight + negativeSpacing;
return { maxWidth, maxHeight, negativeSpacing };
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
return sprites.map((sprite, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
// With negative spacing, sprites are positioned at bottom-right of cell
// (spacing added to top and left)
return {
id: sprite.id,
canvasX: col * maxWidth + negativeSpacing + sprite.x,
canvasY: row * maxHeight + negativeSpacing + sprite.y,
cellX: col * maxWidth,
cellY: row * maxHeight,
width: sprite.width,
height: sprite.height,
maxWidth,
maxHeight,
col,
row,
index,
x: sprite.x,
y: sprite.y,
};
});
});
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight } = calculateMaxDimensions();
const col = Math.floor(x / maxWidth);
const row = Math.floor(y / maxHeight);
const totalRows = Math.ceil(sprites.length / columns);
if (col >= 0 && col < columns && row >= 0 && row < totalRows) {
const index = row * columns + col;
if (index < sprites.length) {
return { col, row, index };
}
}
return null;
};
const findSpriteAtPosition = (x: number, y: number): Sprite | null => {
const sprites = getSprites();
const positions = spritePositions.value;
for (let i = positions.length - 1; i >= 0; i--) {
const pos = positions[i];
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
return sprites.find(s => s.id === pos.id) || null;
}
}
return null;
};
const startDrag = (event: MouseEvent) => {
const pos = getMousePosition(event, getZoom());
if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
if (clickedSprite) {
isDragging.value = true;
activeSpriteId.value = clickedSprite.id;
dragStartX.value = pos.x;
dragStartY.value = pos.y;
const spritePosition = spritePositions.value.find(p => p.id === clickedSprite.id);
if (spritePosition) {
dragOffsetX.value = pos.x - spritePosition.canvasX;
dragOffsetY.value = pos.y - spritePosition.canvasY;
activeSpriteCellIndex.value = spritePosition.index;
const startCell = findCellAtPosition(pos.x, pos.y);
if (startCell) {
currentHoverCell.value = startCell;
highlightCell.value = null;
}
}
}
};
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
if (!activeSpriteId.value) return;
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
// Use the sprite's current index in the array to calculate cell position
const cellCol = spriteIndex % columns;
const cellRow = Math.floor(spriteIndex / columns);
const cellX = cellCol * maxWidth;
const cellY = cellRow * maxHeight;
// Calculate new position relative to cell origin (without the negative spacing offset)
// The sprite's x,y is stored relative to where it would be drawn after the negativeSpacing offset
const newX = mouseX - cellX - negativeSpacing - dragOffsetX.value;
const newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
// The sprite can move within the full expanded cell area
// Allow negative values up to -negativeSpacing so sprite can fill the expanded area
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
onDraw();
};
const drag = (event: MouseEvent) => {
if (!isDragging.value || !activeSpriteId.value || activeSpriteCellIndex.value === null) return;
const pos = getMousePosition(event, getZoom());
if (!pos) return;
const sprites = getSprites();
const spriteIndex = sprites.findIndex(s => s.id === activeSpriteId.value);
if (spriteIndex === -1) return;
const hoverCell = findCellAtPosition(pos.x, pos.y);
currentHoverCell.value = hoverCell;
if (getAllowCellSwap() && hoverCell) {
if (hoverCell.index !== activeSpriteCellIndex.value) {
highlightCell.value = hoverCell;
ghostSprite.value = {
id: activeSpriteId.value,
x: pos.x - dragOffsetX.value,
y: pos.y - dragOffsetY.value,
};
onDraw();
} else {
highlightCell.value = null;
ghostSprite.value = null;
handleInCellMovement(pos.x, pos.y, spriteIndex);
}
} else {
handleInCellMovement(pos.x, pos.y, spriteIndex);
}
};
const stopDrag = () => {
if (isDragging.value && getAllowCellSwap() && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
if (onUpdateSpriteCell) {
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
}
onUpdateSprite(activeSpriteId.value, 0, 0);
}
isDragging.value = false;
activeSpriteId.value = null;
activeSpriteCellIndex.value = null;
currentHoverCell.value = null;
highlightCell.value = null;
ghostSprite.value = null;
onDraw();
};
// Touch event handlers
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length === 1) {
const touch = event.touches[0];
const mouseEvent = {
clientX: touch.clientX,
clientY: touch.clientY,
preventDefault: () => {},
} as unknown as MouseEvent;
startDrag(mouseEvent);
}
};
const handleTouchMove = (event: TouchEvent) => {
if (isDragging.value) {
event.preventDefault();
}
if (event.touches.length === 1) {
const touch = event.touches[0];
const mouseEvent = {
clientX: touch.clientX,
clientY: touch.clientY,
preventDefault: () => {},
} as unknown as MouseEvent;
drag(mouseEvent);
}
};
return {
// State
isDragging,
activeSpriteId,
ghostSprite,
highlightCell,
spritePositions,
// Methods
startDrag,
drag,
stopDrag,
handleTouchStart,
handleTouchMove,
findSpriteAtPosition,
findCellAtPosition,
calculateMaxDimensions,
};
}

View File

@@ -0,0 +1,225 @@
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>, negativeSpacingEnabled: Ref<boolean>) => {
// Calculate negative spacing based on sprite dimensions
const calculateNegativeSpacing = (): number => {
if (!negativeSpacingEnabled.value || sprites.value.length === 0) return 0;
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const minWidth = Math.min(...sprites.value.map(s => s.width));
const minHeight = Math.min(...sprites.value.map(s => s.height));
const widthDiff = maxWidth - minWidth;
const heightDiff = maxHeight - minHeight;
return Math.max(widthDiff, heightDiff);
};
const downloadSpritesheet = () => {
if (!sprites.value.length) {
alert('Please upload or import sprites before downloading the spritesheet.');
return;
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing();
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
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;
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, 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 negativeSpacing = calculateNegativeSpacing();
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
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);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, cellWidth, cellHeight);
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 { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing();
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
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);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, cellWidth, cellHeight);
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 };
};

View File

@@ -0,0 +1,110 @@
import { ref, type Ref } from 'vue';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
export interface FileDropOptions {
sprites: Ref<Sprite[]> | Sprite[];
onAddSprite: (file: File) => void;
onAddSpriteWithResize: (file: File) => void;
}
export function useFileDrop(options: FileDropOptions) {
const { onAddSprite, onAddSpriteWithResize } = options;
// Helper to get sprites array
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const isDragOver = ref(false);
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
isDragOver.value = true;
};
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragOver.value = true;
};
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLCanvasElement | null) => {
event.preventDefault();
event.stopPropagation();
if (canvasRef) {
const rect = canvasRef.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
isDragOver.value = false;
}
} else {
isDragOver.value = false;
}
};
const processDroppedImage = (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = e => {
if (e.target?.result) {
img.src = e.target.result as string;
}
};
img.onload = () => {
const sprites = getSprites();
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
// Check if the dropped image is larger than current cells
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
onAddSpriteWithResize(file);
} else {
onAddSprite(file);
}
resolve();
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
reject(new Error('Failed to load image'));
};
reader.readAsDataURL(file);
});
};
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragOver.value = false;
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
return;
}
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
if (files.length === 0) {
alert('Please drop image files only.');
return;
}
// Process each dropped file
for (const file of files) {
await processDroppedImage(file);
}
};
return {
isDragOver,
handleDragOver,
handleDragEnter,
handleDragLeave,
handleDrop,
};
}

View 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);
};

View File

@@ -0,0 +1,83 @@
import { ref, computed } from 'vue';
export interface ZoomOptionsStep {
min: number;
max: number;
step: number;
initial?: number;
}
export interface ZoomOptionsAllowed {
allowedValues: number[];
initial?: number;
}
export type ZoomOptions = ZoomOptionsStep | ZoomOptionsAllowed;
function isStepOptions(options: ZoomOptions): options is ZoomOptionsStep {
return 'step' in options;
}
export function useZoom(options: ZoomOptions) {
const initial = options.initial ?? (isStepOptions(options) ? 1 : (options.allowedValues[1] ?? options.allowedValues[0]));
const zoom = ref(initial);
const zoomPercent = computed(() => Math.round(zoom.value * 100));
const increase = () => {
if (isStepOptions(options)) {
zoom.value = Math.min(options.max, zoom.value + options.step);
} else {
const currentIndex = options.allowedValues.indexOf(zoom.value);
if (currentIndex < options.allowedValues.length - 1) {
zoom.value = options.allowedValues[currentIndex + 1];
} else if (currentIndex === -1) {
// Find the nearest higher value
const higher = options.allowedValues.find(v => v > zoom.value);
if (higher !== undefined) {
zoom.value = higher;
}
}
}
};
const decrease = () => {
if (isStepOptions(options)) {
zoom.value = Math.max(options.min, zoom.value - options.step);
} else {
const currentIndex = options.allowedValues.indexOf(zoom.value);
if (currentIndex > 0) {
zoom.value = options.allowedValues[currentIndex - 1];
} else if (currentIndex === -1) {
// Find the nearest lower value
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
if (lower !== undefined) {
zoom.value = lower;
}
}
}
};
const reset = () => {
zoom.value = initial;
};
const setZoom = (value: number) => {
if (isStepOptions(options)) {
zoom.value = Math.max(options.min, Math.min(options.max, value));
} else {
// Snap to nearest allowed value
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
zoom.value = nearest;
}
};
return {
zoom,
zoomPercent,
increase,
decrease,
reset,
setZoom,
};
}

View File

@@ -3,6 +3,7 @@ import { ref, watch } from 'vue';
const pixelPerfect = ref(true); const pixelPerfect = ref(true);
const darkMode = ref(false); const darkMode = ref(false);
const negativeSpacingEnabled = ref(false);
// Initialize dark mode from localStorage or system preference // Initialize dark mode from localStorage or system preference
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -51,12 +52,18 @@ export const useSettingsStore = defineStore('settings', () => {
darkMode.value = value; darkMode.value = value;
} }
function toggleNegativeSpacing() {
negativeSpacingEnabled.value = !negativeSpacingEnabled.value;
}
return { return {
pixelPerfect, pixelPerfect,
darkMode, darkMode,
negativeSpacingEnabled,
togglePixelPerfect, togglePixelPerfect,
setPixelPerfect, setPixelPerfect,
toggleDarkMode, toggleDarkMode,
setDarkMode, setDarkMode,
toggleNegativeSpacing,
}; };
}); });

18
src/types/sprites.ts Normal file
View 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;
}