Compare commits

18 Commits

Author SHA1 Message Date
551d7a027f [FEAT] Text for SEO 2025-11-23 17:08:20 +01:00
6b0c82a630 [FEAT] Updated colors 2025-11-23 17:03:42 +01:00
a0d2b2994b [FEAT] Allow to toggle checker bg 2025-11-23 15:42:02 +01:00
7f8e26255f [FEAT] Canvas > IMG 2025-11-23 15:40:04 +01:00
4ec82317ea [FEAT] Tab bug fix 2025-11-23 15:11:53 +01:00
fed578441a [FEAT] Fix context menu location 2025-11-23 15:09:28 +01:00
62205ed069 Improved UX 2025-11-23 15:05:03 +01:00
6ae8dd37dc Typos 2025-11-23 14:49:41 +01:00
77a1b22faf Improved min height 2025-11-23 14:40:12 +01:00
8b1ea0f5b4 [FEAT] Preview enhancement 2025-11-23 03:52:57 +01:00
9a2bf6b2db [FEAT] UX enhancements 2025-11-23 03:40:26 +01:00
2c25157621 [FEAT] Remove gradients 2025-11-23 01:57:08 +01:00
9fa2bbd15d [FEAT] Add cursor click 2025-11-23 01:53:16 +01:00
b3f530870e [FEAT] More UI enhancements 2025-11-23 01:48:05 +01:00
56858701ef [FEAT] Enhanced UI 2025-11-23 01:16:58 +01:00
f8b4e98f9c [FEAT] Allow to adjust cell size 2025-11-22 21:33:01 +01:00
69fc4c4a7e [FEAT] BG color enhancements 2025-11-22 18:31:06 +01:00
d35ae69265 [FEAT] Fix incorrect offset val. label placement 2025-11-22 18:09:32 +01:00
12 changed files with 1148 additions and 613 deletions

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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);
})();

View File

@@ -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,

View File

@@ -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,
};
});