Compare commits
3 Commits
e42a113c2d
...
2bbe6b0ff0
Author | SHA1 | Date | |
---|---|---|---|
2bbe6b0ff0 | |||
66d1cf94b4 | |||
ad390eed98 |
58
src/App.vue
58
src/App.vue
@ -1,22 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-100 p-4">
|
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 p-6">
|
||||||
<h1 class="text-2xl font-bold text-center mb-6">Spritesheet Generator</h1>
|
<div class="max-w-6xl mx-auto">
|
||||||
<div class="max-w-4xl mx-auto bg-white rounded-lg shadow p-6">
|
<h1 class="text-4xl font-bold text-center mb-8 text-gray-900 tracking-tight">Spritesheet Generator</h1>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-soft p-8">
|
||||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||||
|
|
||||||
<div v-if="sprites.length > 0" class="mt-6">
|
<div v-if="sprites.length > 0" class="mt-8">
|
||||||
|
<div class="flex flex-wrap items-center gap-6 mb-8">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<label for="columns" class="text-gray-700 font-medium">Columns:</label>
|
||||||
|
<input id="columns" type="number" v-model="columns" min="1" max="10" class="w-20 px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="downloadSpritesheet" class="px-6 py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors flex items-center space-x-2">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
<span>Download Spritesheet</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="openPreviewModal" class="px-6 py-2.5 bg-green-500 hover:bg-green-600 text-white font-medium rounded-lg transition-colors flex items-center space-x-2">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
<span>Preview Animation</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||||
|
</div>
|
||||||
<div class="mt-4 flex items-center gap-4">
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<button @click="downloadSpritesheet" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">Download Spritesheet</button>
|
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" :initial-width="800" :initial-height="600" title="Animation Preview">
|
||||||
</div>
|
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -24,6 +39,8 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import FileUploader from './components/FileUploader.vue';
|
import FileUploader from './components/FileUploader.vue';
|
||||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||||
|
import Modal from './components/utilities/Modal.vue';
|
||||||
|
import SpritePreview from './components/SpritePreview.vue';
|
||||||
|
|
||||||
interface Sprite {
|
interface Sprite {
|
||||||
id: string;
|
id: string;
|
||||||
@ -38,6 +55,7 @@
|
|||||||
|
|
||||||
const sprites = ref<Sprite[]>([]);
|
const sprites = ref<Sprite[]>([]);
|
||||||
const columns = ref(4);
|
const columns = ref(4);
|
||||||
|
const isPreviewModalOpen = ref(false);
|
||||||
|
|
||||||
const handleSpritesUpload = (files: File[]) => {
|
const handleSpritesUpload = (files: File[]) => {
|
||||||
Promise.all(
|
Promise.all(
|
||||||
@ -114,4 +132,18 @@
|
|||||||
link.href = canvas.toDataURL('image/png');
|
link.href = canvas.toDataURL('image/png');
|
||||||
link.click();
|
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>
|
</script>
|
||||||
|
3
src/assets/images/file.svg
Normal file
3
src/assets/images/file.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 376 B |
@ -1,17 +1,28 @@
|
|||||||
<template>
|
<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">
|
<div
|
||||||
|
class="border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200"
|
||||||
|
:class="{
|
||||||
|
'border-blue-300 bg-blue-50': isDragging,
|
||||||
|
'border-gray-200 hover:border-blue-300 hover:bg-gray-50': !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" />
|
<input ref="fileInput" type="file" multiple accept="image/*" class="hidden" @change="handleFileChange" />
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-6">
|
||||||
<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">
|
<img src="@/assets/images/file.svg" alt="File upload" class="w-20 h-20 mx-auto mb-4 opacity-75" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<p class="text-lg mb-2">Drag and drop your sprite images here</p>
|
<p class="text-xl font-medium text-gray-700 mb-2">Drag and drop your sprite images here</p>
|
||||||
<p class="text-sm text-gray-500 mb-4">or</p>
|
<p class="text-sm text-gray-500 mb-6">or</p>
|
||||||
|
|
||||||
<button @click="openFileDialog" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Select Files</button>
|
<button @click="openFileDialog" class="px-6 py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors inline-flex items-center space-x-2">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
<span>Select Files</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
298
src/components/SpritePreview.vue
Normal file
298
src/components/SpritePreview.vue
Normal 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 }"><</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 }">></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>
|
161
src/components/utilities/Modal.vue
Normal file
161
src/components/utilities/Modal.vue
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
ref="modalRef"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
}"
|
||||||
|
class="bg-white rounded-2xl border-2 border-gray-300 shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col"
|
||||||
|
>
|
||||||
|
<!-- Header - Make it the drag handle -->
|
||||||
|
<div class="flex justify-between items-center p-6 border-b border-gray-100 cursor-move" @mousedown="startDrag" @touchstart="startDrag">
|
||||||
|
<h3 class="text-2xl font-semibold text-gray-900">{{ title }}</h3>
|
||||||
|
<button @click="close" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-500" 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-6 flex-1 overflow-auto">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const modalRef = ref<HTMLElement | null>(null);
|
||||||
|
const position = ref({ x: 0, y: 0 });
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragOffset = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Center the modal when it opens
|
||||||
|
const centerModal = () => {
|
||||||
|
if (!modalRef.value) return;
|
||||||
|
|
||||||
|
const modalRect = modalRef.value.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
position.value = {
|
||||||
|
x: (viewportWidth - modalRect.width) / 2,
|
||||||
|
y: (viewportHeight - modalRect.height) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!modalRef.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
|
||||||
|
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||||
|
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||||
|
|
||||||
|
dragOffset.value = {
|
||||||
|
x: clientX - position.value.x,
|
||||||
|
y: clientY - position.value.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', drag);
|
||||||
|
document.addEventListener('touchmove', drag, { passive: false });
|
||||||
|
document.addEventListener('mouseup', stopDrag);
|
||||||
|
document.addEventListener('touchend', stopDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drag = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!isDragging.value || !modalRef.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
||||||
|
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
||||||
|
|
||||||
|
const modalRect = modalRef.value.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Calculate new position
|
||||||
|
let newX = clientX - dragOffset.value.x;
|
||||||
|
let newY = clientY - dragOffset.value.y;
|
||||||
|
|
||||||
|
// Constrain to viewport bounds
|
||||||
|
newX = Math.max(0, Math.min(newX, viewportWidth - modalRect.width));
|
||||||
|
newY = Math.max(0, Math.min(newY, viewportHeight - modalRect.height));
|
||||||
|
|
||||||
|
position.value = { x: newX, y: newY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', drag);
|
||||||
|
document.removeEventListener('touchmove', drag);
|
||||||
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
|
document.removeEventListener('touchend', stopDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('close');
|
||||||
|
position.value = { x: 0, y: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle ESC key to close modal
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && props.isOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!isDragging.value) {
|
||||||
|
centerModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for modal opening
|
||||||
|
watch(
|
||||||
|
() => props.isOpen,
|
||||||
|
newValue => {
|
||||||
|
if (newValue) {
|
||||||
|
// Use nextTick to ensure the modal is mounted
|
||||||
|
Vue.nextTick(() => {
|
||||||
|
centerModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
if (props.isOpen) {
|
||||||
|
centerModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
document.removeEventListener('mousemove', drag);
|
||||||
|
document.removeEventListener('touchmove', drag);
|
||||||
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
|
document.removeEventListener('touchend', stopDrag);
|
||||||
|
});
|
||||||
|
</script>
|
@ -1,29 +1,33 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||||
darkMode: false, // or 'media' or 'class'
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
gray: {
|
gray: {
|
||||||
900: '#121212', // bg-primary
|
900: '#111827', // bg-primary
|
||||||
800: '#1e1e1e', // bg-secondary
|
800: '#1F2937', // bg-secondary
|
||||||
700: '#252525', // bg-tertiary
|
700: '#374151', // bg-tertiary
|
||||||
600: '#333333', // border
|
600: '#4B5563', // border
|
||||||
400: '#a0a0a0', // text-secondary
|
400: '#9CA3AF', // text-secondary
|
||||||
200: '#e0e0e0', // text-primary
|
200: '#E5E7EB', // text-primary
|
||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
500: '#0096ff', // accent
|
500: '#3B82F6', // accent
|
||||||
600: '#0077cc', // accent-hover
|
600: '#2563EB', // accent-hover
|
||||||
},
|
},
|
||||||
red: {
|
red: {
|
||||||
500: '#e53935', // danger
|
500: '#EF4444', // danger
|
||||||
600: '#c62828', // danger-hover
|
600: '#DC2626', // danger-hover
|
||||||
},
|
},
|
||||||
green: {
|
green: {
|
||||||
500: '#43a047', // success
|
500: '#10B981', // success
|
||||||
|
600: '#059669', // success-hover
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user