This commit is contained in:
Dennis Postma 2025-04-03 02:50:23 +02:00
parent 22781b1883
commit 362efc7bda
15 changed files with 520 additions and 583 deletions

View File

@ -1,6 +1,11 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"semi": true,
"singleQuote": true,
"printWidth": 100
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 300,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"vueIndentScriptAndStyle": true
}

38
package-lock.json generated
View File

@ -13,13 +13,13 @@
"vue": "^3.5.13"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.13.14",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",
"npm-run-all2": "^7.0.2",
"postcss": "^8.5.3",
"prettier": "3.5.3",
"tailwindcss": "^4.1.1",
"typescript": "~5.8.0",
@ -897,6 +897,42 @@
"node": ">=18"
}
},
"node_modules/@ianvs/prettier-plugin-sort-imports": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@ianvs/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.4.1.tgz",
"integrity": "sha512-F0/Hrcfpy8WuxlQyAWJTEren/uxKhYonOGY4OyWmwRdeTvkh9mMSCxowZLjNkhwi/2ipqCgtXwwOk7tW0mWXkA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/generator": "^7.26.2",
"@babel/parser": "^7.26.2",
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.26.0",
"semver": "^7.5.2"
},
"peerDependencies": {
"@vue/compiler-sfc": "2.7.x || 3.x",
"prettier": "2 || 3"
},
"peerDependenciesMeta": {
"@vue/compiler-sfc": {
"optional": true
}
}
},
"node_modules/@ianvs/prettier-plugin-sort-imports/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",

View File

@ -17,6 +17,7 @@
"vue": "^3.5.13"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.13.14",
"@vitejs/plugin-vue": "^5.2.3",

View File

@ -12,21 +12,17 @@
</template>
<script setup lang="ts">
import { ref } 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 Notification from './components/Notification.vue';
import HelpButton from './components/HelpButton.vue';
import { ref } 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 Notification from './components/Notification.vue';
import HelpButton from './components/HelpButton.vue';
const previewModalRef = ref<InstanceType<typeof PreviewModal> | null>(null);
const previewModalRef = ref<InstanceType<typeof PreviewModal> | null>(null);
const showHelpModal = () => {
alert('Keyboard Shortcuts:\n\n' +
'Shift + Drag: Fine-tune sprite position\n' +
'Space: Play/Pause animation\n' +
'Esc: Close preview modal\n' +
'Arrow Keys: Navigate frames when paused');
};
</script>
const showHelpModal = () => {
alert('Keyboard Shortcuts:\n\n' + 'Shift + Drag: Fine-tune sprite position\n' + 'Space: Play/Pause animation\n' + 'Esc: Close preview modal\n' + 'Arrow Keys: Navigate frames when paused');
};
</script>

View File

@ -1 +1 @@
@import "tailwindcss";
@import 'tailwindcss';

View File

@ -5,11 +5,7 @@
<h1 class="text-xl font-semibold text-gray-200">Spritesheet Creator</h1>
</div>
<div class="flex gap-3">
<button
@click="emit('toggleHelp')"
class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors"
title="Keyboard Shortcuts"
>
<button @click="emit('toggleHelp')" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors" title="Keyboard Shortcuts">
<i class="fas fa-keyboard"></i>
</button>
</div>
@ -17,7 +13,7 @@
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: 'toggleHelp'): void
}>()
</script>
const emit = defineEmits<{
(e: 'toggleHelp'): void;
}>();
</script>

View File

