Complete rewrite
This commit is contained in:
parent
22390acfa6
commit
e42a113c2d
160
src/App.vue
160
src/App.vue
@ -1,79 +1,117 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-gray-200 font-sans">
|
||||
<!-- Navigation sidebar -->
|
||||
<navigation @show-help="showHelpModal" />
|
||||
<div class="min-h-screen bg-gray-100 p-4">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">Spritesheet Generator</h1>
|
||||
<div class="max-w-4xl mx-auto bg-white rounded-lg shadow p-6">
|
||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="pl-16">
|
||||
<!-- Add left padding to accommodate the fixed navigation -->
|
||||
<app-header @toggle-help="showHelpModal" />
|
||||
<div v-if="sprites.length > 0" class="mt-6">
|
||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
|
||||
<!-- Admin panel-like layout -->
|
||||
<div class="max-w-7xl mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">Spritesheet creator</h1>
|
||||
<div class="flex gap-3">
|
||||
<button @click="store.isSpritesModalOpen.value = true" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500">
|
||||
<i class="fas fa-images"></i> Sprites
|
||||
<span v-if="sprites.length > 0" class="ml-1 bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{{ sprites.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button @click="store.isSettingsModalOpen.value = true" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500"><i class="fas fa-cog"></i> Settings</button>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<div class="flex items-center">
|
||||
<label for="columns" class="mr-2">Columns:</label>
|
||||
<input id="columns" type="number" v-model="columns" min="1" max="10" class="border rounded px-2 py-1 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-500 bg-opacity-10 border-l-4 border-blue-500 p-4 mb-6 rounded-r">
|
||||
<p>Container size will adjust to fit the largest sprite. All sprites will be placed in cells of the same size.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<sidebar class="lg:col-span-1" />
|
||||
<main-content class="lg:col-span-3" />
|
||||
<button @click="downloadSpritesheet" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">Download Spritesheet</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<preview-modal ref="previewModalRef" />
|
||||
<settings-modal />
|
||||
<sprites-modal />
|
||||
<help-modal />
|
||||
<notification />
|
||||
<help-button @show-help="showHelpModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import AppHeader from './components/AppHeader.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import MainContent from './components/MainContent.vue';
|
||||
import PreviewModal from './components/PreviewModal.vue';
|
||||
import SettingsModal from './components/SettingsModal.vue';
|
||||
import SpritesModal from './components/SpritesModal.vue';
|
||||
import HelpModal from './components/HelpModal.vue';
|
||||
import Navigation from './components/Navigation.vue';
|
||||
import Notification from './components/Notification.vue';
|
||||
import HelpButton from './components/HelpButton.vue';
|
||||
import { useSpritesheetStore } from './composables/useSpritesheetStore';
|
||||
import { ref } from 'vue';
|
||||
import FileUploader from './components/FileUploader.vue';
|
||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const previewModalRef = ref<InstanceType<typeof PreviewModal> | null>(null);
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
interface Sprite {
|
||||
id: string;
|
||||
file: File;
|
||||
img: HTMLImageElement;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Watch for changes to isModalOpen and call the openModal method when it becomes true
|
||||
watch(
|
||||
() => store.isModalOpen.value,
|
||||
isOpen => {
|
||||
if (isOpen && previewModalRef.value) {
|
||||
previewModalRef.value.openModal();
|
||||
}
|
||||
const sprites = ref<Sprite[]>([]);
|
||||
const columns = ref(4);
|
||||
|
||||
const handleSpritesUpload = (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) {
|
||||
sprites.value[spriteIndex].x = x;
|
||||
sprites.value[spriteIndex].y = y;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const showHelpModal = () => {
|
||||
// Open the help modal instead of showing an alert
|
||||
store.isHelpModalOpen.value = true;
|
||||
const downloadSpritesheet = () => {
|
||||
if (sprites.value.length === 0) 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;
|
||||
|
||||
// Apply pixel art optimization for the export canvas
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw sprites
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
|
||||
const cellX = col * maxWidth;
|
||||
const cellY = row * maxHeight;
|
||||
|
||||
ctx.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
};
|
||||
</script>
|
||||
|
@ -1,160 +0,0 @@
|
||||
import { sprites, animation, cellSize, previewBorder } from '@/application/state';
|
||||
import { getSpriteOffset } from '@/application/utilities';
|
||||
|
||||
/**
|
||||
* Start the animation
|
||||
*/
|
||||
export function startAnimation() {
|
||||
if (sprites.value.length === 0 || !animation.canvas) return;
|
||||
|
||||
animation.isPlaying = true;
|
||||
animation.lastFrameTime = performance.now();
|
||||
animation.manualUpdate = false;
|
||||
|
||||
animation.canvas.width = cellSize.width;
|
||||
animation.canvas.height = cellSize.height;
|
||||
|
||||
// Start the animation loop without resetting sprite offset
|
||||
animationLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the animation
|
||||
*/
|
||||
export function stopAnimation() {
|
||||
animation.isPlaying = false;
|
||||
|
||||
if (animation.animationId) {
|
||||
cancelAnimationFrame(animation.animationId);
|
||||
animation.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a specific frame of the animation
|
||||
*/
|
||||
export function renderAnimationFrame(frameIndex: number, showAllSprites = false, spriteOffset = { x: 0, y: 0 }) {
|
||||
if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return;
|
||||
|
||||
// Resize the animation canvas to match the cell size if needed
|
||||
if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) {
|
||||
animation.canvas.width = cellSize.width;
|
||||
animation.canvas.height = cellSize.height;
|
||||
}
|
||||
|
||||
// Clear the canvas
|
||||
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
|
||||
|
||||
// Draw background (transparent by default)
|
||||
animation.ctx.fillStyle = 'transparent';
|
||||
animation.ctx.fillRect(0, 0, animation.canvas.width, animation.canvas.height);
|
||||
|
||||
// Keep pixel art sharp
|
||||
animation.ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw background sprites with reduced opacity if requested
|
||||
if (showAllSprites && sprites.value.length > 1) {
|
||||
renderBackgroundSprites(frameIndex);
|
||||
}
|
||||
|
||||
// Get the current sprite and render it
|
||||
renderCurrentSprite(frameIndex, spriteOffset);
|
||||
|
||||
// Draw border around the cell if enabled
|
||||
renderPreviewBorder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw all sprites except the current one with reduced opacity
|
||||
*/
|
||||
function renderBackgroundSprites(frameIndex: number) {
|
||||
if (!animation.ctx) return;
|
||||
|
||||
// Save the current context state
|
||||
animation.ctx.save();
|
||||
|
||||
// Set global alpha for background sprites
|
||||
animation.ctx.globalAlpha = 0.3;
|
||||
|
||||
// Draw all sprites except the current one
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
if (index !== frameIndex) {
|
||||
const spriteCellX = Math.floor(sprite.x / cellSize.width);
|
||||
const spriteCellY = Math.floor(sprite.y / cellSize.height);
|
||||
|
||||
// Calculate precise offset for pixel-perfect rendering
|
||||
const spriteOffsetX = Math.round(sprite.x - spriteCellX * cellSize.width);
|
||||
const spriteOffsetY = Math.round(sprite.y - spriteCellY * cellSize.height);
|
||||
|
||||
// Draw the sprite with transparency
|
||||
animation.ctx?.drawImage(sprite.img, spriteOffsetX, spriteOffsetY);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore the context to full opacity
|
||||
animation.ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current sprite at full opacity
|
||||
*/
|
||||
function renderCurrentSprite(frameIndex: number, spriteOffset: { x: number; y: number }) {
|
||||
if (!animation.ctx) return;
|
||||
|
||||
// Get the current sprite
|
||||
const currentSprite = sprites.value[frameIndex % sprites.value.length];
|
||||
|
||||
// Draw the current sprite at full opacity at the specified position
|
||||
animation.ctx.drawImage(currentSprite.img, spriteOffset.x, spriteOffset.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a border around the animation preview if enabled
|
||||
*/
|
||||
function renderPreviewBorder() {
|
||||
if (!animation.ctx || !animation.canvas || !previewBorder.enabled) return;
|
||||
|
||||
animation.ctx.strokeStyle = previewBorder.color;
|
||||
animation.ctx.lineWidth = previewBorder.width;
|
||||
|
||||
// Use pixel-perfect coordinates for the border (0.5 offset for crisp lines)
|
||||
const x = 0.5;
|
||||
const y = 0.5;
|
||||
const width = animation.canvas.width - 1;
|
||||
const height = animation.canvas.height - 1;
|
||||
|
||||
animation.ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation loop for continuous playback
|
||||
*/
|
||||
export function animationLoop(timestamp?: number) {
|
||||
if (!animation.isPlaying) return;
|
||||
|
||||
const currentTime = timestamp || performance.now();
|
||||
const elapsed = currentTime - animation.lastFrameTime;
|
||||
const frameInterval = 1000 / animation.frameRate;
|
||||
|
||||
if (elapsed >= frameInterval) {
|
||||
animation.lastFrameTime = currentTime;
|
||||
|
||||
if (sprites.value.length > 0) {
|
||||
// Get the stored offset for the current frame
|
||||
const frameOffset = getSpriteOffset(animation.currentFrame);
|
||||
|
||||
// Render the current frame with its offset
|
||||
renderAnimationFrame(animation.currentFrame, false, frameOffset);
|
||||
|
||||
// Move to the next frame
|
||||
animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length;
|
||||
|
||||
// Update the slider position if available
|
||||
if (animation.slider) {
|
||||
animation.slider.value = animation.currentFrame.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animation.animationId = requestAnimationFrame(animationLoop);
|
||||
}
|
@ -1,332 +0,0 @@
|
||||
// canvasOperations.ts
|
||||
import { sprites, canvas, ctx, cellSize, columns, zoomLevel, previewBorder } from '@/application/state';
|
||||
import { logger, getSpriteOffset, isImageReady, getPixelPerfectCoordinate, showNotification } from '@/application/utilities';
|
||||
|
||||
/**
|
||||
* Update the canvas size based on sprites and cell size
|
||||
*/
|
||||
export function updateCanvasSize() {
|
||||
if (!canvas.value) {
|
||||
logger.warn('Canvas not available for size update');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sprites.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const totalSprites = sprites.value.length;
|
||||
const cols = columns.value;
|
||||
const rows = Math.ceil(totalSprites / cols);
|
||||
|
||||
if (cellSize.width <= 0 || cellSize.height <= 0) {
|
||||
logger.error('Invalid cell size for canvas update', cellSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const newWidth = cols * cellSize.width;
|
||||
const newHeight = rows * cellSize.height;
|
||||
|
||||
// Ensure the canvas is large enough to display all sprites
|
||||
if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) {
|
||||
canvas.value.width = newWidth;
|
||||
canvas.value.height = newHeight;
|
||||
|
||||
// Emit an event to update the wrapper dimensions
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('canvas-size-updated', {
|
||||
detail: { width: newWidth, height: newHeight },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating canvas size:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the grid on the canvas
|
||||
*/
|
||||
export function drawGrid() {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
ctx.value.strokeStyle = '#333';
|
||||
ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level
|
||||
|
||||
// Calculate the visible area based on zoom level
|
||||
const visibleWidth = canvas.value.width / zoomLevel.value;
|
||||
const visibleHeight = canvas.value.height / zoomLevel.value;
|
||||
|
||||
// Draw vertical lines - ensure pixel-perfect grid lines
|
||||
for (let x = 0; x <= visibleWidth; x += cellSize.width) {
|
||||
const pixelX = getPixelPerfectCoordinate(x);
|
||||
ctx.value.beginPath();
|
||||
ctx.value.moveTo(pixelX, 0);
|
||||
ctx.value.lineTo(pixelX, visibleHeight);
|
||||
ctx.value.stroke();
|
||||
}
|
||||
|
||||
// Draw horizontal lines - ensure pixel-perfect grid lines
|
||||
for (let y = 0; y <= visibleHeight; y += cellSize.height) {
|
||||
const pixelY = getPixelPerfectCoordinate(y);
|
||||
ctx.value.beginPath();
|
||||
ctx.value.moveTo(0, pixelY);
|
||||
ctx.value.lineTo(visibleWidth, pixelY);
|
||||
ctx.value.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the spritesheet preview on the canvas
|
||||
*/
|
||||
export function renderSpritesheetPreview(showGrid = true) {
|
||||
if (!ctx.value || !canvas.value) {
|
||||
logger.error('Canvas or context not available for rendering, will retry when ready');
|
||||
setTimeout(() => {
|
||||
if (ctx.value && canvas.value) {
|
||||
renderSpritesheetPreview(showGrid);
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
try {
|
||||
// Make sure the canvas size is correct before rendering
|
||||
updateCanvasSize();
|
||||
|
||||
// Clear the canvas
|
||||
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
|
||||
if (showGrid) {
|
||||
drawGrid();
|
||||
}
|
||||
|
||||
// First, collect all occupied cells
|
||||
const occupiedCells = new Set<string>();
|
||||
sprites.value.forEach(sprite => {
|
||||
const cellX = Math.floor(sprite.x / cellSize.width);
|
||||
const cellY = Math.floor(sprite.y / cellSize.height);
|
||||
occupiedCells.add(`${cellX},${cellY}`);
|
||||
});
|
||||
|
||||
// Draw each sprite - remove the zoom scaling from context
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
try {
|
||||
if (!sprite.img) {
|
||||
logger.warn(`Sprite at index ${index} has no image, skipping render`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the cell coordinates for this sprite
|
||||
const cellX = Math.floor(sprite.x / cellSize.width);
|
||||
const cellY = Math.floor(sprite.y / cellSize.height);
|
||||
|
||||
// Calculate cell boundaries
|
||||
const cellLeft = cellX * cellSize.width;
|
||||
const cellTop = cellY * cellSize.height;
|
||||
|
||||
// Get the frame-specific offset for this sprite
|
||||
const frameOffset = getSpriteOffset(index);
|
||||
|
||||
// Calculate the maximum allowed offset based on sprite and cell size
|
||||
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
|
||||
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
|
||||
|
||||
// Constrain the offset to prevent out-of-bounds positioning
|
||||
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
|
||||
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
|
||||
|
||||
// Calculate final position ensuring sprite stays within cell bounds
|
||||
const finalX = Math.max(cellLeft, Math.min(cellLeft + maxOffsetX, cellLeft + constrainedOffsetX));
|
||||
const finalY = Math.max(cellTop, Math.min(cellTop + maxOffsetY, cellTop + constrainedOffsetY));
|
||||
|
||||
// Update sprite position to stay within bounds
|
||||
sprite.x = finalX;
|
||||
sprite.y = finalY;
|
||||
|
||||
// Update the frame offset with the constrained values
|
||||
frameOffset.x = finalX - cellLeft;
|
||||
frameOffset.y = finalY - cellTop;
|
||||
|
||||
// Draw the image at its final position with pixel-perfect rendering
|
||||
if (isImageReady(sprite.img)) {
|
||||
ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp
|
||||
ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
|
||||
} else {
|
||||
logger.warn(`Sprite image ${index} not fully loaded, setting onload handler`);
|
||||
sprite.img.onload = () => {
|
||||
if (ctx.value && canvas.value) {
|
||||
renderSpriteOnCanvas(sprite, index);
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (spriteError) {
|
||||
logger.error(`Error rendering sprite at index ${index}:`, spriteError);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw borders around occupied cells if enabled (preview only)
|
||||
drawPreviewBorders(occupiedCells);
|
||||
} catch (error) {
|
||||
logger.error('Error in renderSpritesheetPreview:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a single sprite on the canvas
|
||||
*/
|
||||
function renderSpriteOnCanvas(sprite: any, index: number) {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
if (isImageReady(sprite.img)) {
|
||||
// For pixel art, ensure we're drawing at exact pixel boundaries
|
||||
const x = Math.round(sprite.x);
|
||||
const y = Math.round(sprite.y);
|
||||
|
||||
// Get the frame-specific offset for this sprite
|
||||
const frameOffset = getSpriteOffset(index);
|
||||
|
||||
// Calculate the maximum allowed offset based on sprite and cell size
|
||||
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
|
||||
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
|
||||
|
||||
// Constrain the offset to prevent out-of-bounds positioning
|
||||
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
|
||||
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
|
||||
|
||||
// Update the frame offset with the constrained values
|
||||
frameOffset.x = constrainedOffsetX;
|
||||
frameOffset.y = constrainedOffsetY;
|
||||
|
||||
// Apply the constrained offset to the sprite position
|
||||
const finalX = x + constrainedOffsetX;
|
||||
const finalY = y + constrainedOffsetY;
|
||||
|
||||
// Draw the image at its final position with pixel-perfect rendering
|
||||
ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp
|
||||
ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
|
||||
} else {
|
||||
logger.warn(`Sprite image ${index} not fully loaded, setting onload handler`);
|
||||
sprite.img.onload = () => {
|
||||
if (ctx.value && canvas.value) {
|
||||
renderSpriteOnCanvas(sprite, index);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw preview borders around occupied cells if enabled
|
||||
*/
|
||||
function drawPreviewBorders(occupiedCells: Set<string>) {
|
||||
if (!ctx.value || !canvas.value || !previewBorder.enabled || occupiedCells.size === 0) return;
|
||||
|
||||
ctx.value.strokeStyle = previewBorder.color;
|
||||
ctx.value.lineWidth = previewBorder.width / zoomLevel.value; // Adjust for zoom
|
||||
|
||||
// Draw borders around each occupied cell
|
||||
occupiedCells.forEach(cellKey => {
|
||||
const [cellX, cellY] = cellKey.split(',').map(Number);
|
||||
|
||||
// Calculate pixel-perfect coordinates for the cell
|
||||
// Add 0.5 to align with pixel boundaries for crisp lines
|
||||
const x = getPixelPerfectCoordinate(cellX * cellSize.width);
|
||||
const y = getPixelPerfectCoordinate(cellY * cellSize.height);
|
||||
|
||||
// Adjust width and height to ensure the border is inside the cell
|
||||
const width = cellSize.width - 1;
|
||||
const height = cellSize.height - 1;
|
||||
|
||||
ctx.value!.strokeRect(x, y, width, height);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the spritesheet as a PNG
|
||||
*/
|
||||
export function downloadSpritesheet() {
|
||||
if (sprites.value.length === 0 || !canvas.value) {
|
||||
showNotification('No sprites to download', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = canvas.value.width;
|
||||
tempCanvas.height = canvas.value.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (!tempCtx) {
|
||||
showNotification('Failed to create download context', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
|
||||
// Ensure pixel art remains sharp in the downloaded file
|
||||
tempCtx.imageSmoothingEnabled = false;
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
// Get the frame-specific offset for this sprite
|
||||
const frameOffset = getSpriteOffset(index);
|
||||
|
||||
// Calculate the cell coordinates for this sprite
|
||||
const cellX = Math.floor(sprite.x / cellSize.width);
|
||||
const cellY = Math.floor(sprite.y / cellSize.height);
|
||||
|
||||
// Calculate the base position within the cell
|
||||
const baseX = cellX * cellSize.width;
|
||||
const baseY = cellY * cellSize.height;
|
||||
|
||||
// Calculate the maximum allowed offset based on sprite and cell size
|
||||
// This prevents sprites from going out of bounds
|
||||
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
|
||||
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
|
||||
|
||||
// Constrain the offset to prevent out-of-bounds positioning
|
||||
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
|
||||
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
|
||||
|
||||
// Apply the constrained offset to the base position
|
||||
const finalX = baseX + constrainedOffsetX;
|
||||
const finalY = baseY + constrainedOffsetY;
|
||||
|
||||
// Draw the sprite at the calculated position
|
||||
tempCtx.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = tempCanvas.toDataURL('image/png');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showNotification('Spritesheet downloaded successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom-related operations
|
||||
*/
|
||||
export function zoomIn() {
|
||||
// Increase zoom level by 0.1, max 3.0 (300%)
|
||||
zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1);
|
||||
renderSpritesheetPreview();
|
||||
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
|
||||
}
|
||||
|
||||
export function zoomOut() {
|
||||
// Decrease zoom level by 0.1, min 0.5 (50%)
|
||||
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
|
||||
renderSpritesheetPreview();
|
||||
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
|
||||
}
|
||||
|
||||
export function resetZoom() {
|
||||
// Reset to default zoom level (100%)
|
||||
zoomLevel.value = 1;
|
||||
renderSpritesheetPreview();
|
||||
showNotification('Zoom reset to 100%');
|
||||
}
|
@ -1,241 +0,0 @@
|
||||
// spriteOperations.ts
|
||||
import { type Sprite } from '@/application/types';
|
||||
import { sprites, cellSize, canvas, ctx, columns } from '@/application/state';
|
||||
import { logger, getSpriteOffset } from '@/application/utilities';
|
||||
import { updateCanvasSize, renderSpritesheetPreview } from '@/application/canvasOperations';
|
||||
|
||||
/**
|
||||
* Add new sprites to the spritesheet
|
||||
*/
|
||||
export function addSprites(newSprites: Sprite[]) {
|
||||
if (newSprites.length === 0) {
|
||||
logger.warn('Attempted to add empty sprites array');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate sprites before adding them
|
||||
const validSprites = newSprites.filter(sprite => {
|
||||
if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) {
|
||||
logger.error('Invalid sprite detected', sprite);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validSprites.length === 0) {
|
||||
logger.error('No valid sprites to add');
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the number of valid sprites being added
|
||||
logger.info(`Adding ${validSprites.length} valid sprites`);
|
||||
|
||||
sprites.value.push(...validSprites);
|
||||
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
|
||||
|
||||
// Update cell size before arranging sprites
|
||||
logger.info('Updating cell size after adding sprites');
|
||||
updateCellSize();
|
||||
|
||||
// Only auto-arrange if cell size is valid
|
||||
if (cellSize && typeof cellSize.width === 'number' && typeof cellSize.height === 'number' && cellSize.width > 0 && cellSize.height > 0) {
|
||||
logger.info('Auto-arranging sprites');
|
||||
autoArrangeSprites();
|
||||
} else {
|
||||
logger.warn('Skipping auto-arrange due to invalid cell size');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error adding sprites:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cell size based on the largest sprite dimensions
|
||||
*/
|
||||
export function updateCellSize() {
|
||||
if (sprites.value.length === 0) {
|
||||
logger.warn('Cannot update cell size: no sprites available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
let validSpriteCount = 0;
|
||||
|
||||
// Find the maximum dimensions across all sprites
|
||||
sprites.value.forEach(sprite => {
|
||||
if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) {
|
||||
logger.warn('Sprite with invalid dimensions detected', sprite);
|
||||
return;
|
||||
}
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
validSpriteCount++;
|
||||
});
|
||||
|
||||
if (maxWidth === 0 || maxHeight === 0 || validSpriteCount === 0) {
|
||||
logger.error('Failed to calculate valid cell size - no valid sprites found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a small buffer to ensure sprites fit completely (optional)
|
||||
const buffer = 0; // Increase if you want padding between sprites
|
||||
|
||||
// Set cell size with validation
|
||||
const newWidth = maxWidth + buffer;
|
||||
const newHeight = maxHeight + buffer;
|
||||
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
logger.error(`Invalid calculated cell dimensions: ${newWidth}x${newHeight}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Updating cell size to ${newWidth}x${newHeight}`);
|
||||
cellSize.width = newWidth;
|
||||
cellSize.height = newHeight;
|
||||
|
||||
// Ensure all sprites are within their cell bounds after resize
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const column = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
|
||||
// Calculate base position for the sprite's cell
|
||||
const cellX = column * cellSize.width;
|
||||
const cellY = row * cellSize.height;
|
||||
|
||||
// Center the sprite within its cell if smaller than cell size
|
||||
const offsetX = Math.floor((cellSize.width - sprite.width) / 2);
|
||||
const offsetY = Math.floor((cellSize.height - sprite.height) / 2);
|
||||
|
||||
sprite.x = cellX + offsetX;
|
||||
sprite.y = cellY + offsetY;
|
||||
});
|
||||
|
||||
updateCanvasSize();
|
||||
renderSpritesheetPreview();
|
||||
} catch (error) {
|
||||
logger.error('Error updating cell size:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically arrange sprites in a grid
|
||||
*/
|
||||
export function autoArrangeSprites() {
|
||||
if (sprites.value.length === 0) {
|
||||
logger.warn('No sprites to arrange');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure cell size is valid before proceeding
|
||||
if (!cellSize || typeof cellSize.width !== 'number' || typeof cellSize.height !== 'number' || cellSize.width <= 0 || cellSize.height <= 0) {
|
||||
logger.error('Invalid cell size for auto-arranging', cellSize);
|
||||
|
||||
// Try to update cell size first
|
||||
updateCellSize();
|
||||
|
||||
// Check again after update attempt
|
||||
if (!cellSize || typeof cellSize.width !== 'number' || typeof cellSize.height !== 'number' || cellSize.width <= 0 || cellSize.height <= 0) {
|
||||
logger.error('Still invalid cell size after update attempt', cellSize);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('Cell size was updated and is now valid');
|
||||
}
|
||||
|
||||
// First update the canvas size to ensure it's large enough
|
||||
updateCanvasSize();
|
||||
|
||||
// Then position each sprite in its grid cell
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const column = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
|
||||
sprite.x = column * cellSize.width;
|
||||
sprite.y = row * cellSize.height;
|
||||
});
|
||||
|
||||
// Check if the canvas is ready before attempting to render
|
||||
if (!ctx.value || !canvas.value) {
|
||||
logger.warn('Canvas or context not available for rendering after auto-arrange');
|
||||
return;
|
||||
}
|
||||
|
||||
renderSpritesheetPreview();
|
||||
} catch (error) {
|
||||
logger.error('Error auto-arranging sprites:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a specific sprite by its ID
|
||||
*/
|
||||
export function highlightSprite(spriteId: string) {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
const sprite = sprites.value.find(s => s.id === spriteId);
|
||||
if (!sprite) return;
|
||||
|
||||
// Calculate the cell coordinates
|
||||
const cellX = Math.floor(sprite.x / cellSize.width);
|
||||
const cellY = Math.floor(sprite.y / cellSize.height);
|
||||
|
||||
// Briefly flash the cell
|
||||
ctx.value.save();
|
||||
ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)';
|
||||
ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height);
|
||||
ctx.value.restore();
|
||||
|
||||
// Reset after a short delay
|
||||
setTimeout(() => {
|
||||
renderSpritesheetPreview();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sprites and reset the canvas
|
||||
*/
|
||||
export function clearAllSprites(animation: any) {
|
||||
if (!canvas.value || !ctx.value) return;
|
||||
|
||||
sprites.value = [];
|
||||
canvas.value.width = 400;
|
||||
canvas.value.height = 300;
|
||||
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
|
||||
if (animation.canvas && animation.ctx) {
|
||||
animation.canvas.width = 200;
|
||||
animation.canvas.height = 200;
|
||||
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
|
||||
}
|
||||
|
||||
animation.currentFrame = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply frame-specific offsets to the main sprite positions
|
||||
*/
|
||||
export function applyOffsetsToMainView(currentSpriteOffset: any) {
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const frameOffset = getSpriteOffset(index);
|
||||
if (frameOffset.x !== 0 || frameOffset.y !== 0) {
|
||||
// Update the sprite's position to include the offset
|
||||
sprite.x += frameOffset.x;
|
||||
sprite.y += frameOffset.y;
|
||||
|
||||
// Reset the offset
|
||||
frameOffset.x = 0;
|
||||
frameOffset.y = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset current offset
|
||||
currentSpriteOffset.x = 0;
|
||||
currentSpriteOffset.y = 0;
|
||||
|
||||
// Re-render the main view
|
||||
renderSpritesheetPreview();
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
// state.ts
|
||||
import { ref, reactive } from 'vue';
|
||||
import { type Sprite, type CellSize, type AnimationState, type NotificationState, type PreviewBorderSettings } from '@/application/types';
|
||||
|
||||
// Core state
|
||||
export const sprites = ref<Sprite[]>([]);
|
||||
export const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
export const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
export const cellSize = reactive<CellSize>({ width: 0, height: 0 });
|
||||
export const columns = ref(4); // Default number of columns
|
||||
|
||||
// UI state
|
||||
export const draggedSprite = ref<Sprite | null>(null);
|
||||
export const dragOffset = reactive({ x: 0, y: 0 });
|
||||
export const isShiftPressed = ref(false);
|
||||
export const isModalOpen = ref(false);
|
||||
export const isSettingsModalOpen = ref(false);
|
||||
export const isSpritesModalOpen = ref(false);
|
||||
export const isHelpModalOpen = ref(false);
|
||||
export const zoomLevel = ref(1); // Default zoom level (1 = 100%)
|
||||
|
||||
// Preview border settings
|
||||
export const previewBorder = reactive<PreviewBorderSettings>({
|
||||
enabled: false,
|
||||
color: '#ff0000', // Default red color
|
||||
width: 2, // Default width in pixels
|
||||
});
|
||||
|
||||
// Animation state
|
||||
export const animation = reactive<AnimationState>({
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
currentFrame: 0,
|
||||
isPlaying: false,
|
||||
frameRate: 10,
|
||||
lastFrameTime: 0,
|
||||
animationId: null,
|
||||
slider: null,
|
||||
manualUpdate: false,
|
||||
});
|
||||
|
||||
// Notification state
|
||||
export const notification = reactive<NotificationState>({
|
||||
isVisible: false,
|
||||
message: '',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
// Store the current sprite offset for animation playback
|
||||
// We'll use a Map to store offsets for each frame, so they're preserved when switching frames
|
||||
export const spriteOffsets = reactive(new Map<number, { x: number; y: number }>());
|
||||
|
||||
// Current sprite offset is a reactive object that will be used for the current frame
|
||||
export const currentSpriteOffset = reactive({ x: 0, y: 0 });
|
@ -1,45 +0,0 @@
|
||||
// types.ts
|
||||
export interface Sprite {
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
id: string;
|
||||
uploadOrder: number;
|
||||
}
|
||||
|
||||
export interface CellSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface AnimationState {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
ctx: CanvasRenderingContext2D | null;
|
||||
currentFrame: number;
|
||||
isPlaying: boolean;
|
||||
frameRate: number;
|
||||
lastFrameTime: number;
|
||||
animationId: number | null;
|
||||
slider: HTMLInputElement | null;
|
||||
manualUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface SpriteOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface NotificationState {
|
||||
isVisible: boolean;
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
export interface PreviewBorderSettings {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
@ -1,149 +0,0 @@
|
||||
import { spriteOffsets, notification } from '@/application/state';
|
||||
import { type Sprite } from '@/application/types';
|
||||
|
||||
/**
|
||||
* Logger utility with consistent error format
|
||||
*/
|
||||
export const logger = {
|
||||
info: (message: string, details?: any) => {
|
||||
console.log(`Spritesheet: ${message}`, details || '');
|
||||
},
|
||||
warn: (message: string, details?: any) => {
|
||||
console.warn(`Spritesheet: ${message}`, details || '');
|
||||
},
|
||||
error: (message: string, error?: any) => {
|
||||
console.error(`Spritesheet: ${message}`, error || '');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely execute a function with error handling
|
||||
*/
|
||||
export function safeExecute<T>(fn: () => T, errorMessage: string): T | undefined {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
logger.error(errorMessage, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification
|
||||
*/
|
||||
export function showNotification(message: string, type: 'success' | 'error' = 'success') {
|
||||
notification.message = message;
|
||||
notification.type = type;
|
||||
notification.isVisible = true;
|
||||
|
||||
setTimeout(() => {
|
||||
notification.isVisible = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the offset for a specific frame
|
||||
*/
|
||||
export function getSpriteOffset(frameIndex: number) {
|
||||
if (!spriteOffsets.has(frameIndex)) {
|
||||
spriteOffsets.set(frameIndex, { x: 0, y: 0 });
|
||||
}
|
||||
return spriteOffsets.get(frameIndex)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image is fully loaded and ready to use
|
||||
*/
|
||||
export function isImageReady(img: HTMLImageElement): boolean {
|
||||
return img.complete && img.naturalWidth !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pixel-perfect coordinates aligned to pixel boundaries
|
||||
*/
|
||||
export function getPixelPerfectCoordinate(value: number): number {
|
||||
return Math.floor(value) + 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sprite object from a file
|
||||
*/
|
||||
export function createSpriteFromFile(file: File, index: number): Promise<Sprite> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create a URL for the file
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
const img = new Image();
|
||||
|
||||
// Set up event handlers
|
||||
img.onload = () => {
|
||||
// Verify the image has loaded properly
|
||||
if (img.width === 0 || img.height === 0) {
|
||||
logger.error('Image loaded with invalid dimensions:', { name: file.name, width: img.width, height: img.height });
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error(`Image has invalid dimensions: ${file.name}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the sprite object
|
||||
const sprite: Sprite = {
|
||||
img,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
name: file.name,
|
||||
id: `sprite-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
uploadOrder: index,
|
||||
};
|
||||
|
||||
// Keep the objectUrl reference and don't revoke it yet
|
||||
// The image is still needed for rendering later
|
||||
resolve(sprite);
|
||||
};
|
||||
|
||||
img.onerror = error => {
|
||||
logger.error('Error loading image:', { name: file.name, error });
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error(`Failed to load image: ${file.name}`));
|
||||
};
|
||||
|
||||
// Set the source to the object URL
|
||||
img.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple files and create sprites
|
||||
*/
|
||||
export async function processImageFiles(files: FileList): Promise<{ newSprites: Sprite[]; errorCount: number }> {
|
||||
const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
return { newSprites: [], errorCount: 0 };
|
||||
}
|
||||
|
||||
const newSprites: Sprite[] = [];
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < imageFiles.length; i++) {
|
||||
const file = imageFiles[i];
|
||||
|
||||
try {
|
||||
const sprite = await createSpriteFromFile(file, i);
|
||||
newSprites.push(sprite);
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error('Error loading sprite:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { newSprites, errorCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a specific length and add ellipsis if needed
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number = 15): string {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
<!-- Play/Pause controls -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="$emit('start')"
|
||||
:disabled="sprites.length === 0"
|
||||
:class="{
|
||||
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': !animation.isPlaying,
|
||||
}"
|
||||
class="flex items-center gap-2 border rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-play"></i> Play
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('stop')"
|
||||
:disabled="sprites.length === 0"
|
||||
:class="{
|
||||
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': animation.isPlaying,
|
||||
}"
|
||||
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
||||
>
|
||||
<i class="fas fa-pause"></i> Pause
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Frame slider -->
|
||||
<div class="flex flex-col gap-2 flex-grow">
|
||||
<div class="flex justify-between text-sm text-gray-400">
|
||||
<label for="frame-slider">Frame:</label>
|
||||
<span>{{ currentFrameDisplay }}</span>
|
||||
</div>
|
||||
<input type="range" id="frame-slider" v-model="frameValue" :min="0" :max="Math.max(0, sprites.length - 1)" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" />
|
||||
</div>
|
||||
|
||||
<!-- Frame rate slider -->
|
||||
<div class="flex flex-col gap-2 flex-grow">
|
||||
<div class="flex justify-between text-sm text-gray-400">
|
||||
<label for="framerate">Frame Rate:</label>
|
||||
<span>{{ animation.frameRate }} FPS</span>
|
||||
</div>
|
||||
<input type="range" id="framerate" v-model.number="frameRateValue" min="1" max="30" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type Sprite, type AnimationState } from '@/application/types';
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
animation: AnimationState;
|
||||
currentFrame: number;
|
||||
currentFrameDisplay: string;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'start'): void;
|
||||
(e: 'stop'): void;
|
||||
(e: 'frameChange'): void;
|
||||
(e: 'frameRateChange'): void;
|
||||
}>();
|
||||
|
||||
// Computed values with two-way binding
|
||||
const frameValue = computed({
|
||||
get: () => props.currentFrame,
|
||||
set: () => emit('frameChange'),
|
||||
});
|
||||
|
||||
const frameRateValue = computed({
|
||||
get: () => props.animation.frameRate,
|
||||
set: () => emit('frameRateChange'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #0096ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #0096ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<header class="flex items-center justify-between bg-gray-800 p-3 shadow-md sticky top-0 z-40">
|
||||
<!-- Breadcrumb navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-200">Spritesheet editor</span>
|
||||
</div>
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="flex gap-3 items-center">
|
||||
<!-- Zoom display -->
|
||||
<div class="text-sm text-gray-400 mr-2">
|
||||
<span>Zoom: {{ Math.round(zoomLevel * 100) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick zoom controls -->
|
||||
<button @click="zoomIn" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors" title="Zoom In">
|
||||
<i class="fas fa-search-plus"></i>
|
||||
</button>
|
||||
<button @click="zoomOut" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors" title="Zoom Out">
|
||||
<i class="fas fa-search-minus"></i>
|
||||
</button>
|
||||
|
||||
<!-- Download button -->
|
||||
<tooltip text="Download Spritesheet" position="bottom">
|
||||
<button @click="downloadSpritesheet" :disabled="sprites.length === 0" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
|
||||
<!-- Help button -->
|
||||
<tooltip text="Keyboard Shortcuts" position="bottom">
|
||||
<button @click="openHelpModal" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors">
|
||||
<i class="fas fa-keyboard"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import Tooltip from './Tooltip.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const zoomLevel = computed(() => store.zoomLevel.value);
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleHelp'): void;
|
||||
}>();
|
||||
|
||||
const openHelpModal = () => {
|
||||
store.isHelpModalOpen.value = true;
|
||||
};
|
||||
|
||||
// Expose store methods directly
|
||||
const { zoomIn, zoomOut, downloadSpritesheet } = store;
|
||||
</script>
|
@ -1,223 +0,0 @@
|
||||
<template>
|
||||
<!-- Base Modal Component -->
|
||||
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="modelValue">
|
||||
<!-- Modal backdrop with semi-transparent background (only if showBackdrop is true) -->
|
||||
<div v-if="showBackdrop" class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
|
||||
|
||||
<!-- Modal content -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-lg pointer-events-auto relative flex flex-col" :class="[{ 'border border-gray-400': showBorder }, { 'max-w-2xl w-full max-h-[90vh]': !movable }, customClass]" :style="modalStyle" ref="modalRef">
|
||||
<!-- Modal header - fixed -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600" :class="{ 'cursor-move': movable }" @mousedown="movable ? startDrag($event) : null">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<slot name="header-icon">
|
||||
<i class="fas fa-window-maximize text-blue-500"></i>
|
||||
</slot>
|
||||
<slot name="header-title">Modal</slot>
|
||||
</div>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-white">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal body - scrollable -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="p-6">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize handle (only if resizable is true) -->
|
||||
<div v-if="resizable" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize group" @mousedown="startResize">
|
||||
<div class="absolute bottom-0 right-0 w-full h-full bg-gray-700 bg-opacity-0 group-hover:bg-opacity-50 transition-colors rounded-tl flex items-end justify-end">
|
||||
<i class="fas fa-arrows-alt text-gray-400 group-hover:text-blue-500 transition-colors m-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Modal',
|
||||
},
|
||||
showBackdrop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showBorder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
movable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
resizable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
initialPosition: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
initialSize: {
|
||||
type: Object,
|
||||
default: () => ({ width: 800, height: 600 }),
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'close']);
|
||||
|
||||
// Refs
|
||||
const modalRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// State
|
||||
const position = ref({
|
||||
x: props.initialPosition.x,
|
||||
y: props.initialPosition.y,
|
||||
});
|
||||
const size = ref({
|
||||
width: props.initialSize.width,
|
||||
height: props.initialSize.height,
|
||||
});
|
||||
const isDragging = ref(false);
|
||||
const dragOffset = ref({ x: 0, y: 0 });
|
||||
const isResizing = ref(false);
|
||||
const resizeStart = ref({ x: 0, y: 0 });
|
||||
const initialSize = ref({ width: 0, height: 0 });
|
||||
|
||||
// Computed
|
||||
const modalStyle = computed(() => {
|
||||
if (props.movable) {
|
||||
return {
|
||||
transform: `translate3d(${position.value.x}px, ${position.value.y}px, 0)`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Methods
|
||||
const closeModal = () => {
|
||||
emit('update:modelValue', false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const startDrag = (e: MouseEvent) => {
|
||||
// Only start dragging if it's the header (not a button in the header)
|
||||
if ((e.target as HTMLElement).tagName === 'BUTTON' || (e.target as HTMLElement).closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging.value = true;
|
||||
dragOffset.value = {
|
||||
x: e.clientX - position.value.x,
|
||||
y: e.clientY - position.value.y,
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
};
|
||||
|
||||
const handleDrag = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
position.value = {
|
||||
x: e.clientX - dragOffset.value.x,
|
||||
y: e.clientY - dragOffset.value.y,
|
||||
};
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
};
|
||||
|
||||
const startResize = (e: MouseEvent) => {
|
||||
isResizing.value = true;
|
||||
resizeStart.value = { x: e.clientX, y: e.clientY };
|
||||
initialSize.value = { ...size.value };
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
const handleResize = (e: MouseEvent) => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
const deltaX = e.clientX - resizeStart.value.x;
|
||||
const deltaY = e.clientY - resizeStart.value.y;
|
||||
|
||||
size.value = {
|
||||
width: Math.max(300, initialSize.value.width + deltaX),
|
||||
height: Math.max(200, initialSize.value.height + deltaY),
|
||||
};
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
isResizing.value = false;
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
// Center the modal in the viewport when it opens
|
||||
const centerModal = () => {
|
||||
if (props.movable && props.modelValue) {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
position.value = {
|
||||
x: (viewportWidth - size.value.width) / 2,
|
||||
y: (viewportHeight - size.value.height) / 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
if (props.movable) {
|
||||
centerModal();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
});
|
||||
|
||||
// Watch for changes in modelValue to center the modal when it opens
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
if (newValue && props.movable) {
|
||||
centerModal();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Expose methods
|
||||
defineExpose({
|
||||
closeModal,
|
||||
centerModal,
|
||||
});
|
||||
</script>
|
@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
@click="openFileDialog"
|
||||
@dragover.prevent.stop="onDragOver"
|
||||
@dragleave.prevent.stop="onDragLeave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
class="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center cursor-pointer transition-all"
|
||||
:class="{ 'border-blue-500 bg-blue-500 bg-opacity-5': isDragOver }"
|
||||
>
|
||||
<i class="fas fa-cloud-upload-alt text-blue-500 text-3xl mb-4"></i>
|
||||
<p class="text-gray-400">Drag & drop sprite images here<br />or click to select files</p>
|
||||
<input type="file" ref="fileInput" multiple accept="image/*" class="hidden" @change="onFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { type Sprite, useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
|
||||
const emit = defineEmits<{
|
||||
'files-uploaded': [sprites: Sprite[]];
|
||||
}>();
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
const openFileDialog = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = () => {
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
isDragOver.value = false;
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false;
|
||||
if (e.dataTransfer?.files.length) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onFileChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
handleFiles(input.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
store.showNotification('Please upload image files only', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the utility function to process image files
|
||||
const { newSprites, errorCount } = await store.processImageFiles(files);
|
||||
|
||||
// Handle individual file errors
|
||||
if (errorCount > 0) {
|
||||
store.showNotification(`Failed to load ${errorCount} file(s)`, 'error');
|
||||
}
|
||||
|
||||
if (newSprites.length > 0) {
|
||||
store.addSprites(newSprites);
|
||||
emit('files-uploaded', newSprites);
|
||||
store.showNotification(`Added ${newSprites.length} sprites successfully`);
|
||||
} else if (errorCount > 0) {
|
||||
store.showNotification(`Failed to load all sprites`, 'error');
|
||||
}
|
||||
};
|
||||
</script>
|
55
src/components/FileUploader.vue
Normal file
55
src/components/FileUploader.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center" :class="{ 'bg-blue-50 border-blue-300': isDragging }" @dragenter.prevent="isDragging = true" @dragleave.prevent="isDragging = false" @dragover.prevent @drop.prevent="handleDrop">
|
||||
<input ref="fileInput" type="file" multiple accept="image/*" class="hidden" @change="handleFileChange" />
|
||||
|
||||
<div class="mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-lg mb-2">Drag and drop your sprite images here</p>
|
||||
<p class="text-sm text-gray-500 mb-4">or</p>
|
||||
|
||||
<button @click="openFileDialog" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Select Files</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploadSprites', files: File[]): void;
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const files = Array.from(input.files);
|
||||
emit('uploadSprites', files);
|
||||
// Reset input value so uploading the same file again will trigger the event
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false;
|
||||
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files).filter(file => {
|
||||
return file.type.startsWith('image/');
|
||||
});
|
||||
|
||||
if (files.length > 0) {
|
||||
emit('uploadSprites', files);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<tooltip text="Help & Support" position="left">
|
||||
<button @click="openHelpModal" class="fixed bottom-5 right-5 w-12 h-12 bg-blue-500 text-white rounded-full flex items-center justify-center text-xl shadow-lg cursor-pointer transition-all hover:bg-blue-600 hover:-translate-y-1 z-40">
|
||||
<i class="fas fa-question"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import Tooltip from './Tooltip.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const emit = defineEmits<{
|
||||
showHelp: [];
|
||||
}>();
|
||||
|
||||
const openHelpModal = () => {
|
||||
store.isHelpModalOpen.value = true;
|
||||
emit('showHelp');
|
||||
};
|
||||
</script>
|
@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true">
|
||||
<template #header-icon>
|
||||
<i class="fas fa-question-circle text-blue-500"></i>
|
||||
</template>
|
||||
<template #header-title>Help</template>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Help content tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="flex border-b border-gray-600 mb-4">
|
||||
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" class="px-4 py-2 font-medium text-sm transition-colors" :class="activeTab === tab.id ? 'text-blue-500 border-b-2 border-blue-500' : 'text-gray-400 hover:text-gray-200'">
|
||||
<i :class="tab.icon" class="mr-2"></i>{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts Tab -->
|
||||
<div v-if="activeTab === 'shortcuts'" class="space-y-4">
|
||||
<h3 class="text-lg font-semibold mb-2">Keyboard Shortcuts</h3>
|
||||
<div class="bg-gray-700 rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div v-for="(shortcut, index) in shortcuts" :key="index" class="flex justify-between">
|
||||
<span class="font-medium">{{ shortcut.key }}</span>
|
||||
<span class="text-gray-300">{{ shortcut.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Guide Tab -->
|
||||
<div v-if="activeTab === 'guide'" class="space-y-4">
|
||||
<h3 class="text-lg font-semibold mb-2">Usage Guide</h3>
|
||||
<div class="bg-gray-700 rounded-lg p-4 space-y-3">
|
||||
<p>This tool helps you create spritesheets from individual sprite images.</p>
|
||||
<ol class="list-decimal pl-5 space-y-2">
|
||||
<li>Upload your sprite images using the upload area</li>
|
||||
<li>Arrange sprites by dragging them to desired positions</li>
|
||||
<li>Adjust settings like column count in the Settings panel</li>
|
||||
<li>Preview animation by clicking the Play button</li>
|
||||
<li>Download your spritesheet when ready</li>
|
||||
</ol>
|
||||
<p class="bg-blue-600 p-2 rounded">Questions? Add me on discord: <b>nu11ed</b></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Donation Tab -->
|
||||
<div v-if="activeTab === 'donate'" class="space-y-4">
|
||||
<h3 class="text-lg font-semibold mb-2">Buy me a coffee</h3>
|
||||
<p class="text-gray-300 mb-4">If you find this tool useful, please consider supporting its development with a donation.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-for="wallet in wallets" :key="wallet.type" class="bg-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<i :class="wallet.icon" class="text-xl mr-2 bg-gray-800 p-1 px-2 rounded-lg" :style="{ color: wallet.color }"></i>
|
||||
<span class="font-medium">{{ wallet.name }}</span>
|
||||
</div>
|
||||
<button @click="copyToClipboard(wallet.address)" class="text-xs bg-gray-600 hover:bg-gray-500 px-2 py-1 rounded transition-colors">Copy</button>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-2 rounded text-xs font-mono break-all">
|
||||
{{ wallet.address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const isModalOpen = computed({
|
||||
get: () => store.isHelpModalOpen.value,
|
||||
set: value => {
|
||||
store.isHelpModalOpen.value = value;
|
||||
},
|
||||
});
|
||||
const activeTab = ref('shortcuts');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: 'fas fa-keyboard' },
|
||||
{ id: 'guide', label: 'Guide', icon: 'fas fa-book' },
|
||||
{ id: 'donate', label: 'Donate', icon: 'fas fa-heart' },
|
||||
];
|
||||
|
||||
const shortcuts = [
|
||||
{ key: 'Shift + Drag', description: 'Fine-tune sprite position' },
|
||||
{ key: 'Space', description: 'Play/Pause animation' },
|
||||
{ key: 'Esc', description: 'Close preview modal' },
|
||||
{ key: 'Arrow Keys', description: 'Navigate frames when paused' },
|
||||
];
|
||||
|
||||
const wallets = [
|
||||
{
|
||||
type: 'paypal',
|
||||
name: 'PayPal',
|
||||
address: 'https://www.paypal.com/paypalme/DennisPostma298',
|
||||
icon: 'fab fa-paypal',
|
||||
color: '#00457c',
|
||||
},
|
||||
{
|
||||
type: 'btc',
|
||||
name: 'Bitcoin native segwit (BTC)',
|
||||
address: 'bc1ql2a3nxnhfwft7qex0cclj5ar2lfsslvs0aygeq',
|
||||
icon: 'fab fa-bitcoin',
|
||||
color: '#f7931a',
|
||||
},
|
||||
{
|
||||
type: 'eth',
|
||||
name: 'Ethereum (ETH)',
|
||||
address: '0x30843c72DF6E9A9226d967bf2403602f1C2aB67b',
|
||||
icon: 'fab fa-ethereum',
|
||||
color: '#627eea',
|
||||
},
|
||||
{
|
||||
type: 'ltc',
|
||||
name: 'Litecoin native segwit (LTC)',
|
||||
address: 'ltc1qdkn46hpt39ppmhk25ed2eycu7m2pj5cdzuxw84',
|
||||
icon: 'fas fa-litecoin-sign',
|
||||
color: '#345d9d',
|
||||
},
|
||||
];
|
||||
|
||||
const closeModal = () => {
|
||||
store.isHelpModalOpen.value = false;
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
store.showNotification('Address copied to clipboard');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
store.showNotification('Failed to copy address', 'error');
|
||||
});
|
||||
};
|
||||
</script>
|
@ -1,513 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden w-full">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-th-large text-blue-500"></i>
|
||||
<span>Spritesheet</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
<span>Zoom: {{ Math.round(store.zoomLevel.value * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div
|
||||
ref="containerEl"
|
||||
class="relative overflow-auto rounded border border-gray-600 h-96"
|
||||
:class="{ 'cursor-grab': !isPanning, 'cursor-grabbing': isPanning }"
|
||||
:style="{
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%)
|
||||
`,
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
|
||||
backgroundColor: '#2d3748',
|
||||
}"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="block pixel-art"
|
||||
:style="{
|
||||
transform: `scale(${store.zoomLevel.value})`,
|
||||
transformOrigin: 'top left',
|
||||
imageRendering: 'pixelated',
|
||||
backgroundColor: 'transparent',
|
||||
}"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import { logger, getPixelPerfectCoordinate } from '../application/utilities';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
||||
const containerEl = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Access the preview border settings
|
||||
const previewBorder = computed(() => store.previewBorder);
|
||||
|
||||
// Panning state
|
||||
const isPanning = ref(false);
|
||||
const isAltPressed = ref(false);
|
||||
const lastPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
// Tooltip state
|
||||
const isTooltipVisible = ref(false);
|
||||
const tooltipText = ref('');
|
||||
const tooltipPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
// Responsive canvas sizing
|
||||
const containerWidth = ref(0);
|
||||
const containerHeight = ref(0);
|
||||
const baseCanvasWidth = ref(0);
|
||||
const baseCanvasHeight = ref(0);
|
||||
|
||||
// Watch for zoom changes to update the container scroll position
|
||||
watch(
|
||||
() => store.zoomLevel.value,
|
||||
(newZoom, oldZoom) => {
|
||||
if (!containerEl.value) return;
|
||||
|
||||
// Adjust scroll position to keep the center point consistent when zooming
|
||||
const centerX = containerEl.value.scrollLeft + containerEl.value.clientWidth / 2;
|
||||
const centerY = containerEl.value.scrollTop + containerEl.value.clientHeight / 2;
|
||||
|
||||
// Calculate new scroll position based on new zoom level
|
||||
const scaleChange = newZoom / oldZoom;
|
||||
containerEl.value.scrollLeft = centerX * scaleChange - containerEl.value.clientWidth / 2;
|
||||
containerEl.value.scrollTop = centerY * scaleChange - containerEl.value.clientHeight / 2;
|
||||
|
||||
// Re-render the canvas with the new zoom level
|
||||
updateCanvasSize();
|
||||
store.renderSpritesheetPreview();
|
||||
}
|
||||
);
|
||||
|
||||
// Watch for changes in border settings to update the canvas
|
||||
watch(
|
||||
() => previewBorder.value,
|
||||
() => {
|
||||
if (store.sprites.value.length > 0) {
|
||||
store.renderSpritesheetPreview();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* Update canvas size based on container dimensions
|
||||
*/
|
||||
const updateCanvasSize = () => {
|
||||
if (!canvasEl.value || !containerEl.value) return;
|
||||
|
||||
// Get the container dimensions
|
||||
containerWidth.value = containerEl.value.clientWidth;
|
||||
containerHeight.value = containerEl.value.clientHeight;
|
||||
|
||||
// Set the base canvas size to fill the container
|
||||
baseCanvasWidth.value = Math.max(containerWidth.value, store.cellSize.width * Math.ceil(containerWidth.value / store.cellSize.width));
|
||||
baseCanvasHeight.value = Math.max(containerHeight.value, store.cellSize.height * Math.ceil(containerHeight.value / store.cellSize.height));
|
||||
|
||||
// Set the actual canvas dimensions
|
||||
canvasEl.value.width = baseCanvasWidth.value;
|
||||
canvasEl.value.height = baseCanvasHeight.value;
|
||||
|
||||
// Trigger a re-render
|
||||
store.renderSpritesheetPreview();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle mouse down to detect which sprite was clicked
|
||||
*/
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (!canvasEl.value || store.sprites.value.length === 0) return;
|
||||
|
||||
const rect = canvasEl.value.getBoundingClientRect();
|
||||
// Adjust coordinates based on zoom level
|
||||
const x = (e.clientX - rect.left) / store.zoomLevel.value;
|
||||
const y = (e.clientY - rect.top) / store.zoomLevel.value;
|
||||
|
||||
// Find which sprite was clicked - start from top (last rendered)
|
||||
for (let i = store.sprites.value.length - 1; i >= 0; i--) {
|
||||
const sprite = store.sprites.value[i];
|
||||
// Check if click is within the actual sprite bounds, not just the cell
|
||||
if (x >= sprite.x && x <= sprite.x + sprite.width && y >= sprite.y && y <= sprite.y + sprite.height) {
|
||||
store.draggedSprite.value = sprite;
|
||||
store.dragOffset.x = x - sprite.x;
|
||||
store.dragOffset.y = y - sprite.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle mouse movement for tooltips and sprite dragging
|
||||
*/
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// Don't process sprite movement or tooltips while panning
|
||||
if (isPanning.value) return;
|
||||
|
||||
if (!canvasEl.value) return;
|
||||
|
||||
// Prevent default to avoid any browser handling that might interfere
|
||||
e.preventDefault();
|
||||
|
||||
const rect = canvasEl.value.getBoundingClientRect();
|
||||
// Adjust coordinates for zoom level
|
||||
const x = (e.clientX - rect.left) / store.zoomLevel.value;
|
||||
const y = (e.clientY - rect.top) / store.zoomLevel.value;
|
||||
|
||||
// Update tooltip with cell coordinates
|
||||
const cellX = Math.floor(x / store.cellSize.width);
|
||||
const cellY = Math.floor(y / store.cellSize.height);
|
||||
|
||||
// Show tooltip if mouse is within canvas bounds
|
||||
if (canvasEl.value && cellX >= 0 && cellX < canvasEl.value.width / store.cellSize.width && cellY >= 0 && cellY < canvasEl.value.height / store.cellSize.height) {
|
||||
isTooltipVisible.value = true;
|
||||
tooltipText.value = `Cell: (${cellX}, ${cellY})`;
|
||||
tooltipPosition.value.x = e.pageX;
|
||||
tooltipPosition.value.y = e.pageY;
|
||||
} else {
|
||||
isTooltipVisible.value = false;
|
||||
}
|
||||
|
||||
// Handle sprite dragging
|
||||
if (store.draggedSprite.value) {
|
||||
const sprite = store.draggedSprite.value;
|
||||
const spriteIndex = store.sprites.value.findIndex(s => s.id === sprite.id);
|
||||
|
||||
if (store.isShiftPressed.value) {
|
||||
// --- FREE POSITIONING WITHIN CELL ---
|
||||
// Determine the current cell the sprite is in
|
||||
const cellX = Math.floor(sprite.x / store.cellSize.width);
|
||||
const cellY = Math.floor(sprite.y / store.cellSize.height);
|
||||
|
||||
// Calculate cell boundaries
|
||||
const cellLeft = cellX * store.cellSize.width;
|
||||
const cellTop = cellY * store.cellSize.height;
|
||||
const cellRight = cellLeft + store.cellSize.width;
|
||||
const cellBottom = cellTop + store.cellSize.height;
|
||||
|
||||
// Calculate new position based on mouse movement
|
||||
const newX = x - store.dragOffset.x;
|
||||
const newY = y - store.dragOffset.y;
|
||||
|
||||
// Calculate maximum allowed position to keep sprite strictly within cell
|
||||
// This ensures the sprite cannot extend beyond its assigned cell
|
||||
const maxX = cellLeft + (store.cellSize.width - sprite.width);
|
||||
const maxY = cellTop + (store.cellSize.height - sprite.height);
|
||||
|
||||
// Constrain position to keep sprite fully within the cell
|
||||
const constrainedX = Math.max(cellLeft, Math.min(newX, maxX));
|
||||
const constrainedY = Math.max(cellTop, Math.min(newY, maxY));
|
||||
|
||||
// Update sprite position
|
||||
sprite.x = constrainedX;
|
||||
sprite.y = constrainedY;
|
||||
|
||||
// Calculate and update the offset within the cell
|
||||
const offsetX = sprite.x - cellLeft;
|
||||
const offsetY = sprite.y - cellTop;
|
||||
|
||||
if (spriteIndex !== -1) {
|
||||
const frameOffset = store.getSpriteOffset(spriteIndex);
|
||||
frameOffset.x = offsetX;
|
||||
frameOffset.y = offsetY;
|
||||
|
||||
// Also update current offset for UI consistency
|
||||
store.currentSpriteOffset.x = offsetX;
|
||||
store.currentSpriteOffset.y = offsetY;
|
||||
}
|
||||
} else {
|
||||
// --- GRID SNAPPING MODE ---
|
||||
if (!canvasEl.value) return;
|
||||
|
||||
// Calculate target cell coordinates based on mouse position
|
||||
const targetCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width);
|
||||
const targetCellY = Math.floor((y - store.dragOffset.y) / store.cellSize.height);
|
||||
|
||||
// Calculate how many cells the sprite occupies
|
||||
const spriteCellsWide = Math.ceil(sprite.width / store.cellSize.width);
|
||||
const spriteCellsHigh = Math.ceil(sprite.height / store.cellSize.height);
|
||||
|
||||
// Calculate maximum valid cell position to prevent overflow
|
||||
const maxValidCellX = Math.floor(canvasEl.value.width / store.cellSize.width) - spriteCellsWide;
|
||||
const maxValidCellY = Math.floor(canvasEl.value.height / store.cellSize.height) - spriteCellsHigh;
|
||||
|
||||
// Ensure we don't place sprites where they would extend beyond canvas bounds
|
||||
const boundedCellX = Math.max(0, Math.min(targetCellX, maxValidCellX));
|
||||
const boundedCellY = Math.max(0, Math.min(targetCellY, maxValidCellY));
|
||||
|
||||
// Update sprite position to align with cell grid
|
||||
sprite.x = boundedCellX * store.cellSize.width;
|
||||
sprite.y = boundedCellY * store.cellSize.height;
|
||||
|
||||
// Reset any offsets for grid-snapped sprites
|
||||
if (spriteIndex !== -1) {
|
||||
const frameOffset = store.getSpriteOffset(spriteIndex);
|
||||
frameOffset.x = 0;
|
||||
frameOffset.y = 0;
|
||||
|
||||
// Also update current offset for UI consistency
|
||||
store.currentSpriteOffset.x = 0;
|
||||
store.currentSpriteOffset.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a re-render
|
||||
store.renderSpritesheetPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
store.draggedSprite.value = null;
|
||||
|
||||
// Ensure the event is captured
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
isTooltipVisible.value = false;
|
||||
store.draggedSprite.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle keyboard down events
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
store.isShiftPressed.value = true;
|
||||
}
|
||||
|
||||
if (e.key === 'Alt') {
|
||||
e.preventDefault(); // Prevent browser from focusing address bar
|
||||
isAltPressed.value = true;
|
||||
}
|
||||
|
||||
// Handle keyboard shortcuts for zooming
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
store.zoomIn();
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
store.zoomOut();
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
store.resetZoom();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
store.isShiftPressed.value = false;
|
||||
}
|
||||
|
||||
if (e.key === 'Alt') {
|
||||
isAltPressed.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseDown = (e: MouseEvent) => {
|
||||
// Middle mouse button or Alt + left click for panning
|
||||
if (e.button === 1 || (e.button === 0 && isAltPressed.value)) {
|
||||
e.preventDefault();
|
||||
isPanning.value = true;
|
||||
lastPosition.value = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
// Regular sprite dragging
|
||||
handleMouseDown(e);
|
||||
}
|
||||
|
||||
// Ensure the event is captured
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle mouse movement on the canvas for panning and sprite interactions
|
||||
*/
|
||||
const handleCanvasMouseMove = (e: MouseEvent) => {
|
||||
// Always prevent default to ensure consistent behavior
|
||||
e.preventDefault();
|
||||
|
||||
if (isPanning.value && containerEl.value) {
|
||||
// Calculate the distance moved since last position
|
||||
const dx = e.clientX - lastPosition.value.x;
|
||||
const dy = e.clientY - lastPosition.value.y;
|
||||
|
||||
// Scroll the container in the opposite direction of the mouse movement
|
||||
// for natural panning behavior
|
||||
containerEl.value.scrollLeft -= dx;
|
||||
containerEl.value.scrollTop -= dy;
|
||||
|
||||
// Update the last position for the next movement
|
||||
lastPosition.value = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
// Handle regular mouse move for sprites and tooltip
|
||||
handleMouseMove(e);
|
||||
}
|
||||
|
||||
// Ensure the event is captured
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = (e: MouseEvent) => {
|
||||
isPanning.value = false;
|
||||
handleMouseUp(e);
|
||||
|
||||
// Ensure the event is captured
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleCanvasMouseLeave = () => {
|
||||
isPanning.value = false;
|
||||
handleMouseOut();
|
||||
};
|
||||
|
||||
const preventContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up all canvas event listeners
|
||||
*/
|
||||
const setupCanvasEvents = () => {
|
||||
if (!canvasEl.value) {
|
||||
logger.warn('Cannot set up canvas events: canvas element not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up mouse events for the canvas
|
||||
canvasEl.value.addEventListener('mousedown', handleCanvasMouseDown, { passive: false });
|
||||
canvasEl.value.addEventListener('mousemove', handleCanvasMouseMove, { passive: false });
|
||||
canvasEl.value.addEventListener('mouseup', handleCanvasMouseUp, { passive: false });
|
||||
canvasEl.value.addEventListener('mouseleave', handleCanvasMouseLeave, { passive: false });
|
||||
canvasEl.value.addEventListener('contextmenu', preventContextMenu, { passive: false });
|
||||
|
||||
// Add global event listeners to ensure drag operations complete even if cursor leaves canvas
|
||||
window.addEventListener('mousemove', handleCanvasMouseMove, { passive: false });
|
||||
window.addEventListener('mouseup', handleCanvasMouseUp, { passive: false });
|
||||
};
|
||||
|
||||
// Handle window resize to update canvas dimensions
|
||||
const handleResize = () => {
|
||||
updateCanvasSize();
|
||||
};
|
||||
|
||||
/**
|
||||
* Component lifecycle hook - setup
|
||||
*/
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Set up global event listeners
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Initialize the canvas after DOM is updated
|
||||
await nextTick();
|
||||
initializeCanvas();
|
||||
|
||||
// Set up ResizeObserver to handle container size changes
|
||||
if ('ResizeObserver' in window) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
if (containerEl.value) {
|
||||
resizeObserver.observe(containerEl.value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during component mount:', error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the canvas and set up required event handlers
|
||||
*/
|
||||
const initializeCanvas = () => {
|
||||
if (!canvasEl.value || !containerEl.value) {
|
||||
logger.error('Canvas or container element not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = canvasEl.value.getContext('2d');
|
||||
if (!context) {
|
||||
logger.error('Failed to get 2D context from canvas');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas and context in the store
|
||||
store.canvas.value = canvasEl.value;
|
||||
store.ctx.value = context;
|
||||
|
||||
// Set up canvas mouse events
|
||||
setupCanvasEvents();
|
||||
|
||||
// Set the initial canvas size based on container
|
||||
updateCanvasSize();
|
||||
|
||||
// Update sprites if there are any loaded
|
||||
if (store.sprites.value.length > 0) {
|
||||
store.updateCellSize();
|
||||
store.autoArrangeSprites();
|
||||
store.renderSpritesheetPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error initializing canvas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Component lifecycle hook - cleanup
|
||||
*/
|
||||
onBeforeUnmount(() => {
|
||||
try {
|
||||
// Remove global event listeners
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
|
||||
// Remove canvas event listeners
|
||||
if (canvasEl.value) {
|
||||
canvasEl.value.removeEventListener('mousedown', handleCanvasMouseDown);
|
||||
canvasEl.value.removeEventListener('mousemove', handleCanvasMouseMove);
|
||||
canvasEl.value.removeEventListener('mouseup', handleCanvasMouseUp);
|
||||
canvasEl.value.removeEventListener('mouseleave', handleCanvasMouseLeave);
|
||||
canvasEl.value.removeEventListener('contextmenu', preventContextMenu);
|
||||
}
|
||||
|
||||
// Remove global event listeners
|
||||
window.removeEventListener('mousemove', handleCanvasMouseMove);
|
||||
window.removeEventListener('mouseup', handleCanvasMouseUp);
|
||||
} catch (error) {
|
||||
logger.error('Error during component unmount:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.cursor-grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<nav class="fixed left-0 top-0 bottom-0 w-16 bg-gray-800 shadow-lg z-40 flex flex-col items-center py-4">
|
||||
<!-- Logo -->
|
||||
<div class="mb-8">
|
||||
<i class="fas fa-gamepad text-blue-500 text-2xl"></i>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<div class="flex flex-col gap-6 items-center">
|
||||
<!-- Dashboard/Home -->
|
||||
<tooltip text="Dashboard" position="right">
|
||||
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'dashboard' }" @click="setActiveSection('dashboard')">
|
||||
<i class="fas fa-home"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
|
||||
<!-- Sprites -->
|
||||
<tooltip text="Manage sprites" position="right">
|
||||
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors relative" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'sprites' }" @click="openSpritesModal">
|
||||
<i class="fas fa-images"></i>
|
||||
<span v-if="sprites.length > 0" class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{{ sprites.length }}
|
||||
</span>
|
||||
</button>
|
||||
</tooltip>
|
||||
|
||||
<!-- Preview -->
|
||||
<tooltip text="Preview animation" position="right">
|
||||
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'preview' }" @click="openPreviewModal" :disabled="sprites.length === 0">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
|
||||
<!-- Settings -->
|
||||
<tooltip text="Settings" position="bottom">
|
||||
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'settings' }" @click="openSettingsModal">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Help Button at Bottom -->
|
||||
<div class="mt-auto">
|
||||
<tooltip text="Help & Support" position="bottom">
|
||||
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" @click="showHelp">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</button>
|
||||
</tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import Tooltip from './Tooltip.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
const activeSection = ref('dashboard');
|
||||
|
||||
const setActiveSection = (section: string) => {
|
||||
activeSection.value = section;
|
||||
};
|
||||
|
||||
const openSpritesModal = () => {
|
||||
setActiveSection('sprites');
|
||||
store.isSpritesModalOpen.value = true;
|
||||
};
|
||||
|
||||
const openSettingsModal = () => {
|
||||
setActiveSection('settings');
|
||||
store.isSettingsModalOpen.value = true;
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
store.showNotification('Please add sprites first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSection('preview');
|
||||
store.isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showHelp'): void;
|
||||
}>();
|
||||
|
||||
const showHelp = () => {
|
||||
// Instead of just emitting, we'll now open the help modal directly
|
||||
store.isHelpModalOpen.value = true;
|
||||
};
|
||||
</script>
|
@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-5 right-5 bg-gray-700 text-gray-200 p-4 rounded shadow-lg z-50 flex items-center gap-3 transform transition-all duration-300"
|
||||
:class="{
|
||||
'translate-y-0 opacity-100': notification.isVisible,
|
||||
'translate-y-24 opacity-0': !notification.isVisible,
|
||||
'border-l-4 border-green-500': notification.type === 'success',
|
||||
'border-l-4 border-red-500': notification.type === 'error',
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="text-xl"
|
||||
:class="{
|
||||
'fas fa-check-circle text-green-500': notification.type === 'success',
|
||||
'fas fa-exclamation-circle text-red-500': notification.type === 'error',
|
||||
}"
|
||||
></i>
|
||||
<span>{{ notification.message }}</span>
|
||||
<button @click="closeNotification" class="ml-2 text-gray-400 hover:text-gray-200">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
|
||||
const notification = computed(() => store.notification);
|
||||
|
||||
const closeNotification = () => {
|
||||
store.notification.isVisible = false;
|
||||
};
|
||||
</script>
|
@ -1,311 +0,0 @@
|
||||
<template>
|
||||
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true" :initialSize="modalSize" customClass="scrollbar-hide">
|
||||
<template #header-icon>
|
||||
<i class="fas fa-film text-blue-500"></i>
|
||||
</template>
|
||||
<template #header-title>Animation Preview</template>
|
||||
|
||||
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
|
||||
<!-- Animation controls -->
|
||||
<AnimationControls :sprites="sprites" :animation="animation" :currentFrame="currentFrame" :currentFrameDisplay="currentFrameDisplay" @start="startAnimation" @stop="stopAnimation" @frameChange="handleFrameChange" @frameRateChange="handleFrameRateChange" />
|
||||
|
||||
<!-- View controls -->
|
||||
<ViewControls :previewZoom="previewZoom" :spriteOffset="spriteOffset" :hasSpriteOffset="hasSpriteOffset" :showAllSprites="showAllSprites" @zoomIn="zoomIn" @zoomOut="zoomOut" @resetZoom="resetZoom" @resetPosition="resetSpritePosition" @toggleShowAllSprites="showAllSprites = !showAllSprites" />
|
||||
|
||||
<!-- Canvas container -->
|
||||
<div class="flex flex-col justify-center items-center bg-gray-700 p-6 rounded mb-6 relative overflow-auto flex-grow">
|
||||
<!-- Position info -->
|
||||
<div class="text-xs text-gray-400 mb-2" v-if="sprites.length > 0">
|
||||
<span>Position: {{ Math.round(spriteOffset?.x ?? 0) }}px, {{ Math.round(spriteOffset?.y ?? 0) }}px (drag to move within cell)</span>
|
||||
</div>
|
||||
|
||||
<!-- Canvas viewport -->
|
||||
<div
|
||||
class="canvas-container relative transition-transform duration-100 flex items-center justify-center"
|
||||
:style="{
|
||||
minWidth: `${store.cellSize.width * previewZoom}px`,
|
||||
minHeight: `${store.cellSize.height * previewZoom}px`,
|
||||
cursor: previewZoom > 1 ? (isViewportDragging ? 'grabbing' : 'grab') : 'default',
|
||||
}"
|
||||
@mousedown.prevent="e => startViewportDrag(e, isCanvasDragging)"
|
||||
@wheel.prevent="handleCanvasWheel"
|
||||
>
|
||||
<div
|
||||
class="sprite-wrapper"
|
||||
:style="{
|
||||
transform: `scale(${previewZoom}) translate(${viewportOffset.x}px, ${viewportOffset.y}px)`,
|
||||
cursor: isCanvasDragging ? 'grabbing' : 'move',
|
||||
width: `${store.cellSize.width}px`,
|
||||
height: `${store.cellSize.height}px`,
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%)
|
||||
`,
|
||||
backgroundSize: '10px 10px',
|
||||
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
|
||||
backgroundColor: '#2d3748',
|
||||
}"
|
||||
@mousedown.prevent.stop="e => startCanvasDrag(e, isViewportDragging, previewZoom)"
|
||||
title="Drag to move sprite within cell"
|
||||
>
|
||||
<canvas ref="animCanvas" class="block pixel-art absolute top-0 left-0"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import AnimationControls from './AnimationControls.vue';
|
||||
import ViewControls from './ViewControls.vue';
|
||||
import { useSpritesheetStore } from '@/composables/useSpritesheetStore';
|
||||
|
||||
// Import custom composables
|
||||
import { useAnimation } from '@/composables/useAnimation';
|
||||
import { useSpritePosition } from '@/composables/useSpritePosition';
|
||||
import { useViewport } from '@/composables/useViewport';
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts';
|
||||
import { useCanvasInitialization } from '@/composables/useCanvasInitialization';
|
||||
|
||||
// Main store
|
||||
const store = useSpritesheetStore();
|
||||
|
||||
// Computed refs for sprites and state
|
||||
const isModalOpen = computed({
|
||||
get: () => store.isModalOpen.value,
|
||||
set: value => {
|
||||
store.isModalOpen.value = value;
|
||||
},
|
||||
});
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
const animation = computed(() => store.animation);
|
||||
const previewBorder = computed(() => store.previewBorder);
|
||||
|
||||
// Modal size state - used by BaseModal
|
||||
const modalSize = ref({ width: 800, height: 600 });
|
||||
|
||||
// Initialize canvas management
|
||||
const { animCanvas, initializeCanvas, updateCanvasSize } = useCanvasInitialization(animation, store.cellSize);
|
||||
|
||||
// Handle frame rendering
|
||||
const renderFrame = (frameIndex: number, offset: { x: number; y: number }) => {
|
||||
store.renderAnimationFrame(frameIndex, showAllSprites.value, offset);
|
||||
store.renderSpritesheetPreview();
|
||||
};
|
||||
|
||||
// Animation controls
|
||||
const { currentFrame, showAllSprites, currentFrameDisplay, startAnimation, stopAnimation, handleFrameChange, handleFrameRateChange, updateCurrentFrame: updateFrame, nextFrame, prevFrame } = useAnimation(sprites, animation, renderFrame, store.getSpriteOffset);
|
||||
|
||||
// Sprite positioning
|
||||
const { isCanvasDragging, spriteOffset, hasSpriteOffset, resetSpritePosition, startCanvasDrag } = useSpritePosition(sprites, currentFrame, store.cellSize, store.getSpriteOffset, () => updateFrame(), store.showNotification);
|
||||
|
||||
// Viewport controls
|
||||
const { previewZoom, viewportOffset, isViewportDragging, zoomIn, zoomOut, resetZoom, updateCanvasContainerSize, startViewportDrag, handleCanvasWheel, panViewport } = useViewport(animation, () => updateFrame(), store.showNotification);
|
||||
|
||||
// Open modal function
|
||||
const openModal = async () => {
|
||||
if (sprites.value.length === 0) {
|
||||
store.showNotification('Please add sprites first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset zoom but keep sprite offset if it exists
|
||||
previewZoom.value = 1;
|
||||
viewportOffset.value = { x: 0, y: 0 };
|
||||
|
||||
// Reset modal size to default
|
||||
modalSize.value = { width: 800, height: 600 };
|
||||
|
||||
// Reset to first frame
|
||||
currentFrame.value = 0;
|
||||
animation.value.currentFrame = 0;
|
||||
|
||||
// Initialize canvas and retry if it fails
|
||||
await initializeCanvas();
|
||||
|
||||
// If canvas is still not initialized, try again after a short delay
|
||||
if (!animCanvas.value || !store.animation.canvas) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await initializeCanvas();
|
||||
}
|
||||
|
||||
// Set modal open state if not already open
|
||||
if (!store.isModalOpen.value) {
|
||||
store.isModalOpen.value = true;
|
||||
}
|
||||
|
||||
// Wait for next frame to ensure DOM is updated
|
||||
await nextTick();
|
||||
|
||||
// Ensure cell size is valid before proceeding
|
||||
if (!store.cellSize.value || typeof store.cellSize.value.width !== 'number' || typeof store.cellSize.value.height !== 'number' || store.cellSize.value.width <= 0 || store.cellSize.value.height <= 0) {
|
||||
console.log('Attempting to update cell size...', store.cellSize.value);
|
||||
|
||||
// Try to update cell size if there are sprites
|
||||
if (sprites.value.length > 0) {
|
||||
store.updateCellSize();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
// Check again after update attempt
|
||||
if (!store.cellSize.value || typeof store.cellSize.value.width !== 'number' || typeof store.cellSize.value.height !== 'number' || store.cellSize.value.width <= 0 || store.cellSize.value.height <= 0) {
|
||||
console.warn('Failed to set valid cell dimensions', store.cellSize.value);
|
||||
store.showNotification('Invalid cell dimensions. Please check sprite sizes.', 'error');
|
||||
return; // Don't proceed if we can't set valid cell dimensions
|
||||
} else {
|
||||
console.log('Successfully updated cell size to', store.cellSize.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Set proper canvas size before rendering
|
||||
updateCanvasSize();
|
||||
|
||||
// Force render the first frame
|
||||
if (sprites.value.length > 0) {
|
||||
// Get the current sprite
|
||||
const currentSprite = sprites.value[0];
|
||||
|
||||
// Safely calculate center position with null checks
|
||||
let centerX = 0;
|
||||
let centerY = 0;
|
||||
|
||||
if (store.cellSize && typeof store.cellSize.width === 'number' && typeof store.cellSize.height === 'number') {
|
||||
centerX = Math.max(0, Math.floor((store.cellSize.width - currentSprite.width) / 2));
|
||||
centerY = Math.max(0, Math.floor((store.cellSize.height - currentSprite.height) / 2));
|
||||
}
|
||||
|
||||
// Get the frame-specific offset for the first frame
|
||||
const frameOffset = store.getSpriteOffset(0);
|
||||
|
||||
// If the offset is (0,0), center the sprite
|
||||
if (frameOffset.x === 0 && frameOffset.y === 0) {
|
||||
frameOffset.x = centerX;
|
||||
frameOffset.y = centerY;
|
||||
}
|
||||
|
||||
// Render with the frame-specific offset
|
||||
store.renderAnimationFrame(0, showAllSprites.value, frameOffset);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal function
|
||||
const closeModal = () => {
|
||||
store.isModalOpen.value = false;
|
||||
|
||||
// Stop animation if it's playing
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts(isModalOpen, sprites, animation, closeModal, startAnimation, stopAnimation, nextFrame, prevFrame, zoomIn, zoomOut, resetZoom, resetSpritePosition, panViewport, store.showNotification);
|
||||
|
||||
// Watch for changes in sprites to update the canvas when new sprites are added
|
||||
watch(
|
||||
() => sprites.value,
|
||||
newSprites => {
|
||||
if (isModalOpen.value && newSprites.length > 0) {
|
||||
updateCanvasSize();
|
||||
updateCanvasContainerSize();
|
||||
updateFrame();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch zoom changes to update container size
|
||||
watch(
|
||||
() => previewZoom.value,
|
||||
() => {
|
||||
updateCanvasContainerSize();
|
||||
}
|
||||
);
|
||||
|
||||
// Watch for changes in border settings to update the preview
|
||||
watch(
|
||||
() => previewBorder.value,
|
||||
() => {
|
||||
if (isModalOpen.value && sprites.value.length > 0) {
|
||||
updateFrame();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Watch for changes in showAllSprites to update the preview
|
||||
watch(
|
||||
() => showAllSprites.value,
|
||||
() => {
|
||||
if (isModalOpen.value && sprites.value.length > 0) {
|
||||
updateFrame();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Watch for changes in cellSize to update the canvas
|
||||
watch(
|
||||
() => store.cellSize.value,
|
||||
newCellSize => {
|
||||
if (isModalOpen.value && sprites.value.length > 0) {
|
||||
if (newCellSize && typeof newCellSize.width === 'number' && typeof newCellSize.height === 'number' && newCellSize.width > 0 && newCellSize.height > 0) {
|
||||
console.log('Cell size changed, updating canvas...', newCellSize);
|
||||
updateCanvasSize();
|
||||
updateCanvasContainerSize();
|
||||
updateFrame();
|
||||
} else {
|
||||
console.warn('Invalid cell size detected in watcher', newCellSize);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Expose openModal for external use
|
||||
defineExpose({ openModal });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Cursor styles */
|
||||
.cursor-move {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar styles */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* Canvas container styles */
|
||||
.canvas-container {
|
||||
margin: auto;
|
||||
transition:
|
||||
min-width 0.2s,
|
||||
min-height 0.2s;
|
||||
}
|
||||
|
||||
.sprite-wrapper {
|
||||
transform-origin: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.pixel-art {
|
||||
image-rendering: optimizeSpeed;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
}
|
||||
</style>
|
@ -1,199 +0,0 @@
|
||||
<template>
|
||||
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true">
|
||||
<template #header-icon>
|
||||
<i class="fas fa-cog text-blue-500"></i>
|
||||
</template>
|
||||
<template #header-title>Settings</template>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Tools Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-tools text-blue-500"></i> Tools</h3>
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<button @click="autoArrangeSprites" :disabled="sprites.length === 0" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">
|
||||
<i class="fas fa-th"></i> Auto arrange
|
||||
</button>
|
||||
<button @click="openPreviewModal" :disabled="sprites.length === 0" class="flex items-center gap-2 bg-blue-500 border border-blue-500 text-white rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:bg-blue-600 hover:border-blue-600">
|
||||
<i class="fas fa-play"></i> Preview animation
|
||||
</button>
|
||||
<button @click="downloadSpritesheet" :disabled="sprites.length === 0" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</button>
|
||||
<button @click="confirmClearAll" :disabled="sprites.length === 0" class="flex items-center gap-2 bg-red-600 border border-red-600 text-white rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:bg-red-700 hover:border-red-700">
|
||||
<i class="fas fa-trash-alt"></i> Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Controls Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-th text-blue-500"></i> Layout controls</h3>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-gray-400 mb-2">Column Count</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="range" min="1" max="10" v-model.number="columnCount" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
|
||||
<span class="text-gray-200 font-medium w-8 text-center">{{ columnCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="applyColumnCount" class="flex items-center gap-2 bg-blue-500 text-white border border-blue-500 rounded px-4 py-2 text-sm transition-colors hover:bg-blue-600 hover:border-blue-600"><i class="fas fa-check"></i> Apply layout</button>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-search text-blue-500"></i> Zoom controls</h3>
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<button @click="zoomIn" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500"><i class="fas fa-search-plus"></i> Zoom in</button>
|
||||
<button @click="zoomOut" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500"><i class="fas fa-search-minus"></i> Zoom out</button>
|
||||
<button @click="resetZoom" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500"><i class="fas fa-undo"></i> Reset zoom</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Border Settings Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-border-style text-blue-500"></i> Preview border</h3>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm text-gray-400">Enable border (preview only)</label>
|
||||
<div class="relative inline-block w-10 align-middle select-none">
|
||||
<input type="checkbox" v-model="previewBorder.enabled" id="toggle-border" class="sr-only" />
|
||||
<label for="toggle-border" class="block h-6 rounded-full bg-gray-600 cursor-pointer"></label>
|
||||
<div :class="{ 'translate-x-4': previewBorder.enabled, 'translate-x-0': !previewBorder.enabled }" class="absolute left-0 top-0 w-6 h-6 rounded-full bg-white border border-gray-300 transform transition-transform duration-200 ease-in-out"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mt-4" v-if="previewBorder.enabled">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">Border color</label>
|
||||
<input type="color" v-model="previewBorder.color" class="w-full h-8 bg-gray-700 rounded cursor-pointer" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">Border width: {{ previewBorder.width }}px</label>
|
||||
<input type="range" v-model.number="previewBorder.width" min="1" max="10" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 mt-2"><i class="fas fa-info-circle mr-1"></i> Border will only be visible in the preview and won't be included in the downloaded spritesheet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts Section -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-keyboard text-blue-500"></i> Keyboard shortcuts</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Shift + Drag</kbd>
|
||||
<span>Fine-tune position</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Space</kbd>
|
||||
<span>Play/Pause animation</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Esc</kbd>
|
||||
<span>Close preview</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">←/→</kbd>
|
||||
<span>Navigate frames</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
const isModalOpen = computed({
|
||||
get: () => store.isSettingsModalOpen.value,
|
||||
set: value => {
|
||||
store.isSettingsModalOpen.value = value;
|
||||
},
|
||||
});
|
||||
const previewBorder = computed(() => store.previewBorder);
|
||||
|
||||
// Column count control
|
||||
const columnCount = ref(store.columns.value);
|
||||
|
||||
const closeModal = () => {
|
||||
store.isSettingsModalOpen.value = false;
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (store.sprites.value.length === 0) {
|
||||
store.showNotification('Please add sprites first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close settings modal and open preview modal
|
||||
closeModal();
|
||||
store.isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const confirmClearAll = () => {
|
||||
if (confirm('Are you sure you want to clear all sprites?')) {
|
||||
store.clearAllSprites();
|
||||
store.showNotification('All sprites cleared');
|
||||
}
|
||||
};
|
||||
|
||||
// Expose store methods directly
|
||||
const { autoArrangeSprites, downloadSpritesheet, zoomIn, zoomOut, resetZoom } = store;
|
||||
|
||||
// Apply column count changes
|
||||
const applyColumnCount = () => {
|
||||
store.columns.value = columnCount.value;
|
||||
store.updateCanvasSize();
|
||||
store.autoArrangeSprites();
|
||||
store.showNotification(`Column count updated to ${columnCount.value}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Toggle switch styles */
|
||||
input[type='checkbox'] + label {
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + label {
|
||||
background-color: #0096ff;
|
||||
}
|
||||
|
||||
/* Range input styles */
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #0096ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #0096ff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Color input styles */
|
||||
input[type='color'] {
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type='color']::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type='color']::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Upload Card -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-upload text-blue-500"></i>
|
||||
<span>Upload sprites</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<drop-zone @files-uploaded="handleUpload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-bolt text-blue-500"></i>
|
||||
<span>Quick Actions</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<button @click="openSpritesModal" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500">
|
||||
<i class="fas fa-images"></i> Manage sprites
|
||||
<span v-if="sprites.length > 0" class="ml-auto bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{{ sprites.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button @click="openSettingsModal" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500"><i class="fas fa-cog"></i> Settings & tools</button>
|
||||
|
||||
<button @click="openPreviewModal" :disabled="sprites.length === 0" class="flex items-center gap-2 bg-blue-500 border border-blue-500 text-white rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:bg-blue-600 hover:border-blue-600">
|
||||
<i class="fas fa-play"></i> Preview animation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div v-if="sprites.length > 0" class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<i class="fas fa-chart-bar text-blue-500"></i>
|
||||
<span>Stats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-gray-700 p-3 rounded">
|
||||
<div class="text-sm text-gray-400">Sprites</div>
|
||||
<div class="text-xl font-semibold">{{ sprites.length }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 p-3 rounded">
|
||||
<div class="text-sm text-gray-400">Cell Size</div>
|
||||
<div class="text-xl font-semibold">{{ cellSize.width }}×{{ cellSize.height }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 p-3 rounded">
|
||||
<div class="text-sm text-gray-400">Zoom</div>
|
||||
<div class="text-xl font-semibold">{{ Math.round(zoomLevel * 100) }}%</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 p-3 rounded">
|
||||
<div class="text-sm text-gray-400">Columns</div>
|
||||
<div class="text-xl font-semibold">{{ columns }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type Sprite, useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import DropZone from './DropZone.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
const cellSize = computed(() => store.cellSize);
|
||||
const zoomLevel = computed(() => store.zoomLevel.value);
|
||||
const columns = computed(() => store.columns.value);
|
||||
|
||||
const handleUpload = (sprites: Sprite[]) => {
|
||||
// The dropzone component handles adding sprites to the store
|
||||
// This is just for event handling if needed
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (store.sprites.value.length === 0) {
|
||||
store.showNotification('Please add sprites first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
store.isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const openSpritesModal = () => {
|
||||
store.isSpritesModalOpen.value = true;
|
||||
};
|
||||
|
||||
const openSettingsModal = () => {
|
||||
store.isSettingsModalOpen.value = true;
|
||||
};
|
||||
</script>
|
234
src/components/SpriteCanvas.vue
Normal file
234
src/components/SpriteCanvas.vue
Normal file
@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="pixelPerfect" class="mr-2" @change="drawCanvas" />
|
||||
<label for="pixel-perfect">Pixel perfect rendering (for pixel art)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative border border-gray-300 rounded-lg">
|
||||
<canvas ref="canvasRef" @mousedown="startDrag" @mousemove="drag" @mouseup="stopDrag" @mouseleave="stopDrag" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="stopDrag" class="w-full"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
// Pixel art optimization
|
||||
const pixelPerfect = ref(true);
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
|
||||
const spritePositions = computed(() => {
|
||||
if (!canvasRef.value) return [];
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
return { maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
|
||||
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 clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||
|
||||
if (clickedSprite) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = clickedSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
|
||||
// Find current sprite position
|
||||
const sprite = props.sprites.find(s => s.id === clickedSprite.id);
|
||||
if (sprite) {
|
||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value) return;
|
||||
|
||||
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 deltaX = mouseX - dragStartX.value;
|
||||
const deltaY = mouseY - dragStartY.value;
|
||||
|
||||
// Calculate new position with constraints
|
||||
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||
if (spriteIndex === -1) return;
|
||||
|
||||
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
|
||||
if (!position) return;
|
||||
|
||||
let newX = spritePosBeforeDrag.value.x + deltaX;
|
||||
let newY = spritePosBeforeDrag.value.y + deltaY;
|
||||
|
||||
// Constrain movement strictly within cell
|
||||
newX = Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX));
|
||||
newY = Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
startDrag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
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];
|
||||
const sprite = props.sprites.find(s => s.id === pos.id);
|
||||
|
||||
if (!sprite) continue;
|
||||
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + sprite.width && y >= pos.canvasY && y <= pos.canvasY + sprite.height) {
|
||||
return sprite;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const drawCanvas = () => {
|
||||
if (!canvasRef.value || !ctx.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.ceil(props.sprites.length / props.columns);
|
||||
canvasRef.value.width = maxWidth * props.columns;
|
||||
canvasRef.value.height = maxHeight * rows;
|
||||
|
||||
// Clear canvas
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
|
||||
// Set pixel art optimization
|
||||
if (pixelPerfect.value) {
|
||||
ctx.value.imageSmoothingEnabled = false;
|
||||
} else {
|
||||
ctx.value.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
ctx.value.strokeStyle = '#e5e7eb';
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
ctx.value.strokeRect(col * maxWidth, row * maxHeight, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw sprites
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
const cellX = col * maxWidth;
|
||||
const cellY = row * maxHeight;
|
||||
|
||||
// Draw sprite
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
drawCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.sprites, drawCanvas, { deep: true });
|
||||
watch(() => props.columns, drawCanvas);
|
||||
</script>
|
@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">No sprites uploaded yet</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-3 max-h-72 overflow-y-auto p-4">
|
||||
<div v-for="(sprite, index) in sprites" :key="sprite.id" @click="$emit('spriteClicked', sprite.id)" class="border border-gray-600 rounded bg-gray-700 p-2 text-center transition-all cursor-pointer hover:border-blue-500 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<img :src="sprite.img.src" :alt="sprite.name" class="max-w-full max-h-16 mx-auto mb-2 bg-black bg-opacity-20 rounded" />
|
||||
<div class="text-xs text-gray-400 truncate">{{ index + 1 }}. {{ truncateName(sprite.name) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSpritesheetStore, type Sprite } from '../composables/useSpritesheetStore';
|
||||
|
||||
defineProps<{
|
||||
sprites: Sprite[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
spriteClicked: [id: string];
|
||||
}>();
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
|
||||
// Use the utility function for truncating text with a custom length
|
||||
const truncateName = (name: string) => {
|
||||
return store.truncateText(name, 10);
|
||||
};
|
||||
</script>
|
@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true">
|
||||
<template #header-icon>
|
||||
<i class="fas fa-images text-blue-500"></i>
|
||||
</template>
|
||||
<template #header-title>Sprites</template>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Sprites List -->
|
||||
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">
|
||||
<i class="fas fa-image text-4xl mb-4 opacity-30"></i>
|
||||
<p>No sprites uploaded yet</p>
|
||||
<button @click="showUploadSection" class="mt-4 flex items-center gap-2 bg-blue-500 border border-blue-500 text-white rounded px-4 py-2 text-sm transition-colors mx-auto hover:bg-blue-600 hover:border-blue-600"><i class="fas fa-upload"></i> Upload sprites</button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-200 flex items-center gap-2"><i class="fas fa-images text-blue-500"></i> Uploaded Sprites</h3>
|
||||
<span class="text-sm text-gray-400">{{ sprites.length }} sprites</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 max-h-96 overflow-y-auto p-2">
|
||||
<div v-for="(sprite, index) in sprites" :key="sprite.id" @click="handleSpriteClick(sprite.id)" class="border border-gray-600 rounded bg-gray-700 p-3 text-center transition-all cursor-pointer hover:border-blue-500 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<img :src="sprite.img.src" :alt="sprite.name" class="max-w-full max-h-20 mx-auto mb-2 bg-black bg-opacity-20 rounded" />
|
||||
<div class="text-xs text-gray-400 truncate">{{ index + 1 }}. {{ truncateName(sprite.name) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button @click="showUploadSection" class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors hover:border-blue-500"><i class="fas fa-upload"></i> Upload more</button>
|
||||
<button @click="confirmClearAll" class="flex items-center gap-2 bg-red-600 border border-red-600 text-white rounded px-4 py-2 text-sm transition-colors hover:bg-red-700 hover:border-red-700"><i class="fas fa-trash-alt"></i> Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const store = useSpritesheetStore();
|
||||
const sprites = computed(() => store.sprites.value);
|
||||
const isModalOpen = computed({
|
||||
get: () => store.isSpritesModalOpen.value,
|
||||
set: value => {
|
||||
store.isSpritesModalOpen.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
store.isSpritesModalOpen.value = false;
|
||||
};
|
||||
|
||||
const handleSpriteClick = (spriteId: string) => {
|
||||
store.highlightSprite(spriteId);
|
||||
};
|
||||
|
||||
const showUploadSection = () => {
|
||||
// Close sprites modal and focus on upload section
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const confirmClearAll = () => {
|
||||
if (confirm('Are you sure you want to clear all sprites?')) {
|
||||
store.clearAllSprites();
|
||||
store.showNotification('All sprites cleared');
|
||||
}
|
||||
};
|
||||
|
||||
// Use the utility function for truncating text
|
||||
const truncateName = (name: string) => {
|
||||
return store.truncateText(name);
|
||||
};
|
||||
</script>
|
@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<div @mouseenter="showTooltip" @mouseleave="hideTooltip">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-show="isVisible" ref="tooltipEl" class="tooltip fixed z-50 px-2 py-1 text-xs font-medium text-white bg-gray-800 rounded shadow-lg whitespace-nowrap" :style="tooltipStyle">
|
||||
{{ text }}
|
||||
<div class="absolute w-2 h-2 bg-gray-800 transform rotate-45" :style="arrowStyle"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
position?: 'top' | 'right' | 'bottom' | 'left';
|
||||
offset?: number;
|
||||
}>();
|
||||
|
||||
const isVisible = ref(false);
|
||||
const tooltipEl = ref<HTMLElement | null>(null);
|
||||
const position = computed(() => props.position || 'top');
|
||||
const offset = computed(() => props.offset || 8);
|
||||
|
||||
// Dynamic styles for tooltip and arrow
|
||||
const tooltipStyle = ref({
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
});
|
||||
|
||||
const arrowStyle = ref({
|
||||
left: '50%',
|
||||
top: '100%',
|
||||
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||
});
|
||||
|
||||
const updatePosition = (event: MouseEvent) => {
|
||||
if (!isVisible.value || !tooltipEl.value) return;
|
||||
|
||||
const tooltip = tooltipEl.value;
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const padding = 10; // Padding from screen edges
|
||||
|
||||
let left = event.clientX;
|
||||
let top = event.clientY;
|
||||
|
||||
// Calculate positions based on available space
|
||||
const spaceAbove = top;
|
||||
const spaceBelow = window.innerHeight - top;
|
||||
const spaceLeft = left;
|
||||
const spaceRight = window.innerWidth - left;
|
||||
|
||||
// Determine best position
|
||||
let finalPosition = position.value;
|
||||
if (finalPosition === 'top' && spaceAbove < tooltipRect.height + padding) {
|
||||
finalPosition = spaceBelow > tooltipRect.height + padding ? 'bottom' : 'right';
|
||||
} else if (finalPosition === 'bottom' && spaceBelow < tooltipRect.height + padding) {
|
||||
finalPosition = spaceAbove > tooltipRect.height + padding ? 'top' : 'right';
|
||||
} else if (finalPosition === 'left' && spaceLeft < tooltipRect.width + padding) {
|
||||
finalPosition = spaceRight > tooltipRect.width + padding ? 'right' : 'top';
|
||||
} else if (finalPosition === 'right' && spaceRight < tooltipRect.width + padding) {
|
||||
finalPosition = spaceLeft > tooltipRect.width + padding ? 'left' : 'top';
|
||||
}
|
||||
|
||||
// Position tooltip based on final position
|
||||
switch (finalPosition) {
|
||||
case 'top':
|
||||
left -= tooltipRect.width / 2;
|
||||
top -= tooltipRect.height + offset.value;
|
||||
arrowStyle.value = {
|
||||
left: '50%',
|
||||
top: '100%',
|
||||
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||
};
|
||||
break;
|
||||
case 'bottom':
|
||||
left -= tooltipRect.width / 2;
|
||||
top += offset.value;
|
||||
arrowStyle.value = {
|
||||
left: '50%',
|
||||
top: '0',
|
||||
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||
};
|
||||
break;
|
||||
case 'left':
|
||||
left -= tooltipRect.width + offset.value;
|
||||
top -= tooltipRect.height / 2;
|
||||
arrowStyle.value = {
|
||||
left: '100%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||
};
|
||||
break;
|
||||
case 'right':
|
||||
left += offset.value;
|
||||
top -= tooltipRect.height / 2;
|
||||
arrowStyle.value = {
|
||||
left: '0',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure tooltip stays within screen bounds
|
||||
left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding));
|
||||
top = Math.max(padding, Math.min(top, window.innerHeight - tooltipRect.height - padding));
|
||||
|
||||
tooltipStyle.value = {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
};
|
||||
};
|
||||
|
||||
const showTooltip = (event: MouseEvent) => {
|
||||
isVisible.value = true;
|
||||
// Wait for next tick to ensure tooltip is rendered
|
||||
setTimeout(() => updatePosition(event), 0);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
isVisible.value = false;
|
||||
};
|
||||
|
||||
// Track mouse movement when tooltip is visible
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (isVisible.value) {
|
||||
updatePosition(event);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
</script>
|
@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="$emit('zoomOut')" :disabled="previewZoom <= 1" class="zoom-button" title="Zoom out">
|
||||
<i class="fas fa-search-minus"></i>
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-gray-300">{{ Math.round(previewZoom * 100) }}%</span>
|
||||
|
||||
<button @click="$emit('zoomIn')" :disabled="previewZoom >= 5" class="zoom-button" title="Zoom in">
|
||||
<i class="fas fa-search-plus"></i>
|
||||
</button>
|
||||
|
||||
<button @click="$emit('resetZoom')" :disabled="previewZoom === 1" class="control-button text-xs" title="Reset zoom level">Reset Zoom</button>
|
||||
</div>
|
||||
|
||||
<!-- View Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Show all sprites toggle -->
|
||||
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" :checked="showAllSprites" @change="$emit('toggleShowAllSprites')" class="form-checkbox h-4 w-4 text-blue-500 rounded border-gray-600 bg-gray-700 focus:ring-blue-500" />
|
||||
Show all frames
|
||||
</label>
|
||||
|
||||
<!-- Reset sprite position button -->
|
||||
<button @click="$emit('resetPosition')" :disabled="!hasSpriteOffset" class="control-button text-xs" title="Center sprite in cell">
|
||||
<i class="fas fa-crosshairs"></i>
|
||||
Center Sprite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Props
|
||||
defineProps<{
|
||||
previewZoom: number;
|
||||
spriteOffset: { x: number; y: number };
|
||||
hasSpriteOffset: boolean;
|
||||
showAllSprites: boolean;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
defineEmits<{
|
||||
(e: 'zoomIn'): void;
|
||||
(e: 'zoomOut'): void;
|
||||
(e: 'resetZoom'): void;
|
||||
(e: 'resetPosition'): void;
|
||||
(e: 'toggleShowAllSprites'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.zoom-button {
|
||||
@apply flex items-center justify-center w-8 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
@apply flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500;
|
||||
}
|
||||
</style>
|
@ -1,136 +0,0 @@
|
||||
import { ref, computed, watch, type Ref } from 'vue';
|
||||
import { type Sprite, type AnimationState } from '@/application/types';
|
||||
|
||||
export function useAnimation(sprites: Ref<Sprite[]>, animation: Ref<AnimationState>, onUpdateFrame: (frameIndex: number, offset: { x: number; y: number }) => void, getSpriteOffset: (frameIndex: number) => { x: number; y: number }) {
|
||||
// State
|
||||
const currentFrame = ref(0);
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
// Computed
|
||||
const currentFrameDisplay = computed(() => {
|
||||
if (sprites.value.length === 0) return '0 / 0';
|
||||
return `${currentFrame.value + 1} / ${sprites.value.length}`;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const startAnimation = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
animation.value.isPlaying = true;
|
||||
animation.value.lastFrameTime = performance.now();
|
||||
animation.value.manualUpdate = false;
|
||||
|
||||
if (animation.value.canvas) {
|
||||
animation.value.canvas.width = animation.value.canvas.width; // Reset canvas
|
||||
}
|
||||
|
||||
// Start animation loop
|
||||
if (!animation.value.animationId) {
|
||||
animationLoop();
|
||||
}
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
animation.value.isPlaying = false;
|
||||
|
||||
if (animation.value.animationId) {
|
||||
cancelAnimationFrame(animation.value.animationId);
|
||||
animation.value.animationId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const animationLoop = (timestamp?: number) => {
|
||||
if (!animation.value.isPlaying) return;
|
||||
|
||||
const currentTime = timestamp || performance.now();
|
||||
const elapsed = currentTime - animation.value.lastFrameTime;
|
||||
const frameInterval = 1000 / animation.value.frameRate;
|
||||
|
||||
if (elapsed >= frameInterval) {
|
||||
animation.value.lastFrameTime = currentTime;
|
||||
|
||||
if (sprites.value.length > 0) {
|
||||
// Get the stored offset for the current frame
|
||||
const frameOffset = getSpriteOffset(animation.value.currentFrame);
|
||||
|
||||
// Update frame with offset
|
||||
onUpdateFrame(animation.value.currentFrame, frameOffset);
|
||||
|
||||
// Move to the next frame
|
||||
animation.value.currentFrame = (animation.value.currentFrame + 1) % sprites.value.length;
|
||||
currentFrame.value = animation.value.currentFrame;
|
||||
|
||||
// Update slider if available
|
||||
if (animation.value.slider) {
|
||||
animation.value.slider.value = animation.value.currentFrame.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animation.value.animationId = requestAnimationFrame(animationLoop);
|
||||
};
|
||||
|
||||
const handleFrameChange = () => {
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
}
|
||||
|
||||
// Ensure frame is within bounds
|
||||
currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1));
|
||||
animation.value.currentFrame = currentFrame.value;
|
||||
updateCurrentFrame();
|
||||
};
|
||||
|
||||
const handleFrameRateChange = () => {
|
||||
// If animation is currently playing, restart it with the new frame rate
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
startAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const updateCurrentFrame = () => {
|
||||
// Ensure frame is within bounds
|
||||
currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1));
|
||||
animation.value.currentFrame = currentFrame.value;
|
||||
animation.value.manualUpdate = true;
|
||||
|
||||
// Get the offset for the current frame
|
||||
const offset = getSpriteOffset(currentFrame.value);
|
||||
|
||||
// Update frame with the current offset
|
||||
onUpdateFrame(currentFrame.value, offset);
|
||||
};
|
||||
|
||||
const nextFrame = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
|
||||
updateCurrentFrame();
|
||||
};
|
||||
|
||||
const prevFrame = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
|
||||
updateCurrentFrame();
|
||||
};
|
||||
|
||||
// Keep currentFrame in sync with animation.currentFrame
|
||||
watch(
|
||||
() => animation.value.currentFrame,
|
||||
newVal => {
|
||||
currentFrame.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
currentFrame,
|
||||
showAllSprites,
|
||||
currentFrameDisplay,
|
||||
startAnimation,
|
||||
stopAnimation,
|
||||
handleFrameChange,
|
||||
handleFrameRateChange,
|
||||
updateCurrentFrame,
|
||||
nextFrame,
|
||||
prevFrame,
|
||||
};
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
// composables/useCanvasInitialization.ts
|
||||
import { ref, nextTick, type Ref } from 'vue';
|
||||
import { type CellSize, type AnimationState } from '@/application/types';
|
||||
|
||||
export function useCanvasInitialization(animation: Ref<AnimationState>, cellSize: CellSize) {
|
||||
const animCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const initializeCanvas = async (): Promise<boolean> => {
|
||||
// Wait for the next tick to ensure the canvas element is rendered
|
||||
await nextTick();
|
||||
|
||||
if (!animCanvas.value) {
|
||||
console.error('PreviewModal: Animation canvas not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = animCanvas.value.getContext('2d');
|
||||
if (!context) {
|
||||
console.error('PreviewModal: Failed to get 2D context from animation canvas');
|
||||
return false;
|
||||
}
|
||||
|
||||
animation.value.canvas = animCanvas.value;
|
||||
animation.value.ctx = context;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('PreviewModal: Error initializing animation canvas:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateCanvasSize = () => {
|
||||
if (!animCanvas.value) {
|
||||
console.warn('PreviewModal: Cannot update canvas size - canvas not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// More robust check for valid cell dimensions
|
||||
if (cellSize && typeof cellSize.width === 'number' && typeof cellSize.height === 'number' && cellSize.width > 0 && cellSize.height > 0) {
|
||||
animCanvas.value.width = cellSize.width;
|
||||
animCanvas.value.height = cellSize.height;
|
||||
} else {
|
||||
console.warn('PreviewModal: Cannot update canvas size - invalid cell dimensions', cellSize ? `width: ${cellSize.width}, height: ${cellSize.height}` : 'cellSize is undefined');
|
||||
|
||||
// Set a default minimum size to prevent rendering errors
|
||||
if (animCanvas.value.width === 0 || animCanvas.value.height === 0) {
|
||||
animCanvas.value.width = animCanvas.value.width || 100;
|
||||
animCanvas.value.height = animCanvas.value.height || 100;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
animCanvas,
|
||||
initializeCanvas,
|
||||
updateCanvasSize,
|
||||
};
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
import { onMounted, onBeforeUnmount, type Ref } from 'vue';
|
||||
import { type Sprite, type AnimationState } from '@/application/types';
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
isModalOpen: Ref<boolean>,
|
||||
sprites: Ref<Sprite[]>,
|
||||
animation: Ref<AnimationState>,
|
||||
closeModal: () => void,
|
||||
startAnimation: () => void,
|
||||
stopAnimation: () => void,
|
||||
nextFrame: () => void,
|
||||
prevFrame: () => void,
|
||||
zoomIn: () => void,
|
||||
zoomOut: () => void,
|
||||
resetZoom: () => void,
|
||||
resetSpritePosition: () => void,
|
||||
panViewport: (direction: 'left' | 'right' | 'up' | 'down', amount?: number) => void,
|
||||
showNotification: (message: string, type?: 'success' | 'error') => void
|
||||
) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isModalOpen.value) return;
|
||||
|
||||
// Modal control
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Animation control
|
||||
if (e.key === ' ' || e.key === 'Spacebar') {
|
||||
// Toggle play/pause
|
||||
if (animation.value.isPlaying) {
|
||||
stopAnimation();
|
||||
} else if (sprites.value.length > 0) {
|
||||
startAnimation();
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Frame navigation
|
||||
if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
|
||||
nextFrame();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
|
||||
prevFrame();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoom controls
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
zoomIn();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '-' || e.key === '_') {
|
||||
zoomOut();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '0') {
|
||||
resetZoom();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset sprite position
|
||||
if (e.key === 'r') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+R: Reset sprite position only
|
||||
resetSpritePosition();
|
||||
} else {
|
||||
// R: Reset both sprite position and viewport
|
||||
resetSpritePosition();
|
||||
resetZoom();
|
||||
showNotification('View and position reset');
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Viewport panning when animation is playing
|
||||
if (animation.value.isPlaying) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
panViewport('left');
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
panViewport('right');
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
panViewport('up');
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
panViewport('down');
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import { type Sprite, type CellSize } from '@/application/types';
|
||||
|
||||
export function useSpritePosition(sprites: Ref<Sprite[]>, currentFrame: Ref<number>, cellSize: Ref<CellSize>, getSpriteOffset: (frameIndex: number) => { x: number; y: number }, onUpdateFrame: () => void, showNotification: (message: string, type?: 'success' | 'error') => void) {
|
||||
// State
|
||||
const isCanvasDragging = ref(false);
|
||||
const canvasDragStart = ref({ x: 0, y: 0 });
|
||||
|
||||
// Computed
|
||||
const spriteOffset = computed({
|
||||
get: () => {
|
||||
// Get the offset for the current frame
|
||||
return getSpriteOffset(currentFrame.value);
|
||||
},
|
||||
set: val => {
|
||||
// Update the frame-specific offset directly
|
||||
const frameOffset = getSpriteOffset(currentFrame.value);
|
||||
frameOffset.x = val.x;
|
||||
frameOffset.y = val.y;
|
||||
|
||||
// Re-render the frame
|
||||
onUpdateFrame();
|
||||
},
|
||||
});
|
||||
|
||||
const hasSpriteOffset = computed(() => {
|
||||
return spriteOffset.value.x !== 0 || spriteOffset.value.y !== 0;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const resetSpritePosition = () => {
|
||||
// Get current sprite
|
||||
const sprite = sprites.value[currentFrame.value];
|
||||
if (!sprite) return;
|
||||
|
||||
// Check if cellSize is properly defined
|
||||
if (!cellSize.value || typeof cellSize.value.width !== 'number' || typeof cellSize.value.height !== 'number') {
|
||||
console.warn('Invalid cell dimensions during position reset');
|
||||
showNotification('Could not reset position - invalid cell dimensions', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate center position
|
||||
const centerX = Math.max(0, Math.floor((cellSize.value.width - sprite.width) / 2));
|
||||
const centerY = Math.max(0, Math.floor((cellSize.value.height - sprite.height) / 2));
|
||||
|
||||
// Update the sprite offset
|
||||
spriteOffset.value = { x: centerX, y: centerY };
|
||||
|
||||
// Update the frame
|
||||
onUpdateFrame();
|
||||
|
||||
// Show a notification
|
||||
showNotification('Sprite position reset to center');
|
||||
};
|
||||
|
||||
const startCanvasDrag = (e: MouseEvent, isViewportDragging: Ref<boolean>, previewZoom: Ref<number>) => {
|
||||
if (sprites.value.length === 0) return;
|
||||
if (isViewportDragging.value) return;
|
||||
|
||||
// Validate cell size before starting drag
|
||||
if (!cellSize.value || typeof cellSize.value.width !== 'number' || typeof cellSize.value.height !== 'number' || cellSize.value.width <= 0 || cellSize.value.height <= 0) {
|
||||
console.warn('Cannot start drag - invalid cell dimensions', cellSize.value ? `width: ${cellSize.value.width}, height: ${cellSize.value.height}` : 'cellSize is undefined');
|
||||
showNotification('Cannot drag sprite - invalid cell dimensions', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
isCanvasDragging.value = true;
|
||||
|
||||
// Store initial position
|
||||
canvasDragStart.value = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
|
||||
// Add event listeners with passive: false to ensure preventDefault works
|
||||
const boundHandleCanvasDrag = (e: MouseEvent) => handleCanvasDrag(e, previewZoom);
|
||||
window.addEventListener('mousemove', boundHandleCanvasDrag, { capture: true, passive: false });
|
||||
window.addEventListener('mouseup', stopCanvasDrag, { capture: true, passive: false });
|
||||
|
||||
// Store the bound function for later removal
|
||||
(window as any).__boundHandleCanvasDrag = boundHandleCanvasDrag;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleCanvasDrag = (e: MouseEvent, previewZoom: Ref<number>) => {
|
||||
if (!isCanvasDragging.value) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Get current sprite
|
||||
const sprite = sprites.value[currentFrame.value];
|
||||
if (!sprite) return;
|
||||
|
||||
// More robust check for cellSize validity
|
||||
if (!cellSize.value || typeof cellSize.value.width !== 'number' || typeof cellSize.value.height !== 'number' || cellSize.value.width <= 0 || cellSize.value.height <= 0) {
|
||||
console.warn('Invalid cell dimensions during drag operation', cellSize.value ? `width: ${cellSize.value.width}, height: ${cellSize.value.height}` : 'cellSize is undefined');
|
||||
showNotification('Cannot drag sprite - invalid cell dimensions', 'error');
|
||||
isCanvasDragging.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate delta from last position
|
||||
const deltaX = e.clientX - canvasDragStart.value.x;
|
||||
const deltaY = e.clientY - canvasDragStart.value.y;
|
||||
|
||||
// Only move when delta exceeds the threshold for one pixel movement at current zoom
|
||||
const pixelThreshold = previewZoom.value; // One pixel at current zoom level
|
||||
|
||||
// Calculate the maximum allowed offset
|
||||
const maxOffsetX = Math.max(0, cellSize.value.width - sprite.width);
|
||||
const maxOffsetY = Math.max(0, cellSize.value.height - sprite.height);
|
||||
|
||||
// Move one pixel at a time when threshold is reached
|
||||
if (Math.abs(deltaX) >= pixelThreshold) {
|
||||
const pixelsToMove = Math.sign(deltaX);
|
||||
const newX = spriteOffset.value.x + pixelsToMove;
|
||||
spriteOffset.value.x = Math.max(0, Math.min(maxOffsetX, newX));
|
||||
|
||||
// Reset the start X position for next pixel move
|
||||
canvasDragStart.value.x = e.clientX;
|
||||
}
|
||||
|
||||
if (Math.abs(deltaY) >= pixelThreshold) {
|
||||
const pixelsToMove = Math.sign(deltaY);
|
||||
const newY = spriteOffset.value.y + pixelsToMove;
|
||||
spriteOffset.value.y = Math.max(0, Math.min(maxOffsetY, newY));
|
||||
|
||||
// Reset the start Y position for next pixel move
|
||||
canvasDragStart.value.y = e.clientY;
|
||||
}
|
||||
|
||||
// Update the frame
|
||||
onUpdateFrame();
|
||||
});
|
||||
};
|
||||
|
||||
const stopCanvasDrag = (e: MouseEvent) => {
|
||||
if (!isCanvasDragging.value) return;
|
||||
|
||||
isCanvasDragging.value = false;
|
||||
// Use the stored bound function for removal
|
||||
window.removeEventListener('mousemove', (window as any).__boundHandleCanvasDrag, { capture: true });
|
||||
window.removeEventListener('mouseup', stopCanvasDrag, { capture: true });
|
||||
|
||||
// Clean up the reference
|
||||
delete (window as any).__boundHandleCanvasDrag;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return {
|
||||
isCanvasDragging,
|
||||
canvasDragStart,
|
||||
spriteOffset,
|
||||
hasSpriteOffset,
|
||||
resetSpritePosition,
|
||||
startCanvasDrag,
|
||||
handleCanvasDrag,
|
||||
stopCanvasDrag,
|
||||
};
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
import { sprites, canvas, ctx, cellSize, columns, draggedSprite, dragOffset, isShiftPressed, isModalOpen, isSettingsModalOpen, isSpritesModalOpen, isHelpModalOpen, zoomLevel, previewBorder, animation, notification, currentSpriteOffset, spriteOffsets } from '@/application/state';
|
||||
|
||||
import { getSpriteOffset, showNotification, createSpriteFromFile, processImageFiles, truncateText } from '@/application/utilities';
|
||||
|
||||
import { addSprites, updateCellSize, autoArrangeSprites, highlightSprite, clearAllSprites, applyOffsetsToMainView } from '@/application/spriteOperations';
|
||||
|
||||
import { updateCanvasSize, renderSpritesheetPreview, drawGrid, downloadSpritesheet, zoomIn, zoomOut, resetZoom } from '@/application/canvasOperations';
|
||||
|
||||
import { startAnimation, stopAnimation, renderAnimationFrame, animationLoop } from '@/application/animationController';
|
||||
|
||||
/**
|
||||
* Main store function that provides access to all spritesheet functionality
|
||||
*/
|
||||
export function useSpritesheetStore() {
|
||||
return {
|
||||
// State
|
||||
sprites,
|
||||
canvas,
|
||||
ctx,
|
||||
cellSize,
|
||||
columns,
|
||||
draggedSprite,
|
||||
dragOffset,
|
||||
isShiftPressed,
|
||||
isModalOpen,
|
||||
isSettingsModalOpen,
|
||||
isSpritesModalOpen,
|
||||
isHelpModalOpen,
|
||||
animation,
|
||||
notification,
|
||||
zoomLevel,
|
||||
previewBorder,
|
||||
currentSpriteOffset,
|
||||
spriteOffsets,
|
||||
|
||||
// Utils
|
||||
getSpriteOffset,
|
||||
showNotification,
|
||||
createSpriteFromFile,
|
||||
processImageFiles,
|
||||
truncateText,
|
||||
|
||||
// Sprite operations
|
||||
addSprites,
|
||||
updateCellSize,
|
||||
autoArrangeSprites,
|
||||
highlightSprite,
|
||||
clearAllSprites: () => clearAllSprites(animation),
|
||||
applyOffsetsToMainView: () => applyOffsetsToMainView(currentSpriteOffset),
|
||||
|
||||
// Canvas operations
|
||||
updateCanvasSize,
|
||||
renderSpritesheetPreview,
|
||||
drawGrid,
|
||||
downloadSpritesheet,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
|
||||
// Animation
|
||||
startAnimation,
|
||||
stopAnimation,
|
||||
renderAnimationFrame,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export { type Sprite, type CellSize, type AnimationState } from '@/application/types';
|
@ -1,165 +0,0 @@
|
||||
import { ref, nextTick, type Ref } from 'vue';
|
||||
import { type AnimationState } from '@/application/types';
|
||||
|
||||
export function useViewport(animation: Ref<AnimationState>, onUpdateFrame: () => void, showNotification: (message: string, type?: 'success' | 'error') => void) {
|
||||
// State
|
||||
const previewZoom = ref(1);
|
||||
const viewportOffset = ref({ x: 0, y: 0 });
|
||||
const isViewportDragging = ref(false);
|
||||
const viewportDragStart = ref({ x: 0, y: 0 });
|
||||
|
||||
// Methods
|
||||
const zoomIn = () => {
|
||||
if (previewZoom.value < 5) {
|
||||
previewZoom.value = Math.min(5, previewZoom.value + 0.5);
|
||||
|
||||
// Adjust container size after zoom change
|
||||
nextTick(() => {
|
||||
updateCanvasContainerSize();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
if (previewZoom.value > 1) {
|
||||
previewZoom.value = Math.max(1, previewZoom.value - 0.5);
|
||||
|
||||
// Adjust container size after zoom change
|
||||
nextTick(() => {
|
||||
updateCanvasContainerSize();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
previewZoom.value = 1;
|
||||
viewportOffset.value = { x: 0, y: 0 };
|
||||
|
||||
// Adjust container size after zoom change
|
||||
nextTick(() => {
|
||||
updateCanvasContainerSize();
|
||||
});
|
||||
};
|
||||
|
||||
const updateCanvasContainerSize = () => {
|
||||
// This is a no-op in the composable, but can be implemented if needed
|
||||
// The actual container size is managed through reactive bindings in the template
|
||||
};
|
||||
|
||||
const startViewportDrag = (e: MouseEvent, isCanvasDragging: Ref<boolean>) => {
|
||||
// Only enable viewport dragging when zoomed in
|
||||
if (previewZoom.value <= 1 || isCanvasDragging.value) return;
|
||||
|
||||
isViewportDragging.value = true;
|
||||
viewportDragStart.value = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
|
||||
// Add temporary event listeners with passive: false to ensure preventDefault works
|
||||
window.addEventListener('mousemove', handleViewportDrag, { passive: false });
|
||||
window.addEventListener('mouseup', stopViewportDrag, { passive: false });
|
||||
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleViewportDrag = (e: MouseEvent) => {
|
||||
if (!isViewportDragging.value) return;
|
||||
|
||||
// Prevent default browser behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const deltaX = e.clientX - viewportDragStart.value.x;
|
||||
const deltaY = e.clientY - viewportDragStart.value.y;
|
||||
|
||||
// Update viewport offset with the delta, scaled by zoom level
|
||||
viewportOffset.value = {
|
||||
x: viewportOffset.value.x + deltaX / previewZoom.value,
|
||||
y: viewportOffset.value.y + deltaY / previewZoom.value,
|
||||
};
|
||||
|
||||
// Reset drag start position
|
||||
viewportDragStart.value = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const stopViewportDrag = (e: MouseEvent) => {
|
||||
if (!isViewportDragging.value) return;
|
||||
|
||||
isViewportDragging.value = false;
|
||||
window.removeEventListener('mousemove', handleViewportDrag);
|
||||
window.removeEventListener('mouseup', stopViewportDrag);
|
||||
|
||||
// Prevent default browser behavior
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasWheel = (e: WheelEvent) => {
|
||||
// Prevent the default scroll behavior
|
||||
e.preventDefault();
|
||||
|
||||
if (e.ctrlKey) {
|
||||
// Ctrl + wheel = zoom in/out
|
||||
if (e.deltaY < 0) {
|
||||
zoomIn();
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
} else {
|
||||
// Just wheel = pan when zoomed in
|
||||
if (previewZoom.value > 1) {
|
||||
// Adjust pan amount by zoom level
|
||||
const panFactor = 0.5 / previewZoom.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + wheel = horizontal pan
|
||||
viewportOffset.value.x -= e.deltaY * panFactor;
|
||||
} else {
|
||||
// Regular wheel = vertical pan
|
||||
viewportOffset.value.y -= e.deltaY * panFactor;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const panViewport = (direction: 'left' | 'right' | 'up' | 'down', amount: number = 10) => {
|
||||
const panAmount = amount / previewZoom.value;
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
viewportOffset.value.x += panAmount;
|
||||
break;
|
||||
case 'right':
|
||||
viewportOffset.value.x -= panAmount;
|
||||
break;
|
||||
case 'up':
|
||||
viewportOffset.value.y += panAmount;
|
||||
break;
|
||||
case 'down':
|
||||
viewportOffset.value.y -= panAmount;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
previewZoom,
|
||||
viewportOffset,
|
||||
isViewportDragging,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
updateCanvasContainerSize,
|
||||
startViewportDrag,
|
||||
handleViewportDrag,
|
||||
stopViewportDrag,
|
||||
handleCanvasWheel,
|
||||
panViewport,
|
||||
};
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user