This commit is contained in:
Dennis Postma 2025-04-03 02:40:51 +02:00
commit a989c8719f
28 changed files with 5183 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"Vue.volar",
"esbenp.prettier-vscode"
]
}

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# spritesheetgen
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spritesheet generator</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3680
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "spritesheetgen",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write src/"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.1",
"pinia": "^3.0.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@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",
"prettier": "3.5.3",
"tailwindcss": "^4.1.1",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

32
src/App.vue Normal file
View File

@ -0,0 +1,32 @@
<template>
<div class="min-h-screen bg-gray-900 text-gray-200 font-sans">
<app-header @toggle-help="showHelpModal" />
<div class="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
<sidebar class="lg:col-span-1" />
<main-content class="lg:col-span-3" />
</div>
<preview-modal ref="previewModalRef" />
<notification />
<help-button @show-help="showHelpModal" />
</div>
</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';
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>

1
src/assets/main.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,26 @@
<template>
<header class="flex items-center justify-between bg-gray-800 p-3 shadow-md sticky top-0 z-50">
<div class="flex items-center gap-3">
<i class="fas fa-gamepad text-blue-500 text-2xl"></i>
<h1 class="text-xl font-semibold text-gray-200">Noxious 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"
>
<i class="fas fa-keyboard"></i>
</button>
</div>
</header>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'AppHeader',
emits: ['toggleHelp']
});
</script>

133
src/components/DropZone.vue Normal file
View File

@ -0,0 +1,133 @@
<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}"
>
<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"
>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { Sprite } from '../composables/useSpritesheetStore';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
export default defineComponent({
name: 'DropZone',
emits: ['files-uploaded'],
setup(props, { emit }) {
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);
}
}
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;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
return {
fileInput,
isDragOver,
openFileDialog,
onDragOver,
onDragLeave,
onDrop,
onFileChange
};
}
});
</script>

View File

@ -0,0 +1,17 @@
<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"
>
<i class="fas fa-question"></i>
</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'HelpButton',
emits: ['showHelp']
});
</script>

View File

@ -0,0 +1,228 @@
<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>
<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 }}
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, computed, onBeforeUnmount } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
export default defineComponent({
name: 'MainContent',
setup() {
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 });
const tooltipStyle = computed(() => ({
left: `${tooltipPosition.value.x + 15}px`,
top: `${tooltipPosition.value.y + 15}px`
}));
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;
// Set up checkerboard background pattern
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);
}
});
const setupCheckerboardPattern = () => {
if (!canvasEl.value) return;
// 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';
};
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);
};
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;
// 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));
// Update sprite position to snap to grid
store.draggedSprite.value.x = boundedCellX * store.cellSize.width;
store.draggedSprite.value.y = boundedCellY * store.cellSize.height;
}
}
store.renderSpritesheetPreview();
// Update animation preview if paused
if (!store.animation.isPlaying && store.sprites.value.length > 0 && store.isModalOpen.value) {
store.renderAnimationFrame(store.animation.currentFrame);
}
}
};
const handleMouseUp = () => {
store.draggedSprite.value = null;
};
const handleMouseOut = () => {
store.draggedSprite.value = null;
isTooltipVisible.value = false;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
store.isShiftPressed.value = true;
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
store.isShiftPressed.value = false;
}
};
return {
canvasEl,
isTooltipVisible,
tooltipText,
tooltipStyle
};
}
});
</script>

View File

@ -0,0 +1,49 @@
<template>
<div
class="fixed bottom-5 right-5 bg-gray-700 text-gray-200 p-4 rounded shadow-lg z-50 flex items-center gap-3 transform transition-all duration-300"
:class="{
'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'
}"
>
<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'
}"
></i>
<span>{{ notification.message }}</span>
<button
@click="closeNotification"
class="ml-2 text-gray-400 hover:text-gray-200"
>
<i class="fas fa-times"></i>
</button>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
export default defineComponent({
name: 'Notification',
setup() {
const store = useSpritesheetStore();
const notification = computed(() => store.notification);
const closeNotification = () => {
store.notification.isVisible = false;
};
return {
notification,
closeNotification
};
}
});
</script>

View File