@ -1,120 +1,104 @@
<template>
<div
@click="openFileDialog"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="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}"
>
<div @click="openFileDialog" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="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"
>
<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';
import { ref } from 'vue';
import { type Sprite, useSpritesheetStore } from '../composables/useSpritesheetStore';
const emit = defineEmits<{
'files-uploaded': [sprites: Sprite[]]
}>();
const emit = defineEmits<{
'files-uploaded': [sprites: Sprite[]];
}>();
const store = useSpritesheetStore();
const fileInput = ref<HTMLInputElement | null>(null);
const isDragOver = ref(false);
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);
}
};
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;
}
const newSprites: Sprite[] = [];
for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
try {
const sprite = await createSpriteFromFile(file, i);
newSprites.push(sprite);
} catch (error) {
console.error('Error loading sprite:', error);
const openFileDialog = () => {
if (fileInput.value) {
fileInput.value.click();
}
}
};
if (newSprites.length > 0) {
store.addSprites(newSprites);
emit('files-uploaded', newSprites);
store.showNotification(`Added ${newSprites.length} sprites successfully`);
}
};
const onDragOver = () => {
isDragOver.value = true;
};
const createSpriteFromFile = (file: File, index: number): Promise<Sprite> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const onDragLeave = () => {
isDragOver.value = false;
};
reader.onload = (e) => {
const img = new Image();
const onDrop = (e: DragEvent) => {
isDragOver.value = false;
if (e.dataTransfer?.files.length) {
handleFiles(e.dataTransfer.files);
}
};
img.onload = () => {
resolve({
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
});
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;
}
const newSprites: Sprite[] = [];
for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
try {
const sprite = await createSpriteFromFile(file, i);
newSprites.push(sprite);
} catch (error) {
console.error('Error loading sprite:', error);
}
}
if (newSprites.length > 0) {
store.addSprites(newSprites);
emit('files-uploaded', newSprites);
store.showNotification(`Added ${newSprites.length} sprites successfully`);
}
};
const createSpriteFromFile = (file: File, index: number): Promise<Sprite> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
resolve({
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,
});
};
img.onerror = reject;
img.src = e.target?.result as string;
};
img.onerror = reject;
img.src = e.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
</script>
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
</script>

View File

@ -1,14 +1,11 @@
<template>
<button
@click="emit('showHelp')"
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"
>
<button @click="emit('showHelp')" 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>
</template>
<script setup lang="ts">
const emit = defineEmits<{
showHelp: []
}>();
</script>
const emit = defineEmits<{
showHelp: [];
}>();
</script>

View File

