Added preview modal

This commit is contained in:
Dennis Postma 2025-04-05 22:37:37 +02:00
parent ad390eed98
commit 66d1cf94b4
3 changed files with 379 additions and 3 deletions

View File

@ -4,9 +4,7 @@
<div class="max-w-4xl mx-auto bg-white rounded-lg shadow p-6">
<file-uploader @upload-sprites="handleSpritesUpload" />
<div v-if="sprites.length > 0" class="mt-6">
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
<div v-if="sprites.length > 0">
<div class="mt-4 flex items-center gap-4">
<div class="flex items-center">
<label for="columns" class="mr-2">Columns:</label>
@ -14,9 +12,17 @@
</div>
<button @click="downloadSpritesheet" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">Download Spritesheet</button>
<button @click="openPreviewModal" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">Preview</button>
</div>
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
</div>
</div>
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" :initial-width="800" :initial-height="600" title="Preview">
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
</Modal>
</div>
</template>
@ -24,6 +30,8 @@
import { ref } from 'vue';
import FileUploader from './components/FileUploader.vue';
import SpriteCanvas from './components/SpriteCanvas.vue';
import Modal from './components/utilities/Modal.vue';
import SpritePreview from './components/SpritePreview.vue';
interface Sprite {
id: string;
@ -38,6 +46,7 @@
const sprites = ref<Sprite[]>([]);
const columns = ref(4);
const isPreviewModalOpen = ref(false);
const handleSpritesUpload = (files: File[]) => {
Promise.all(
@ -114,4 +123,18 @@
link.href = canvas.toDataURL('image/png');
link.click();
};
// Preview modal control
const openPreviewModal = () => {
console.log('Opening preview modal');
if (sprites.value.length === 0) {
console.log('No sprites to preview');
return;
}
isPreviewModalOpen.value = true;
};
const closePreviewModal = () => {
isPreviewModalOpen.value = false;
};
</script>

View File

@ -0,0 +1,298 @@
<template>
<div class="spritesheet-preview">
<!-- Preview Canvas -->
<div class="relative border border-gray-300 rounded-lg mb-4 overflow-hidden" :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }">
<canvas ref="previewCanvasRef" @mousedown="startDrag" @mousemove="drag" @mouseup="stopDrag" @mouseleave="stopDrag" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="stopDrag" class="block" :class="{ 'cursor-move': isDraggable }"></canvas>
</div>
<!-- Controls -->
<div class="space-y-4">
<!-- Playback Controls -->
<div class="flex items-center gap-4">
<button @click="togglePlayback" class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded">
{{ isPlaying ? 'Pause' : 'Play' }}
</button>
<div class="flex items-center gap-2">
<button @click="previousFrame" class="bg-gray-200 hover:bg-gray-300 px-3 py-1 rounded" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">&lt;</button>
<button @click="nextFrame" class="bg-gray-200 hover:bg-gray-300 px-3 py-1 rounded" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">&gt;</button>
</div>
<div class="flex items-center">
<input id="sprite-position" type="checkbox" v-model="isDraggable" class="mr-2" />
<label for="sprite-position">Allow repositioning</label>
</div>
</div>
<!-- Frame Navigation Slider -->
<div class="flex items-center gap-2">
<label for="frame-slider" class="min-w-16">Frame {{ currentFrameIndex + 1 }}/{{ totalFrames }}</label>
<input id="frame-slider" type="range" min="0" :max="totalFrames - 1" v-model.number="currentFrameIndex" class="w-full" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
</div>
<!-- FPS Slider -->
<div class="flex items-center gap-2">
<label for="fps-slider" class="min-w-16">FPS: {{ fps }}</label>
<input id="fps-slider" type="range" min="1" max="60" v-model.number="fps" class="w-full" />
</div>
<!-- Zoom Slider -->
<div class="flex items-center gap-2">
<label for="zoom-slider" class="min-w-16">Zoom: {{ Math.round(zoom * 100) }}%</label>
<input id="zoom-slider" type="range" min="0.5" max="4" step="0.1" v-model.number="zoom" class="w-full" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } 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;
}>();
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 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 });
// Computed properties
const totalFrames = () => props.sprites.length;
// Canvas drawing
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 drawPreviewCanvas = () => {
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
const sprite = props.sprites[currentFrameIndex.value];
if (!sprite) return;
const { maxWidth, maxHeight } = calculateMaxDimensions();
// 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);
ctx.value.strokeStyle = '#e5e7eb';
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
// Apply pixel art optimization
ctx.value.imageSmoothingEnabled = false;
// Draw current sprite
ctx.value.drawImage(sprite.img, sprite.x, sprite.y);
// Highlight current frame if draggable
if (isDraggable.value) {
ctx.value.strokeStyle = '#3b82f6';
ctx.value.lineWidth = 2;
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 (props.sprites.length === 0) return;
currentFrameIndex.value = (currentFrameIndex.value + 1) % props.sprites.length;
drawPreviewCanvas();
};
const previousFrame = () => {
if (props.sprites.length === 0) return;
currentFrameIndex.value = (currentFrameIndex.value - 1 + props.sprites.length) % props.sprites.length;
drawPreviewCanvas();
};
// 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 = mouseX - dragStartX.value;
const deltaY = mouseY - dragStartY.value;
const sprite = props.sprites[currentFrameIndex.value];
if (!sprite || sprite.id !== activeSpriteId.value) return;
const { maxWidth, maxHeight } = calculateMaxDimensions();
// Calculate new position with constraints
let newX = spritePosBeforeDrag.value.x + deltaX;
let newY = 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;
};
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);
}
};
// Lifecycle hooks
onMounted(() => {
if (previewCanvasRef.value) {
ctx.value = previewCanvasRef.value.getContext('2d');
drawPreviewCanvas();
}
});
onUnmounted(() => {
stopAnimation();
});
// Watchers
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
watch(currentFrameIndex, drawPreviewCanvas);
watch(zoom, drawPreviewCanvas);
watch(isDraggable, drawPreviewCanvas);
// Initial draw
if (props.sprites.length > 0) {
drawPreviewCanvas();
}
</script>
<style scoped>
.spritesheet-preview {
width: 100%;
max-width: 100%;
}
</style>

View File

@ -0,0 +1,55 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Modal content -->
<div class="relative bg-white border-gray-300 border-2 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="flex justify-between items-center p-4 border-b border-b-gray-400">
<h3 class="text-xl font-semibold">{{ title }}</h3>
<button @click="close" class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="p-4 flex-1 overflow-auto">
<slot></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const props = defineProps<{
isOpen: boolean;
title: string;
}>();
const emit = defineEmits<{
(e: 'close'): void;
}>();
const close = () => {
emit('close');
};
// Handle ESC key to close modal
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) {
close();
}
};
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
</script>