Compare commits
10 Commits
a989c8719f
...
9c0f10b977
Author | SHA1 | Date | |
---|---|---|---|
9c0f10b977 | |||
183ea2b9eb | |||
085fbde0a3 | |||
33efe08207 | |||
319a052d48 | |||
516bf02409 | |||
7cb5605b54 | |||
ca28f66997 | |||
362efc7bda | |||
22781b1883 |
@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/prettierrc",
|
"semi": true,
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 300,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"vueIndentScriptAndStyle": true
|
||||||
}
|
}
|
||||||
|
38
package-lock.json
generated
38
package-lock.json
generated
@ -13,13 +13,13 @@
|
|||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||||
"@tsconfig/node22": "^22.0.1",
|
"@tsconfig/node22": "^22.0.1",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"npm-run-all2": "^7.0.2",
|
"npm-run-all2": "^7.0.2",
|
||||||
"postcss": "^8.5.3",
|
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"typescript": "~5.8.0",
|
"typescript": "~5.8.0",
|
||||||
@ -897,6 +897,42 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.8",
|
"version": "0.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||||
"@tsconfig/node22": "^22.0.1",
|
"@tsconfig/node22": "^22.0.1",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
84
src/App.vue
84
src/App.vue
@ -1,32 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-900 text-gray-200 font-sans">
|
<div class="min-h-screen bg-gray-900 text-gray-200 font-sans">
|
||||||
<app-header @toggle-help="showHelpModal" />
|
<!-- Navigation sidebar -->
|
||||||
<div class="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<navigation @show-help="showHelpModal" />
|
||||||
<sidebar class="lg:col-span-1" />
|
|
||||||
<main-content class="lg:col-span-3" />
|
<!-- Main content area -->
|
||||||
|
<div class="pl-16">
|
||||||
|
<!-- Add left padding to accommodate the fixed navigation -->
|
||||||
|
<app-header @toggle-help="showHelpModal" />
|
||||||
|
|
||||||
|
<!-- Admin panel-like layout -->
|
||||||
|
<div class="max-w-7xl mx-auto px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Spritesheet creator</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button @click="store.isSpritesModalOpen.value = true" 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 hover:border-blue-500">
|
||||||
|
<i class="fas fa-images"></i> Sprites
|
||||||
|
<span v-if="sprites.length > 0" class="ml-1 bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{{ sprites.length }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button @click="store.isSettingsModalOpen.value = true" 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 hover:border-blue-500"><i class="fas fa-cog"></i> Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-500 bg-opacity-10 border-l-4 border-blue-500 p-4 mb-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 class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<sidebar class="lg:col-span-1" />
|
||||||
|
<main-content class="lg:col-span-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
<preview-modal ref="previewModalRef" />
|
<preview-modal ref="previewModalRef" />
|
||||||
|
<settings-modal />
|
||||||
|
<sprites-modal />
|
||||||
<notification />
|
<notification />
|
||||||
<help-button @show-help="showHelpModal" />
|
<help-button @show-help="showHelpModal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import AppHeader from './components/AppHeader.vue';
|
import AppHeader from './components/AppHeader.vue';
|
||||||
import Sidebar from './components/Sidebar.vue';
|
import Sidebar from './components/Sidebar.vue';
|
||||||
import MainContent from './components/MainContent.vue';
|
import MainContent from './components/MainContent.vue';
|
||||||
import PreviewModal from './components/PreviewModal.vue';
|
import PreviewModal from './components/PreviewModal.vue';
|
||||||
import Notification from './components/Notification.vue';
|
import SettingsModal from './components/SettingsModal.vue';
|
||||||
import HelpButton from './components/HelpButton.vue';
|
import SpritesModal from './components/SpritesModal.vue';
|
||||||
|
import Navigation from './components/Navigation.vue';
|
||||||
|
import Notification from './components/Notification.vue';
|
||||||
|
import HelpButton from './components/HelpButton.vue';
|
||||||
|
import { useSpritesheetStore } from './composables/useSpritesheetStore';
|
||||||
|
|
||||||
const previewModalRef = ref<InstanceType<typeof PreviewModal> | null>(null);
|
const store = useSpritesheetStore();
|
||||||
|
const previewModalRef = ref<InstanceType<typeof PreviewModal> | null>(null);
|
||||||
|
const sprites = computed(() => store.sprites.value);
|
||||||
|
|
||||||
const showHelpModal = () => {
|
// Watch for changes to isModalOpen and call the openModal method when it becomes true
|
||||||
alert('Keyboard Shortcuts:\n\n' +
|
watch(
|
||||||
'Shift + Drag: Fine-tune sprite position\n' +
|
() => store.isModalOpen.value,
|
||||||
'Space: Play/Pause animation\n' +
|
isOpen => {
|
||||||
'Esc: Close preview modal\n' +
|
if (isOpen && previewModalRef.value) {
|
||||||
'Arrow Keys: Navigate frames when paused');
|
previewModalRef.value.openModal();
|
||||||
};
|
}
|
||||||
</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>
|
||||||
|
@ -1 +1 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
@ -1,26 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="flex items-center justify-between bg-gray-800 p-3 shadow-md sticky top-0 z-50">
|
<header class="flex items-center justify-between bg-gray-800 p-3 shadow-md sticky top-0 z-40">
|
||||||
<div class="flex items-center gap-3">
|
<!-- Breadcrumb navigation -->
|
||||||
<i class="fas fa-gamepad text-blue-500 text-2xl"></i>
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-xl font-semibold text-gray-200">Noxious Spritesheet Creator</h1>
|
<!-- <span class="text-gray-400">Dashboard</span>-->
|
||||||
|
<!-- <i class="fas fa-chevron-right text-xs text-gray-500"></i>-->
|
||||||
|
<span class="text-gray-200">Spritesheet editor</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
<!-- Right side actions -->
|
||||||
@click="$emit('toggleHelp')"
|
<div class="flex gap-3 items-center">
|
||||||
class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors"
|
<!-- Zoom display -->
|
||||||
title="Keyboard Shortcuts"
|
<div class="text-sm text-gray-400 mr-2">
|
||||||
>
|
<span>Zoom: {{ Math.round(zoomLevel * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick zoom controls -->
|
||||||
|
<button @click="zoomIn" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors" title="Zoom In">
|
||||||
|
<i class="fas fa-search-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="zoomOut" class="p-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition-colors" title="Zoom Out">
|
||||||
|
<i class="fas fa-search-minus"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Help button -->
|
||||||
|
<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>
|
<i class="fas fa-keyboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
export default defineComponent({
|
const store = useSpritesheetStore();
|
||||||
name: 'AppHeader',
|
const zoomLevel = computed(() => store.zoomLevel.value);
|
||||||
emits: ['toggleHelp']
|
|
||||||
});
|
const emit = defineEmits<{
|
||||||
</script>
|
(e: 'toggleHelp'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Expose store methods directly
|
||||||
|
const { zoomIn, zoomOut } = store;
|
||||||
|
</script>
|
||||||
|
@ -1,133 +1,128 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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 }">
|
||||||
@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>
|
<i class="fas fa-cloud-upload-alt text-blue-500 text-3xl mb-4"></i>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-400">Drag & drop sprite images here<br />or click to select files</p>
|
||||||
Drag & drop sprite images here<br>or click to select files
|
<input type="file" ref="fileInput" multiple accept="image/*" class="hidden" @change="onFileChange" />
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref="fileInput"
|
|
||||||
multiple
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
@change="onFileChange"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Sprite } from '../composables/useSpritesheetStore';
|
import { type Sprite, useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
|
||||||
|
|
||||||
export default defineComponent({
|
const emit = defineEmits<{
|
||||||
name: 'DropZone',
|
'files-uploaded': [sprites: Sprite[]];
|
||||||
emits: ['files-uploaded'],
|
}>();
|
||||||
setup(props, { emit }) {
|
|
||||||
const store = useSpritesheetStore();
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const isDragOver = ref(false);
|
|
||||||
|
|
||||||
const openFileDialog = () => {
|
const store = useSpritesheetStore();
|
||||||
if (fileInput.value) {
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
fileInput.value.click();
|
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[] = [];
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < imageFiles.length; i++) {
|
||||||
|
const file = imageFiles[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sprite = await createSpriteFromFile(file, i);
|
||||||
|
newSprites.push(sprite);
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
console.error('Error loading sprite:', error);
|
||||||
|
store.showNotification(`Failed to load ${file.name}`, 'error');
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onDragOver = () => {
|
if (newSprites.length > 0) {
|
||||||
isDragOver.value = true;
|
store.addSprites(newSprites);
|
||||||
};
|
emit('files-uploaded', newSprites);
|
||||||
|
store.showNotification(`Added ${newSprites.length} sprites successfully`);
|
||||||
|
} else if (errorCount > 0) {
|
||||||
|
store.showNotification(`Failed to load all ${errorCount} sprites`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onDragLeave = () => {
|
const createSpriteFromFile = (file: File, index: number): Promise<Sprite> => {
|
||||||
isDragOver.value = false;
|
return new Promise((resolve, reject) => {
|
||||||
};
|
// Create a URL for the file
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
const img = new Image();
|
||||||
isDragOver.value = false;
|
|
||||||
if (e.dataTransfer?.files.length) {
|
|
||||||
handleFiles(e.dataTransfer.files);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFileChange = (e: Event) => {
|
// Set up event handlers
|
||||||
const input = e.target as HTMLInputElement;
|
img.onload = () => {
|
||||||
if (input.files?.length) {
|
// Verify the image has loaded properly
|
||||||
handleFiles(input.files);
|
if (img.width === 0 || img.height === 0) {
|
||||||
}
|
console.error('Image loaded with invalid dimensions:', file.name, img.width, img.height);
|
||||||
};
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
reject(new Error(`Image has invalid dimensions: ${file.name}`));
|
||||||
const handleFiles = async (files: FileList) => {
|
return;
|
||||||
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) {
|
// Create the sprite object
|
||||||
store.addSprites(newSprites);
|
const sprite: Sprite = {
|
||||||
emit('files-uploaded', newSprites);
|
img,
|
||||||
store.showNotification(`Added ${newSprites.length} sprites successfully`);
|
width: img.width,
|
||||||
}
|
height: img.height,
|
||||||
};
|
x: 0,
|
||||||
|
y: 0,
|
||||||
const createSpriteFromFile = (file: File, index: number): Promise<Sprite> => {
|
name: file.name,
|
||||||
return new Promise((resolve, reject) => {
|
id: `sprite-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
const reader = new FileReader();
|
uploadOrder: index,
|
||||||
|
|
||||||
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;
|
// Keep the objectUrl reference and don't revoke it yet
|
||||||
reader.readAsDataURL(file);
|
// The image is still needed for rendering later
|
||||||
});
|
// URL.revokeObjectURL(objectUrl); - Don't do this anymore
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
resolve(sprite);
|
||||||
fileInput,
|
};
|
||||||
isDragOver,
|
|
||||||
openFileDialog,
|
img.onerror = error => {
|
||||||
onDragOver,
|
console.error('Error loading image:', file.name, error);
|
||||||
onDragLeave,
|
URL.revokeObjectURL(objectUrl);
|
||||||
onDrop,
|
reject(new Error(`Failed to load image: ${file.name}`));
|
||||||
onFileChange
|
};
|
||||||
};
|
|
||||||
}
|
// Set the source to the object URL
|
||||||
});
|
img.src = objectUrl;
|
||||||
</script>
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
@ -1,17 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<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">
|
||||||
@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>
|
<i class="fas fa-question"></i>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
const emit = defineEmits<{
|
||||||
|
showHelp: [];
|
||||||
export default defineComponent({
|
}>();
|
||||||
name: 'HelpButton',
|
</script>
|
||||||
emits: ['showHelp']
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
@ -1,228 +1,393 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden w-full">
|
<div>
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden w-full">
|
||||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||||
<i class="fas fa-th-large text-blue-500"></i>
|
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||||
<span>Spritesheet</span>
|
<i class="fas fa-th-large text-blue-500"></i>
|
||||||
|
<span>Spritesheet</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
<span>Zoom: {{ Math.round(store.zoomLevel.value * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div ref="containerEl" class="relative overflow-auto bg-gray-700 rounded border border-gray-600 h-96" :class="{ 'cursor-grab': !isPanning, 'cursor-grabbing': isPanning }">
|
||||||
|
<canvas
|
||||||
|
ref="canvasEl"
|
||||||
|
class="block"
|
||||||
|
:style="{
|
||||||
|
transform: `scale(${store.zoomLevel.value})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
}"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</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 }}
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, ref, onMounted, computed, onBeforeUnmount } from 'vue';
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
export default defineComponent({
|
const store = useSpritesheetStore();
|
||||||
name: 'MainContent',
|
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
||||||
setup() {
|
const containerEl = ref<HTMLDivElement | null>(null);
|
||||||
const store = useSpritesheetStore();
|
|
||||||
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
|
||||||
|
|
||||||
// Tooltip state
|
// Panning state
|
||||||
const isTooltipVisible = ref(false);
|
const isPanning = ref(false);
|
||||||
const tooltipText = ref('');
|
const isAltPressed = ref(false);
|
||||||
const tooltipPosition = ref({ x: 0, y: 0 });
|
const lastPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
const tooltipStyle = computed(() => ({
|
// Tooltip state
|
||||||
left: `${tooltipPosition.value.x + 15}px`,
|
const isTooltipVisible = ref(false);
|
||||||
top: `${tooltipPosition.value.y + 15}px`
|
const tooltipText = ref('');
|
||||||
}));
|
const tooltipPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
onMounted(() => {
|
// Responsive canvas sizing
|
||||||
if (canvasEl.value) {
|
const containerWidth = ref(0);
|
||||||
store.canvas.value = canvasEl.value;
|
const containerHeight = ref(0);
|
||||||
store.ctx.value = canvasEl.value.getContext('2d');
|
const baseCanvasWidth = ref(0);
|
||||||
|
const baseCanvasHeight = ref(0);
|
||||||
|
|
||||||
// Initialize canvas size
|
// Computed properties for zoomed dimensions
|
||||||
canvasEl.value.width = 400;
|
const zoomedWidth = computed(() => {
|
||||||
canvasEl.value.height = 300;
|
return baseCanvasWidth.value * store.zoomLevel.value;
|
||||||
|
});
|
||||||
|
|
||||||
// Set up checkerboard background pattern
|
const zoomedHeight = computed(() => {
|
||||||
setupCheckerboardPattern();
|
return baseCanvasHeight.value * store.zoomLevel.value;
|
||||||
|
});
|
||||||
|
|
||||||
setupCanvasEvents();
|
const tooltipStyle = computed(() => ({
|
||||||
|
left: `${tooltipPosition.value.x + 15}px`,
|
||||||
|
top: `${tooltipPosition.value.y + 15}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
// Setup keyboard events for modifiers
|
// Watch for zoom changes to update the container scroll position
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
watch(
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
() => store.zoomLevel.value,
|
||||||
|
(newZoom, oldZoom) => {
|
||||||
|
if (!containerEl.value) return;
|
||||||
|
|
||||||
|
// Adjust scroll position to keep the center point consistent when zooming
|
||||||
|
const centerX = containerEl.value.scrollLeft + containerEl.value.clientWidth / 2;
|
||||||
|
const centerY = containerEl.value.scrollTop + containerEl.value.clientHeight / 2;
|
||||||
|
|
||||||
|
// Calculate new scroll position based on new zoom level
|
||||||
|
const scaleChange = newZoom / oldZoom;
|
||||||
|
containerEl.value.scrollLeft = centerX * scaleChange - containerEl.value.clientWidth / 2;
|
||||||
|
containerEl.value.scrollTop = centerY * scaleChange - containerEl.value.clientHeight / 2;
|
||||||
|
|
||||||
|
// Re-render the canvas with the new zoom level
|
||||||
|
updateCanvasSize();
|
||||||
|
store.renderSpritesheetPreview();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupCheckerboardPattern = () => {
|
||||||
|
if (!canvasEl.value) return;
|
||||||
|
|
||||||
|
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 updateCanvasSize = () => {
|
||||||
|
if (!canvasEl.value || !containerEl.value) return;
|
||||||
|
|
||||||
|
// Get the container dimensions
|
||||||
|
containerWidth.value = containerEl.value.clientWidth;
|
||||||
|
containerHeight.value = containerEl.value.clientHeight;
|
||||||
|
|
||||||
|
// Set the base canvas size to fill the container
|
||||||
|
// These are the "unzoomed" dimensions
|
||||||
|
baseCanvasWidth.value = Math.max(containerWidth.value, store.cellSize.width * Math.ceil(containerWidth.value / store.cellSize.width));
|
||||||
|
baseCanvasHeight.value = Math.max(containerHeight.value, store.cellSize.height * Math.ceil(containerHeight.value / store.cellSize.height));
|
||||||
|
|
||||||
|
// Set the actual canvas dimensions - remove any zoom scaling here
|
||||||
|
canvasEl.value.width = baseCanvasWidth.value;
|
||||||
|
canvasEl.value.height = baseCanvasHeight.value;
|
||||||
|
|
||||||
|
// Trigger a re-render
|
||||||
|
store.renderSpritesheetPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (!canvasEl.value || store.sprites.value.length === 0) return;
|
||||||
|
|
||||||
|
const rect = canvasEl.value.getBoundingClientRect();
|
||||||
|
// Adjust coordinates based on zoom level
|
||||||
|
const x = (e.clientX - rect.left) / store.zoomLevel.value;
|
||||||
|
const y = (e.clientY - rect.top) / store.zoomLevel.value;
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
// Don't process sprite movement or tooltips while panning
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
if (isPanning.value) return;
|
||||||
|
|
||||||
if (canvasEl.value) {
|
if (!canvasEl.value) return;
|
||||||
canvasEl.value.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
canvasEl.value.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
canvasEl.value.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
canvasEl.value.removeEventListener('mouseout', handleMouseOut);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupCheckerboardPattern = () => {
|
const rect = canvasEl.value.getBoundingClientRect();
|
||||||
if (!canvasEl.value) return;
|
// Adjust coordinates for zoom
|
||||||
|
const x = (e.clientX - rect.left) / store.zoomLevel.value;
|
||||||
|
const y = (e.clientY - rect.top) / store.zoomLevel.value;
|
||||||
|
|
||||||
// This will be done with CSS using Tailwind's bg utilities
|
// Update tooltip
|
||||||
canvasEl.value.style.backgroundImage = `
|
const cellX = Math.floor(x / store.cellSize.width);
|
||||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
const cellY = Math.floor(y / store.cellSize.height);
|
||||||
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 && cellX >= 0 && cellX < canvasEl.value.width / store.cellSize.width && cellY >= 0 && cellY < canvasEl.value.height / store.cellSize.height) {
|
||||||
if (!canvasEl.value) return;
|
isTooltipVisible.value = true;
|
||||||
|
tooltipText.value = `Cell: (${cellX}, ${cellY})`;
|
||||||
canvasEl.value.addEventListener('mousedown', handleMouseDown);
|
tooltipPosition.value.x = e.pageX;
|
||||||
canvasEl.value.addEventListener('mousemove', handleMouseMove);
|
tooltipPosition.value.y = e.pageY;
|
||||||
canvasEl.value.addEventListener('mouseup', handleMouseUp);
|
} else {
|
||||||
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;
|
isTooltipVisible.value = false;
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
// Move the sprite if we're dragging one
|
||||||
if (e.key === 'Shift') {
|
if (store.draggedSprite.value) {
|
||||||
store.isShiftPressed.value = true;
|
if (store.isShiftPressed.value) {
|
||||||
|
// Free positioning within the cell bounds when shift is pressed
|
||||||
|
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.img.width;
|
||||||
|
const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.img.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
// Trigger a re-render
|
||||||
if (e.key === 'Shift') {
|
store.renderSpritesheetPreview();
|
||||||
store.isShiftPressed.value = false;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Alt') {
|
||||||
|
e.preventDefault(); // Prevent browser from focusing address bar
|
||||||
|
isAltPressed.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add keyboard shortcuts for zooming
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === '=' || e.key === '+') {
|
||||||
|
e.preventDefault();
|
||||||
|
store.zoomIn();
|
||||||
|
} else if (e.key === '-') {
|
||||||
|
e.preventDefault();
|
||||||
|
store.zoomOut();
|
||||||
|
} else if (e.key === '0') {
|
||||||
|
e.preventDefault();
|
||||||
|
store.resetZoom();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
canvasEl,
|
if (e.key === 'Shift') {
|
||||||
isTooltipVisible,
|
store.isShiftPressed.value = false;
|
||||||
tooltipText,
|
}
|
||||||
tooltipStyle
|
|
||||||
};
|
if (e.key === 'Alt') {
|
||||||
|
isAltPressed.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseDown = (e: MouseEvent) => {
|
||||||
|
// Middle mouse button or Alt + left click for panning
|
||||||
|
if (e.button === 1 || (e.button === 0 && isAltPressed.value)) {
|
||||||
|
e.preventDefault();
|
||||||
|
isPanning.value = true;
|
||||||
|
lastPosition.value = { x: e.clientX, y: e.clientY };
|
||||||
|
} else {
|
||||||
|
// Regular sprite dragging
|
||||||
|
handleMouseDown(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isPanning.value && containerEl.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = e.clientX - lastPosition.value.x;
|
||||||
|
const dy = e.clientY - lastPosition.value.y;
|
||||||
|
|
||||||
|
// Scroll the container in the opposite direction of the mouse movement
|
||||||
|
containerEl.value.scrollLeft -= dx;
|
||||||
|
containerEl.value.scrollTop -= dy;
|
||||||
|
|
||||||
|
// Update the last position for the next movement
|
||||||
|
lastPosition.value = { x: e.clientX, y: e.clientY };
|
||||||
|
} else {
|
||||||
|
// Handle regular mouse move for sprites and tooltip
|
||||||
|
handleMouseMove(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseUp = () => {
|
||||||
|
isPanning.value = false;
|
||||||
|
handleMouseUp();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseLeave = () => {
|
||||||
|
isPanning.value = false;
|
||||||
|
handleMouseOut();
|
||||||
|
};
|
||||||
|
|
||||||
|
const preventContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCanvasEvents = () => {
|
||||||
|
if (!canvasEl.value) return;
|
||||||
|
|
||||||
|
// Set up mouse events for the canvas
|
||||||
|
canvasEl.value.addEventListener('mousedown', handleCanvasMouseDown);
|
||||||
|
canvasEl.value.addEventListener('mousemove', handleCanvasMouseMove);
|
||||||
|
canvasEl.value.addEventListener('mouseup', handleCanvasMouseUp);
|
||||||
|
canvasEl.value.addEventListener('mouseleave', handleCanvasMouseLeave);
|
||||||
|
canvasEl.value.addEventListener('contextmenu', preventContextMenu);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle window resize to update canvas dimensions
|
||||||
|
const handleResize = () => {
|
||||||
|
updateCanvasSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Set up global event listeners
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Initialize the canvas
|
||||||
|
await nextTick();
|
||||||
|
initializeCanvas();
|
||||||
|
|
||||||
|
// Observe container size changes
|
||||||
|
if ('ResizeObserver' in window) {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateCanvasSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containerEl.value) {
|
||||||
|
resizeObserver.observe(containerEl.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initializeCanvas = () => {
|
||||||
|
if (!canvasEl.value || !containerEl.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = canvasEl.value.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
console.error('Failed to get 2D context from canvas');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas and context in the store
|
||||||
|
store.canvas.value = canvasEl.value;
|
||||||
|
store.ctx.value = context;
|
||||||
|
|
||||||
|
// Set up the checkerboard pattern
|
||||||
|
setupCheckerboardPattern();
|
||||||
|
|
||||||
|
// Set up canvas mouse events
|
||||||
|
setupCanvasEvents();
|
||||||
|
|
||||||
|
// Set the initial canvas size based on container
|
||||||
|
updateCanvasSize();
|
||||||
|
|
||||||
|
// Update sprites if there are any loaded
|
||||||
|
if (store.sprites.value.length > 0) {
|
||||||
|
store.updateCellSize();
|
||||||
|
store.autoArrangeSprites();
|
||||||
|
store.renderSpritesheetPreview();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing canvas:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// Remove global event listeners
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Remove canvas event listeners
|
||||||
|
if (canvasEl.value) {
|
||||||
|
canvasEl.value.removeEventListener('mousedown', handleCanvasMouseDown);
|
||||||
|
canvasEl.value.removeEventListener('mousemove', handleCanvasMouseMove);
|
||||||
|
canvasEl.value.removeEventListener('mouseup', handleCanvasMouseUp);
|
||||||
|
canvasEl.value.removeEventListener('mouseleave', handleCanvasMouseLeave);
|
||||||
|
canvasEl.value.removeEventListener('contextmenu', preventContextMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cursor-grab {
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
</script>
|
.cursor-grabbing {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
88
src/components/Navigation.vue
Normal file
88
src/components/Navigation.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="fixed left-0 top-0 bottom-0 w-16 bg-gray-800 shadow-lg z-40 flex flex-col items-center py-4">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<i class="fas fa-gamepad text-blue-500 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Items -->
|
||||||
|
<div class="flex flex-col gap-6 items-center">
|
||||||
|
<!-- Dashboard/Home -->
|
||||||
|
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'dashboard' }" @click="setActiveSection('dashboard')" title="Dashboard">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sprites -->
|
||||||
|
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors relative" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'sprites' }" @click="openSpritesModal" title="Sprites">
|
||||||
|
<i class="fas fa-images"></i>
|
||||||
|
<span v-if="sprites.length > 0" class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{{ sprites.length }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<button
|
||||||
|
class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors"
|
||||||
|
:class="{ 'bg-gray-700 text-blue-500': activeSection === 'preview' }"
|
||||||
|
@click="openPreviewModal"
|
||||||
|
title="Preview Animation"
|
||||||
|
:disabled="sprites.length === 0"
|
||||||
|
>
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" :class="{ 'bg-gray-700 text-blue-500': activeSection === 'settings' }" @click="openSettingsModal" title="Settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Button at Bottom -->
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button class="w-10 h-10 rounded-lg flex items-center justify-center text-gray-200 hover:bg-gray-700 hover:text-blue-500 transition-colors" @click="showHelp" title="Help">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
|
const store = useSpritesheetStore();
|
||||||
|
const sprites = computed(() => store.sprites.value);
|
||||||
|
const activeSection = ref('dashboard');
|
||||||
|
|
||||||
|
const setActiveSection = (section: string) => {
|
||||||
|
activeSection.value = section;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSpritesModal = () => {
|
||||||
|
setActiveSection('sprites');
|
||||||
|
store.isSpritesModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettingsModal = () => {
|
||||||
|
setActiveSection('settings');
|
||||||
|
store.isSettingsModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPreviewModal = () => {
|
||||||
|
if (sprites.value.length === 0) {
|
||||||
|
store.showNotification('Please add sprites first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSection('preview');
|
||||||
|
store.isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'showHelp'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showHelp = () => {
|
||||||
|
emit('showHelp');
|
||||||
|
};
|
||||||
|
</script>
|
@ -5,45 +5,32 @@
|
|||||||
'translate-y-0 opacity-100': notification.isVisible,
|
'translate-y-0 opacity-100': notification.isVisible,
|
||||||
'translate-y-24 opacity-0': !notification.isVisible,
|
'translate-y-24 opacity-0': !notification.isVisible,
|
||||||
'border-l-4 border-green-500': notification.type === 'success',
|
'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
|
<i
|
||||||
class="text-xl"
|
class="text-xl"
|
||||||
:class="{
|
:class="{
|
||||||
'fas fa-check-circle text-green-500': notification.type === 'success',
|
'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>
|
></i>
|
||||||
<span>{{ notification.message }}</span>
|
<span>{{ notification.message }}</span>
|
||||||
<button
|
<button @click="closeNotification" class="ml-2 text-gray-400 hover:text-gray-200">
|
||||||
@click="closeNotification"
|
|
||||||
class="ml-2 text-gray-400 hover:text-gray-200"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
export default defineComponent({
|
const store = useSpritesheetStore();
|
||||||
name: 'Notification',
|
|
||||||
setup() {
|
|
||||||
const store = useSpritesheetStore();
|
|
||||||
|
|
||||||
const notification = computed(() => store.notification);
|
const notification = computed(() => store.notification);
|
||||||
|
|
||||||
const closeNotification = () => {
|
const closeNotification = () => {
|
||||||
store.notification.isVisible = false;
|
store.notification.isVisible = false;
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
return {
|
|
||||||
notification,
|
|
||||||
closeNotification
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<!-- Make the outer container always pointer-events-none so clicks pass through -->
|
||||||
class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-all duration-300"
|
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none">
|
||||||
:class="{ 'opacity-0 invisible': !isModalOpen, 'opacity-100 visible': isModalOpen }"
|
<!-- Apply pointer-events-auto ONLY to the modal itself so it can be interacted with -->
|
||||||
@click.self="closeModal"
|
<div class="bg-gray-800 rounded-lg max-w-4xl max-h-[90vh] overflow-auto scrollbar-hide shadow-lg pointer-events-auto" :class="{ invisible: !isModalOpen, visible: isModalOpen }" :style="{ transform: `translate3d(${position.x}px, ${position.y + (isModalOpen ? 0 : -20)}px, 0)` }">
|
||||||
>
|
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600 cursor-move" @mousedown="startDrag">
|
||||||
<div
|
<div class="flex items-center gap-2 text-lg font-semibold select-none">
|
||||||
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>
|
<i class="fas fa-film text-blue-500"></i>
|
||||||
<span>Animation Preview</span>
|
<span>Animation Preview</span>
|
||||||
</div>
|
</div>
|
||||||
@ -24,7 +19,9 @@
|
|||||||
<button
|
<button
|
||||||
@click="startAnimation"
|
@click="startAnimation"
|
||||||
:disabled="sprites.length === 0"
|
: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"
|
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
|
<i class="fas fa-play"></i> Play
|
||||||
@ -32,7 +29,9 @@
|
|||||||
<button
|
<button
|
||||||
@click="stopAnimation"
|
@click="stopAnimation"
|
||||||
:disabled="sprites.length === 0"
|
: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"
|
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
|
<i class="fas fa-pause"></i> Pause
|
||||||
@ -44,15 +43,7 @@
|
|||||||
<label for="frame-slider">Frame:</label>
|
<label for="frame-slider">Frame:</label>
|
||||||
<span>{{ currentFrameDisplay }}</span>
|
<span>{{ currentFrameDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<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" />
|
||||||
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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 flex-grow">
|
<div class="flex flex-col gap-2 flex-grow">
|
||||||
@ -60,15 +51,7 @@
|
|||||||
<label for="framerate">Frame Rate:</label>
|
<label for="framerate">Frame Rate:</label>
|
||||||
<span>{{ animation.frameRate }} FPS</span>
|
<span>{{ animation.frameRate }} FPS</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<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" />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
@ -80,164 +63,245 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, ref, onMounted, computed, watch, onBeforeUnmount } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
export default defineComponent({
|
const store = useSpritesheetStore();
|
||||||
name: 'PreviewModal',
|
const animCanvas = ref<HTMLCanvasElement | null>(null);
|
||||||
setup() {
|
|
||||||
const store = useSpritesheetStore();
|
|
||||||
const animCanvas = ref<HTMLCanvasElement | null>(null);
|
|
||||||
|
|
||||||
const isModalOpen = computed(() => store.isModalOpen.value);
|
const isModalOpen = computed(() => store.isModalOpen.value);
|
||||||
const sprites = computed(() => store.sprites.value);
|
const sprites = computed(() => store.sprites.value);
|
||||||
const animation = computed(() => store.animation);
|
const animation = computed(() => store.animation);
|
||||||
|
|
||||||
const currentFrame = ref(0);
|
const currentFrame = ref(0);
|
||||||
|
const position = ref({ x: 0, y: 0 });
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragOffset = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
const currentFrameDisplay = computed(() => {
|
const currentFrameDisplay = computed(() => {
|
||||||
const totalFrames = Math.max(1, sprites.value.length);
|
const totalFrames = Math.max(1, sprites.value.length);
|
||||||
const frame = Math.min(currentFrame.value + 1, totalFrames);
|
const frame = Math.min(currentFrame.value + 1, totalFrames);
|
||||||
return `${frame} / ${totalFrames}`;
|
return `${frame} / ${totalFrames}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (animCanvas.value) {
|
if (!isModalOpen.value) return;
|
||||||
store.animation.canvas = animCanvas.value;
|
|
||||||
store.animation.ctx = animCanvas.value.getContext('2d');
|
|
||||||
|
|
||||||
// Initialize canvas size
|
if (e.key === 'Escape') {
|
||||||
animCanvas.value.width = 200;
|
closeModal();
|
||||||
animCanvas.value.height = 200;
|
} else if (e.key === ' ' || e.key === 'Spacebar') {
|
||||||
|
// Toggle play/pause
|
||||||
// Setup keyboard shortcuts for the modal
|
if (animation.value.isPlaying) {
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
const openModal = async () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
if (sprites.value.length === 0) {
|
||||||
});
|
store.showNotification('Please add sprites first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
// Reset position when opening
|
||||||
if (!isModalOpen.value) return;
|
position.value = { x: 0, y: 0 };
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
// Reset to first frame
|
||||||
closeModal();
|
currentFrame.value = 0;
|
||||||
} else if (e.key === ' ' || e.key === 'Spacebar') {
|
animation.value.currentFrame = 0;
|
||||||
// Toggle play/pause
|
|
||||||
if (animation.value.isPlaying) {
|
await initializeCanvas();
|
||||||
stopAnimation();
|
|
||||||
} else if (sprites.value.length > 0) {
|
// Set modal open state if not already open
|
||||||
startAnimation();
|
if (!store.isModalOpen.value) {
|
||||||
}
|
store.isModalOpen.value = true;
|
||||||
e.preventDefault();
|
}
|
||||||
} else if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
|
|
||||||
// Next frame
|
// Wait for next frame to ensure DOM is updated
|
||||||
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
updateFrame();
|
|
||||||
} else if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
|
// Set proper canvas size before rendering
|
||||||
// Previous frame
|
updateCanvasSize();
|
||||||
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
|
|
||||||
updateFrame();
|
// Force render the first frame
|
||||||
}
|
if (sprites.value.length > 0) {
|
||||||
|
store.renderAnimationFrame(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCanvasSize = () => {
|
||||||
|
if (animCanvas.value && store.cellSize.width && store.cellSize.height) {
|
||||||
|
animCanvas.value.width = store.cellSize.width;
|
||||||
|
animCanvas.value.height = store.cellSize.height;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (e: MouseEvent) => {
|
||||||
|
isDragging.value = true;
|
||||||
|
dragOffset.value = {
|
||||||
|
x: e.clientX - position.value.x,
|
||||||
|
y: e.clientY - position.value.y,
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModal = () => {
|
// Add temporary event listeners
|
||||||
if (sprites.value.length === 0) {
|
window.addEventListener('mousemove', handleDrag);
|
||||||
store.showNotification('Please add sprites first', 'error');
|
window.addEventListener('mouseup', stopDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: MouseEvent) => {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
position.value = {
|
||||||
|
x: e.clientX - dragOffset.value.x,
|
||||||
|
y: e.clientY - dragOffset.value.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
window.removeEventListener('mousemove', handleDrag);
|
||||||
|
window.removeEventListener('mouseup', stopDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeCanvas = async () => {
|
||||||
|
if (!animCanvas.value) {
|
||||||
|
console.error('PreviewModal: Animation canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = animCanvas.value.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
console.error('PreviewModal: Failed to get 2D context from animation canvas');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
store.isModalOpen.value = true;
|
store.animation.canvas = animCanvas.value;
|
||||||
|
store.animation.ctx = context;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PreviewModal: Error initializing animation canvas:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Show the current frame
|
onMounted(() => {
|
||||||
if (!animation.value.isPlaying && sprites.value.length > 0) {
|
initializeCanvas();
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('mousemove', handleDrag);
|
||||||
|
window.removeEventListener('mouseup', stopDrag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep currentFrame in sync with animation.currentFrame
|
||||||
|
watch(
|
||||||
|
() => animation.value.currentFrame,
|
||||||
|
newVal => {
|
||||||
|
currentFrame.value = newVal;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch for changes in sprites to update the canvas when new sprites are added
|
||||||
|
watch(
|
||||||
|
() => sprites.value,
|
||||||
|
newSprites => {
|
||||||
|
if (isModalOpen.value && newSprites.length > 0) {
|
||||||
|
updateCanvasSize();
|
||||||
store.renderAnimationFrame(currentFrame.value);
|
store.renderAnimationFrame(currentFrame.value);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
const closeModal = () => {
|
// Expose openModal for external use
|
||||||
store.isModalOpen.value = false;
|
defineExpose({ openModal });
|
||||||
|
|
||||||
// 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
input[type="range"]::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
background: #0096ff;
|
background: #0096ff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-moz-range-thumb {
|
input[type='range']::-moz-range-thumb {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
background: #0096ff;
|
background: #0096ff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
/* Prevent text selection while dragging */
|
||||||
|
.cursor-move {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar styles */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
132
src/components/SettingsModal.vue
Normal file
132
src/components/SettingsModal.vue
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="isModalOpen">
|
||||||
|
<!-- Modal backdrop with semi-transparent background -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
|
||||||
|
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-auto shadow-lg pointer-events-auto relative">
|
||||||
|
<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-cog text-blue-500"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</div>
|
||||||
|
<button @click="closeModal" class="text-gray-400 hover:text-white">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Tools Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-tools text-blue-500"></i> Tools</h3>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Layout Controls Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-th text-blue-500"></i> Layout controls</h3>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Column Count</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="range" min="1" max="10" v-model.number="columnCount" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
|
||||||
|
<span class="text-gray-200 font-medium w-8 text-center">{{ columnCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="applyColumnCount" class="flex items-center gap-2 bg-blue-500 text-white border border-blue-500 rounded px-4 py-2 text-sm transition-colors hover:bg-blue-600 hover:border-blue-600"><i class="fas fa-check"></i> Apply layout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom Controls Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-search text-blue-500"></i> Zoom controls</h3>
|
||||||
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
|
<button @click="zoomIn" 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 hover:border-blue-500"><i class="fas fa-search-plus"></i> Zoom in</button>
|
||||||
|
<button @click="zoomOut" 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 hover:border-blue-500"><i class="fas fa-search-minus"></i> Zoom out</button>
|
||||||
|
<button @click="resetZoom" 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 hover:border-blue-500"><i class="fas fa-undo"></i> Reset zoom</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keyboard Shortcuts Section -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-200 mb-4 flex items-center gap-2"><i class="fas fa-keyboard text-blue-500"></i> Keyboard shortcuts</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-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 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">←/→</kbd>
|
||||||
|
<span>Navigate frames</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
|
const store = useSpritesheetStore();
|
||||||
|
const sprites = computed(() => store.sprites.value);
|
||||||
|
const isModalOpen = computed(() => store.isSettingsModalOpen.value);
|
||||||
|
|
||||||
|
// Column count control
|
||||||
|
const columnCount = ref(store.columns.value);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
store.isSettingsModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPreviewModal = () => {
|
||||||
|
if (store.sprites.value.length === 0) {
|
||||||
|
store.showNotification('Please add sprites first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close settings modal and open preview modal
|
||||||
|
closeModal();
|
||||||
|
store.isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
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, zoomIn, zoomOut, resetZoom } = store;
|
||||||
|
|
||||||
|
// Apply column count changes
|
||||||
|
const applyColumnCount = () => {
|
||||||
|
store.columns.value = columnCount.value;
|
||||||
|
store.updateCanvasSize();
|
||||||
|
store.autoArrangeSprites();
|
||||||
|
store.showNotification(`Column count updated to ${columnCount.value}`);
|
||||||
|
};
|
||||||
|
</script>
|
@ -5,83 +5,65 @@
|
|||||||
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
|
<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">
|
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||||
<i class="fas fa-upload text-blue-500"></i>
|
<i class="fas fa-upload text-blue-500"></i>
|
||||||
<span>Upload Sprites</span>
|
<span>Upload sprites</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<drop-zone @files-uploaded="handleUpload" />
|
<drop-zone @files-uploaded="handleUpload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-blue-500 bg-opacity-10 border-l-4 border-blue-500 p-4 mt-6 rounded-r">
|
<!-- Quick Actions -->
|
||||||
<p>Container size will adjust to fit the largest sprite. All sprites will be placed in cells of the same size.</p>
|
<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-bolt text-blue-500"></i>
|
||||||
|
<span>Quick Actions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button @click="openSpritesModal" 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 hover:border-blue-500">
|
||||||
|
<i class="fas fa-images"></i> Manage sprites
|
||||||
|
<span v-if="sprites.length > 0" class="ml-auto bg-blue-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{{ sprites.length }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="openSettingsModal" 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 hover:border-blue-500"><i class="fas fa-cog"></i> Settings & tools</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sprites Card -->
|
<!-- Stats Card -->
|
||||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
<div v-if="sprites.length > 0" 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 justify-between p-4 bg-gray-700 border-b border-gray-600">
|
||||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||||
<i class="fas fa-images text-blue-500"></i>
|
<i class="fas fa-chart-bar text-blue-500"></i>
|
||||||
<span>Sprites</span>
|
<span>Stats</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-4">
|
||||||
<sprite-list :sprites="sprites" @sprite-clicked="handleSpriteClick" />
|
<div class="grid grid-cols-2 gap-4">
|
||||||
</div>
|
<div class="bg-gray-700 p-3 rounded">
|
||||||
</div>
|
<div class="text-sm text-gray-400">Sprites</div>
|
||||||
|
<div class="text-xl font-semibold">{{ sprites.length }}</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>
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
<div class="bg-gray-700 p-3 rounded">
|
||||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Space</kbd>
|
<div class="text-sm text-gray-400">Cell Size</div>
|
||||||
<span>Play/Pause animation</span>
|
<div class="text-xl font-semibold">{{ cellSize.width }}×{{ cellSize.height }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-400">
|
<div class="bg-gray-700 p-3 rounded">
|
||||||
<kbd class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs font-mono">Esc</kbd>
|
<div class="text-sm text-gray-400">Zoom</div>
|
||||||
<span>Close preview</span>
|
<div class="text-xl font-semibold">{{ Math.round(zoomLevel * 100) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-700 p-3 rounded">
|
||||||
|
<div class="text-sm text-gray-400">Columns</div>
|
||||||
|
<div class="text-xl font-semibold">{{ columns }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,55 +71,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
import { type Sprite, useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
import DropZone from './DropZone.vue';
|
import DropZone from './DropZone.vue';
|
||||||
import SpriteList from './SpriteList.vue';
|
|
||||||
|
|
||||||
export default defineComponent({
|
const store = useSpritesheetStore();
|
||||||
name: 'Sidebar',
|
const sprites = computed(() => store.sprites.value);
|
||||||
components: {
|
const cellSize = computed(() => store.cellSize);
|
||||||
DropZone,
|
const zoomLevel = computed(() => store.zoomLevel.value);
|
||||||
SpriteList
|
const columns = computed(() => store.columns.value);
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const store = useSpritesheetStore();
|
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = (sprites: Sprite[]) => {
|
||||||
// The dropzone component handles adding sprites to the store
|
// The dropzone component handles adding sprites to the store
|
||||||
// This is just for event handling if needed
|
// This is just for event handling if needed
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSpriteClick = (spriteId: string) => {
|
const openPreviewModal = () => {
|
||||||
store.highlightSprite(spriteId);
|
if (store.sprites.value.length === 0) {
|
||||||
};
|
store.showNotification('Please add sprites first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const openPreviewModal = () => {
|
store.isModalOpen.value = true;
|
||||||
if (store.sprites.value.length === 0) {
|
};
|
||||||
store.showNotification('Please add sprites first', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.isModalOpen.value = true;
|
const openSpritesModal = () => {
|
||||||
};
|
store.isSpritesModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const confirmClearAll = () => {
|
const openSettingsModal = () => {
|
||||||
if (confirm('Are you sure you want to clear all sprites?')) {
|
store.isSettingsModalOpen.value = true;
|
||||||
store.clearAllSprites();
|
};
|
||||||
store.showNotification('All sprites cleared');
|
</script>
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
sprites: computed(() => store.sprites.value),
|
|
||||||
autoArrangeSprites: store.autoArrangeSprites,
|
|
||||||
downloadSpritesheet: store.downloadSpritesheet,
|
|
||||||
confirmClearAll,
|
|
||||||
handleUpload,
|
|
||||||
handleSpriteClick,
|
|
||||||
openPreviewModal
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
@ -1,48 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">
|
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">No sprites uploaded yet</div>
|
||||||
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-else class="grid grid-cols-2 gap-3 max-h-72 overflow-y-auto p-4">
|
||||||
<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">
|
||||||
v-for="(sprite, index) in sprites"
|
<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" />
|
||||||
:key="sprite.id"
|
<div class="text-xs text-gray-400 truncate">{{ index + 1 }}. {{ truncateName(sprite.name) }}</div>
|
||||||
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent, type PropType } from 'vue';
|
import type { Sprite } from '../composables/useSpritesheetStore';
|
||||||
import { type Sprite } from '../composables/useSpritesheetStore';
|
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
name: 'SpriteList',
|
sprites: Sprite[];
|
||||||
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 {
|
defineEmits<{
|
||||||
truncateName
|
spriteClicked: [id: string];
|
||||||
};
|
}>();
|
||||||
}
|
|
||||||
});
|
const truncateName = (name: string) => {
|
||||||
</script>
|
return name.length > 10 ? `${name.substring(0, 10)}...` : name;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
81
src/components/SpritesModal.vue
Normal file
81
src/components/SpritesModal.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Sprites Modal -->
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="isModalOpen">
|
||||||
|
<!-- Modal backdrop with semi-transparent background -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
|
||||||
|
|
||||||
|
<!-- Modal content -->
|
||||||
|
<div class="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-auto shadow-lg pointer-events-auto relative">
|
||||||
|
<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>
|
||||||
|
<button @click="closeModal" class="text-gray-400 hover:text-white">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Sprites List -->
|
||||||
|
<div v-if="sprites.length === 0" class="text-center text-gray-400 py-8">
|
||||||
|
<i class="fas fa-image text-4xl mb-4 opacity-30"></i>
|
||||||
|
<p>No sprites uploaded yet</p>
|
||||||
|
<button @click="showUploadSection" class="mt-4 flex items-center gap-2 bg-blue-500 border border-blue-500 text-white rounded px-4 py-2 text-sm transition-colors mx-auto hover:bg-blue-600 hover:border-blue-600"><i class="fas fa-upload"></i> Upload sprites</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-200 flex items-center gap-2"><i class="fas fa-images text-blue-500"></i> Uploaded Sprites</h3>
|
||||||
|
<span class="text-sm text-gray-400">{{ sprites.length }} sprites</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4 max-h-96 overflow-y-auto p-2">
|
||||||
|
<div v-for="(sprite, index) in sprites" :key="sprite.id" @click="handleSpriteClick(sprite.id)" class="border border-gray-600 rounded bg-gray-700 p-3 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-20 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>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button @click="showUploadSection" 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 hover:border-blue-500"><i class="fas fa-upload"></i> Upload more</button>
|
||||||
|
<button @click="confirmClearAll" class="flex items-center gap-2 bg-red-600 border border-red-600 text-white rounded px-4 py-2 text-sm transition-colors hover:bg-red-700 hover:border-red-700"><i class="fas fa-trash-alt"></i> Clear all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
||||||
|
|
||||||
|
const store = useSpritesheetStore();
|
||||||
|
const sprites = computed(() => store.sprites.value);
|
||||||
|
const isModalOpen = computed(() => store.isSpritesModalOpen.value);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
store.isSpritesModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpriteClick = (spriteId: string) => {
|
||||||
|
store.highlightSprite(spriteId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showUploadSection = () => {
|
||||||
|
// Close sprites modal and focus on upload section
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmClearAll = () => {
|
||||||
|
if (confirm('Are you sure you want to clear all sprites?')) {
|
||||||
|
store.clearAllSprites();
|
||||||
|
store.showNotification('All sprites cleared');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateName = (name: string) => {
|
||||||
|
return name.length > 15 ? `${name.substring(0, 15)}...` : name;
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,12 +0,0 @@
|
|||||||
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 }
|
|
||||||
})
|
|
@ -28,17 +28,20 @@ export interface AnimationState {
|
|||||||
manualUpdate: boolean;
|
manualUpdate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpritesheetStore() {
|
const sprites = ref<Sprite[]>([]);
|
||||||
const sprites = ref<Sprite[]>([]);
|
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
const cellSize = reactive<CellSize>({ width: 0, height: 0 });
|
||||||
const cellSize = reactive<CellSize>({ width: 0, height: 0 });
|
const columns = ref(4); // Default number of columns
|
||||||
const columns = ref(4); // Default number of columns
|
const draggedSprite = ref<Sprite | null>(null);
|
||||||
const draggedSprite = ref<Sprite | null>(null);
|
const dragOffset = reactive({ x: 0, y: 0 });
|
||||||
const dragOffset = reactive({ x: 0, y: 0 });
|
const isShiftPressed = ref(false);
|
||||||
const isShiftPressed = ref(false);
|
const isModalOpen = ref(false);
|
||||||
const isModalOpen = ref(false);
|
const isSettingsModalOpen = ref(false);
|
||||||
|
const isSpritesModalOpen = ref(false);
|
||||||
|
const zoomLevel = ref(1); // Default zoom level (1 = 100%)
|
||||||
|
|
||||||
|
export function useSpritesheetStore() {
|
||||||
const animation = reactive<AnimationState>({
|
const animation = reactive<AnimationState>({
|
||||||
canvas: null,
|
canvas: null,
|
||||||
ctx: null,
|
ctx: null,
|
||||||
@ -48,105 +51,246 @@ export function useSpritesheetStore() {
|
|||||||
lastFrameTime: 0,
|
lastFrameTime: 0,
|
||||||
animationId: null,
|
animationId: null,
|
||||||
slider: null,
|
slider: null,
|
||||||
manualUpdate: false
|
manualUpdate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const notification = reactive({
|
const notification = reactive({
|
||||||
isVisible: false,
|
isVisible: false,
|
||||||
message: '',
|
message: '',
|
||||||
type: 'success' as 'success' | 'error'
|
type: 'success' as 'success' | 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
function addSprites(newSprites: Sprite[]) {
|
function addSprites(newSprites: Sprite[]) {
|
||||||
sprites.value.push(...newSprites);
|
if (newSprites.length === 0) {
|
||||||
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
|
console.warn('Store: Attempted to add empty sprites array');
|
||||||
updateCellSize();
|
return;
|
||||||
autoArrangeSprites();
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate sprites before adding them
|
||||||
|
const validSprites = newSprites.filter(sprite => {
|
||||||
|
if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) {
|
||||||
|
console.error('Store: Invalid sprite detected', sprite);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validSprites.length === 0) {
|
||||||
|
console.error('Store: No valid sprites to add');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sprites.value.push(...validSprites);
|
||||||
|
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
|
||||||
|
updateCellSize();
|
||||||
|
autoArrangeSprites();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: Error adding sprites:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCellSize() {
|
function updateCellSize() {
|
||||||
if (sprites.value.length === 0) return;
|
if (sprites.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let maxWidth = 0;
|
try {
|
||||||
let maxHeight = 0;
|
let maxWidth = 0;
|
||||||
|
let maxHeight = 0;
|
||||||
|
|
||||||
sprites.value.forEach(sprite => {
|
sprites.value.forEach(sprite => {
|
||||||
maxWidth = Math.max(maxWidth, sprite.width);
|
if (sprite.width <= 0 || sprite.height <= 0) {
|
||||||
maxHeight = Math.max(maxHeight, sprite.height);
|
console.warn('Store: Sprite with invalid dimensions detected', sprite);
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
maxWidth = Math.max(maxWidth, sprite.width);
|
||||||
|
maxHeight = Math.max(maxHeight, sprite.height);
|
||||||
|
});
|
||||||
|
|
||||||
cellSize.width = maxWidth;
|
if (maxWidth === 0 || maxHeight === 0) {
|
||||||
cellSize.height = maxHeight;
|
console.error('Store: Failed to calculate valid cell size');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateCanvasSize();
|
cellSize.width = maxWidth;
|
||||||
|
cellSize.height = maxHeight;
|
||||||
|
|
||||||
|
updateCanvasSize();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: Error updating cell size:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCanvasSize() {
|
function updateCanvasSize() {
|
||||||
if (!canvas.value || sprites.value.length === 0) return;
|
if (!canvas.value) {
|
||||||
|
console.warn('Store: Canvas not available for size update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const totalSprites = sprites.value.length;
|
if (sprites.value.length === 0) {
|
||||||
const cols = columns.value;
|
return;
|
||||||
const rows = Math.ceil(totalSprites / cols);
|
}
|
||||||
|
|
||||||
canvas.value.width = cols * cellSize.width;
|
try {
|
||||||
canvas.value.height = rows * cellSize.height;
|
const totalSprites = sprites.value.length;
|
||||||
|
const cols = columns.value;
|
||||||
|
const rows = Math.ceil(totalSprites / cols);
|
||||||
|
|
||||||
|
console.log(`Store: Updating canvas size for ${totalSprites} sprites, ${cols} columns, ${rows} rows`);
|
||||||
|
|
||||||
|
if (cellSize.width <= 0 || cellSize.height <= 0) {
|
||||||
|
console.error('Store: Invalid cell size for canvas update', cellSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidth = cols * cellSize.width;
|
||||||
|
const newHeight = rows * cellSize.height;
|
||||||
|
|
||||||
|
// Ensure the canvas is large enough to display all sprites
|
||||||
|
if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) {
|
||||||
|
console.log(`Store: Resizing canvas from ${canvas.value.width}x${canvas.value.height} to ${newWidth}x${newHeight}`);
|
||||||
|
canvas.value.width = newWidth;
|
||||||
|
canvas.value.height = newHeight;
|
||||||
|
|
||||||
|
// Emit an event to update the wrapper dimensions
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('canvas-size-updated', {
|
||||||
|
detail: { width: newWidth, height: newHeight },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: Error updating canvas size:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoArrangeSprites() {
|
function autoArrangeSprites() {
|
||||||
if (sprites.value.length === 0) return;
|
if (sprites.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
sprites.value.forEach((sprite, index) => {
|
try {
|
||||||
const column = index % columns.value;
|
if (cellSize.width <= 0 || cellSize.height <= 0) {
|
||||||
const row = Math.floor(index / columns.value);
|
console.error('Store: Invalid cell size for auto-arranging', cellSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
sprite.x = column * cellSize.width;
|
console.log(`Store: Auto-arranging ${sprites.value.length} sprites with ${columns.value} columns`);
|
||||||
sprite.y = row * cellSize.height;
|
|
||||||
});
|
|
||||||
|
|
||||||
renderSpritesheetPreview();
|
// First update the canvas size to ensure it's large enough
|
||||||
|
updateCanvasSize();
|
||||||
|
|
||||||
if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) {
|
// Then position each sprite in its grid cell
|
||||||
renderAnimationFrame(animation.currentFrame);
|
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;
|
||||||
|
|
||||||
|
// Log the position of each sprite for debugging
|
||||||
|
console.log(`Store: Sprite ${index} (${sprite.name}) positioned at (${sprite.x}, ${sprite.y})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the canvas is ready before attempting to render
|
||||||
|
if (!ctx.value || !canvas.value) {
|
||||||
|
console.warn('Store: Canvas or context not available for rendering after auto-arrange');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSpritesheetPreview();
|
||||||
|
|
||||||
|
if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) {
|
||||||
|
renderAnimationFrame(animation.currentFrame);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: Error auto-arranging sprites:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSpritesheetPreview(showGrid = true) {
|
function renderSpritesheetPreview(showGrid = true) {
|
||||||
if (!ctx.value || !canvas.value || sprites.value.length === 0) return;
|
if (!ctx.value || !canvas.value) {
|
||||||
|
console.error('Store: Canvas or context not available for rendering, will retry when ready');
|
||||||
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
setTimeout(() => {
|
||||||
|
if (ctx.value && canvas.value) {
|
||||||
if (showGrid) {
|
renderSpritesheetPreview(showGrid);
|
||||||
drawGrid();
|
}
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sprites.value.forEach(sprite => {
|
if (sprites.value.length === 0) return;
|
||||||
ctx.value!.drawImage(
|
|
||||||
sprite.img,
|
try {
|
||||||
sprite.x,
|
// Make sure the canvas size is correct before rendering
|
||||||
sprite.y
|
updateCanvasSize();
|
||||||
);
|
|
||||||
});
|
console.log(`Store: Rendering ${sprites.value.length} sprites on canvas ${canvas.value.width}x${canvas.value.height}`);
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||||
|
|
||||||
|
if (showGrid) {
|
||||||
|
drawGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw each sprite - remove the zoom scaling from context
|
||||||
|
sprites.value.forEach((sprite, index) => {
|
||||||
|
try {
|
||||||
|
if (!sprite.img) {
|
||||||
|
console.warn(`Store: Sprite at index ${index} has no image, skipping render`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sprite is within canvas bounds
|
||||||
|
if (sprite.x >= 0 && sprite.y >= 0 && sprite.x + sprite.width <= canvas.value!.width && sprite.y + sprite.height <= canvas.value!.height) {
|
||||||
|
if (sprite.img.complete && sprite.img.naturalWidth !== 0) {
|
||||||
|
// Draw the image at its original size
|
||||||
|
ctx.value!.drawImage(sprite.img, sprite.x, sprite.y, sprite.width, sprite.height);
|
||||||
|
} else {
|
||||||
|
console.warn(`Store: Sprite image ${index} not fully loaded, setting onload handler`);
|
||||||
|
sprite.img.onload = () => {
|
||||||
|
if (ctx.value && canvas.value) {
|
||||||
|
ctx.value.drawImage(sprite.img, sprite.x, sprite.y, sprite.width, sprite.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Store: Sprite at index ${index} is outside canvas bounds: sprite(${sprite.x},${sprite.y}) canvas(${canvas.value!.width},${canvas.value!.height})`);
|
||||||
|
}
|
||||||
|
} catch (spriteError) {
|
||||||
|
console.error(`Store: Error rendering sprite at index ${index}:`, spriteError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: Error in renderSpritesheetPreview:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawGrid() {
|
function drawGrid() {
|
||||||
if (!ctx.value || !canvas.value) return;
|
if (!ctx.value || !canvas.value) return;
|
||||||
|
|
||||||
ctx.value.strokeStyle = '#333';
|
ctx.value.strokeStyle = '#333';
|
||||||
ctx.value.lineWidth = 1;
|
ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level
|
||||||
|
|
||||||
|
// Calculate the visible area based on zoom level
|
||||||
|
const visibleWidth = canvas.value.width / zoomLevel.value;
|
||||||
|
const visibleHeight = canvas.value.height / zoomLevel.value;
|
||||||
|
|
||||||
// Draw vertical lines
|
// Draw vertical lines
|
||||||
for (let x = 0; x <= canvas.value.width; x += cellSize.width) {
|
for (let x = 0; x <= visibleWidth; x += cellSize.width) {
|
||||||
ctx.value.beginPath();
|
ctx.value.beginPath();
|
||||||
ctx.value.moveTo(x, 0);
|
ctx.value.moveTo(x, 0);
|
||||||
ctx.value.lineTo(x, canvas.value.height);
|
ctx.value.lineTo(x, visibleHeight);
|
||||||
ctx.value.stroke();
|
ctx.value.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw horizontal lines
|
// Draw horizontal lines
|
||||||
for (let y = 0; y <= canvas.value.height; y += cellSize.height) {
|
for (let y = 0; y <= visibleHeight; y += cellSize.height) {
|
||||||
ctx.value.beginPath();
|
ctx.value.beginPath();
|
||||||
ctx.value.moveTo(0, y);
|
ctx.value.moveTo(0, y);
|
||||||
ctx.value.lineTo(canvas.value.width, y);
|
ctx.value.lineTo(visibleWidth, y);
|
||||||
ctx.value.stroke();
|
ctx.value.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,12 +308,7 @@ export function useSpritesheetStore() {
|
|||||||
// Briefly flash the cell
|
// Briefly flash the cell
|
||||||
ctx.value.save();
|
ctx.value.save();
|
||||||
ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)';
|
ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)';
|
||||||
ctx.value.fillRect(
|
ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height);
|
||||||
cellX * cellSize.width,
|
|
||||||
cellY * cellSize.height,
|
|
||||||
cellSize.width,
|
|
||||||
cellSize.height
|
|
||||||
);
|
|
||||||
ctx.value.restore();
|
ctx.value.restore();
|
||||||
|
|
||||||
// Reset after a short delay
|
// Reset after a short delay
|
||||||
@ -215,15 +354,11 @@ export function useSpritesheetStore() {
|
|||||||
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
|
|
||||||
sprites.value.forEach(sprite => {
|
sprites.value.forEach(sprite => {
|
||||||
tempCtx.drawImage(
|
tempCtx.drawImage(sprite.img, sprite.x, sprite.y);
|
||||||
sprite.img,
|
|
||||||
sprite.x,
|
|
||||||
sprite.y
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.download = 'noxious-spritesheet.png';
|
link.download = 'spritesheet.png';
|
||||||
link.href = tempCanvas.toDataURL('image/png');
|
link.href = tempCanvas.toDataURL('image/png');
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@ -257,8 +392,7 @@ export function useSpritesheetStore() {
|
|||||||
function renderAnimationFrame(frameIndex: number) {
|
function renderAnimationFrame(frameIndex: number) {
|
||||||
if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return;
|
if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return;
|
||||||
|
|
||||||
if (animation.canvas.width !== cellSize.width ||
|
if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) {
|
||||||
animation.canvas.height !== cellSize.height) {
|
|
||||||
animation.canvas.width = cellSize.width;
|
animation.canvas.width = cellSize.width;
|
||||||
animation.canvas.height = cellSize.height;
|
animation.canvas.height = cellSize.height;
|
||||||
}
|
}
|
||||||
@ -270,14 +404,10 @@ export function useSpritesheetStore() {
|
|||||||
const cellX = Math.floor(currentSprite.x / cellSize.width);
|
const cellX = Math.floor(currentSprite.x / cellSize.width);
|
||||||
const cellY = Math.floor(currentSprite.y / cellSize.height);
|
const cellY = Math.floor(currentSprite.y / cellSize.height);
|
||||||
|
|
||||||
const offsetX = currentSprite.x - (cellX * cellSize.width);
|
const offsetX = currentSprite.x - cellX * cellSize.width;
|
||||||
const offsetY = currentSprite.y - (cellY * cellSize.height);
|
const offsetY = currentSprite.y - cellY * cellSize.height;
|
||||||
|
|
||||||
animation.ctx.drawImage(
|
animation.ctx.drawImage(currentSprite.img, offsetX, offsetY);
|
||||||
currentSprite.img,
|
|
||||||
offsetX,
|
|
||||||
offsetY
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function animationLoop(timestamp?: number) {
|
function animationLoop(timestamp?: number) {
|
||||||
@ -314,6 +444,27 @@ export function useSpritesheetStore() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
// Increase zoom level by 0.1, max 3.0 (300%)
|
||||||
|
zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1);
|
||||||
|
renderSpritesheetPreview();
|
||||||
|
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
// Decrease zoom level by 0.1, min 0.5 (50%)
|
||||||
|
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
|
||||||
|
renderSpritesheetPreview();
|
||||||
|
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
// Reset to default zoom level (100%)
|
||||||
|
zoomLevel.value = 1;
|
||||||
|
renderSpritesheetPreview();
|
||||||
|
showNotification('Zoom reset to 100%');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sprites,
|
sprites,
|
||||||
canvas,
|
canvas,
|
||||||
@ -324,8 +475,11 @@ export function useSpritesheetStore() {
|
|||||||
dragOffset,
|
dragOffset,
|
||||||
isShiftPressed,
|
isShiftPressed,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
|
isSettingsModalOpen,
|
||||||
|
isSpritesModalOpen,
|
||||||
animation,
|
animation,
|
||||||
notification,
|
notification,
|
||||||
|
zoomLevel,
|
||||||
addSprites,
|
addSprites,
|
||||||
updateCellSize,
|
updateCellSize,
|
||||||
updateCanvasSize,
|
updateCanvasSize,
|
||||||
@ -338,6 +492,9 @@ export function useSpritesheetStore() {
|
|||||||
startAnimation,
|
startAnimation,
|
||||||
stopAnimation,
|
stopAnimation,
|
||||||
renderAnimationFrame,
|
renderAnimationFrame,
|
||||||
showNotification
|
showNotification,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
14
src/main.ts
14
src/main.ts
@ -1,11 +1,11 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css';
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue';
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia';
|
||||||
import App from './App.vue'
|
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');
|
||||||
|
Loading…
x
Reference in New Issue
Block a user