Compare commits
18 Commits
feature/la
...
551d7a027f
| Author | SHA1 | Date | |
|---|---|---|---|
| 551d7a027f | |||
| 6b0c82a630 | |||
| a0d2b2994b | |||
| 7f8e26255f | |||
| 4ec82317ea | |||
| fed578441a | |||
| 62205ed069 | |||
| 6ae8dd37dc | |||
| 77a1b22faf | |||
| 8b1ea0f5b4 | |||
| 9a2bf6b2db | |||
| 2c25157621 | |||
| 9fa2bbd15d | |||
| b3f530870e | |||
| 56858701ef | |||
| f8b4e98f9c | |||
| 69fc4c4a7e | |||
| d35ae69265 |
10
index.html
10
index.html
@@ -14,24 +14,24 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Spritesheet Generator - Create Game Spritesheets Online</title>
|
||||
<meta name="title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<title>Spritesheet generator - Create Game Spritesheets Online</title>
|
||||
<meta name="title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta name="description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta name="keywords" content="spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||
<meta name="keywords" content="Spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||
<meta name="author" content="nu11ed">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://spritesheetgenerator.online/">
|
||||
<meta property="og:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta property="og:title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta property="og:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta property="og:image" content="/og-image.png">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="https://spritesheetgenerator.online/">
|
||||
<meta property="twitter:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta property="twitter:title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta property="twitter:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta property="twitter:image" content="/og-image.png">
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.8.0] - 2025-11-23
|
||||
- Fix context menu location
|
||||
- You can now reposition all sprites in current frame
|
||||
|
||||
## [1.7.0] - 2025-11-22
|
||||
- Add layer support
|
||||
- Add background color picker
|
||||
|
||||
534
src/App.vue
534
src/App.vue
@@ -1,144 +1,305 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-3 sm:p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 transition-colors duration-300">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-8 gap-2">
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight text-center sm:text-left">Spritesheet generator</h1>
|
||||
<dark-mode-toggle />
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-4 sm:mb-8">
|
||||
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="source-link"> <i class="fab fa-github"></i> Source </a>
|
||||
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="discord-link"> <i class="fab fa-discord"></i> Discord </a>
|
||||
<a href="#" @click.prevent="openHelpModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="help-link"> <i class="fas fa-question-circle"></i> Help </a>
|
||||
<a href="#" @click.prevent="openFeedbackModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="feedback-link"> <i class="fas fa-comment-dots"></i> Feedback </a>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-soft dark:shadow-gray-900/30 p-4 sm:p-8 transition-colors duration-300">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-6 gap-3">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Upload sprites</h2>
|
||||
<button
|
||||
@click="openJSONImportDialog"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-indigo-500 hover:bg-indigo-600 dark:bg-indigo-600 dark:hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center sm:justify-start space-x-2"
|
||||
data-rybbit-event="import-json"
|
||||
>
|
||||
<i class="fas fa-file-import"></i>
|
||||
<span>Import JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||
<div v-if="!visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="mt-2 leading-relaxed space-y-2">
|
||||
<p>Create spritesheets for your game development and animation projects with our completely free, open-source spritesheet generator.</p>
|
||||
<p>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>
|
||||
<p class="font-bold text-2xl pb-3 pt-2">How it works:</p>
|
||||
<video controls playsinline class="w-full h-full object-contain rounded-lg shadow-md" title="Spritesheet generator tutorial" aria-label="Spritesheet generator tutorial">
|
||||
<source src="@/assets/tut2.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div class="min-h-screen p-4 sm:p-8 bg-slate-50 dark:bg-gray-950 transition-colors duration-300">
|
||||
<div class="max-w-[1600px] mx-auto">
|
||||
<header class="mb-8 sm:mb-12">
|
||||
<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 justify-center sm:justify-start items-center 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 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-all hover:shadow-md"
|
||||
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 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-all hover:shadow-md"
|
||||
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 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-all hover:shadow-md"
|
||||
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 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-all hover:shadow-md"
|
||||
data-rybbit-event="feedback-link"
|
||||
>
|
||||
<i class="fas fa-comment-dots"></i>
|
||||
<span class="font-medium">Feedback</span>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div v-if="visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Layers</span>
|
||||
<button @click="addLayer()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 rounded">Add</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="layer in layers" :key="layer.id" class="flex items-center gap-2 px-2 py-1 rounded border border-gray-200 dark:border-gray-600" :class="{ 'ring-2 ring-blue-500': layer.id === activeLayerId }">
|
||||
<button @click="activeLayerId = layer.id" class="px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">{{ layer.name }}</button>
|
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300"> <input type="checkbox" v-model="layer.visible" /> Visible </label>
|
||||
<button @click="moveLayer(layer.id, 'up')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↑</button>
|
||||
<button @click="moveLayer(layer.id, 'down')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↓</button>
|
||||
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-200 rounded">Remove</button>
|
||||
<main class="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 transition-all duration-300 overflow-hidden">
|
||||
<!-- Welcome state -->
|
||||
<div v-if="!visibleLayers.some(l => l.sprites.length)" class="p-6 sm:p-10">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload sprites</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>
|
||||
<li class="text-gray-700 dark:text-gray-300 mb-6 space-y-2">
|
||||
<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>
|
||||
</li>
|
||||
<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/tut2.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-3 sm:gap-6 mb-6 sm:mb-8">
|
||||
<div class="flex items-center space-x-1">
|
||||
<label for="columns" class="text-gray-700 dark:text-gray-200 font-medium">Columns:</label>
|
||||
<input
|
||||
id="columns"
|
||||
type="number"
|
||||
v-model.number="columns"
|
||||
min="1"
|
||||
max="10"
|
||||
class="w-20 px-3 py-2 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Cell size:</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ cellSize.width }} × {{ cellSize.height }}px</span>
|
||||
</div>
|
||||
|
||||
<!-- Add mass position buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<button @click="alignSprites('left')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Left" data-rybbit-event="align-left">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('center')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Center" data-rybbit-event="align-center">
|
||||
<i class="fas fa-arrows-left-right"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('right')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Right" data-rybbit-event="align-right">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('top')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Top" data-rybbit-event="align-top">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('middle')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Middle" data-rybbit-event="align-middle">
|
||||
<i class="fas fa-arrows-up-down"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('bottom')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Bottom" data-rybbit-event="align-bottom">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="downloadSpritesheet" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="download-spritesheet">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Download spritesheet</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="exportSpritesheetJSON"
|
||||
class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-purple-500 hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2"
|
||||
data-rybbit-event="export-json"
|
||||
>
|
||||
<i class="fas fa-file-code"></i>
|
||||
<span>Export as JSON</span>
|
||||
</button>
|
||||
|
||||
<button @click="openGifFpsModal" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="download-gif">
|
||||
<i class="fas fa-film"></i>
|
||||
<span>Download as GIF</span>
|
||||
</button>
|
||||
|
||||
<button @click="downloadAsZip" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-teal-500 hover:bg-teal-600 dark:bg-teal-600 dark:hover:bg-teal-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="download-zip">
|
||||
<i class="fas fa-file-archive"></i>
|
||||
<span>Download as ZIP</span>
|
||||
</button>
|
||||
|
||||
<button @click="openPreviewModal" class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center space-x-2" data-rybbit-event="preview-animation">
|
||||
<i class="fas fa-play"></i>
|
||||
<span>Preview animation</span>
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
@add-sprite-with-resize="addSpriteWithResize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
</Modal>
|
||||
<!-- Two-column layout: Left controls, Right preview -->
|
||||
<div v-if="visibleLayers.some(l => l.sprites.length)" class="grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] min-h-[600px]">
|
||||
<!-- Left sidebar - Controls -->
|
||||
<div class="border-r border-gray-200 dark:border-gray-700 p-6 overflow-y-auto max-h-[calc(100vh-200px)] bg-gray-50/50 dark:bg-gray-900/30">
|
||||
<div class="space-y-6">
|
||||
<!-- Upload Section -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-base font-bold text-gray-800 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fas fa-upload text-gray-700 dark:text-gray-300 text-sm"></i>
|
||||
Upload
|
||||
</h3>
|
||||
<button @click="openJSONImportDialog" class="px-3 py-1.5 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-all flex items-center gap-1.5 cursor-pointer" data-rybbit-event="import-json">
|
||||
<i class="fas fa-file-import text-xs"></i>
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-4 text-center 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="text-base font-bold text-gray-800 dark:text-gray-100 flex items-center gap-2">
|
||||
<i class="fas fa-layer-group text-gray-700 dark:text-gray-300 text-sm"></i>
|
||||
Layers
|
||||
</h3>
|
||||
<button @click="addLayer()" class="px-3 py-1.5 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-all flex items-center gap-1.5 cursor-pointer">
|
||||
<i class="fas fa-plus text-xs"></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 rounded-lg bg-white dark:bg-gray-800 border 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 ? 'fas fa-eye text-gray-800 dark:text-gray-200' : 'fas fa-eye-slash text-gray-400 dark:text-gray-500'" class="text-sm"></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 border border-gray-800 dark:border-gray-400 dark:bg-gray-700 dark:text-gray-100 rounded text-sm focus:ring-2 focus:ring-gray-800 dark:focus:ring-gray-400 outline-none"
|
||||
ref="layerNameInput"
|
||||
@click.stop
|
||||
/>
|
||||
<button v-else @click="activeLayerId = layer.id" class="flex-1 text-left px-2 py-1 rounded text-sm font-medium 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="text-xs opacity-60 ml-1">({{ 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="fas fa-pen text-xs text-gray-600 dark:text-gray-400"></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="fas fa-chevron-up text-xs text-gray-600 dark:text-gray-400"></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="fas fa-chevron-down text-xs text-gray-600 dark:text-gray-400"></i>
|
||||
</button>
|
||||
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded transition-colors cursor-pointer" title="Delete">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Grid Settings -->
|
||||
<section>
|
||||
<h3 class="text-base font-bold text-gray-800 dark:text-gray-100 mb-3 flex items-center gap-2">
|
||||
<i class="fas fa-th text-gray-700 dark:text-gray-300 text-sm"></i>
|
||||
Grid
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between bg-white dark:bg-gray-800 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<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 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded text-sm focus:ring-2 focus:ring-gray-500 outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<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 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded text-sm focus:ring-2 focus:ring-gray-500 outline-none"
|
||||
placeholder="W"
|
||||
/>
|
||||
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">×</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="settingsStore.manualCellHeight"
|
||||
min="1"
|
||||
max="2048"
|
||||
class="w-full min-w-0 px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded text-sm focus:ring-2 focus:ring-gray-500 outline-none"
|
||||
placeholder="H"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1 break-words">{{ cellSize.width }} × {{ cellSize.height }}px</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alignment -->
|
||||
<section>
|
||||
<h3 class="text-base font-bold text-gray-800 dark:text-gray-100 mb-3 flex items-center gap-2">
|
||||
<i class="fas fa-align-center text-gray-700 dark:text-gray-300 text-sm"></i>
|
||||
Align
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button @click="alignSprites('left')" class="px-3 py-2 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-100 rounded-lg transition-all text-xs font-medium cursor-pointer" title="Left">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('center')" class="px-3 py-2 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-100 rounded-lg transition-all text-xs font-medium cursor-pointer" title="Center">
|
||||
<i class="fas fa-arrows-left-right"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('right')" class="px-3 py-2 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-100 rounded-lg transition-all text-xs font-medium cursor-pointer" title="Right">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('top')" class="px-3 py-2 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-100 rounded-lg transition-all text-xs font-medium cursor-pointer" title="Top">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('middle')" class="px-3 py-2 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-100 rounded-lg transition-all text-xs font-medium cursor-pointer" title="Middle">
|
||||
<i class="fas fa-arrows-up-down"></i>
|
||||
</button>
|
||||
<button @click="alignSprites('bottom')" class="px-3 py-2 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-100 rounded-lg transition-all text-xs font-medium cursor-pointer" title="Bottom">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Export -->
|
||||
<section>
|
||||
<h3 class="text-base font-bold text-gray-800 dark:text-gray-100 mb-3 flex items-center gap-2">
|
||||
<i class="fas fa-download text-gray-700 dark:text-gray-300 text-sm"></i>
|
||||
Export
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button @click="downloadSpritesheet" class="px-3 py-2 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-all text-xs font-medium flex items-center justify-center gap-1.5 cursor-pointer" data-rybbit-event="download-spritesheet">
|
||||
<i class="fas fa-image"></i>
|
||||
<span>PNG</span>
|
||||
</button>
|
||||
<button @click="exportSpritesheetJSON" class="px-3 py-2 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-all text-xs font-medium flex items-center justify-center gap-1.5 cursor-pointer" data-rybbit-event="export-json">
|
||||
<i class="fas fa-file-code"></i>
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
<button @click="openGifFpsModal" class="px-3 py-2 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-all text-xs font-medium flex items-center justify-center gap-1.5 cursor-pointer" data-rybbit-event="download-gif">
|
||||
<i class="fas fa-film"></i>
|
||||
<span>GIF</span>
|
||||
</button>
|
||||
<button @click="downloadAsZip" class="px-3 py-2 bg-gray-700 hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-all text-xs font-medium flex items-center justify-center gap-1.5 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 min-w-0">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/30">
|
||||
<div class="flex gap-1 p-2">
|
||||
<button
|
||||
@click="activeTab = 'canvas'"
|
||||
:class="['flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium', 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 rounded-lg transition-all text-sm font-medium', 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 overflow-y-auto overflow-x-hidden max-h-[calc(100vh-260px)] min-w-0">
|
||||
<div v-if="activeTab === 'canvas'" class="max-w-full">
|
||||
<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'" class="max-w-full">
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
|
||||
@@ -151,10 +312,10 @@
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">💬</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Help us improve!</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">We'd love to hear your thoughts about the spritesheet generator. Would you like to share your feedback?</p>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">We'd love to hear your thoughts about the Spritesheet generator. Would you like to share your feedback?</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<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">Maybe later</button>
|
||||
<button @click="handleFeedbackPopupResponse(true)" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors">Share feedback</button>
|
||||
<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 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors cursor-pointer">Share feedback</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +327,6 @@
|
||||
import { ref, onMounted, toRef, computed } from 'vue';
|
||||
import FileUploader from './components/FileUploader.vue';
|
||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||
import Modal from './components/utilities/Modal.vue';
|
||||
import SpritePreview from './components/SpritePreview.vue';
|
||||
import HelpModal from './components/HelpModal.vue';
|
||||
import FeedbackModal from './components/FeedbackModal.vue';
|
||||
@@ -181,37 +341,52 @@
|
||||
import type { SpriteFile } from './types/sprites';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
|
||||
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'));
|
||||
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 cellSize = computed(() => {
|
||||
if (!layers.value.length) return { width: 0, height: 0 };
|
||||
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 isPreviewModalOpen = ref(false);
|
||||
};
|
||||
|
||||
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) {
|
||||
try {
|
||||
await importSpritesheetJSON(jsonFile);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
await handleJSONImport(jsonFile);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,16 +412,13 @@
|
||||
processImageFiles(files);
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites to preview an animation.');
|
||||
return;
|
||||
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.');
|
||||
}
|
||||
isPreviewModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closePreviewModal = () => {
|
||||
isPreviewModalOpen.value = false;
|
||||
};
|
||||
|
||||
const openHelpModal = () => {
|
||||
@@ -297,14 +469,8 @@
|
||||
const handleJSONFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const jsonFile = input.files[0];
|
||||
try {
|
||||
await importSpritesheetJSON(jsonFile);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
||||
await handleJSONImport(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -325,4 +491,42 @@
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-4 sm:p-8 text-center transition-all duration-200"
|
||||
class="relative border-3 border-dashed rounded-2xl p-8 sm:p-12 text-center transition-all duration-300 cursor-pointer group overflow-hidden"
|
||||
:class="{
|
||||
'border-blue-300 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/30': isDragging,
|
||||
'border-gray-200 hover:border-blue-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:border-blue-500 dark:hover:bg-gray-700/50': !isDragging,
|
||||
'border-blue-400 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/40 scale-[1.02]': isDragging,
|
||||
'border-gray-300 bg-gray-50/50 hover:border-blue-400 hover:bg-blue-50/80 dark:border-gray-600 dark:bg-gray-800/30 dark:hover:border-blue-400 dark:hover:bg-blue-900/30': !isDragging,
|
||||
}"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@@ -12,19 +12,35 @@
|
||||
@click="openFileDialog"
|
||||
data-rybbit-event="file-upload-area"
|
||||
>
|
||||
<div class="absolute inset-0 bg-blue-400/0 group-hover:bg-blue-400/5 transition-all duration-300"></div>
|
||||
|
||||
<input ref="fileInput" type="file" multiple accept="image/*,.json" class="hidden" @change="handleFileChange" />
|
||||
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<img src="@/assets/images/file.svg" alt="File upload" class="w-16 h-16 sm:w-20 sm:h-20 mx-auto mb-2 sm:mb-4 opacity-75 dark:invert" />
|
||||
<div class="relative z-10">
|
||||
<div class="mb-6 transform transition-transform duration-300" :class="isDragging ? 'scale-110' : 'group-hover:scale-105'">
|
||||
<div class="w-20 h-20 sm:w-24 sm:h-24 mx-auto mb-4 bg-blue-100 dark:bg-blue-900/50 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl sm:text-5xl text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-800 dark:text-gray-100 mb-3">
|
||||
<span v-if="isDragging">Drop your files here</span>
|
||||
<span v-else>Upload Your Sprites</span>
|
||||
</h3>
|
||||
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 mb-2">Drag and drop sprite images or JSON files</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Supports PNG, JPG, GIF, and JSON</p>
|
||||
|
||||
<div class="flex items-center justify-center gap-4 mb-6">
|
||||
<div class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></div>
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">or</span>
|
||||
<div class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<button class="px-8 py-3.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white font-semibold rounded-xl shadow-lg hover:shadow-xl transition-all transform hover:scale-105 flex items-center justify-center gap-3 mx-auto" data-rybbit-event="select-files">
|
||||
<i class="fas fa-folder-open text-lg"></i>
|
||||
<span>Browse Files</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-lg sm:text-xl font-medium text-gray-700 dark:text-gray-200 mb-2">Drag and drop your sprite images or JSON file here</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4 sm:mb-6">or</p>
|
||||
|
||||
<button class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors flex sm:inline-flex items-center justify-center space-x-2 cursor-pointer" data-rybbit-event="select-files">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>Select files</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,60 +1,101 @@
|
||||
<template>
|
||||
<div class="p-2 bg-cyan-600 rounded w-full my-4">
|
||||
<p>Developer's tip: Right click a sprite to open the context menu and add, replace or remove sprites.</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2 w-4 h-4" @change="requestDraw" />
|
||||
<label for="pixel-perfect" class="dark:text-gray-200 text-sm sm:text-base">Pixel perfect rendering</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="mr-2 w-4 h-4" />
|
||||
<label for="allow-cell-swap" class="dark:text-gray-200 text-sm sm:text-base">Allow moving between cells</label>
|
||||
</div>
|
||||
<!-- Add new checkbox for showing all sprites -->
|
||||
<div class="flex items-center">
|
||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
||||
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
||||
</div>
|
||||
<!-- Negative spacing control -->
|
||||
<div class="flex items-center">
|
||||
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
|
||||
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
|
||||
</div>
|
||||
<!-- Background color picker -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="bg-color" class="dark:text-gray-200 text-sm sm:text-base">Background:</label>
|
||||
<select id="bg-color" v-model="settingsStore.backgroundColor" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 dark:text-gray-200 text-sm">
|
||||
<option value="transparent">Transparent</option>
|
||||
<option value="#ffffff">White</option>
|
||||
<option value="#000000">Black</option>
|
||||
<option value="#f9fafb">Light Gray</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<input v-if="settingsStore.backgroundColor === 'custom'" type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="w-8 h-8 border border-gray-300 dark:border-gray-600 rounded cursor-pointer" />
|
||||
<Teleport to="body">
|
||||
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||
<button @click="addSprite" class="w-full px-5 py-3 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
|
||||
<span>Add Sprite</span>
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-5 py-3 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
|
||||
<span>Replace Sprite</span>
|
||||
</button>
|
||||
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-5 py-3 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Remove Sprite</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="space-y-6 w-full max-w-full overflow-hidden">
|
||||
<div class="bg-cyan-500 dark:bg-cyan-600 rounded-xl p-4 shadow-lg border border-cyan-400/50 dark:border-cyan-500/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-lightbulb text-yellow-300 text-xl mt-0.5 flex-shrink-0"></i>
|
||||
<div class="min-w-0">
|
||||
<h4 class="font-semibold text-white mb-1">Tip from developer</h4>
|
||||
<p class="text-cyan-50 text-sm">Right-click any sprite to open the context menu for quick actions: add, replace, or remove sprites.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto">
|
||||
<!-- Zoom controls - visible on all screen sizes and positioned outside cells -->
|
||||
<div class="relative flex space-x-2 bg-white/90 dark:bg-gray-800/90 p-2 rounded-lg shadow-md z-20">
|
||||
<button @click="zoomIn" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button @click="zoomOut" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
<button @click="resetZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
||||
<i class="fas fa-expand"></i>
|
||||
</button>
|
||||
<section class="w-full">
|
||||
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-cog text-blue-600 dark:text-blue-400"></i>
|
||||
Canvas options
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 rounded" />
|
||||
<span class="text-sm font-medium dark:text-gray-200">Pixel Perfect</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
|
||||
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="w-4 h-4 rounded" />
|
||||
<span class="text-sm font-medium dark:text-gray-200">Cell Swapping</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
|
||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="w-4 h-4 rounded" />
|
||||
<span class="text-sm font-medium dark:text-gray-200">Compare Sprites</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
|
||||
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="w-4 h-4 rounded" />
|
||||
<span class="text-sm font-medium dark:text-gray-200">Negative Spacing</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
|
||||
<input id="checkerboard" type="checkbox" v-model="settingsStore.checkerboardEnabled" class="w-4 h-4 rounded" />
|
||||
<span class="text-sm font-medium dark:text-gray-200">Checkerboard</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<label for="bg-color" class="text-sm font-medium dark:text-gray-200">Background:</label>
|
||||
<select id="bg-color" v-model="bgSelectValue" class="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all">
|
||||
<option value="transparent">Transparent</option>
|
||||
<option value="#ffffff">White</option>
|
||||
<option value="#000000">Black</option>
|
||||
<option value="#f9fafb">Light Gray</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input v-if="bgSelectValue === 'custom'" type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="w-10 h-10 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<span class="text-sm font-medium dark:text-gray-200 mr-1">Zoom:</span>
|
||||
<button @click="zoomIn" class="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded transition-all" title="Zoom In">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
</button>
|
||||
<button @click="zoomOut" class="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded transition-all" title="Zoom Out">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
</button>
|
||||
<button @click="resetZoom" class="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded transition-all" title="Reset Zoom">
|
||||
<i class="fas fa-expand text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
|
||||
<input id="show-offset-labels" type="checkbox" v-model="showOffsetLabels" class="w-4 h-4 rounded" />
|
||||
<span class="text-sm font-medium dark:text-gray-200">Show Offset Labels</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="canvas-container touch-manipulation relative" :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto max-h-[calc(100vh-400px)] min-h-[500px] w-full">
|
||||
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
||||
<div
|
||||
ref="gridContainerRef"
|
||||
:style="{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${gridDimensions.width}px`,
|
||||
height: `${gridDimensions.height}px`,
|
||||
position: 'relative',
|
||||
}"
|
||||
class="inline-block"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@@ -67,20 +108,102 @@
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="handleDrop"
|
||||
class="w-full transition-all"
|
||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
||||
></canvas>
|
||||
|
||||
<!-- Offset labels in corners -->
|
||||
<div v-if="canvasRef" class="absolute inset-0 pointer-events-none">
|
||||
>
|
||||
<!-- Grid cells -->
|
||||
<div
|
||||
v-for="position in spritePositions"
|
||||
:key="position.id"
|
||||
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm"
|
||||
v-for="cellIndex in totalCells"
|
||||
:key="`cell-${cellIndex - 1}`"
|
||||
class="absolute"
|
||||
:style="{
|
||||
left: `calc(${(position.cellX / canvasRef.width) * 100}% + ${(position.maxWidth / canvasRef.width) * 100}% - 2px)`,
|
||||
top: `calc(${(position.cellY / canvasRef.height) * 100}% + ${(position.maxHeight / canvasRef.height) * 100}% - 2px)`,
|
||||
left: `${getCellPosition(cellIndex - 1).x}px`,
|
||||
top: `${getCellPosition(cellIndex - 1).y}px`,
|
||||
width: `${gridMetrics.maxWidth}px`,
|
||||
height: `${gridMetrics.maxHeight}px`,
|
||||
backgroundColor: getCellBackground(),
|
||||
backgroundImage: getCellBackgroundImage(),
|
||||
backgroundSize: getCellBackgroundSize(),
|
||||
backgroundPosition: getCellBackgroundPosition(),
|
||||
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
|
||||
}"
|
||||
:class="{
|
||||
'bg-blue-500 bg-opacity-20': highlightCell && highlightCell.col === (cellIndex - 1) % columns && highlightCell.row === Math.floor((cellIndex - 1) / columns),
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Background sprites (for compare mode) -->
|
||||
<template v-if="showAllSprites">
|
||||
<template v-for="layer in visibleLayers" :key="`bg-layer-${layer.id}`">
|
||||
<template v-for="(sprite, spriteIndex) in layer.sprites" :key="`bg-${sprite.id}`">
|
||||
<template v-for="cellIndex in totalCells" :key="`bg-${sprite.id}-${cellIndex}`">
|
||||
<img
|
||||
v-if="spriteIndex !== cellIndex - 1 && !(activeSpriteId === sprite.id && ghostSprite)"
|
||||
:src="sprite.img.src"
|
||||
class="absolute pointer-events-none"
|
||||
:style="{
|
||||
left: `${getCellPosition(cellIndex - 1).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||
top: `${getCellPosition(cellIndex - 1).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||
width: `${sprite.width}px`,
|
||||
height: `${sprite.height}px`,
|
||||
opacity: '0.25',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Layer sprites -->
|
||||
<template v-for="layer in layers" :key="layer.id">
|
||||
<template v-if="layer.visible">
|
||||
<template v-for="(sprite, index) in layer.sprites" :key="sprite.id">
|
||||
<img
|
||||
v-if="!(activeSpriteId === sprite.id && ghostSprite)"
|
||||
:src="sprite.img.src"
|
||||
class="absolute cursor-move"
|
||||
:style="{
|
||||
left: `${getCellPosition(index).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||
top: `${getCellPosition(index).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||
width: `${sprite.width}px`,
|
||||
height: `${sprite.height}px`,
|
||||
opacity: layer.id === activeLayerId ? '1' : '0.85',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
}"
|
||||
:data-sprite-id="sprite.id"
|
||||
:data-layer-id="layer.id"
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Ghost sprite (while dragging) -->
|
||||
<img
|
||||
v-if="ghostSprite && activeSpriteId"
|
||||
:src="activeSpriteSprite?.img.src"
|
||||
class="absolute pointer-events-none"
|
||||
:style="{
|
||||
left: `${ghostSprite.x}px`,
|
||||
top: `${ghostSprite.y}px`,
|
||||
width: `${activeSpriteSprite?.width}px`,
|
||||
height: `${activeSpriteSprite?.height}px`,
|
||||
opacity: '0.6',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- Offset labels -->
|
||||
<div
|
||||
v-if="showOffsetLabels"
|
||||
v-for="position in spritePositions"
|
||||
:key="`label-${position.id}`"
|
||||
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm pointer-events-none"
|
||||
:style="{
|
||||
left: `${position.cellX + position.maxWidth - 2}px`,
|
||||
top: `${position.cellY + position.maxHeight - 2}px`,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
}"
|
||||
>
|
||||
@@ -90,32 +213,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50 py-1" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||
<button @click="addSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
Add sprite
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
Replace sprite
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
<i class="fas fa-trash"></i>
|
||||
Remove sprite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input for replace functionality -->
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, toRef, computed } from 'vue';
|
||||
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useDragSprite } from '@/composables/useDragSprite';
|
||||
import { useFileDrop } from '@/composables/useFileDrop';
|
||||
@@ -140,21 +245,7 @@
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
// rAF-based draw scheduler to coalesce multiple draw requests into a single frame
|
||||
// Define before usage to avoid TDZ issues when passing into composables during setup
|
||||
let rafId: number | null = null;
|
||||
function requestDraw() {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
drawCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize composables
|
||||
const canvas2D = useCanvas2D(canvasRef);
|
||||
const gridContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
zoom,
|
||||
@@ -170,6 +261,17 @@
|
||||
|
||||
const allowCellSwap = ref(false);
|
||||
|
||||
const getMousePosition = (event: MouseEvent, z: number) => {
|
||||
if (!gridContainerRef.value) return null;
|
||||
const rect = gridContainerRef.value.getBoundingClientRect();
|
||||
const scaleX = gridContainerRef.value.offsetWidth / (rect.width / z);
|
||||
const scaleY = gridContainerRef.value.offsetHeight / (rect.height / z);
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / z) * scaleX,
|
||||
y: ((event.clientY - rect.top) / z) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
activeSpriteId,
|
||||
@@ -185,17 +287,23 @@
|
||||
calculateMaxDimensions,
|
||||
} = useDragSprite({
|
||||
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
||||
layers: toRef(props, 'layers'),
|
||||
columns: toRef(props, 'columns'),
|
||||
zoom,
|
||||
allowCellSwap,
|
||||
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
||||
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
||||
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
||||
getMousePosition,
|
||||
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||
onDraw: requestDraw,
|
||||
onDraw: () => {},
|
||||
});
|
||||
|
||||
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
|
||||
const visibleLayers = computed(() => props.layers.filter(l => l.visible));
|
||||
const activeSpriteSprite = computed(() => activeSprites.value.find(s => s.id === activeSpriteId.value));
|
||||
|
||||
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
||||
sprites: activeSprites,
|
||||
@@ -204,6 +312,7 @@
|
||||
});
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
const showOffsetLabels = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
@@ -212,8 +321,114 @@
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const customColor = ref('#ffffff');
|
||||
|
||||
// Grid metrics
|
||||
const gridMetrics = computed(() => calculateMaxDimensions());
|
||||
|
||||
const totalCells = computed(() => {
|
||||
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
||||
});
|
||||
|
||||
const gridDimensions = computed(() => {
|
||||
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
||||
return {
|
||||
width: gridMetrics.value.maxWidth * props.columns,
|
||||
height: gridMetrics.value.maxHeight * rows,
|
||||
};
|
||||
});
|
||||
|
||||
const getCellPosition = (index: number) => {
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
return {
|
||||
x: col * gridMetrics.value.maxWidth,
|
||||
y: row * gridMetrics.value.maxHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const getCellBackground = () => {
|
||||
const bg = settingsStore.backgroundColor;
|
||||
if (bg === 'transparent') {
|
||||
return 'transparent';
|
||||
}
|
||||
return bg;
|
||||
};
|
||||
|
||||
const getCellBackgroundImage = () => {
|
||||
const bg = settingsStore.backgroundColor;
|
||||
if (bg === 'transparent' && settingsStore.checkerboardEnabled) {
|
||||
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
|
||||
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
||||
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||
}
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const getCellBackgroundSize = () => {
|
||||
const bg = settingsStore.backgroundColor;
|
||||
if (bg === 'transparent') {
|
||||
return '20px 20px';
|
||||
}
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
const getCellBackgroundPosition = () => {
|
||||
const bg = settingsStore.backgroundColor;
|
||||
if (bg === 'transparent') {
|
||||
return '0 0, 0 10px, 10px -10px, -10px 0px';
|
||||
}
|
||||
return '0 0';
|
||||
};
|
||||
|
||||
// Background select handling
|
||||
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
|
||||
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
|
||||
|
||||
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
|
||||
customColor.value = settingsStore.backgroundColor;
|
||||
}
|
||||
|
||||
const isCustomMode = ref(isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any));
|
||||
|
||||
const bgSelectValue = computed<string>({
|
||||
get() {
|
||||
if (isCustomMode.value) {
|
||||
const val = settingsStore.backgroundColor;
|
||||
if (isHexColor(val)) {
|
||||
customColor.value = val;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
const val = settingsStore.backgroundColor;
|
||||
if (presetBgColors.includes(val as any)) return val;
|
||||
if (isHexColor(val)) {
|
||||
customColor.value = val;
|
||||
isCustomMode.value = true;
|
||||
return 'custom';
|
||||
}
|
||||
return 'transparent';
|
||||
},
|
||||
set(v: string) {
|
||||
if (v === 'custom') {
|
||||
isCustomMode.value = true;
|
||||
const fallback = '#ffffff';
|
||||
const current = settingsStore.backgroundColor;
|
||||
const fromStore = isHexColor(current) ? current : null;
|
||||
const fromLocal = isHexColor(customColor.value) ? customColor.value : null;
|
||||
const color = fromStore || fromLocal || fallback;
|
||||
customColor.value = color;
|
||||
settingsStore.setBackgroundColor(color);
|
||||
} else {
|
||||
isCustomMode.value = false;
|
||||
settingsStore.setBackgroundColor(v);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
if (!gridContainerRef.value) return;
|
||||
|
||||
// Hide context menu if open
|
||||
showContextMenu.value = false;
|
||||
@@ -221,25 +436,26 @@
|
||||
// Handle right-click for context menu
|
||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||
event.preventDefault();
|
||||
const pos = canvas2D.getMousePosition(event, zoom.value);
|
||||
const pos = getMousePosition(event, zoom.value);
|
||||
if (!pos) return;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||
|
||||
contextMenuX.value = event.clientX;
|
||||
contextMenuY.value = event.clientY;
|
||||
|
||||
showContextMenu.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
||||
// Ignore non-left mouse buttons
|
||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||
|
||||
// Delegate to composable for actual drag handling
|
||||
dragStart(event);
|
||||
};
|
||||
|
||||
// Wrapper for drag move
|
||||
const drag = (event: MouseEvent) => {
|
||||
dragMove(event);
|
||||
};
|
||||
@@ -254,10 +470,8 @@
|
||||
|
||||
const replaceSprite = () => {
|
||||
if (contextMenuSpriteId.value && fileInput.value) {
|
||||
// Store the sprite ID separately so it persists after context menu closes
|
||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||
fileInput.value.click();
|
||||
// Hide context menu immediately since we've stored the ID
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
@@ -266,7 +480,6 @@
|
||||
const addSprite = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
// Hide context menu immediately
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
@@ -281,14 +494,12 @@
|
||||
if (replacingSpriteId.value) {
|
||||
emit('replaceSprite', replacingSpriteId.value, file);
|
||||
} else {
|
||||
// Adding new sprite
|
||||
emit('addSprite', file);
|
||||
}
|
||||
} else {
|
||||
alert('Please select an image file.');
|
||||
}
|
||||
}
|
||||
// Clean up after file selection
|
||||
replacingSpriteId.value = null;
|
||||
input.value = '';
|
||||
};
|
||||
@@ -298,146 +509,26 @@
|
||||
contextMenuSpriteId.value = null;
|
||||
};
|
||||
|
||||
// Wrapper for drag leave to pass canvasRef
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
handleDragLeave(event, canvasRef.value);
|
||||
};
|
||||
|
||||
function drawCanvas() {
|
||||
if (!canvasRef.value || !canvas2D.ctx.value) return;
|
||||
|
||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
||||
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
|
||||
|
||||
// Clear canvas
|
||||
canvas2D.clear();
|
||||
|
||||
// Apply pixel art optimization
|
||||
canvas2D.applySmoothing();
|
||||
|
||||
// Draw background for each cell
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw cell background
|
||||
canvas2D.fillCellBackground(cellX, cellY, maxWidth, maxHeight);
|
||||
|
||||
// Highlight the target cell if specified
|
||||
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
||||
canvas2D.fillRect(cellX, cellY, maxWidth, maxHeight, 'rgba(59, 130, 246, 0.2)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If showing all sprites, draw all sprites with transparency in each cell
|
||||
if (showAllSprites.value) {
|
||||
const total = Math.max(...props.layers.map(l => (l.visible ? l.sprites.length : 0)));
|
||||
for (let cellIndex = 0; cellIndex < total; cellIndex++) {
|
||||
const cellCol = cellIndex % props.columns;
|
||||
const cellRow = Math.floor(cellIndex / props.columns);
|
||||
const cellX = Math.floor(cellCol * maxWidth);
|
||||
const cellY = Math.floor(cellRow * maxHeight);
|
||||
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (sprite) canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.35);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Draw layers in order; active layer will be interactable
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
layer.sprites.forEach((sprite, index) => {
|
||||
// Skip the active sprite if we're showing a ghost instead
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) return;
|
||||
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
const alpha = layer.id === props.activeLayerId ? 1 : 0.85;
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, alpha);
|
||||
});
|
||||
});
|
||||
|
||||
// Draw ghost sprite if we're dragging between cells
|
||||
if (ghostSprite.value && activeSpriteId.value) {
|
||||
const sprite = activeSprites.value.find(s => s.id === activeSpriteId.value);
|
||||
if (sprite) {
|
||||
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid lines on top of everything
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
canvas2D.strokeGridCell(cellX, cellY, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track which images already have listeners
|
||||
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
||||
|
||||
const attachImageListeners = () => {
|
||||
const sprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.attachImageListeners(sprites, handleForceRedraw, imagesWithListeners);
|
||||
handleDragLeave(event, gridContainerRef.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
canvas2D.initContext();
|
||||
requestDraw();
|
||||
|
||||
// Attach listeners for current sprites
|
||||
attachImageListeners();
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
|
||||
// Hide context menu when clicking elsewhere
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
document.removeEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure integer positioning for crisp rendering on the active layer
|
||||
canvas2D.ensureIntegerPositions(activeSprites.value);
|
||||
canvas2D.applySmoothing();
|
||||
requestDraw();
|
||||
};
|
||||
|
||||
// Re-attach listeners and redraw whenever layers/sprites change
|
||||
// Watch for background color changes
|
||||
watch(
|
||||
() => props.layers,
|
||||
() => {
|
||||
attachImageListeners();
|
||||
requestDraw();
|
||||
},
|
||||
{ deep: true }
|
||||
() => settingsStore.backgroundColor,
|
||||
async () => {
|
||||
await nextTick();
|
||||
}
|
||||
);
|
||||
watch(() => props.columns, requestDraw);
|
||||
watch(() => settingsStore.pixelPerfect, requestDraw);
|
||||
watch(() => settingsStore.darkMode, requestDraw);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
|
||||
watch(() => settingsStore.backgroundColor, requestDraw);
|
||||
watch(showAllSprites, requestDraw);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,24 +1,74 @@
|
||||
<template>
|
||||
<div class="spritesheet-preview w-full">
|
||||
<!-- Main Layout: Canvas Left, Controls Right -->
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Canvas Area (Left/Main) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto min-h-[300px] sm:min-h-[520px] shadow-sm hover:shadow-md transition-shadow duration-200">
|
||||
<canvas
|
||||
ref="previewCanvasRef"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
class="block touch-manipulation"
|
||||
:class="{ 'cursor-move': isDraggable }"
|
||||
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
|
||||
<div class="spritesheet-preview w-full h-full">
|
||||
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<div
|
||||
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
>
|
||||
<div
|
||||
ref="previewContainerRef"
|
||||
class="relative touch-manipulation inline-block"
|
||||
:style="{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${cellDimensions.cellWidth}px`,
|
||||
height: `${cellDimensions.cellHeight}px`,
|
||||
backgroundColor: '#f9fafb',
|
||||
backgroundImage: getPreviewBackgroundImage(),
|
||||
backgroundSize: settingsStore.backgroundColor === 'transparent' ? '20px 20px' : 'auto',
|
||||
backgroundPosition: settingsStore.backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
|
||||
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
|
||||
}"
|
||||
>
|
||||
</canvas>
|
||||
<!-- Background sprites (dimmed for comparison) -->
|
||||
<template v-if="showAllSprites">
|
||||
<template v-for="i in maxFrames()" :key="`bg-${i}`">
|
||||
<template v-if="i !== currentFrameIndex && !hiddenFrames.includes(i)">
|
||||
<template v-for="layer in getVisibleLayers()" :key="`${layer.id}-${i}`">
|
||||
<img
|
||||
v-if="layer.sprites[i]"
|
||||
:src="layer.sprites[i].img.src"
|
||||
class="absolute pointer-events-none"
|
||||
:style="{
|
||||
left: `${cellDimensions.negativeSpacing + layer.sprites[i].x}px`,
|
||||
top: `${cellDimensions.negativeSpacing + layer.sprites[i].y}px`,
|
||||
width: `${layer.sprites[i].width}px`,
|
||||
height: `${layer.sprites[i].height}px`,
|
||||
opacity: '0.3',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Current frame sprites -->
|
||||
<template v-for="layer in getVisibleLayers()" :key="layer.id">
|
||||
<img
|
||||
v-if="layer.sprites[currentFrameIndex]"
|
||||
:src="layer.sprites[currentFrameIndex].img.src"
|
||||
class="absolute"
|
||||
:class="{ 'cursor-move': isDraggable }"
|
||||
:style="{
|
||||
left: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].x}px`,
|
||||
top: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].y}px`,
|
||||
width: `${layer.sprites[currentFrameIndex].width}px`,
|
||||
height: `${layer.sprites[currentFrameIndex].height}px`,
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
}"
|
||||
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||
draggable="false"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mobile zoom controls -->
|
||||
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
|
||||
@@ -32,10 +82,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Sidebar (Right) -->
|
||||
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
<!-- Playback Controls -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Playback</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -107,13 +155,18 @@
|
||||
<span class="text-sm dark:text-gray-200">Reposition</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer ml-4" :class="{ 'opacity-50 cursor-not-allowed': !isDraggable }">
|
||||
<input type="checkbox" v-model="repositionAllLayers" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" :disabled="!isDraggable" />
|
||||
<span class="text-sm dark:text-gray-200">All layers</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
||||
<span class="text-sm dark:text-gray-200">Compare sprites</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
|
||||
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
||||
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -161,7 +214,6 @@
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||
@@ -174,16 +226,14 @@
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Initialize composables
|
||||
const canvas2D = useCanvas2D(previewCanvasRef);
|
||||
|
||||
const {
|
||||
zoom,
|
||||
increase: increaseZoom,
|
||||
@@ -206,22 +256,29 @@
|
||||
}
|
||||
return frames;
|
||||
},
|
||||
onDraw: drawPreviewCanvas,
|
||||
onDraw: () => {}, // No longer needed for canvas drawing
|
||||
});
|
||||
|
||||
// Preview state
|
||||
const isDraggable = ref(false);
|
||||
const repositionAllLayers = ref(false);
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
const compositeFrames = computed<Sprite[]>(() => {
|
||||
const v = getVisibleLayers();
|
||||
const len = maxFrames();
|
||||
const arr: Sprite[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = v.find(l => l.sprites[i])?.sprites[i];
|
||||
if (s) arr.push(s);
|
||||
// Show frames from the active layer for the thumbnail list
|
||||
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
||||
if (!activeLayer) {
|
||||
// Fallback to first visible layer if no active layer
|
||||
const v = getVisibleLayers();
|
||||
const len = maxFrames();
|
||||
const arr: Sprite[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = v.find(l => l.sprites[i])?.sprites[i];
|
||||
if (s) arr.push(s);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
return arr;
|
||||
return activeLayer.sprites;
|
||||
});
|
||||
|
||||
const currentFrameSprite = computed<Sprite | null>(() => {
|
||||
@@ -230,98 +287,89 @@
|
||||
return layer.sprites[currentFrameIndex.value] || null;
|
||||
});
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
|
||||
// Canvas drawing
|
||||
|
||||
function drawPreviewCanvas() {
|
||||
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
|
||||
// Computed cell dimensions
|
||||
const cellDimensions = computed(() => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
|
||||
// If manual cell size is enabled, use manual values
|
||||
if (settingsStore.manualCellSizeEnabled) {
|
||||
return {
|
||||
cellWidth: settingsStore.manualCellWidth,
|
||||
cellHeight: settingsStore.manualCellHeight,
|
||||
negativeSpacing: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, calculate from sprite dimensions
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const allSprites = visibleLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply pixel art optimization
|
||||
canvas2D.applySmoothing();
|
||||
|
||||
// Set canvas size to fit one sprite cell (expanded with negative spacing)
|
||||
canvas2D.setCanvasSize(cellWidth, cellHeight);
|
||||
|
||||
// Clear canvas
|
||||
canvas2D.clear();
|
||||
|
||||
// Draw grid background (cell)
|
||||
canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
|
||||
|
||||
const frameIndex = currentFrameIndex.value;
|
||||
|
||||
if (showAllSprites.value) {
|
||||
const len = maxFrames();
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[i];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
|
||||
});
|
||||
}
|
||||
// Helper for background image (dark mode friendly)
|
||||
const getPreviewBackgroundImage = () => {
|
||||
if (settingsStore.backgroundColor === 'transparent' && settingsStore.checkerboardEnabled) {
|
||||
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
||||
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||
}
|
||||
return 'none';
|
||||
};
|
||||
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[frameIndex];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y);
|
||||
});
|
||||
|
||||
// Draw cell border
|
||||
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
|
||||
}
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const activeLayerId = ref<string | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
||||
|
||||
// Drag functionality
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!isDraggable.value || !previewCanvasRef.value) return;
|
||||
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||
if (!isDraggable.value || !previewContainerRef.value) return;
|
||||
|
||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
||||
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
const vLayers = getVisibleLayers();
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
if (repositionAllLayers.value) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = 'ALL_LAYERS'; // Special marker for all layers
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
|
||||
// Check if click is on sprite (accounting for negative spacing offset)
|
||||
if (activeSprite) {
|
||||
const spriteCanvasX = negativeSpacing + activeSprite.x;
|
||||
const spriteCanvasY = negativeSpacing + activeSprite.y;
|
||||
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + activeSprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + activeSprite.height) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = activeSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
|
||||
}
|
||||
// Store initial positions for all sprites in this frame from all visible layers
|
||||
allSpritesPosBeforeDrag.value.clear();
|
||||
const visibleLayers = getVisibleLayers();
|
||||
visibleLayers.forEach(layer => {
|
||||
const s = layer.sprites[currentFrameIndex.value];
|
||||
if (s) {
|
||||
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = sprite.id;
|
||||
activeLayerId.value = layerId;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||
}
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || !previewCanvasRef.value) return;
|
||||
if (!isDragging.value || !activeSpriteId.value || !previewContainerRef.value) return;
|
||||
|
||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
||||
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
@@ -329,34 +377,52 @@
|
||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||
|
||||
const vLayers = getVisibleLayers();
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(vLayers);
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
if (activeSpriteId.value === 'ALL_LAYERS') {
|
||||
// Move all sprites in current frame from all visible layers
|
||||
const visibleLayers = getVisibleLayers();
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[currentFrameIndex.value];
|
||||
if (!sprite) return;
|
||||
|
||||
// Calculate new position with constraints and round to integers
|
||||
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
||||
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
||||
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
|
||||
if (!originalPos) return;
|
||||
|
||||
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||
// Calculate new position with constraints
|
||||
let newX = Math.round(originalPos.x + deltaX);
|
||||
let newY = Math.round(originalPos.y + deltaY);
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
drawPreviewCanvas();
|
||||
// Constrain movement within expanded cell
|
||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
||||
|
||||
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
||||
});
|
||||
} else {
|
||||
// Move only the active layer sprite
|
||||
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
|
||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||
|
||||
// Calculate new position with constraints and round to integers
|
||||
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
||||
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
||||
|
||||
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
activeLayerId.value = null;
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const handleTouchStart = (event: TouchEvent, sprite: Sprite, layerId: string) => {
|
||||
if (!isDraggable.value) return;
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
@@ -365,7 +431,7 @@
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
startDrag(mouseEvent);
|
||||
startDrag(mouseEvent, sprite, layerId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -388,40 +454,25 @@
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
canvas2D.initContext();
|
||||
drawPreviewCanvas();
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
// No longer need to initialize canvas or draw
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation();
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
const allSprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.ensureIntegerPositions(allSprites);
|
||||
canvas2D.applySmoothing();
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
||||
watch(currentFrameIndex, drawPreviewCanvas);
|
||||
watch(zoom, drawPreviewCanvas);
|
||||
watch(isDraggable, drawPreviewCanvas);
|
||||
watch(showAllSprites, drawPreviewCanvas);
|
||||
watch(hiddenFrames, drawPreviewCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
||||
|
||||
// Initial draw
|
||||
if (props.layers.some(l => l.sprites.length > 0)) {
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
// Watchers - most canvas-related watchers removed
|
||||
// Keep layer watchers to ensure reactivity
|
||||
watch(
|
||||
() => props.layers,
|
||||
() => {},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(
|
||||
() => props.activeLayerId,
|
||||
() => {}
|
||||
);
|
||||
watch(currentFrameIndex, () => {});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<button @click="settingsStore.toggleDarkMode()" class="p-2 rounded-lg transition-colors" :class="settingsStore.darkMode ? 'text-yellow-400 hover:bg-gray-700' : 'text-gray-700 hover:bg-gray-100'" aria-label="Toggle dark mode" data-rybbit-event="toggle-dark-mode">
|
||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
||||
<button
|
||||
@click="settingsStore.toggleDarkMode()"
|
||||
class="relative p-3 rounded-xl transition-all duration-300 shadow-lg hover:shadow-xl group"
|
||||
:class="settingsStore.darkMode ? 'bg-indigo-600 text-yellow-300' : 'bg-blue-500 text-white'"
|
||||
aria-label="Toggle dark mode"
|
||||
data-rybbit-event="toggle-dark-mode"
|
||||
>
|
||||
<div class="relative w-6 h-6 flex items-center justify-center">
|
||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'" class="text-xl transition-all duration-300 group-hover:scale-110"></i>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import type { Sprite, Layer } from '@/types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface SpritePosition {
|
||||
|
||||
export interface DragSpriteOptions {
|
||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
|
||||
columns: Ref<number> | number;
|
||||
zoom?: Ref<number>;
|
||||
allowCellSwap?: Ref<boolean>;
|
||||
negativeSpacingEnabled?: Ref<boolean>;
|
||||
manualCellSizeEnabled?: Ref<boolean>;
|
||||
manualCellWidth?: Ref<number>;
|
||||
manualCellHeight?: Ref<number>;
|
||||
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||
@@ -43,10 +47,14 @@ export function useDragSprite(options: DragSpriteOptions) {
|
||||
|
||||
// Helper to get reactive values
|
||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
||||
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||
const getZoom = () => options.zoom?.value ?? 1;
|
||||
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
||||
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
|
||||
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
|
||||
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
|
||||
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false);
|
||||
@@ -67,16 +75,34 @@ export function useDragSprite(options: DragSpriteOptions) {
|
||||
const lastMaxHeight = ref(1);
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
const sprites = getSprites();
|
||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||
const base = getMaxDimensions(sprites);
|
||||
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
||||
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
||||
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
||||
|
||||
// If manual cell size is enabled, use manual dimensions
|
||||
if (manualCellSizeEnabled) {
|
||||
const maxWidth = getManualCellWidth();
|
||||
const maxHeight = getManualCellHeight();
|
||||
// When manual cell size is used, negative spacing is not applied
|
||||
const negativeSpacing = 0;
|
||||
// Don't update lastMaxWidth/lastMaxHeight when in manual mode
|
||||
return { maxWidth, maxHeight, negativeSpacing };
|
||||
}
|
||||
|
||||
// Get all sprites to calculate dimensions from
|
||||
// If layers are provided, use all visible layers; otherwise use current sprites
|
||||
const layers = getLayers();
|
||||
const spritesToMeasure = layers ? layers.filter(l => l.visible).flatMap(l => l.sprites) : getSprites();
|
||||
|
||||
// Otherwise, calculate based on sprite dimensions across all visible layers
|
||||
const base = getMaxDimensions(spritesToMeasure);
|
||||
// When switching back from manual mode, reset to actual sprite dimensions
|
||||
const baseMaxWidth = Math.max(1, base.maxWidth);
|
||||
const baseMaxHeight = Math.max(1, base.maxHeight);
|
||||
lastMaxWidth.value = baseMaxWidth;
|
||||
lastMaxHeight.value = baseMaxHeight;
|
||||
|
||||
// Calculate negative spacing using shared composable
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites, negativeSpacingEnabled);
|
||||
const negativeSpacing = calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled);
|
||||
|
||||
// Add negative spacing to expand each cell
|
||||
const maxWidth = baseMaxWidth + negativeSpacing;
|
||||
|
||||
@@ -6,17 +6,34 @@ import type { Sprite } from '../types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
|
||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||
const getCellDimensions = () => {
|
||||
// If manual cell size is enabled, use manual values
|
||||
if (manualCellSizeEnabled?.value) {
|
||||
return {
|
||||
cellWidth: manualCellWidth?.value ?? 64,
|
||||
cellHeight: manualCellHeight?.value ?? 64,
|
||||
negativeSpacing: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, calculate from sprite dimensions
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -27,6 +44,12 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
@@ -71,6 +94,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
const jsonData = {
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
sprites: spritesData.filter(Boolean),
|
||||
};
|
||||
const jsonString = JSON.stringify(jsonData, null, 2);
|
||||
@@ -91,6 +118,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
|
||||
if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled;
|
||||
if (typeof jsonData.backgroundColor === 'string' && backgroundColor) backgroundColor.value = jsonData.backgroundColor;
|
||||
if (typeof jsonData.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = jsonData.manualCellSizeEnabled;
|
||||
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
|
||||
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
|
||||
|
||||
// revoke existing blob urls
|
||||
if (sprites.value.length) {
|
||||
@@ -141,10 +172,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -156,6 +184,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
@@ -179,10 +212,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -192,6 +222,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
|
||||
@@ -6,10 +6,31 @@ import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from './useLayers';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>) => {
|
||||
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
|
||||
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||
|
||||
const getCellDimensions = () => {
|
||||
// If manual cell size is enabled, use manual values
|
||||
if (manualCellSizeEnabled?.value) {
|
||||
return {
|
||||
cellWidth: manualCellWidth?.value ?? 64,
|
||||
cellHeight: manualCellHeight?.value ?? 64,
|
||||
negativeSpacing: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, calculate from sprite dimensions
|
||||
const visibleLayers = getVisibleLayers();
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
// Apply background color if not transparent
|
||||
@@ -32,10 +53,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
const rows = Math.ceil(maxLen / columns.value);
|
||||
|
||||
@@ -46,6 +64,12 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Apply background color to entire canvas if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
for (let index = 0; index < maxLen; index++) {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
@@ -57,6 +81,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
if (!cellCtx) return;
|
||||
cellCanvas.width = cellWidth;
|
||||
cellCanvas.height = cellHeight;
|
||||
cellCtx.imageSmoothingEnabled = false;
|
||||
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
|
||||
ctx.drawImage(cellCanvas, cellX, cellY);
|
||||
}
|
||||
@@ -92,7 +117,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
})
|
||||
);
|
||||
|
||||
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData };
|
||||
const json = {
|
||||
version: 2,
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
layers: layersData,
|
||||
};
|
||||
const jsonString = JSON.stringify(json, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -126,6 +160,10 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
|
||||
if (typeof data.columns === 'number') columns.value = data.columns;
|
||||
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
|
||||
if (typeof data.backgroundColor === 'string' && backgroundColor) backgroundColor.value = data.backgroundColor;
|
||||
if (typeof data.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = data.manualCellSizeEnabled;
|
||||
if (typeof data.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = data.manualCellWidth;
|
||||
if (typeof data.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = data.manualCellHeight;
|
||||
|
||||
if (Array.isArray(data.layers)) {
|
||||
const newLayers: Layer[] = [];
|
||||
@@ -172,10 +210,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -210,10 +245,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
}
|
||||
const zip = new JSZip();
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -244,7 +276,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, name: s.file.name }))),
|
||||
}))
|
||||
);
|
||||
const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersPayload };
|
||||
const meta = {
|
||||
version: 2,
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
layers: layersPayload,
|
||||
};
|
||||
const metaStr = JSON.stringify(meta, null, 2);
|
||||
jsonFolder.file('spritesheet.meta.json', metaStr);
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
export const createEmptyLayer = (name: string): Layer => ({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -14,6 +15,7 @@ export const useLayers = () => {
|
||||
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
||||
const activeLayerId = ref<string>(layers.value[0].id);
|
||||
const columns = ref(4);
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
watch(columns, val => {
|
||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||
@@ -35,10 +37,35 @@ export const useLayers = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateSpriteInLayer = (layerId: string, spriteId: string, x: number, y: number) => {
|
||||
const l = layers.value.find(layer => layer.id === layerId);
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === spriteId);
|
||||
if (i !== -1) {
|
||||
l.sprites[i].x = Math.floor(x);
|
||||
l.sprites[i].y = Math.floor(y);
|
||||
}
|
||||
};
|
||||
|
||||
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||
const l = activeLayer.value;
|
||||
if (!l || !l.sprites.length) return;
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
|
||||
|
||||
// Determine the cell dimensions to align within
|
||||
let cellWidth: number;
|
||||
let cellHeight: number;
|
||||
|
||||
if (settingsStore.manualCellSizeEnabled) {
|
||||
// Use manual cell size (without negative spacing)
|
||||
cellWidth = settingsStore.manualCellWidth;
|
||||
cellHeight = settingsStore.manualCellHeight;
|
||||
} else {
|
||||
// Use auto-calculated dimensions based on ALL visible layers (not just active layer)
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||
cellWidth = maxWidth;
|
||||
cellHeight = maxHeight;
|
||||
}
|
||||
|
||||
l.sprites = l.sprites.map(sprite => {
|
||||
let x = sprite.x;
|
||||
let y = sprite.y;
|
||||
@@ -47,19 +74,19 @@ export const useLayers = () => {
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
x = Math.floor((cellWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
x = Math.floor(cellWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
y = Math.floor((cellHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
y = Math.floor(cellHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
@@ -121,9 +148,7 @@ export const useLayers = () => {
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => addSpriteWithResize(file);
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const addSprite = (file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
@@ -146,7 +171,7 @@ export const useLayers = () => {
|
||||
};
|
||||
|
||||
const processImageFiles = async (files: File[]) => {
|
||||
for (const f of files) addSpriteWithResize(f);
|
||||
for (const f of files) addSprite(f);
|
||||
};
|
||||
|
||||
const addLayer = (name?: string) => {
|
||||
@@ -186,11 +211,11 @@ export const useLayers = () => {
|
||||
columns,
|
||||
getMaxDimensions,
|
||||
updateSpritePosition,
|
||||
updateSpriteInLayer,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
addSpriteWithResize,
|
||||
processImageFiles,
|
||||
alignSprites,
|
||||
addLayer,
|
||||
|
||||
@@ -5,6 +5,10 @@ const pixelPerfect = ref(true);
|
||||
const darkMode = ref(false);
|
||||
const negativeSpacingEnabled = ref(false);
|
||||
const backgroundColor = ref('transparent');
|
||||
const manualCellSizeEnabled = ref(false);
|
||||
const manualCellWidth = ref(64);
|
||||
const manualCellHeight = ref(64);
|
||||
const checkerboardEnabled = ref(true);
|
||||
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -61,16 +65,46 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
backgroundColor.value = color;
|
||||
}
|
||||
|
||||
function toggleManualCellSize() {
|
||||
manualCellSizeEnabled.value = !manualCellSizeEnabled.value;
|
||||
}
|
||||
|
||||
function setManualCellWidth(width: number) {
|
||||
manualCellWidth.value = Math.max(1, Math.floor(width));
|
||||
}
|
||||
|
||||
function setManualCellHeight(height: number) {
|
||||
manualCellHeight.value = Math.max(1, Math.floor(height));
|
||||
}
|
||||
|
||||
function setManualCellSize(width: number, height: number) {
|
||||
setManualCellWidth(width);
|
||||
setManualCellHeight(height);
|
||||
}
|
||||
|
||||
function toggleCheckerboard() {
|
||||
checkerboardEnabled.value = !checkerboardEnabled.value;
|
||||
}
|
||||
|
||||
return {
|
||||
pixelPerfect,
|
||||
darkMode,
|
||||
negativeSpacingEnabled,
|
||||
backgroundColor,
|
||||
manualCellSizeEnabled,
|
||||
manualCellWidth,
|
||||
manualCellHeight,
|
||||
checkerboardEnabled,
|
||||
togglePixelPerfect,
|
||||
setPixelPerfect,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
toggleNegativeSpacing,
|
||||
setBackgroundColor,
|
||||
toggleManualCellSize,
|
||||
setManualCellWidth,
|
||||
setManualCellHeight,
|
||||
setManualCellSize,
|
||||
toggleCheckerboard,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user