Files
spritesheet-generator/src/components/SpritePreview.vue

525 lines
22 KiB
Vue

<template>
<div class="spritesheet-preview w-full">
<!-- Main Layout: Canvas Left, Controls Right -->
<div class="flex flex-col lg:flex-row gap-4">
<!-- Canvas Area (Left/Main) -->
<div class="flex-1 min-w-0">
<div class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto min-h-[300px] sm:min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
<canvas
ref="previewCanvasRef"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="stopDrag"
class="block touch-manipulation"
:class="{ 'cursor-move': isDraggable }"
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
>
</canvas>
<!-- Mobile zoom controls -->
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
<button @click="increaseZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
<i class="fas fa-plus"></i>
</button>
<button @click="decreaseZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
</div>
<!-- Controls Sidebar (Right) -->
<div class="lg:w-80 xl:w-96 flex-shrink-0">
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-4">
<!-- Playback Controls -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Playback</h3>
<div class="flex items-center gap-2">
<button @click="togglePlayback" class="flex items-center justify-center gap-1.5 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors cursor-pointer flex-1">
<span v-if="isPlaying" class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
</svg>
Pause
</span>
<span v-else class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
</svg>
Play
</span>
</button>
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(90 12 12)" />
</svg>
</button>
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 rounded-md transition-colors duration-200 cursor-pointer" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 01.75.75v12.06l4.72-4.72a.75.75 0 111.06 1.06l-6 6a.75.75 0 01-1.06 0l-6-6a.75.75 0 011.06-1.06l4.72 4.72V5.97a.75.75 0 01.75-.75z" clip-rule="evenodd" transform="rotate(-90 12 12)" />
</svg>
</button>
</div>
</div>
<!-- Sliders -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Controls</h3>
<!-- Frame Navigation -->
<div class="space-y-1">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Frame</span>
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
</div>
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
</div>
<!-- FPS Control -->
<div class="space-y-1">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">FPS</span>
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ fps }}</span>
</div>
<input type="range" min="1" max="60" v-model.number="fps" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
</div>
<!-- Zoom Control -->
<div class="space-y-1">
<div class="flex justify-between items-center">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Zoom</span>
<span class="text-xs font-mono text-gray-700 dark:text-gray-300">{{ Math.round(zoom * 100) }}%</span>
</div>
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" />
</div>
</div>
<!-- Options -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Options</h3>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="isDraggable" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm dark:text-gray-200">Reposition</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm dark:text-gray-200">Compare sprites</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
</label>
</div>
</div>
<!-- Current frame offset display -->
<div v-if="props.sprites[currentFrameIndex]" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Offset</span>
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ props.sprites[currentFrameIndex].x }}, y: {{ props.sprites[currentFrameIndex].y }}</span>
</div>
</div>
<!-- Frame Selection (when Compare sprites is enabled) -->
<div v-if="showAllSprites" class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Frames</h3>
<div class="flex gap-1">
<button @click="showAllFrames" class="px-2 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors">All</button>
<button @click="hideAllFrames" class="px-2 py-1 text-xs bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">None</button>
</div>
</div>
<div class="rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800">
<div class="max-h-[180px] overflow-y-auto">
<div class="space-y-0.5 p-1">
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-3.5 h-3.5 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden flex-shrink-0">
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
<span class="text-xs dark:text-gray-200">{{ index + 1 }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from '@/composables/useSprites';
const props = defineProps<{
sprites: Sprite[];
columns: number;
}>();
const emit = defineEmits<{
(e: 'updateSprite', id: string, x: number, y: number): void;
}>();
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
// Preview state
const currentFrameIndex = ref(0);
const isPlaying = ref(false);
const fps = ref(12);
const zoom = ref(1);
const isDraggable = ref(false);
const showAllSprites = ref(false);
const animationFrameId = ref<number | null>(null);
const lastFrameTime = ref(0);
// 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 });
// Add this after other refs
const hiddenFrames = ref<number[]>([]);
// Get settings from store
const settingsStore = useSettingsStore();
// Add these computed properties
const visibleFrames = computed(() => props.sprites.filter((_, index) => !hiddenFrames.value.includes(index)));
const visibleFramesCount = computed(() => visibleFrames.value.length);
const visibleFrameIndex = computed(() => {
return visibleFrames.value.findIndex((_, idx) => idx === visibleFrames.value.findIndex(s => s === props.sprites[currentFrameIndex.value]));
});
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Canvas drawing
const drawPreviewCanvas = () => {
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
const currentSprite = props.sprites[currentFrameIndex.value];
if (!currentSprite) return;
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
// Apply pixel art optimization consistently from store
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
// Set canvas size to just fit one sprite cell
previewCanvasRef.value.width = maxWidth;
previewCanvasRef.value.height = maxHeight;
// Clear canvas
ctx.value.clearRect(0, 0, previewCanvasRef.value.width, previewCanvasRef.value.height);
// Draw grid background (cell)
ctx.value.fillStyle = '#f9fafb';
ctx.value.fillRect(0, 0, maxWidth, maxHeight);
// Keep pixel art optimization consistent throughout all drawing operations
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
// Draw all sprites with transparency if enabled
if (showAllSprites.value && props.sprites.length > 1) {
ctx.value.globalAlpha = 0.3;
props.sprites.forEach((sprite, index) => {
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
// Use Math.floor for pixel-perfect positioning
ctx.value?.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
}
});
ctx.value.globalAlpha = 1.0;
}
// Draw current sprite with integer positions for pixel-perfect rendering
ctx.value.drawImage(currentSprite.img, Math.floor(currentSprite.x), Math.floor(currentSprite.y));
// Draw cell border
ctx.value.strokeStyle = '#e5e7eb';
ctx.value.lineWidth = 1;
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
};
// Animation control
const togglePlayback = () => {
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
startAnimation();
} else {
stopAnimation();
}
};
const startAnimation = () => {
lastFrameTime.value = performance.now();
animateFrame();
};
const stopAnimation = () => {
if (animationFrameId.value !== null) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
};
const animateFrame = () => {
const now = performance.now();
const elapsed = now - lastFrameTime.value;
const frameTime = 1000 / fps.value;
if (elapsed >= frameTime) {
lastFrameTime.value = now - (elapsed % frameTime);
nextFrame();
}
animationFrameId.value = requestAnimationFrame(animateFrame);
};
const nextFrame = () => {
if (visibleFrames.value.length === 0) return;
const currentVisibleIndex = visibleFrameIndex.value;
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
drawPreviewCanvas();
};
const previousFrame = () => {
if (visibleFrames.value.length === 0) return;
const currentVisibleIndex = visibleFrameIndex.value;
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
drawPreviewCanvas();
};
// Add this method to handle slider input
const handleSliderInput = (event: Event) => {
const target = event.target as HTMLInputElement;
const index = parseInt(target.value);
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[index]);
};
// Drag functionality
const startDrag = (event: MouseEvent) => {
if (!isDraggable.value || !previewCanvasRef.value) return;
const rect = previewCanvasRef.value.getBoundingClientRect();
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
const sprite = props.sprites[currentFrameIndex.value];
// Check if click is on sprite
if (sprite && mouseX >= sprite.x && mouseX <= sprite.x + sprite.width && mouseY >= sprite.y && mouseY <= sprite.y + sprite.height) {
isDragging.value = true;
activeSpriteId.value = sprite.id;
dragStartX.value = mouseX;
dragStartY.value = mouseY;
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
}
};
const drag = (event: MouseEvent) => {
if (!isDragging.value || !activeSpriteId.value || !previewCanvasRef.value) return;
const rect = previewCanvasRef.value.getBoundingClientRect();
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
const deltaX = Math.round(mouseX - dragStartX.value);
const deltaY = Math.round(mouseY - dragStartY.value);
const sprite = props.sprites[currentFrameIndex.value];
if (!sprite || sprite.id !== activeSpriteId.value) return;
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
// Calculate new position with constraints and round to integers
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
// Constrain movement within cell
newX = Math.max(0, Math.min(maxWidth - sprite.width, newX));
newY = Math.max(0, Math.min(maxHeight - sprite.height, newY));
emit('updateSprite', activeSpriteId.value, newX, newY);
drawPreviewCanvas();
};
const stopDrag = () => {
isDragging.value = false;
activeSpriteId.value = null;
};
// Add helper methods for mobile zoom controls
const increaseZoom = () => {
const zoomValues = [0.5, 1, 2, 3, 4];
const currentIndex = zoomValues.indexOf(Number(zoom.value));
if (currentIndex < zoomValues.length - 1) {
zoom.value = zoomValues[currentIndex + 1];
}
};
const decreaseZoom = () => {
const zoomValues = [0.5, 1, 2, 3, 4];
const currentIndex = zoomValues.indexOf(Number(zoom.value));
if (currentIndex > 0) {
zoom.value = zoomValues[currentIndex - 1];
}
};
const handleTouchStart = (event: TouchEvent) => {
if (!isDraggable.value) return;
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 (!isDraggable.value) return;
if (isDragging.value) {
event.preventDefault(); // Only prevent default when actually dragging
}
if (event.touches.length === 1) {
const touch = event.touches[0];
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY,
});
drag(mouseEvent);
}
};
// Lifecycle hooks
onMounted(() => {
if (previewCanvasRef.value) {
ctx.value = previewCanvasRef.value.getContext('2d');
drawPreviewCanvas();
}
// Listen for forceRedraw event from App.vue
window.addEventListener('forceRedraw', handleForceRedraw);
});
onUnmounted(() => {
stopAnimation();
window.removeEventListener('forceRedraw', handleForceRedraw);
});
// Handler for force redraw event
const handleForceRedraw = () => {
// Ensure we're using integer positions for pixel-perfect rendering
props.sprites.forEach(sprite => {
sprite.x = Math.floor(sprite.x);
sprite.y = Math.floor(sprite.y);
});
// Force a redraw with the correct image smoothing settings
if (ctx.value) {
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
drawPreviewCanvas();
}
};
// Watchers
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
watch(currentFrameIndex, drawPreviewCanvas);
watch(zoom, drawPreviewCanvas);
watch(isDraggable, drawPreviewCanvas);
watch(showAllSprites, drawPreviewCanvas);
watch(hiddenFrames, drawPreviewCanvas);
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
// Initial draw
if (props.sprites.length > 0) {
drawPreviewCanvas();
}
const toggleHiddenFrame = (index: number) => {
const currentIndex = hiddenFrames.value.indexOf(index);
if (currentIndex === -1) {
// Adding to hidden frames
hiddenFrames.value.push(index);
// If we're hiding the current frame, switch to the next visible frame
if (index === currentFrameIndex.value) {
const nextVisibleSprite = props.sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
if (nextVisibleSprite !== -1) {
currentFrameIndex.value = nextVisibleSprite;
}
}
} else {
// Removing from hidden frames
hiddenFrames.value.splice(currentIndex, 1);
}
// Force a redraw
drawPreviewCanvas();
};
const showAllFrames = () => {
hiddenFrames.value = [];
drawPreviewCanvas();
};
const hideAllFrames = () => {
hiddenFrames.value = props.sprites.map((_, index) => index);
// Keep at least one frame visible
if (hiddenFrames.value.length > 0) {
hiddenFrames.value.splice(currentFrameIndex.value, 1);
}
drawPreviewCanvas();
};
</script>
<style scoped>
/* Custom styling for range inputs */
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: none;
}
</style>