Compare commits
6 Commits
feature/la
...
9fa2bbd15d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fa2bbd15d | |||
| b3f530870e | |||
| 56858701ef | |||
| f8b4e98f9c | |||
| 69fc4c4a7e | |||
| d35ae69265 |
10
index.html
10
index.html
@@ -14,24 +14,24 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Spritesheet Generator - Create Game Spritesheets Online</title>
|
||||
<meta name="title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<title>Spritesheet generator - Create Game Spritesheets Online</title>
|
||||
<meta name="title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta name="description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta name="keywords" content="spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||
<meta name="keywords" content="Spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||
<meta name="author" content="nu11ed">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://spritesheetgenerator.online/">
|
||||
<meta property="og:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta property="og:title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta property="og:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta property="og:image" content="/og-image.png">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="https://spritesheetgenerator.online/">
|
||||
<meta property="twitter:title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<meta property="twitter:title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta property="twitter:description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta property="twitter:image" content="/og-image.png">
|
||||
|
||||
|
||||
455
src/App.vue
455
src/App.vue
@@ -1,142 +1,272 @@
|
||||
<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-gradient-to-br from-slate-50 via-blue-50 to-slate-50 dark:from-gray-950 dark:via-gray-900 dark:to-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 bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent 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" />
|
||||
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||
|
||||
<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>
|
||||
<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-blue-600 dark:text-blue-400"></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>
|
||||
|
||||
<!-- 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">
|
||||
<button
|
||||
@click="openPreviewModal"
|
||||
class="col-span-2 px-3 py-2 bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-all text-xs font-medium flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
data-rybbit-event="preview-animation"
|
||||
>
|
||||
<i class="fas fa-play"></i>
|
||||
<span>Preview animation</span>
|
||||
</button>
|
||||
<!-- 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-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
Upload
|
||||
</h3>
|
||||
<button @click="openJSONImportDialog" class="px-3 py-1.5 bg-indigo-500 hover:bg-indigo-600 dark:bg-indigo-600 dark:hover:bg-indigo-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-blue-400 dark:hover:border-blue-500 transition-colors cursor-pointer" @click="openFileDialog">
|
||||
<i class="fas fa-plus-circle text-2xl text-blue-500 dark:text-blue-400 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-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
Layers
|
||||
</h3>
|
||||
<button @click="addLayer()" class="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-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-blue-500 ring-1 ring-blue-500' : '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-blue-600 dark:text-blue-400' : '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-blue-500 dark:border-blue-400 dark:bg-gray-700 dark:text-gray-100 rounded text-sm focus:ring-2 focus:ring-blue-500 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-blue-600 dark:text-blue-400' : '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-blue-600 dark:text-blue-400 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-blue-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-2 mt-2">
|
||||
<input type="number" v-model.number="settingsStore.manualCellWidth" min="1" max="2048" class="flex-1 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-blue-500 outline-none" placeholder="W" />
|
||||
<span class="text-gray-500 dark:text-gray-400">×</span>
|
||||
<input type="number" v-model.number="settingsStore.manualCellHeight" min="1" max="2048" class="flex-1 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-blue-500 outline-none" placeholder="H" />
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1">{{ 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-blue-600 dark:text-blue-400 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-blue-50 dark:hover:bg-blue-900/20 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-blue-50 dark:hover:bg-blue-900/20 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-blue-50 dark:hover:bg-blue-900/20 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-blue-50 dark:hover:bg-blue-900/20 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-blue-50 dark:hover:bg-blue-900/20 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-blue-50 dark:hover:bg-blue-900/20 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-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
Export
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button @click="downloadSpritesheet" class="px-3 py-2 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-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-purple-500 hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-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-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-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-teal-500 hover:bg-teal-600 dark:bg-teal-600 dark:hover:bg-teal-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 - Canvas Preview -->
|
||||
<div class="p-6 overflow-y-auto overflow-x-auto max-h-[calc(100vh-200px)]">
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation Preview">
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
</Modal>
|
||||
|
||||
@@ -151,10 +281,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-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors cursor-pointer">Share feedback</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,10 +313,26 @@
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, 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 cellSize = computed(() => {
|
||||
if (!layers.value.length) return { width: 0, height: 0 };
|
||||
|
||||
// If manual cell size is enabled, use the manual values
|
||||
if (settingsStore.manualCellSizeEnabled) {
|
||||
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
|
||||
}
|
||||
|
||||
// Otherwise, calculate based on max dimensions
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
@@ -198,9 +344,13 @@
|
||||
const isSpritesheetSplitterOpen = ref(false);
|
||||
const isGifFpsModalOpen = ref(false);
|
||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadInput = 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'));
|
||||
@@ -325,4 +475,43 @@
|
||||
openFeedbackModal();
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
uploadInput.value?.click();
|
||||
};
|
||||
|
||||
const handleUploadChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const files = Array.from(input.files);
|
||||
await handleSpritesUpload(files);
|
||||
if (uploadInput.value) uploadInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingLayer = (layerId: string, currentName: string) => {
|
||||
editingLayerId.value = layerId;
|
||||
editingLayerName.value = currentName;
|
||||
// Focus the input on next tick
|
||||
setTimeout(() => {
|
||||
layerNameInput.value?.focus();
|
||||
layerNameInput.value?.select();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const finishEditingLayer = () => {
|
||||
if (editingLayerId.value && editingLayerName.value.trim()) {
|
||||
const layer = layers.value.find(l => l.id === editingLayerId.value);
|
||||
if (layer) {
|
||||
layer.name = editingLayerName.value.trim();
|
||||
}
|
||||
}
|
||||
editingLayerId.value = null;
|
||||
editingLayerName.value = '';
|
||||
};
|
||||
|
||||
const cancelEditingLayer = () => {
|
||||
editingLayerId.value = null;
|
||||
editingLayerName.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-4 sm:p-8 text-center transition-all duration-200"
|
||||
class="relative border-3 border-dashed rounded-2xl p-8 sm:p-12 text-center transition-all duration-300 cursor-pointer group overflow-hidden"
|
||||
:class="{
|
||||
'border-blue-300 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/30': isDragging,
|
||||
'border-gray-200 hover:border-blue-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:border-blue-500 dark:hover:bg-gray-700/50': !isDragging,
|
||||
'border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 dark:border-blue-400 dark:from-blue-900/40 dark:to-indigo-900/40 scale-[1.02]': isDragging,
|
||||
'border-gray-300 bg-gradient-to-br from-gray-50/50 to-slate-50/50 hover:border-blue-400 hover:from-blue-50/80 hover:to-indigo-50/80 dark:border-gray-600 dark:from-gray-800/30 dark:to-gray-700/30 dark:hover:border-blue-400 dark:hover:from-blue-900/30 dark:hover:to-indigo-900/30': !isDragging,
|
||||
}"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@@ -12,19 +12,38 @@
|
||||
@click="openFileDialog"
|
||||
data-rybbit-event="file-upload-area"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-400/0 to-indigo-400/0 group-hover:from-blue-400/5 group-hover:to-indigo-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-gradient-to-br from-blue-100 to-indigo-100 dark:from-blue-900/50 dark:to-indigo-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-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 dark:from-blue-600 dark:to-indigo-700 dark:hover:from-blue-700 dark:hover:to-indigo-800 text-white font-semibold rounded-xl shadow-lg hover:shadow-xl transition-all transform hover:scale-105 flex items-center justify-center gap-3 mx-auto"
|
||||
data-rybbit-event="select-files"
|
||||
>
|
||||
<i class="fas fa-folder-open text-lg"></i>
|
||||
<span>Browse Files</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-lg sm:text-xl font-medium text-gray-700 dark:text-gray-200 mb-2">Drag and drop your sprite images or JSON file here</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4 sm:mb-6">or</p>
|
||||
|
||||
<button class="w-full sm:w-auto px-6 py-3 sm:py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors flex sm:inline-flex items-center justify-center space-x-2 cursor-pointer" data-rybbit-event="select-files">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>Select files</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,86 +1,100 @@
|
||||
<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" />
|
||||
<div class="space-y-6">
|
||||
<div class="bg-gradient-to-r from-cyan-500 to-blue-500 dark:from-cyan-600 dark:to-blue-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"></i>
|
||||
<div>
|
||||
<h4 class="font-semibold text-white mb-1">Pro Tip</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>
|
||||
<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" @change="requestDraw" />
|
||||
<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>
|
||||
<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"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
@contextmenu.prevent
|
||||
@dragover="handleDragOver"
|
||||
@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>
|
||||
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto">
|
||||
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
||||
<div :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }" class="inline-block">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
@contextmenu.prevent
|
||||
@dragover="handleDragOver"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="handleDrop"
|
||||
class="block transition-all"
|
||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Offset labels in corners -->
|
||||
<div v-if="canvasRef" class="absolute inset-0 pointer-events-none">
|
||||
<!-- Offset labels in corners (not scaled with zoom) -->
|
||||
<div v-if="canvasRef && showOffsetLabels" class="absolute inset-0 pointer-events-none">
|
||||
<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"
|
||||
: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)`,
|
||||
// Position at bottom-right corner of each cell, scaled by zoom
|
||||
left: `${(position.cellX + position.maxWidth) * zoom - 2}px`,
|
||||
top: `${(position.cellY + position.maxHeight) * zoom - 2}px`,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
}"
|
||||
>
|
||||
@@ -90,29 +104,28 @@
|
||||
</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
|
||||
<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-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 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>
|
||||
<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">
|
||||
<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>
|
||||
Remove sprite
|
||||
<span>Remove Sprite</span>
|
||||
</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';
|
||||
@@ -185,10 +198,14 @@
|
||||
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'),
|
||||
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
||||
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
||||
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
||||
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||
@@ -204,6 +221,7 @@
|
||||
});
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
const showOffsetLabels = ref(true);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
@@ -212,6 +230,72 @@
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const customColor = ref('#ffffff');
|
||||
|
||||
// Background select handling: keep the select stable on "custom"
|
||||
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);
|
||||
|
||||
// Ensure customColor mirrors the current stored custom color (only when needed)
|
||||
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
|
||||
customColor.value = settingsStore.backgroundColor;
|
||||
}
|
||||
|
||||
// Track if user is in custom mode to keep picker visible
|
||||
// Initialize to true if current color is custom
|
||||
const isCustomMode = ref(isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any));
|
||||
|
||||
const bgSelectValue = computed<string>({
|
||||
get() {
|
||||
// If user explicitly entered custom mode, stay in it regardless of color value
|
||||
if (isCustomMode.value) {
|
||||
// Keep color picker synced
|
||||
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)) {
|
||||
// Keep the color picker open and sync its swatch
|
||||
customColor.value = val;
|
||||
isCustomMode.value = true; // Auto-enable custom mode for non-preset hex colors
|
||||
return 'custom';
|
||||
}
|
||||
// Fallback
|
||||
return 'transparent';
|
||||
},
|
||||
set(v: string) {
|
||||
if (v === 'custom') {
|
||||
isCustomMode.value = true;
|
||||
// Switch UI to custom mode but keep the stored value as a color
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure canvas redraw and UI flush after background changes
|
||||
watch(
|
||||
() => settingsStore.backgroundColor,
|
||||
async () => {
|
||||
await nextTick();
|
||||
requestDraw();
|
||||
}
|
||||
);
|
||||
|
||||
// Grid metrics used to position offset labels relative to cell size
|
||||
const gridMetrics = computed(() => calculateMaxDimensions());
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
|
||||
@@ -311,7 +395,9 @@
|
||||
// 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);
|
||||
const newCanvasWidth = maxWidth * props.columns;
|
||||
const newCanvasHeight = maxHeight * rows;
|
||||
canvas2D.setCanvasSize(newCanvasWidth, newCanvasHeight);
|
||||
|
||||
// Clear canvas
|
||||
canvas2D.clear();
|
||||
@@ -335,19 +421,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
// If showing all sprites, draw all sprites with transparency in each cell
|
||||
// If showing all sprites, overlay all sprites from all visible layers ghosted 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 visibleLayers = props.layers.filter(l => l.visible);
|
||||
const maxSprites = Math.max(...visibleLayers.map(l => l.sprites.length), 0);
|
||||
|
||||
for (let cellIndex = 0; cellIndex < maxSprites; 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 all sprites from all visible layers ghosted in this cell
|
||||
visibleLayers.forEach(layer => {
|
||||
layer.sprites.forEach((sprite, spriteIndex) => {
|
||||
// Skip drawing the sprite that belongs in this cell (it's already drawn below)
|
||||
if (spriteIndex === cellIndex) return;
|
||||
// Skip the active sprite if we're dragging it
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) return;
|
||||
|
||||
if (sprite && sprite.img) {
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.25);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -437,6 +533,9 @@
|
||||
watch(() => settingsStore.darkMode, requestDraw);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
|
||||
watch(() => settingsStore.backgroundColor, requestDraw);
|
||||
watch(() => settingsStore.manualCellSizeEnabled, requestDraw);
|
||||
watch(() => settingsStore.manualCellWidth, requestDraw);
|
||||
watch(() => settingsStore.manualCellHeight, requestDraw);
|
||||
watch(showAllSprites, requestDraw);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<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
|
||||
@@ -32,10 +30,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">
|
||||
@@ -214,14 +210,20 @@
|
||||
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>(() => {
|
||||
@@ -239,16 +241,34 @@
|
||||
|
||||
// Canvas drawing
|
||||
|
||||
const getCellDimensions = () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
// 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);
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
function drawPreviewCanvas() {
|
||||
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
|
||||
|
||||
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;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
// Apply pixel art optimization
|
||||
canvas2D.applySmoothing();
|
||||
@@ -265,6 +285,7 @@
|
||||
const frameIndex = currentFrameIndex.value;
|
||||
|
||||
if (showAllSprites.value) {
|
||||
// When comparing sprites, show all frames from all visible layers (dimmed)
|
||||
const len = maxFrames();
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
||||
@@ -276,6 +297,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Always draw current frame from all visible layers
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[frameIndex];
|
||||
if (!sprite) return;
|
||||
@@ -298,9 +320,7 @@
|
||||
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);
|
||||
const { negativeSpacing } = getCellDimensions();
|
||||
|
||||
// Check if click is on sprite (accounting for negative spacing offset)
|
||||
if (activeSprite) {
|
||||
@@ -332,12 +352,7 @@
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||
|
||||
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;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
// Calculate new position with constraints and round to integers
|
||||
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
||||
@@ -410,6 +425,7 @@
|
||||
|
||||
// Watchers
|
||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
||||
watch(() => props.activeLayerId, drawPreviewCanvas);
|
||||
watch(currentFrameIndex, drawPreviewCanvas);
|
||||
watch(zoom, drawPreviewCanvas);
|
||||
watch(isDraggable, drawPreviewCanvas);
|
||||
@@ -417,6 +433,9 @@
|
||||
watch(hiddenFrames, drawPreviewCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
||||
watch(() => settingsStore.manualCellSizeEnabled, drawPreviewCanvas);
|
||||
watch(() => settingsStore.manualCellWidth, drawPreviewCanvas);
|
||||
watch(() => settingsStore.manualCellHeight, drawPreviewCanvas);
|
||||
|
||||
// Initial draw
|
||||
if (props.layers.some(l => l.sprites.length > 0)) {
|
||||
|
||||
@@ -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-gradient-to-br from-indigo-600 to-purple-700 text-yellow-300' : 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'"
|
||||
aria-label="Toggle dark mode"
|
||||
data-rybbit-event="toggle-dark-mode"
|
||||
>
|
||||
<div class="relative w-6 h-6 flex items-center justify-center">
|
||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'" class="text-xl transition-all duration-300 group-hover:scale-110"></i>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import type { Sprite, Layer } from '@/types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface SpritePosition {
|
||||
|
||||
export interface DragSpriteOptions {
|
||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
|
||||
columns: Ref<number> | number;
|
||||
zoom?: Ref<number>;
|
||||
allowCellSwap?: Ref<boolean>;
|
||||
negativeSpacingEnabled?: Ref<boolean>;
|
||||
manualCellSizeEnabled?: Ref<boolean>;
|
||||
manualCellWidth?: Ref<number>;
|
||||
manualCellHeight?: Ref<number>;
|
||||
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||
@@ -43,10 +47,14 @@ export function useDragSprite(options: DragSpriteOptions) {
|
||||
|
||||
// Helper to get reactive values
|
||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
|
||||
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||
const getZoom = () => options.zoom?.value ?? 1;
|
||||
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
||||
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
|
||||
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
|
||||
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
|
||||
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false);
|
||||
@@ -67,16 +75,34 @@ export function useDragSprite(options: DragSpriteOptions) {
|
||||
const lastMaxHeight = ref(1);
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
const sprites = getSprites();
|
||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||
const base = getMaxDimensions(sprites);
|
||||
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
||||
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
||||
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
||||
|
||||
// If manual cell size is enabled, use manual dimensions
|
||||
if (manualCellSizeEnabled) {
|
||||
const maxWidth = getManualCellWidth();
|
||||
const maxHeight = getManualCellHeight();
|
||||
// When manual cell size is used, negative spacing is not applied
|
||||
const negativeSpacing = 0;
|
||||
// Don't update lastMaxWidth/lastMaxHeight when in manual mode
|
||||
return { maxWidth, maxHeight, negativeSpacing };
|
||||
}
|
||||
|
||||
// Get all sprites to calculate dimensions from
|
||||
// If layers are provided, use all visible layers; otherwise use current sprites
|
||||
const layers = getLayers();
|
||||
const spritesToMeasure = layers ? layers.filter(l => l.visible).flatMap(l => l.sprites) : getSprites();
|
||||
|
||||
// Otherwise, calculate based on sprite dimensions across all visible layers
|
||||
const base = getMaxDimensions(spritesToMeasure);
|
||||
// When switching back from manual mode, reset to actual sprite dimensions
|
||||
const baseMaxWidth = Math.max(1, base.maxWidth);
|
||||
const baseMaxHeight = Math.max(1, base.maxHeight);
|
||||
lastMaxWidth.value = baseMaxWidth;
|
||||
lastMaxHeight.value = baseMaxHeight;
|
||||
|
||||
// Calculate negative spacing using shared composable
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites, negativeSpacingEnabled);
|
||||
const negativeSpacing = calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled);
|
||||
|
||||
// Add negative spacing to expand each cell
|
||||
const maxWidth = baseMaxWidth + negativeSpacing;
|
||||
|
||||
@@ -6,17 +6,34 @@ import type { Sprite } from '../types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
|
||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||
const getCellDimensions = () => {
|
||||
// If manual cell size is enabled, use manual values
|
||||
if (manualCellSizeEnabled?.value) {
|
||||
return {
|
||||
cellWidth: manualCellWidth?.value ?? 64,
|
||||
cellHeight: manualCellHeight?.value ?? 64,
|
||||
negativeSpacing: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, calculate from sprite dimensions
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -27,6 +44,12 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
@@ -71,6 +94,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
const jsonData = {
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
sprites: spritesData.filter(Boolean),
|
||||
};
|
||||
const jsonString = JSON.stringify(jsonData, null, 2);
|
||||
@@ -91,6 +118,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
|
||||
if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled;
|
||||
if (typeof jsonData.backgroundColor === 'string' && backgroundColor) backgroundColor.value = jsonData.backgroundColor;
|
||||
if (typeof jsonData.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = jsonData.manualCellSizeEnabled;
|
||||
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
|
||||
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
|
||||
|
||||
// revoke existing blob urls
|
||||
if (sprites.value.length) {
|
||||
@@ -141,10 +172,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -156,6 +184,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
@@ -179,10 +212,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -192,6 +222,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
|
||||
@@ -6,10 +6,31 @@ import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from './useLayers';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>) => {
|
||||
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
|
||||
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||
|
||||
const getCellDimensions = () => {
|
||||
// If manual cell size is enabled, use manual values
|
||||
if (manualCellSizeEnabled?.value) {
|
||||
return {
|
||||
cellWidth: manualCellWidth?.value ?? 64,
|
||||
cellHeight: manualCellHeight?.value ?? 64,
|
||||
negativeSpacing: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, calculate from sprite dimensions
|
||||
const visibleLayers = getVisibleLayers();
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
// Apply background color if not transparent
|
||||
@@ -32,10 +53,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
const rows = Math.ceil(maxLen / columns.value);
|
||||
|
||||
@@ -46,6 +64,12 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Apply background color to entire canvas if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
for (let index = 0; index < maxLen; index++) {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
@@ -57,6 +81,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
if (!cellCtx) return;
|
||||
cellCanvas.width = cellWidth;
|
||||
cellCanvas.height = cellHeight;
|
||||
cellCtx.imageSmoothingEnabled = false;
|
||||
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
|
||||
ctx.drawImage(cellCanvas, cellX, cellY);
|
||||
}
|
||||
@@ -92,7 +117,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
})
|
||||
);
|
||||
|
||||
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData };
|
||||
const json = {
|
||||
version: 2,
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
layers: layersData,
|
||||
};
|
||||
const jsonString = JSON.stringify(json, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -126,6 +160,10 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
|
||||
if (typeof data.columns === 'number') columns.value = data.columns;
|
||||
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
|
||||
if (typeof data.backgroundColor === 'string' && backgroundColor) backgroundColor.value = data.backgroundColor;
|
||||
if (typeof data.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = data.manualCellSizeEnabled;
|
||||
if (typeof data.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = data.manualCellWidth;
|
||||
if (typeof data.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = data.manualCellHeight;
|
||||
|
||||
if (Array.isArray(data.layers)) {
|
||||
const newLayers: Layer[] = [];
|
||||
@@ -172,10 +210,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -210,10 +245,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
}
|
||||
const zip = new JSZip();
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -244,7 +276,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, name: s.file.name }))),
|
||||
}))
|
||||
);
|
||||
const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersPayload };
|
||||
const meta = {
|
||||
version: 2,
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
layers: layersPayload,
|
||||
};
|
||||
const metaStr = JSON.stringify(meta, null, 2);
|
||||
jsonFolder.file('spritesheet.meta.json', metaStr);
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
export const createEmptyLayer = (name: string): Layer => ({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -14,6 +15,7 @@ export const useLayers = () => {
|
||||
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
||||
const activeLayerId = ref<string>(layers.value[0].id);
|
||||
const columns = ref(4);
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
watch(columns, val => {
|
||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||
@@ -38,7 +40,22 @@ export const useLayers = () => {
|
||||
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 +64,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) };
|
||||
|
||||
@@ -5,6 +5,9 @@ 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);
|
||||
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -61,16 +64,40 @@ 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);
|
||||
}
|
||||
|
||||
return {
|
||||
pixelPerfect,
|
||||
darkMode,
|
||||
negativeSpacingEnabled,
|
||||
backgroundColor,
|
||||
manualCellSizeEnabled,
|
||||
manualCellWidth,
|
||||
manualCellHeight,
|
||||
togglePixelPerfect,
|
||||
setPixelPerfect,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
toggleNegativeSpacing,
|
||||
setBackgroundColor,
|
||||
toggleManualCellSize,
|
||||
setManualCellWidth,
|
||||
setManualCellHeight,
|
||||
setManualCellSize,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user