2025-04-04 02:42:36 +02:00

805 lines
27 KiB
Vue

<template>
<!-- Make the outer container always pointer-events-none so clicks pass through -->
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none">
<!-- Apply pointer-events-auto ONLY to the modal itself so it can be interacted with -->
<div
class="bg-gray-800 rounded-lg overflow-auto scrollbar-hide shadow-lg pointer-events-auto relative"
:class="{ invisible: !isModalOpen, visible: isModalOpen }"
:style="{
transform: `translate3d(${position.x}px, ${position.y + (isModalOpen ? 0 : -20)}px, 0)`,
width: `${modalSize.width}px`,
height: `${modalSize.height}px`,
maxWidth: '90vw',
maxHeight: '90vh',
}"
>
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600 cursor-move" @mousedown="startDrag">
<div class="flex items-center gap-2 text-lg font-semibold select-none">
<i class="fas fa-film text-blue-500"></i>
<span>Animation Preview</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-gray-200 text-xl">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex gap-2">
<button
@click="startAnimation"
: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="stopAnimation"
: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>
<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="currentFrame" :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" @input="handleFrameChange" />
</div>
<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="animation.frameRate" min="1" max="30" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" @input="handleFrameRateChange" />
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<!-- Zoom controls -->
<div class="flex items-center gap-2">
<button @click="zoomOut" :disabled="previewZoom <= 1" class="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">
<i class="fas fa-search-minus"></i>
</button>
<span class="text-sm text-gray-300">{{ Math.round(previewZoom * 100) }}%</span>
<button @click="zoomIn" :disabled="previewZoom >= 5" class="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">
<i class="fas fa-search-plus"></i>
</button>
<button
@click="applyOffsetsToMainView"
:disabled="!hasSpriteOffset"
class="flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
title="Permanently apply offset to sprite position"
>
<i class="fas fa-save"></i>
Apply Offset
</button>
<button @click="resetZoom" :disabled="previewZoom === 1" class="flex items-center justify-center px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">Reset Zoom</button>
</div>
<!-- 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" v-model="showAllSprites" 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="resetSpritePosition"
:disabled="!hasSpriteOffset"
class="flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
title="Reset sprite to original position"
>
<i class="fas fa-crosshairs"></i>
Reset Position
</button>
</div>
</div>
<div class="flex flex-col justify-center items-center bg-gray-700 p-6 rounded mb-6 relative overflow-auto flex-grow">
<!-- Tooltip for dragging instructions -->
<div class="text-xs text-gray-400 mb-2" v-if="hasSpriteOffset || sprites.length > 0">
<span v-if="hasSpriteOffset">Sprite offset: {{ Math.round(spriteOffset.x) }}px, {{ Math.round(spriteOffset.y) }}px</span>
<span v-else>Click and drag the sprite to move it within the cell</span>
</div>
<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="startViewportDrag"
@wheel="handleCanvasWheel"
>
<div
class="sprite-wrapper"
:style="{
transform: `scale(${previewZoom}) translate(${viewportOffset.x}px, ${viewportOffset.y}px)`,
cursor: isCanvasDragging ? 'grabbing' : 'move',
}"
@mousedown.stop="startCanvasDrag"
title="Drag to move sprite within cell"
>
<canvas ref="animCanvas" class="block" style="image-rendering: pixelated"></canvas>
</div>
</div>
</div>
</div>
<!-- Resize handle - larger and more noticeable -->
<div class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize flex items-end justify-end bg-gradient-to-br from-transparent to-gray-700 hover:to-blue-500 transition-colors duration-200" @mousedown="startResize">
<i class="fas fa-grip-lines-diagonal text-gray-400 hover:text-white p-1"></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
const store = useSpritesheetStore();
const animCanvas = ref<HTMLCanvasElement | null>(null);
const isModalOpen = computed(() => store.isModalOpen.value);
const sprites = computed(() => store.sprites.value);
const animation = computed(() => store.animation);
const previewBorder = computed(() => store.previewBorder);
const currentFrame = ref(0);
const position = ref({ x: 0, y: 0 });
const isDragging = ref(false);
const dragOffset = ref({ x: 0, y: 0 });
// New state for added features
const previewZoom = ref(1);
const showAllSprites = ref(false);
// Use a computed property to access and modify the store's sprite offset for the current frame
const spriteOffset = computed({
get: () => {
// Get the offset for the current frame
const frameOffset = store.getSpriteOffset(currentFrame.value);
// Update the current offset for UI display
store.currentSpriteOffset.x = frameOffset.x;
store.currentSpriteOffset.y = frameOffset.y;
return store.currentSpriteOffset;
},
set: val => {
// Update both the current offset and the frame-specific offset
store.currentSpriteOffset.x = val.x;
store.currentSpriteOffset.y = val.y;
// Get the frame-specific offset and update it
const frameOffset = store.getSpriteOffset(currentFrame.value);
frameOffset.x = val.x;
frameOffset.y = val.y;
},
});
const isCanvasDragging = ref(false);
const canvasDragStart = ref({ x: 0, y: 0 });
// Canvas viewport navigation state
const viewportOffset = ref({ x: 0, y: 0 });
const isViewportDragging = ref(false);
const viewportDragStart = ref({ x: 0, y: 0 });
// Modal size state for resize functionality
const modalSize = ref({ width: 800, height: 600 });
const isResizing = ref(false);
const initialSize = ref({ width: 0, height: 0 });
const resizeStart = ref({ x: 0, y: 0 });
const currentFrameDisplay = computed(() => {
const totalFrames = Math.max(1, sprites.value.length);
const frame = Math.min(currentFrame.value + 1, totalFrames);
return `${frame} / ${totalFrames}`;
});
// Computed property to check if sprite has been moved from original position
const hasSpriteOffset = computed(() => {
return spriteOffset.x !== 0 || spriteOffset.y !== 0;
});
const applyOffsetsToMainView = () => {
store.applyOffsetsToMainView();
store.showNotification('Offset permanently applied to sprite position');
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isModalOpen.value) return;
if (e.key === 'Escape') {
closeModal();
} else if (e.key === ' ' || e.key === 'Spacebar') {
// Toggle play/pause
if (animation.value.isPlaying) {
stopAnimation();
} else if (sprites.value.length > 0) {
startAnimation();
}
e.preventDefault();
} else if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
// Next frame
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
updateFrame();
} else if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
// Previous frame
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
updateFrame();
} else if (e.key === '+' || e.key === '=') {
// Zoom in
zoomIn();
e.preventDefault();
} else if (e.key === '-' || e.key === '_') {
// Zoom out
zoomOut();
e.preventDefault();
} else if (e.key === '0') {
// Reset zoom
resetZoom();
e.preventDefault();
} else if (e.key === 'r') {
if (e.shiftKey) {
// Shift+R: Reset sprite position only
resetSpritePosition();
} else {
// R: Reset both sprite position and viewport
// Reset the sprite offset for the current frame
const frameOffset = store.getSpriteOffset(currentFrame.value);
frameOffset.x = 0;
frameOffset.y = 0;
store.currentSpriteOffset.x = 0;
store.currentSpriteOffset.y = 0;
viewportOffset.value = { x: 0, y: 0 };
updateFrame();
store.showNotification('View and position reset');
}
e.preventDefault();
} else if (previewZoom.value > 1) {
// Arrow key navigation for panning when zoomed in
const panAmount = 10;
if (e.key === 'ArrowUp') {
viewportOffset.value.y += panAmount / previewZoom.value;
e.preventDefault();
} else if (e.key === 'ArrowDown') {
viewportOffset.value.y -= panAmount / previewZoom.value;
e.preventDefault();
} else if (e.key === 'ArrowLeft' && animation.value.isPlaying) {
viewportOffset.value.x += panAmount / previewZoom.value;
e.preventDefault();
} else if (e.key === 'ArrowRight' && animation.value.isPlaying) {
viewportOffset.value.x -= panAmount / previewZoom.value;
e.preventDefault();
}
}
};
const openModal = async () => {
if (sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
return;
}
// Center the modal in the viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
position.value = {
x: (viewportWidth - modalSize.value.width) / 2,
y: (viewportHeight - modalSize.value.height) / 2,
};
// 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;
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 new Promise(resolve => requestAnimationFrame(resolve));
// Set proper canvas size before rendering
updateCanvasSize();
// Force render the first frame
if (sprites.value.length > 0) {
// Get the frame-specific offset for the first frame
const frameOffset = store.getSpriteOffset(0);
// Update the current offset for UI display
store.currentSpriteOffset.x = frameOffset.x;
store.currentSpriteOffset.y = frameOffset.y;
// Render with the frame-specific offset
store.renderAnimationFrame(0, showAllSprites.value, frameOffset);
}
};
const updateCanvasSize = () => {
if (animCanvas.value && store.cellSize.width && store.cellSize.height) {
animCanvas.value.width = store.cellSize.width;
animCanvas.value.height = store.cellSize.height;
// Also update container size
updateCanvasContainerSize();
}
};
const closeModal = () => {
store.isModalOpen.value = false;
// Stop animation if it's playing
if (animation.value.isPlaying) {
stopAnimation();
}
};
const startAnimation = () => {
if (sprites.value.length === 0) return;
store.startAnimation();
};
const stopAnimation = () => {
store.stopAnimation();
};
const handleFrameChange = () => {
// Stop any running animation
if (animation.value.isPlaying) {
stopAnimation();
}
updateFrame();
};
const updateFrame = () => {
animation.value.currentFrame = currentFrame.value;
animation.value.manualUpdate = true;
// Get the frame-specific offset
const frameOffset = store.getSpriteOffset(currentFrame.value);
// Render with the frame-specific offset
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, frameOffset);
};
const handleFrameRateChange = () => {
// If animation is currently playing, restart it with the new frame rate
if (animation.value.isPlaying) {
stopAnimation();
startAnimation();
}
};
// Modal drag functionality
const startDrag = (e: MouseEvent) => {
// Don't allow drag if currently resizing
if (isResizing.value) return;
isDragging.value = true;
dragOffset.value = {
x: e.clientX - position.value.x,
y: e.clientY - position.value.y,
};
// Add temporary event listeners
window.addEventListener('mousemove', handleDrag);
window.addEventListener('mouseup', stopDrag);
};
const handleDrag = (e: MouseEvent) => {
if (!isDragging.value) return;
requestAnimationFrame(() => {
position.value = {
x: e.clientX - dragOffset.value.x,
y: e.clientY - dragOffset.value.y,
};
});
};
const stopDrag = () => {
isDragging.value = false;
window.removeEventListener('mousemove', handleDrag);
window.removeEventListener('mouseup', stopDrag);
};
// NEW: Modal resize functionality
const startResize = (e: MouseEvent) => {
isResizing.value = true;
initialSize.value = { ...modalSize.value };
resizeStart.value = { x: e.clientX, y: e.clientY };
// Add temporary event listeners
window.addEventListener('mousemove', handleResize);
window.addEventListener('mouseup', stopResize);
// Prevent default to avoid text selection
e.preventDefault();
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - resizeStart.value.x;
const deltaY = e.clientY - resizeStart.value.y;
requestAnimationFrame(() => {
// Calculate new size with minimum constraints
const newWidth = Math.max(400, initialSize.value.width + deltaX);
const newHeight = Math.max(400, initialSize.value.height + deltaY);
// Update modal size
modalSize.value = {
width: newWidth,
height: newHeight,
};
});
};
const stopResize = () => {
isResizing.value = false;
window.removeEventListener('mousemove', handleResize);
window.removeEventListener('mouseup', stopResize);
};
const initializeCanvas = async () => {
if (!animCanvas.value) {
console.error('PreviewModal: Animation canvas not found');
return;
}
try {
const context = animCanvas.value.getContext('2d');
if (!context) {
console.error('PreviewModal: Failed to get 2D context from animation canvas');
return;
}
store.animation.canvas = animCanvas.value;
store.animation.ctx = context;
} catch (error) {
console.error('PreviewModal: Error initializing animation canvas:', error);
}
};
// Zoom functions
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();
});
};
// Reset sprite position to original
const resetSpritePosition = () => {
// Reset the sprite offset for the current frame to zero
const frameOffset = store.getSpriteOffset(currentFrame.value);
frameOffset.x = 0;
frameOffset.y = 0;
// Also update the current offset
store.currentSpriteOffset.x = 0;
store.currentSpriteOffset.y = 0;
// Update the frame to reflect the change
updateFrame();
// Show a notification
store.showNotification('Sprite position reset to original');
};
// Update canvas container size based on zoom level
const updateCanvasContainerSize = () => {
// This ensures the container grows with zoom while keeping the sprite visible
if (!store.cellSize.width || !store.cellSize.height) return;
// We'll update the container if needed through the reactive bindings
};
// Canvas drag functions for moving the sprite within its cell
const startCanvasDrag = (e: MouseEvent) => {
if (sprites.value.length === 0) return;
// Don't start sprite dragging if we're already dragging the viewport
if (isViewportDragging.value) return;
isCanvasDragging.value = true;
canvasDragStart.value = {
x: e.clientX,
y: e.clientY,
};
// Add temporary event listeners
window.addEventListener('mousemove', handleCanvasDrag);
window.addEventListener('mouseup', stopCanvasDrag);
// Prevent default to avoid text selection
e.preventDefault();
};
const handleCanvasDrag = (e: MouseEvent) => {
if (!isCanvasDragging.value) return;
const deltaX = e.clientX - canvasDragStart.value.x;
const deltaY = e.clientY - canvasDragStart.value.y;
// Update sprite offset with the delta, scaled by zoom level
// Use requestAnimationFrame for smoother dragging
requestAnimationFrame(() => {
// Get the frame-specific offset
const frameOffset = store.getSpriteOffset(currentFrame.value);
// Update both the current offset and the frame-specific offset
const newX = frameOffset.x + deltaX / previewZoom.value;
const newY = frameOffset.y + deltaY / previewZoom.value;
frameOffset.x = newX;
frameOffset.y = newY;
store.currentSpriteOffset.x = newX;
store.currentSpriteOffset.y = newY;
// Reset drag start position
canvasDragStart.value = {
x: e.clientX,
y: e.clientY,
};
// Update the frame with the new offset
updateFrame();
// Re-render the main view to reflect the changes
store.renderSpritesheetPreview();
});
};
const stopCanvasDrag = () => {
isCanvasDragging.value = false;
window.removeEventListener('mousemove', handleCanvasDrag);
window.removeEventListener('mouseup', stopCanvasDrag);
};
// Canvas viewport navigation functions
const startViewportDrag = (e: MouseEvent) => {
// 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
window.addEventListener('mousemove', handleViewportDrag);
window.addEventListener('mouseup', stopViewportDrag);
// Prevent default to avoid text selection
e.preventDefault();
};
const handleViewportDrag = (e: MouseEvent) => {
if (!isViewportDragging.value) return;
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 = () => {
isViewportDragging.value = false;
window.removeEventListener('mousemove', handleViewportDrag);
window.removeEventListener('mouseup', stopViewportDrag);
};
// Handle mouse wheel zooming and panning
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;
}
}
}
};
onMounted(() => {
initializeCanvas();
window.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousemove', handleDrag);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('mousemove', handleCanvasDrag);
window.removeEventListener('mouseup', stopCanvasDrag);
window.removeEventListener('mousemove', handleResize);
window.removeEventListener('mouseup', stopResize);
window.removeEventListener('mousemove', handleViewportDrag);
window.removeEventListener('mouseup', stopViewportDrag);
});
// Keep currentFrame in sync with animation.currentFrame
watch(
() => animation.value.currentFrame,
newVal => {
currentFrame.value = newVal;
}
);
// 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();
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
}
},
{ 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) {
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
}
},
{ deep: true }
);
// Watch for changes in showAllSprites to update the preview
watch(
() => showAllSprites.value,
() => {
if (isModalOpen.value && sprites.value.length > 0) {
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
}
}
);
// Expose openModal for external use
defineExpose({ openModal });
</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;
}
/* Prevent text selection while dragging */
.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 */
}
/* Cursor styles */
.cursor-se-resize {
cursor: se-resize;
}
/* Canvas container styles */
.canvas-container {
margin: auto;
transition:
min-width 0.2s,
min-height 0.2s;
}
.sprite-wrapper {
transform-origin: center;
}
</style>