@ -1,206 +1,194 @@
<template>
<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="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>
<div class="p-6">
<div class="relative overflow-auto bg-gray-700 rounded border border-gray-600">
<canvas ref="canvasEl" class="block mx-auto"></canvas>
</div>
</div>
</div>
<div class="p-6">
<div class="relative overflow-auto bg-gray-700 rounded border border-gray-600">
<canvas
ref="canvasEl"
class="block mx-auto"
></canvas>
</div>
</div>
</div>
<!-- Tooltip -->
<div
v-if="isTooltipVisible"
:style="tooltipStyle"
class="absolute bg-gray-700 text-gray-200 px-3 py-2 rounded text-xs z-50 pointer-events-none shadow-md border border-gray-600"
>
{{ tooltipText }}
<!-- Tooltip -->
<div v-if="isTooltipVisible" :style="tooltipStyle" class="absolute bg-gray-700 text-gray-200 px-3 py-2 rounded text-xs z-50 pointer-events-none shadow-md border border-gray-600">
{{ tooltipText }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
const store = useSpritesheetStore();
const canvasEl = ref<HTMLCanvasElement | null>(null);
const store = useSpritesheetStore();
const canvasEl = ref<HTMLCanvasElement | null>(null);
// Tooltip state
const isTooltipVisible = ref(false);
const tooltipText = ref('');
const tooltipPosition = ref({ x: 0, y: 0 });
// Tooltip state
const isTooltipVisible = ref(false);
const tooltipText = ref('');
const tooltipPosition = ref({ x: 0, y: 0 });
const tooltipStyle = computed(() => ({
left: `${tooltipPosition.value.x + 15}px`,
top: `${tooltipPosition.value.y + 15}px`
}));
const tooltipStyle = computed(() => ({
left: `${tooltipPosition.value.x + 15}px`,
top: `${tooltipPosition.value.y + 15}px`,
}));
const setupCheckerboardPattern = () => {
if (!canvasEl.value) return;
const setupCheckerboardPattern = () => {
if (!canvasEl.value) return;
// This will be done with CSS using Tailwind's bg utilities
canvasEl.value.style.backgroundImage = `
// This will be done with CSS using Tailwind's bg utilities
canvasEl.value.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%)
`;
canvasEl.value.style.backgroundSize = '20px 20px';
canvasEl.value.style.backgroundPosition = '0 0, 0 10px, 10px -10px, -10px 0px';
};
canvasEl.value.style.backgroundSize = '20px 20px';
canvasEl.value.style.backgroundPosition = '0 0, 0 10px, 10px -10px, -10px 0px';
};
const handleMouseDown = (e: MouseEvent) => {
if (!canvasEl.value || store.sprites.value.length === 0) return;
const handleMouseDown = (e: MouseEvent) => {
if (!canvasEl.value || store.sprites.value.length === 0) return;
const rect = canvasEl.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const rect = canvasEl.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Find which sprite was clicked
for (let i = store.sprites.value.length - 1; i >= 0; i--) {
const sprite = store.sprites.value[i];
if (
x >= sprite.x &&
x <= sprite.x + store.cellSize.width &&
y >= sprite.y &&
y <= sprite.y + store.cellSize.height
) {
store.draggedSprite.value = sprite;
store.dragOffset.x = x - sprite.x;
store.dragOffset.y = y - sprite.y;
break;
}
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!canvasEl.value) return;
const rect = canvasEl.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Update tooltip
const cellX = Math.floor(x / store.cellSize.width);
const cellY = Math.floor(y / store.cellSize.height);
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.clientX;
tooltipPosition.value.y = e.clientY;
} else {
isTooltipVisible.value = false;
}
// Move the sprite if we're dragging one
if (store.draggedSprite.value) {
if (store.isShiftPressed.value) {
// Free positioning within the cell bounds when shift is pressed
// First determine which cell we're in
const cellX = Math.floor(store.draggedSprite.value.x / store.cellSize.width);
const cellY = Math.floor(store.draggedSprite.value.y / store.cellSize.height);
// Calculate new position with constraints to stay within the cell
const newX = x - store.dragOffset.x;
const newY = y - store.dragOffset.y;
// Calculate cell boundaries
const cellLeft = cellX * store.cellSize.width;
const cellTop = cellY * store.cellSize.height;
const cellRight = cellLeft + store.cellSize.width - store.draggedSprite.value.width;
const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.height;
// Constrain position to stay within the cell
store.draggedSprite.value.x = Math.max(cellLeft, Math.min(newX, cellRight));
store.draggedSprite.value.y = Math.max(cellTop, Math.min(newY, cellBottom));
} else {
// Calculate new position based on grid cells (snap to grid)
const newCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width);
const newCellY = Math.floor((y - store.dragOffset.y) / store.cellSize.height);
// Make sure we stay within bounds
if (canvasEl.value) {
const maxCellX = Math.floor(canvasEl.value.width / store.cellSize.width) - 1;
const maxCellY = Math.floor(canvasEl.value.height / store.cellSize.height) - 1;
const boundedCellX = Math.max(0, Math.min(newCellX, maxCellX));
const boundedCellY = Math.max(0, Math.min(newCellY, maxCellY));
store.draggedSprite.value.x = boundedCellX * store.cellSize.width;
store.draggedSprite.value.y = boundedCellY * store.cellSize.height;
// Find which sprite was clicked
for (let i = store.sprites.value.length - 1; i >= 0; i--) {
const sprite = store.sprites.value[i];
if (x >= sprite.x && x <= sprite.x + store.cellSize.width && y >= sprite.y && y <= sprite.y + store.cellSize.height) {
store.draggedSprite.value = sprite;
store.dragOffset.x = x - sprite.x;
store.dragOffset.y = y - sprite.y;
break;
}
}
}
};
};
const handleMouseUp = () => {
store.draggedSprite.value = null;
};
const handleMouseMove = (e: MouseEvent) => {
if (!canvasEl.value) return;
const handleMouseOut = () => {
isTooltipVisible.value = false;
store.draggedSprite.value = null;
};
const rect = canvasEl.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
store.isShiftPressed.value = true;
}
};
// Update tooltip
const cellX = Math.floor(x / store.cellSize.width);
const cellY = Math.floor(y / store.cellSize.height);
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
store.isShiftPressed.value = false;
}
};
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.clientX;
tooltipPosition.value.y = e.clientY;
} else {
isTooltipVisible.value = false;
}
const setupCanvasEvents = () => {
if (!canvasEl.value) return;
// Move the sprite if we're dragging one
if (store.draggedSprite.value) {
if (store.isShiftPressed.value) {
// Free positioning within the cell bounds when shift is pressed
// First determine which cell we're in
const cellX = Math.floor(store.draggedSprite.value.x / store.cellSize.width);
const cellY = Math.floor(store.draggedSprite.value.y / store.cellSize.height);
canvasEl.value.addEventListener('mousedown', handleMouseDown);
canvasEl.value.addEventListener('mousemove', handleMouseMove);
canvasEl.value.addEventListener('mouseup', handleMouseUp);
canvasEl.value.addEventListener('mouseout', handleMouseOut);
};
// Calculate new position with constraints to stay within the cell
const newX = x - store.dragOffset.x;
const newY = y - store.dragOffset.y;
onMounted(() => {
if (canvasEl.value) {
store.canvas.value = canvasEl.value;
store.ctx.value = canvasEl.value.getContext('2d');
// Calculate cell boundaries
const cellLeft = cellX * store.cellSize.width;
const cellTop = cellY * store.cellSize.height;
const cellRight = cellLeft + store.cellSize.width - store.draggedSprite.value.width;
const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.height;
// Initialize canvas size
canvasEl.value.width = 400;
canvasEl.value.height = 300;
// Constrain position to stay within the cell
store.draggedSprite.value.x = Math.max(cellLeft, Math.min(newX, cellRight));
store.draggedSprite.value.y = Math.max(cellTop, Math.min(newY, cellBottom));
} else {
// Calculate new position based on grid cells (snap to grid)
const newCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width);
const newCellY = Math.floor((y - store.dragOffset.y) / store.cellSize.height);
setupCheckerboardPattern();
setupCanvasEvents();
// Make sure we stay within bounds
if (canvasEl.value) {
const maxCellX = Math.floor(canvasEl.value.width / store.cellSize.width) - 1;
const maxCellY = Math.floor(canvasEl.value.height / store.cellSize.height) - 1;
// Setup keyboard events for modifiers
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
}
});
const boundedCellX = Math.max(0, Math.min(newCellX, maxCellX));
const boundedCellY = Math.max(0, Math.min(newCellY, maxCellY));
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
store.draggedSprite.value.x = boundedCellX * store.cellSize.width;
store.draggedSprite.value.y = boundedCellY * store.cellSize.height;
}
}
}
};
if (canvasEl.value) {
canvasEl.value.removeEventListener('mousedown', handleMouseDown);
canvasEl.value.removeEventListener('mousemove', handleMouseMove);
canvasEl.value.removeEventListener('mouseup', handleMouseUp);
canvasEl.value.removeEventListener('mouseout', handleMouseOut);
}
});
</script>
const handleMouseUp = () => {
store.draggedSprite.value = null;
};
const handleMouseOut = () => {
isTooltipVisible.value = false;
store.draggedSprite.value = null;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
store.isShiftPressed.value = true;
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
store.isShiftPressed.value = false;
}
};
const setupCanvasEvents = () => {
if (!canvasEl.value) return;
canvasEl.value.addEventListener('mousedown', handleMouseDown);
canvasEl.value.addEventListener('mousemove', handleMouseMove);
canvasEl.value.addEventListener('mouseup', handleMouseUp);
canvasEl.value.addEventListener('mouseout', handleMouseOut);
};
onMounted(() => {
if (canvasEl.value) {
store.canvas.value = canvasEl.value;
store.ctx.value = canvasEl.value.getContext('2d');
// Initialize canvas size
canvasEl.value.width = 400;
canvasEl.value.height = 300;
setupCheckerboardPattern();
setupCanvasEvents();
// Setup keyboard events for modifiers
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
}
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
if (canvasEl.value) {
canvasEl.value.removeEventListener('mousedown', handleMouseDown);
canvasEl.value.removeEventListener('mousemove', handleMouseMove);
canvasEl.value.removeEventListener('mouseup', handleMouseUp);
canvasEl.value.removeEventListener('mouseout', handleMouseOut);
}
});
</script>

