Files
spritesheet-generator/src/App.vue

572 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen lg:h-screen flex flex-col p-4 sm:p-8 bg-slate-50 dark:bg-gray-950 transition-colors duration-300">
<div class="flex flex-col flex-1 lg:overflow-hidden">
<header class="mb-4 sm:mb-6">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mb-6">
<div class="text-center sm:text-left">
<h1 class="text-3xl sm:text-5xl font-bold text-gray-900 dark:text-gray-100 tracking-tight mb-2">Spritesheet generator</h1>
<p class="text-sm sm:text-base text-gray-600 dark:text-gray-400">Create professional spritesheets for your game development projects</p>
</div>
<dark-mode-toggle />
</div>
<nav class="flex flex-wrap items-center justify-center sm:justify-start gap-2 sm:gap-3">
<a
href="https://gitea.adhd.sh/root/spritesheet-generator"
target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:shadow-md transition-all"
data-rybbit-event="source-link"
>
<i class="fab fa-github"></i>
<span class="font-medium">Source</span>
</a>
<a
href="https://discord.gg/JTev3nzeDa"
target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:shadow-md transition-all"
data-rybbit-event="discord-link"
>
<i class="fab fa-discord"></i>
<span class="font-medium">Discord</span>
</a>
<a
href="#"
@click.prevent="openHelpModal"
class="inline-flex items-center gap-2 px-4 py-2 bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:shadow-md transition-all"
data-rybbit-event="help-link"
>
<i class="fas fa-question-circle"></i>
<span class="font-medium">Help</span>
</a>
<a
href="#"
@click.prevent="openFeedbackModal"
class="inline-flex items-center gap-2 px-4 py-2 bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:shadow-md transition-all"
data-rybbit-event="feedback-link"
>
<i class="fas fa-comment-dots"></i>
<span class="font-medium">Feedback</span>
</a>
</nav>
</header>
<main class="flex flex-col flex-1 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm rounded-3xl shadow-xl border border-gray-200/50 dark:border-gray-700/50 lg:overflow-hidden transition-all duration-300">
<!-- Welcome state -->
<div v-if="!layers.some(l => l.sprites.length)" class="p-6 sm:p-10 overflow-y-auto">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload sprites or single image</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
</div>
<file-uploader @upload-sprites="handleSpritesUpload" />
<div class="mt-10">
<div class="prose prose-lg dark:prose-invert max-w-none">
<div>
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Welcome to Spritesheet generator</h3>
<p class="text-gray-700 dark:text-gray-300 mb-4">Create spritesheets for your game development and animation projects with our completely free, open-source Spritesheet generator.</p>
<p class="text-gray-700 dark:text-gray-300 mb-6">This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Key features of this sprite editor</h3>
<ul class="text-gray-700 dark:text-gray-300 mb-6 space-y-2 list-disc">
<li><strong>Free sprite editor</strong>: Edit, organize, and optimize your game sprites directly in your browser</li>
<li><strong>Automatic spritesheet generation</strong>: Convert multiple PNG, JPG, or GIF images into efficient sprite atlases</li>
<li><strong>Customizable grid layouts</strong>: Adjust spacing, padding, and arrangement for pixel-perfect results</li>
<li><strong>Animation preview</strong>: Test your sprite animations before exporting</li>
<li><strong>Cross-platform compatibility</strong>: Works with Unity, Godot, Phaser, Pygame, and other game engines</li>
<li><strong>Zero installation required</strong>: No downloads - use our web-based sprite sheet maker instantly</li>
<li><strong>Batch processing</strong>: Upload and process multiple sprites simultaneously</li>
<li><strong>Export options</strong>: Download spritesheet as PNG, JPG, GIF, ZIP or JSON.</li>
</ul>
<div>
<h4 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
<i class="fas fa-play-circle text-gray-800 dark:text-gray-200"></i>
How it works
</h4>
<video controls playsinline class="w-full rounded-lg shadow-lg border border-gray-200 dark:border-gray-700" title="Spritesheet generator tutorial" aria-label="Spritesheet generator tutorial">
<source src="@/assets/demo.mp4" type="video/mp4" />
</video>
</div>
</div>
</div>
</div>
</div>
<!-- Two-column layout: Left controls, Right preview -->
<div v-if="layers.some(l => l.sprites.length)" class="flex flex-col flex-1 lg:grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] lg:overflow-hidden">
<!-- Left sidebar - Controls -->
<div class="p-6 bg-gray-50/50 dark:bg-gray-900/30 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700 lg:overflow-y-auto lg:overflow-x-auto">
<div class="space-y-6">
<!-- Upload Section -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-upload"></i>
Upload
</h3>
<button @click="openJSONImportDialog" class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-xs font-medium text-white rounded-lg transition-all cursor-pointer" data-rybbit-event="import-json">
<i class="text-xs fas fa-file-import"></i>
<span>JSON</span>
</button>
</div>
<div class="p-4 text-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl hover:border-gray-500 dark:hover:border-gray-400 transition-colors cursor-pointer" @click="openFileDialog">
<i class="fas fa-plus-circle text-2xl text-gray-700 dark:text-gray-300 mb-2"></i>
<p class="text-xs text-gray-600 dark:text-gray-400">Add sprites</p>
</div>
<input ref="uploadInput" type="file" multiple accept="image/*" class="hidden" @change="handleUploadChange" />
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
</section>
<!-- Layers -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-layer-group"></i>
Layers
</h3>
<button @click="addLayer()" class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-xs font-medium text-white rounded-lg transition-all cursor-pointer">
<i class="text-xs fas fa-plus"></i>
<span>Add</span>
</button>
</div>
<div class="space-y-2">
<div
v-for="layer in layers"
:key="layer.id"
class="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 border rounded-lg transition-all"
:class="[layer.id === activeLayerId ? 'border-gray-800 ring-1 ring-gray-800 dark:border-gray-400 dark:ring-gray-400' : 'border-gray-200 dark:border-gray-700', !layer.visible ? 'opacity-50' : '']"
>
<button @click.stop="layer.visible = !layer.visible" class="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer" :title="layer.visible ? 'Hide layer' : 'Show layer'">
<i :class="layer.visible ? 'text-sm text-gray-800 dark:text-gray-200 fas fa-eye' : 'text-sm text-gray-400 dark:text-gray-500 fas fa-eye-slash'"></i>
</button>
<input
v-if="editingLayerId === layer.id"
type="text"
v-model="editingLayerName"
@blur="finishEditingLayer"
@keyup.enter="finishEditingLayer"
@keyup.esc="cancelEditingLayer"
class="flex-1 px-2 py-1 text-sm border border-gray-800 dark:border-gray-400 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-800 dark:focus:ring-gray-400"
ref="layerNameInput"
@click.stop
/>
<button v-else @click="activeLayerId = layer.id" class="flex-1 px-2 py-1 text-sm font-medium text-left rounded transition-all cursor-pointer" :class="layer.id === activeLayerId ? 'text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'">
{{ layer.name }}
<span v-if="layer.sprites.length" class="ml-1 text-xs opacity-60">({{ layer.sprites.length }})</span>
</button>
<button v-if="editingLayerId !== layer.id" @click="startEditingLayer(layer.id, layer.name)" class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer" title="Rename">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-pen"></i>
</button>
<button @click="moveLayer(layer.id, 'up')" class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer" title="Move up">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-up"></i>
</button>
<button @click="moveLayer(layer.id, 'down')" class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer" title="Move down">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-down"></i>
</button>
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="p-1 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors cursor-pointer" title="Delete">
<i class="text-xs fas fa-trash"></i>
</button>
</div>
</div>
</section>
<!-- Grid Settings -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-th"></i>
Grid
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-200">Columns</label>
<input id="columns" type="number" v-model.number="columns" min="1" max="10" class="w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-500" />
</div>
<div class="px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<label class="flex items-center justify-between mb-2 cursor-pointer">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Manual size</span>
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="w-4 h-4 rounded" />
</label>
<div v-if="settingsStore.manualCellSizeEnabled" class="flex items-center gap-1.5 mt-2">
<input
type="number"
v-model.number="settingsStore.manualCellWidth"
min="1"
max="2048"
class="w-full min-w-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-500"
placeholder="W"
/>
<span class="flex-shrink-0 text-gray-500 dark:text-gray-400">×</span>
<input
type="number"
v-model.number="settingsStore.manualCellHeight"
min="1"
max="2048"
class="w-full min-w-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-500"
placeholder="H"
/>
</div>
<div v-else class="mt-1 text-xs font-mono text-gray-500 dark:text-gray-400 break-words">{{ cellSize.width }} × {{ cellSize.height }}px</div>
</div>
</div>
</section>
<!-- Alignment -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-align-center"></i>
Align
</h3>
<div class="grid grid-cols-3 gap-2">
<button @click="alignSprites('left')" class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-all cursor-pointer" title="Left">
<i class="fas fa-arrow-left"></i>
</button>
<button @click="alignSprites('center')" class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-all cursor-pointer" title="Center">
<i class="fas fa-arrows-left-right"></i>
</button>
<button @click="alignSprites('right')" class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-all cursor-pointer" title="Right">
<i class="fas fa-arrow-right"></i>
</button>
<button @click="alignSprites('top')" class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-all cursor-pointer" title="Top">
<i class="fas fa-arrow-up"></i>
</button>
<button @click="alignSprites('middle')" class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-all cursor-pointer" title="Middle">
<i class="fas fa-arrows-up-down"></i>
</button>
<button @click="alignSprites('bottom')" class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-all cursor-pointer" title="Bottom">
<i class="fas fa-arrow-down"></i>
</button>
</div>
</section>
<!-- Export -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-download"></i>
Export
</h3>
<div class="grid grid-cols-2 gap-2">
<button @click="downloadSpritesheet" class="flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-white bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 rounded-lg transition-all cursor-pointer" data-rybbit-event="download-spritesheet">
<i class="fas fa-image"></i>
<span>PNG</span>
</button>
<button @click="exportSpritesheetJSON" class="flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-white bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 rounded-lg transition-all cursor-pointer" data-rybbit-event="export-json">
<i class="fas fa-file-code"></i>
<span>JSON</span>
</button>
<button @click="openGifFpsModal" class="flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-white bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 rounded-lg transition-all cursor-pointer" data-rybbit-event="download-gif">
<i class="fas fa-film"></i>
<span>GIF</span>
</button>
<button @click="downloadAsZip" class="flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium text-white bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 rounded-lg transition-all cursor-pointer" data-rybbit-event="download-zip">
<i class="fas fa-file-archive"></i>
<span>ZIP</span>
</button>
</div>
</section>
</div>
</div>
<!-- Right panel - Tabs -->
<div class="flex flex-col overflow-hidden">
<!-- Tab Navigation -->
<div class="bg-gray-50/50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-1 p-2">
<button
@click="activeTab = 'canvas'"
:class="[
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
activeTab === 'canvas' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
]"
>
<i class="fas fa-th"></i>
<span>Canvas</span>
</button>
<button
@click="activeTab = 'preview'"
:class="[
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
]"
data-rybbit-event="preview-animation"
>
<i class="fas fa-play"></i>
<span>Preview</span>
</button>
</div>
</div>
<!-- Tab Content -->
<div class="p-6 lg:flex-1 lg:overflow-auto">
<div v-if="activeTab === 'canvas'">
<sprite-canvas :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" />
</div>
<div v-if="activeTab === 'preview'">
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
</div>
</div>
</div>
</div>
</main>
<div class="flex-shrink-0 p-3 mt-3 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 border border-blue-200 dark:border-gray-600 rounded-lg">
<div class="flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center gap-2 text-center sm:text-left">
<i class="text-lg text-blue-600 dark:text-blue-400 fas fa-ad"></i>
<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">Your ad here</span>
<span class="hidden sm:inline text-xs text-gray-600 dark:text-gray-400">- Reach thousands of game developers and creative professionals</span>
</div>
<div class="flex gap-2">
<a href="mailto:root@adhd.sh" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded transition-all">
<i class="text-xs fas fa-envelope"></i>
<span class="hidden sm:inline">root@adhd.sh</span>
<span class="sm:hidden">Email</span>
</a>
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded transition-all">
<i class="text-xs fab fa-discord"></i>
<span class="hidden sm:inline">nu11ed</span>
<span class="sm:hidden">Discord</span>
</a>
</div>
</div>
</div>
</div>
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
<!-- One-time feedback popup -->
<div v-if="showFeedbackPopup" class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div class="max-w-md p-6 mx-4 bg-white dark:bg-gray-800 border border-gray-600 rounded-xl shadow-xl">
<div class="text-center">
<div class="mb-4 text-4xl">💬</div>
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-white">Help us improve!</h3>
<p class="mb-6 text-gray-600 dark:text-gray-300">We'd love to hear your thoughts about the Spritesheet generator. Would you like to share your feedback?</p>
<div class="flex justify-center gap-3">
<button @click="handleFeedbackPopupResponse(false)" class="px-4 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors cursor-pointer">Maybe later</button>
<button @click="handleFeedbackPopupResponse(true)" class="px-6 py-2 font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors cursor-pointer">Share feedback</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, toRef, computed } from 'vue';
import FileUploader from './components/FileUploader.vue';
import SpriteCanvas from './components/SpriteCanvas.vue';
import SpritePreview from './components/SpritePreview.vue';
import HelpModal from './components/HelpModal.vue';
import FeedbackModal from './components/FeedbackModal.vue';
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
import GifFpsModal from './components/GifFpsModal.vue';
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
import { useExportLayers } from './composables/useExportLayers';
import { useLayers } from './composables/useLayers';
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
import { useSettingsStore } from './stores/useSettingsStore';
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
import type { SpriteFile } from './types/sprites';
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
const getCellSize = () => {
if (!visibleLayers.value.length) return { width: 0, height: 0 };
if (settingsStore.manualCellSizeEnabled) {
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
};
const cellSize = computed(getCellSize);
const activeTab = ref<'canvas' | 'preview'>('canvas');
const isHelpModalOpen = ref(false);
const isFeedbackModalOpen = ref(false);
const isSpritesheetSplitterOpen = ref(false);
const isGifFpsModalOpen = ref(false);
const uploadInput = ref<HTMLInputElement | null>(null);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const spritesheetImageUrl = ref('');
const spritesheetImageFile = ref<File | null>(null);
const showFeedbackPopup = ref(false);
const editingLayerId = ref<string | null>(null);
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
await handleJSONImport(jsonFile);
return;
}
if (files.length === 1 && files[0].type.startsWith('image/')) {
const file = files[0];
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
spritesheetImageUrl.value = url;
spritesheetImageFile.value = file;
isSpritesheetSplitterOpen.value = true;
return;
}
processImageFiles([file]);
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
};
img.src = url;
};
reader.onerror = () => {
console.error('Failed to read image file:', file.name);
};
reader.readAsDataURL(file);
return;
}
processImageFiles(files);
};
const handleJSONImport = async (jsonFile: File) => {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
const openHelpModal = () => {
isHelpModalOpen.value = true;
};
const closeHelpModal = () => {
isHelpModalOpen.value = false;
};
const openFeedbackModal = () => {
isFeedbackModalOpen.value = true;
};
const closeFeedbackModal = () => {
isFeedbackModalOpen.value = false;
};
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
try {
URL.revokeObjectURL(spritesheetImageUrl.value);
} catch {}
}
spritesheetImageUrl.value = '';
spritesheetImageFile.value = null;
};
const openGifFpsModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
isGifFpsModalOpen.value = true;
};
const closeGifFpsModal = () => {
isGifFpsModalOpen.value = false;
};
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
processImageFiles(spriteFiles.map(s => s.file));
};
const openJSONImportDialog = () => {
jsonFileInput.value?.click();
};
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleJSONImport(input.files[0]);
input.value = '';
}
};
onMounted(() => {
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
if (!hasShownFeedbackPopup) {
setTimeout(() => {
showFeedbackPopup.value = true;
}, 3000);
}
});
const handleFeedbackPopupResponse = (showModal: boolean) => {
showFeedbackPopup.value = false;
localStorage.setItem('hasShownFeedbackPopup', 'true');
if (showModal) {
openFeedbackModal();
}
};
const openFileDialog = () => {
uploadInput.value?.click();
};
const handleUploadChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleSpritesUpload(Array.from(input.files));
input.value = '';
}
};
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
// Focus the input on next tick
setTimeout(() => {
layerNameInput.value?.focus();
layerNameInput.value?.select();
}, 0);
};
const finishEditingLayer = () => {
if (editingLayerId.value && editingLayerName.value.trim()) {
const layer = layers.value.find(l => l.id === editingLayerId.value);
if (layer) {
layer.name = editingLayerName.value.trim();
}
}
editingLayerId.value = null;
editingLayerName.value = '';
};
const cancelEditingLayer = () => {
editingLayerId.value = null;
editingLayerName.value = '';
};
</script>