@ -0,0 +1,243 @@
<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="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>
<span>Animation Preview</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-gray-200 text-xl">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex gap-2">
<button
@click="startAnimation"
:disabled="sprites.length === 0"
:class="{'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': !animation.isPlaying}"
class="flex items-center gap-2 border rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<i class="fas fa-play"></i> Play
</button>
<button
@click="stopAnimation"
:disabled="sprites.length === 0"
:class="{'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': animation.isPlaying}"
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
>
<i class="fas fa-pause"></i> Pause
</button>
</div>
<div class="flex flex-col gap-2 flex-grow">
<div class="flex justify-between text-sm text-gray-400">
<label for="frame-slider">Frame:</label>
<span>{{ currentFrameDisplay }}</span>
</div>
<input
type="range"
id="frame-slider"
v-model="currentFrame"
:min="0"
:max="Math.max(0, sprites.length - 1)"
class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer"
@input="handleFrameChange"
>
</div>
<div class="flex flex-col gap-2 flex-grow">
<div class="flex justify-between text-sm text-gray-400">
<label for="framerate">Frame Rate:</label>
<span>{{ animation.frameRate }} FPS</span>
</div>
<input
type="range"
id="framerate"
v-model.number="animation.frameRate"
min="1"
max="30"
class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer"
@input="handleFrameRateChange"
>
</div>
</div>
<div class="flex justify-center bg-gray-700 p-6 rounded mb-6">
<canvas ref="animCanvas" class="block"></canvas>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, computed, watch, onBeforeUnmount } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
export default defineComponent({
name: 'PreviewModal',
setup() {
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 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}`;
});
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;
// Setup keyboard shortcuts for the modal
window.addEventListener('keydown', handleKeyDown);
}
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
const handleKeyDown = (e: KeyboardEvent) => {
if (!isModalOpen.value) return;
if (e.key === 'Escape') {
closeModal();
} else if (e.key === ' ' || e.key === 'Spacebar') {
// Toggle play/pause
if (animation.value.isPlaying) {
stopAnimation();
} else if (sprites.value.length > 0) {
startAnimation();
}
e.preventDefault();
} else if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
// Next frame
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
updateFrame();
} else if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
// Previous frame
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
updateFrame();
}
};
const openModal = () => {
if (sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
return;
}
store.isModalOpen.value = true;
// Show the current frame
if (!animation.value.isPlaying && sprites.value.length > 0) {
store.renderAnimationFrame(currentFrame.value);
}
};
const closeModal = () => {
store.isModalOpen.value = false;
// Stop animation if it's playing
if (animation.value.isPlaying) {
stopAnimation();
}
};
const startAnimation = () => {
if (sprites.value.length === 0) return;
store.startAnimation();
};
const stopAnimation = () => {
store.stopAnimation();
};
const handleFrameChange = () => {
// Stop any running animation
if (animation.value.isPlaying) {
stopAnimation();
}
updateFrame();
};
const updateFrame = () => {
animation.value.currentFrame = currentFrame.value;
animation.value.manualUpdate = true;
store.renderAnimationFrame(currentFrame.value);
};
const handleFrameRateChange = () => {
// If animation is currently playing, restart it with the new frame rate
if (animation.value.isPlaying) {
stopAnimation();
startAnimation();
}
};
// Keep currentFrame in sync with animation.currentFrame
watch(() => animation.value.currentFrame, (newVal) => {
currentFrame.value = newVal;
});
return {
animCanvas,
isModalOpen,
sprites,
animation,
currentFrame,
currentFrameDisplay,
openModal,
closeModal,
startAnimation,
stopAnimation,
handleFrameChange,
handleFrameRateChange
};
}
});
</script>
<style scoped>
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
</style>

143
src/components/Sidebar.vue Normal file
View File

@ -0,0 +1,143 @@
<template>
<div class="flex flex-col gap-6">
<!-- Upload Card -->
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
<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-upload text-blue-500"></i>
<span>Upload Sprites</span>
</div>
</div>
<div class="p-6">
<drop-zone @files-uploaded="handleUpload" />
<div class="bg-blue-500 bg-opacity-10 border-l-4 border-blue-500 p-4 mt-6 rounded-r">
<p>Container size will adjust to fit the largest sprite. All sprites will be placed in cells of the same size.</p>
</div>
</div>
</div>
<!-- Sprites Card -->
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
<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-images text-blue-500"></i>
<span>Sprites</span>
</div>
</div>
<div class="p-6">
<sprite-list :sprites="sprites" @sprite-clicked="handleSpriteClick" />
</div>
</div>
<!-- Tools Card -->
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
<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-tools text-blue-500"></i>
<span>Tools</span>
</div>
</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"
>
<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"
>
<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"
>
<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"
>
<i class="fas fa-trash-alt"></i> Clear All
</button>
</div>
<div class="flex flex-wrap gap-3 mt-4">
<div class="flex items-center gap-2 text-sm text-gray-400">
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Shift</kbd>
<span>Fine-tune position</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Space</kbd>
<span>Play/Pause animation</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Esc</kbd>
<span>Close preview</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import DropZone from './DropZone.vue';
import SpriteList from './SpriteList.vue';
export default defineComponent({
name: 'Sidebar',
components: {
DropZone,
SpriteList
},
setup() {
const store = useSpritesheetStore();
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 openPreviewModal = () => {
if (store.sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
return;
}
store.isModalOpen.value = true;
};
const confirmClearAll = () => {
if (confirm('Are you sure you want to clear all sprites?')) {
store.clearAllSprites();
store.showNotification('All sprites cleared');
}
};
return {
sprites: computed(() => store.sprites.value),
autoArrangeSprites: store.autoArrangeSprites,
downloadSpritesheet: store.downloadSpritesheet,
confirmClearAll,
handleUpload,
handleSpriteClick,
openPreviewModal
};
}
});
</script>

View File

@ -0,0 +1,48 @@
<template>
<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('sprite-clicked', 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 lang="ts">
import { defineComponent, type PropType } from 'vue';
import { type Sprite } from '../composables/useSpritesheetStore';
export default defineComponent({
name: 'SpriteList',
props: {
sprites: {
type: Array as PropType<Sprite[]>,
required: true
}
},
emits: ['sprite-clicked'],
setup() {
const truncateName = (name: string) => {
return name.length > 10 ? `${name.substring(0, 10)}...` : name;
};
return {
truncateName
};
}
});
</script>

View File

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@ -0,0 +1,343 @@
import { ref, reactive, computed } from 'vue';
export interface Sprite {
img: HTMLImageElement;
width: number;
height: number;
x: number;
y: number;
name: string;
id: string;
uploadOrder: number;
}
export interface CellSize {
width: number;
height: number;
}
export interface AnimationState {
canvas: HTMLCanvasElement | null;
ctx: CanvasRenderingContext2D | null;
currentFrame: number;
isPlaying: boolean;
frameRate: number;
lastFrameTime: number;
animationId: number | null;
slider: HTMLInputElement | null;
manualUpdate: boolean;
}
export function useSpritesheetStore() {
const sprites = ref<Sprite[]>([]);
const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
const cellSize = reactive<CellSize>({ width: 0, height: 0 });
const columns = ref(4); // Default number of columns
const draggedSprite = ref<Sprite | null>(null);
const dragOffset = reactive({ x: 0, y: 0 });
const isShiftPressed = ref(false);
const isModalOpen = ref(false);
const animation = reactive<AnimationState>({
canvas: null,
ctx: null,
currentFrame: 0,
isPlaying: false,
frameRate: 10,
lastFrameTime: 0,
animationId: null,
slider: null,
manualUpdate: false
});
const notification = reactive({
isVisible: false,
message: '',
type: 'success' as 'success' | 'error'
});
function addSprites(newSprites: Sprite[]) {
sprites.value.push(...newSprites);
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
updateCellSize();
autoArrangeSprites();
}
function updateCellSize() {
if (sprites.value.length === 0) return;
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
});
cellSize.width = maxWidth;
cellSize.height = maxHeight;
updateCanvasSize();
}
function updateCanvasSize() {
if (!canvas.value || sprites.value.length === 0) return;
const totalSprites = sprites.value.length;
const cols = columns.value;
const rows = Math.ceil(totalSprites / cols);
canvas.value.width = cols * cellSize.width;
canvas.value.height = rows * cellSize.height;
}
function autoArrangeSprites() {
if (sprites.value.length === 0) return;
sprites.value.forEach((sprite, index) => {
const column = index % columns.value;
const row = Math.floor(index / columns.value);
sprite.x = column * cellSize.width;
sprite.y = row * cellSize.height;
});
renderSpritesheetPreview();
if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) {
renderAnimationFrame(animation.currentFrame);
}
}
function renderSpritesheetPreview(showGrid = true) {
if (!ctx.value || !canvas.value || sprites.value.length === 0) return;
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
if (showGrid) {
drawGrid();
}
sprites.value.forEach(sprite => {
ctx.value!.drawImage(
sprite.img,
sprite.x,
sprite.y
);
});
}
function drawGrid() {
if (!ctx.value || !canvas.value) return;
ctx.value.strokeStyle = '#333';
ctx.value.lineWidth = 1;
// Draw vertical lines
for (let x = 0; x <= canvas.value.width; x += cellSize.width) {
ctx.value.beginPath();
ctx.value.moveTo(x, 0);
ctx.value.lineTo(x, canvas.value.height);
ctx.value.stroke();
}
// Draw horizontal lines
for (let y = 0; y <= canvas.value.height; y += cellSize.height) {
ctx.value.beginPath();
ctx.value.moveTo(0, y);
ctx.value.lineTo(canvas.value.width, y);
ctx.value.stroke();
}
}
function highlightSprite(spriteId: string) {
if (!ctx.value || !canvas.value) return;
const sprite = sprites.value.find(s => s.id === spriteId);
if (!sprite) return;
// Calculate the cell coordinates
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
// 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.restore();
// Reset after a short delay
setTimeout(() => {
renderSpritesheetPreview();
}, 500);
}
function clearAllSprites() {
if (!canvas.value || !ctx.value) return;
sprites.value = [];
canvas.value.width = 400;
canvas.value.height = 300;
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
if (animation.canvas && animation.ctx) {
animation.canvas.width = 200;
animation.canvas.height = 200;
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
}
animation.currentFrame = 0;
isModalOpen.value = false;
}
function downloadSpritesheet() {
if (sprites.value.length === 0 || !canvas.value) {
showNotification('No sprites to download', 'error');
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.value.width;
tempCanvas.height = canvas.value.height;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
showNotification('Failed to create download context', 'error');
return;
}
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
sprites.value.forEach(sprite => {
tempCtx.drawImage(
sprite.img,
sprite.x,
sprite.y
);
});
const link = document.createElement('a');
link.download = 'noxious-spritesheet.png';
link.href = tempCanvas.toDataURL('image/png');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showNotification('Spritesheet downloaded successfully');
}
function startAnimation() {
if (sprites.value.length === 0 || !animation.canvas) return;
animation.isPlaying = true;
animation.lastFrameTime = performance.now();
animation.manualUpdate = false;
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
animationLoop();
}
function stopAnimation() {
animation.isPlaying = false;
if (animation.animationId) {
cancelAnimationFrame(animation.animationId);
animation.animationId = null;
}
}
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) {
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
}
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
const currentSprite = sprites.value[frameIndex % sprites.value.length];
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);
animation.ctx.drawImage(
currentSprite.img,
offsetX,
offsetY
);
}
function animationLoop(timestamp?: number) {
if (!animation.isPlaying) return;
const currentTime = timestamp || performance.now();
const elapsed = currentTime - animation.lastFrameTime;
const frameInterval = 1000 / animation.frameRate;
if (elapsed >= frameInterval) {
animation.lastFrameTime = currentTime;
if (sprites.value.length > 0) {
renderAnimationFrame(animation.currentFrame);
animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length;
if (animation.slider) {
animation.slider.value = animation.currentFrame.toString();
}
}
}
animation.animationId = requestAnimationFrame(animationLoop);
}
function showNotification(message: string, type: 'success' | 'error' = 'success') {
notification.message = message;
notification.type = type;
notification.isVisible = true;
setTimeout(() => {
notification.isVisible = false;
}, 3000);
}
return {
sprites,
canvas,
ctx,
cellSize,
columns,
draggedSprite,
dragOffset,
isShiftPressed,
isModalOpen,
animation,
notification,
addSprites,
updateCellSize,
updateCanvasSize,
autoArrangeSprites,
renderSpritesheetPreview,
drawGrid,
highlightSprite,
clearAllSprites,
downloadSpritesheet,
startAnimation,
stopAnimation,
renderAnimationFrame,
showNotification
};
}

11
src/main.ts Normal file
View File

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

31
tailwind.config.js Normal file
View File

@ -0,0 +1,31 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
gray: {
900: '#121212', // bg-primary
800: '#1e1e1e', // bg-secondary
700: '#252525', // bg-tertiary
600: '#333333', // border
400: '#a0a0a0', // text-secondary
200: '#e0e0e0', // text-primary
},
blue: {
500: '#0096ff', // accent
600: '#0077cc', // accent-hover
},
red: {
500: '#e53935', // danger
600: '#c62828', // danger-hover
},
green: {
500: '#43a047', // success
}
}
},
},
plugins: [],
}

12
tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

20
vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})