View File

@ -5,35 +5,32 @@
'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'
'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'
'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"
>
<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'
import { computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
const store = useSpritesheetStore()
const store = useSpritesheetStore();
const notification = computed(() => store.notification)
const notification = computed(() => store.notification);
const closeNotification = () => {
store.notification.isVisible = false
}
</script>
const closeNotification = () => {
store.notification.isVisible = false;
};
</script>

View File

@ -1,13 +1,6 @@
<template>
<div
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-all duration-300"
:class="{ 'opacity-0 invisible': !isModalOpen, 'opacity-100 visible': isModalOpen }"
@click.self="closeModal"
>
<div
class="bg-gray-800 rounded-lg max-w-4xl max-h-[90vh] overflow-auto shadow-lg transform transition-transform duration-300"
:class="{ '-translate-y-5': !isModalOpen, 'translate-y-0': isModalOpen }"
>
<div class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-all duration-300" :class="{ 'opacity-0 invisible': !isModalOpen, 'opacity-100 visible': isModalOpen }" @click.self="closeModal">
<div class="bg-gray-800 rounded-lg max-w-4xl max-h-[90vh] overflow-auto shadow-lg transform transition-transform duration-300" :class="{ '-translate-y-5': !isModalOpen, 'translate-y-0': isModalOpen }">
<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-film text-blue-500"></i>
@ -24,7 +17,9 @@
<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="{
'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
@ -32,7 +27,9 @@
<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="{
'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
@ -44,15 +41,7 @@
<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"
>
<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">
@ -60,15 +49,7 @@
<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"
>
<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>
@ -81,144 +62,147 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useSpritesheetStore } from '../composables/useSpritesheetStore'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
const store = useSpritesheetStore()
const animCanvas = ref<HTMLCanvasElement | null>(null)
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 isModalOpen = computed(() => store.isModalOpen.value);
const sprites = computed(() => store.sprites.value);
const animation = computed(() => store.animation);
const currentFrame = ref(0)
const currentFrame = ref(0);
const currentFrameDisplay = computed(() => {
const totalFrames = Math.max(1, sprites.value.length)
const frame = Math.min(currentFrame.value + 1, totalFrames)
return `${frame} / ${totalFrames}`
})
const currentFrameDisplay = computed(() => {
const totalFrames = Math.max(1, sprites.value.length);
const frame = Math.min(currentFrame.value + 1, totalFrames);
return `${frame} / ${totalFrames}`;
});
const handleKeyDown = (e: KeyboardEvent) => {
if (!isModalOpen.value) return
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()
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();
}
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()
}
}
};
const openModal = () => {
if (sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error')
return
}
const openModal = () => {
if (sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
return;
}
store.isModalOpen.value = true
store.isModalOpen.value = true;
// Show the current frame
if (!animation.value.isPlaying && sprites.value.length > 0) {
store.renderAnimationFrame(currentFrame.value)
}
}
// Show the current frame
if (!animation.value.isPlaying && sprites.value.length > 0) {
store.renderAnimationFrame(currentFrame.value);
}
};
const closeModal = () => {
store.isModalOpen.value = false
const closeModal = () => {
store.isModalOpen.value = false;
// Stop animation if it's playing
if (animation.value.isPlaying) {
stopAnimation()
}
}
// Stop animation if it's playing
if (animation.value.isPlaying) {
stopAnimation();
}
};
const startAnimation = () => {
if (sprites.value.length === 0) return
store.startAnimation()
}
const startAnimation = () => {
if (sprites.value.length === 0) return;
store.startAnimation();
};
const stopAnimation = () => {
store.stopAnimation()
}
const stopAnimation = () => {
store.stopAnimation();
};
const handleFrameChange = () => {
// Stop any running animation
if (animation.value.isPlaying) {
stopAnimation()
}
updateFrame()
}
const handleFrameChange = () => {
// Stop any running animation
if (animation.value.isPlaying) {
stopAnimation();
}
updateFrame();
};
const updateFrame = () => {
animation.value.currentFrame = currentFrame.value
animation.value.manualUpdate = true
store.renderAnimationFrame(currentFrame.value)
}
const updateFrame = () => {
animation.value.currentFrame = currentFrame.value;
animation.value.manualUpdate = true;
store.renderAnimationFrame(currentFrame.value);
};
const handleFrameRateChange = () => {
// If animation is currently playing, restart it with the new frame rate
if (animation.value.isPlaying) {
stopAnimation()
startAnimation()
}
}
const handleFrameRateChange = () => {
// If animation is currently playing, restart it with the new frame rate
if (animation.value.isPlaying) {
stopAnimation();
startAnimation();
}
};
onMounted(() => {
if (animCanvas.value) {
store.animation.canvas = animCanvas.value
store.animation.ctx = animCanvas.value.getContext('2d')
onMounted(() => {
if (animCanvas.value) {
store.animation.canvas = animCanvas.value;
store.animation.ctx = animCanvas.value.getContext('2d');
// Initialize canvas size
animCanvas.value.width = 200
animCanvas.value.height = 200
// Initialize canvas size
animCanvas.value.width = 200;
animCanvas.value.height = 200;
// Setup keyboard shortcuts for the modal
window.addEventListener('keydown', handleKeyDown)
}
})
// Setup keyboard shortcuts for the modal
window.addEventListener('keydown', handleKeyDown);
}
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
// Keep currentFrame in sync with animation.currentFrame
watch(() => animation.value.currentFrame, (newVal) => {
currentFrame.value = newVal
})
// Keep currentFrame in sync with animation.currentFrame
watch(
() => animation.value.currentFrame,
newVal => {
currentFrame.value = newVal;
}
);
// Expose openModal for external use
defineExpose({ openModal })
// 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']::-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>
input[type='range']::-moz-range-thumb {
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@ -40,32 +40,16 @@
</div>
<div class="p-6">
<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"
>
<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"
>
<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"
>
<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"
>
<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>
@ -90,39 +74,39 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import DropZone from './DropZone.vue';
import SpriteList from './SpriteList.vue';
import { computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import DropZone from './DropZone.vue';
import SpriteList from './SpriteList.vue';
const store = useSpritesheetStore();
const sprites = computed(() => store.sprites.value);
const store = useSpritesheetStore();
const sprites = computed(() => store.sprites.value);
const handleUpload = () => {
// The dropzone component handles adding sprites to the store
// This is just for event handling if needed
};
const handleUpload = () => {
// The dropzone component handles adding sprites to the store
// This is just for event handling if needed
};
const handleSpriteClick = (spriteId: string) => {
store.highlightSprite(spriteId);
};
const handleSpriteClick = (spriteId: string) => {
store.highlightSprite(spriteId);
};
const openPreviewModal = () => {
if (store.sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
return;
}
const openPreviewModal = () => {
if (store.sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
return;
}
store.isModalOpen.value = true;
};
store.isModalOpen.value = true;
};
const confirmClearAll = () => {
if (confirm('Are you sure you want to clear all sprites?')) {
store.clearAllSprites();
store.showNotification('All sprites cleared');
}
};
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 } = store;
</script>
// Expose store methods directly
const { autoArrangeSprites, downloadSpritesheet } = store;
</script>

View File

@ -1,39 +1,26 @@
<template>
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">
No sprites uploaded yet
</div>
<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 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-72 overflow-y-auto pr-2">
<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 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 type { Sprite } from '../composables/useSpritesheetStore'
import type { Sprite } from '../composables/useSpritesheetStore';
defineProps<{
sprites: Sprite[]
}>()
defineProps<{
sprites: Sprite[];
}>();
defineEmits<{
spriteClicked: [id: string]
}>()
defineEmits<{
spriteClicked: [id: string];
}>();
const truncateName = (name: string) => {
return name.length > 10 ? `${name.substring(0, 10)}...` : name
}
</script>
const truncateName = (name: string) => {
return name.length > 10 ? `${name.substring(0, 10)}...` : name;
};
</script>

View File

@ -48,13 +48,13 @@ export function useSpritesheetStore() {
lastFrameTime: 0,
animationId: null,
slider: null,
manualUpdate: false
manualUpdate: false,
});
const notification = reactive({
isVisible: false,
message: '',
type: 'success' as 'success' | 'error'
type: 'success' as 'success' | 'error',
});
function addSprites(newSprites: Sprite[]) {
@ -120,11 +120,7 @@ export function useSpritesheetStore() {
}
sprites.value.forEach(sprite => {
ctx.value!.drawImage(
sprite.img,
sprite.x,
sprite.y
);
ctx.value!.drawImage(sprite.img, sprite.x, sprite.y);
});
}
@ -164,12 +160,7 @@ export function useSpritesheetStore() {
// 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.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height);
ctx.value.restore();
// Reset after a short delay
@ -215,11 +206,7 @@ export function useSpritesheetStore() {
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
sprites.value.forEach(sprite => {
tempCtx.drawImage(
sprite.img,
sprite.x,
sprite.y
);
tempCtx.drawImage(sprite.img, sprite.x, sprite.y);
});
const link = document.createElement('a');
@ -257,8 +244,7 @@ export function useSpritesheetStore() {
function renderAnimationFrame(frameIndex: number) {
if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return;
if (animation.canvas.width !== cellSize.width ||
animation.canvas.height !== cellSize.height) {
if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) {
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
}
@ -270,14 +256,10 @@ export function useSpritesheetStore() {
const cellX = Math.floor(currentSprite.x / cellSize.width);
const cellY = Math.floor(currentSprite.y / cellSize.height);
const offsetX = currentSprite.x - (cellX * cellSize.width);
const offsetY = currentSprite.y - (cellY * cellSize.height);
const offsetX = currentSprite.x - cellX * cellSize.width;
const offsetY = currentSprite.y - cellY * cellSize.height;
animation.ctx.drawImage(
currentSprite.img,
offsetX,
offsetY
);
animation.ctx.drawImage(currentSprite.img, offsetX, offsetY);
}
function animationLoop(timestamp?: number) {
@ -338,6 +320,6 @@ export function useSpritesheetStore() {
startAnimation,
stopAnimation,
renderAnimationFrame,
showNotification
showNotification,
};
}
}

View File

@ -1,11 +1,11 @@
import './assets/main.css'
import './assets/main.css';
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App)
const app = createApp(App);
app.use(createPinia())
app.use(createPinia());
app.mount('#app')
app.mount('#app');