Compare commits
3 Commits
f8b4e98f9c
...
9fa2bbd15d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fa2bbd15d | |||
| b3f530870e | |||
| 56858701ef |
10
index.html
10
index.html
@@ -14,24 +14,24 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>Spritesheet Generator - Create Game Spritesheets Online</title>
|
<title>Spritesheet generator - Create Game Spritesheets Online</title>
|
||||||
<meta name="title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
<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="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="author" content="nu11ed">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://spritesheetgenerator.online/">
|
<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: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">
|
<meta property="og:image" content="/og-image.png">
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image">
|
<meta property="twitter:card" content="summary_large_image">
|
||||||
<meta property="twitter:url" content="https://spritesheetgenerator.online/">
|
<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: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">
|
<meta property="twitter:image" content="/og-image.png">
|
||||||
|
|
||||||
|
|||||||
459
src/App.vue
459
src/App.vue
@@ -1,164 +1,272 @@
|
|||||||
<template>
|
<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="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-7xl mx-auto">
|
<div class="max-w-[1600px] mx-auto">
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-8 gap-2">
|
<header class="mb-8 sm:mb-12">
|
||||||
<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>
|
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mb-6">
|
||||||
<dark-mode-toggle />
|
<div class="text-center sm:text-left">
|
||||||
</div>
|
<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>
|
||||||
<div class="flex flex-wrap justify-center gap-4 mb-4 sm:mb-8">
|
<p class="text-sm sm:text-base text-gray-600 dark:text-gray-400">Create professional spritesheets for your game development projects</p>
|
||||||
<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>
|
</div>
|
||||||
|
<dark-mode-toggle />
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<div class="flex flex-col gap-3 mb-4">
|
<!-- Welcome state -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div v-if="!visibleLayers.some(l => l.sprites.length)" class="p-6 sm:p-10">
|
||||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Layers</span>
|
<div class="mb-8">
|
||||||
<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>
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload Sprites</h2>
|
||||||
</div>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
</div>
|
||||||
<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 }">
|
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||||
<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>
|
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||||
<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>
|
<div class="mt-10">
|
||||||
<button @click="moveLayer(layer.id, 'down')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↓</button>
|
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||||
<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>
|
<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>
|
||||||
</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">
|
|
||||||
<label class="text-gray-700 dark:text-gray-200 font-medium flex items-center gap-1">
|
|
||||||
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="w-4 h-4" />
|
|
||||||
Cell size:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="settingsStore.manualCellSizeEnabled"
|
|
||||||
type="number"
|
|
||||||
v-model.number="settingsStore.manualCellWidth"
|
|
||||||
min="1"
|
|
||||||
max="2048"
|
|
||||||
class="w-20 px-2 py-1 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-sm"
|
|
||||||
placeholder="Width"
|
|
||||||
/>
|
|
||||||
<span v-if="settingsStore.manualCellSizeEnabled" class="text-gray-600 dark:text-gray-300">×</span>
|
|
||||||
<input
|
|
||||||
v-if="settingsStore.manualCellSizeEnabled"
|
|
||||||
type="number"
|
|
||||||
v-model.number="settingsStore.manualCellHeight"
|
|
||||||
min="1"
|
|
||||||
max="2048"
|
|
||||||
class="w-20 px-2 py-1 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-sm"
|
|
||||||
placeholder="Height"
|
|
||||||
/>
|
|
||||||
<span v-if="!settingsStore.manualCellSizeEnabled" 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>
|
|
||||||
|
<!-- 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>
|
</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" />
|
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -173,10 +281,10 @@
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-4xl mb-4">💬</div>
|
<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>
|
<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">
|
<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(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">Share feedback</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,9 +344,13 @@
|
|||||||
const isSpritesheetSplitterOpen = ref(false);
|
const isSpritesheetSplitterOpen = ref(false);
|
||||||
const isGifFpsModalOpen = ref(false);
|
const isGifFpsModalOpen = ref(false);
|
||||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||||
const spritesheetImageUrl = ref('');
|
const spritesheetImageUrl = ref('');
|
||||||
const spritesheetImageFile = ref<File | null>(null);
|
const spritesheetImageFile = ref<File | null>(null);
|
||||||
const showFeedbackPopup = ref(false);
|
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 handleSpritesUpload = async (files: File[]) => {
|
||||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||||
@@ -363,4 +475,43 @@
|
|||||||
openFeedbackModal();
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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="{
|
:class="{
|
||||||
'border-blue-300 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/30': 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-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-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"
|
@dragenter.prevent="isDragging = true"
|
||||||
@dragleave.prevent="isDragging = false"
|
@dragleave.prevent="isDragging = false"
|
||||||
@@ -12,19 +12,38 @@
|
|||||||
@click="openFileDialog"
|
@click="openFileDialog"
|
||||||
data-rybbit-event="file-upload-area"
|
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" />
|
<input ref="fileInput" type="file" multiple accept="image/*,.json" class="hidden" @change="handleFileChange" />
|
||||||
|
|
||||||
<div class="mb-4 sm:mb-6">
|
<div class="relative z-10">
|
||||||
<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="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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +1,100 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-2 bg-cyan-600 rounded w-full my-4">
|
<div class="space-y-6">
|
||||||
<p>Developer's tip: Right click a sprite to open the context menu and add, replace or remove sprites.</p>
|
<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>
|
<div class="flex items-start gap-3">
|
||||||
<div class="space-y-4">
|
<i class="fas fa-lightbulb text-yellow-300 text-xl mt-0.5"></i>
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
<div>
|
||||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
<h4 class="font-semibold text-white mb-1">Pro Tip</h4>
|
||||||
<div class="flex items-center">
|
<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>
|
||||||
<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="bgSelectValue" 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="bgSelectValue === '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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto">
|
<section>
|
||||||
<!-- Zoom controls - visible on all screen sizes and positioned outside cells -->
|
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
|
||||||
<div class="relative flex space-x-2 bg-white/90 dark:bg-gray-800/90 p-2 rounded-lg shadow-md z-20">
|
<i class="fas fa-cog text-blue-600 dark:text-blue-400"></i>
|
||||||
<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">
|
Canvas Options
|
||||||
<i class="fas fa-plus"></i>
|
</h3>
|
||||||
</button>
|
<div class="flex flex-wrap gap-4">
|
||||||
<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">
|
<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">
|
||||||
<i class="fas fa-minus"></i>
|
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 rounded" @change="requestDraw" />
|
||||||
</button>
|
<span class="text-sm font-medium dark:text-gray-200">Pixel Perfect</span>
|
||||||
<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">
|
</label>
|
||||||
<i class="fas fa-expand"></i>
|
<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">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="canvas-container touch-manipulation relative" :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }">
|
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto">
|
||||||
<canvas
|
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
||||||
ref="canvasRef"
|
<div :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }" class="inline-block">
|
||||||
@mousedown="startDrag"
|
<canvas
|
||||||
@mousemove="drag"
|
ref="canvasRef"
|
||||||
@mouseup="stopDrag"
|
@mousedown="startDrag"
|
||||||
@mouseleave="stopDrag"
|
@mousemove="drag"
|
||||||
@touchstart="handleTouchStart"
|
@mouseup="stopDrag"
|
||||||
@touchmove="handleTouchMove"
|
@mouseleave="stopDrag"
|
||||||
@touchend="stopDrag"
|
@touchstart="handleTouchStart"
|
||||||
@contextmenu.prevent
|
@touchmove="handleTouchMove"
|
||||||
@dragover="handleDragOver"
|
@touchend="stopDrag"
|
||||||
@dragenter="handleDragEnter"
|
@contextmenu.prevent
|
||||||
@dragleave="onDragLeave"
|
@dragover="handleDragOver"
|
||||||
@drop="handleDrop"
|
@dragenter="handleDragEnter"
|
||||||
class="w-full transition-all"
|
@dragleave="onDragLeave"
|
||||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
@drop="handleDrop"
|
||||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
class="block transition-all"
|
||||||
></canvas>
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||||
|
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Offset labels in corners -->
|
<!-- Offset labels in corners (not scaled with zoom) -->
|
||||||
<div v-if="canvasRef" class="absolute inset-0 pointer-events-none">
|
<div v-if="canvasRef && showOffsetLabels" class="absolute inset-0 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
v-for="position in spritePositions"
|
v-for="position in spritePositions"
|
||||||
:key="position.id"
|
: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"
|
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="{
|
:style="{
|
||||||
// Use the global cell size so labels line up with the actual grid cells
|
// Position at bottom-right corner of each cell, scaled by zoom
|
||||||
left: `calc(${(position.cellX / canvasWidth) * 100}% + ${(gridMetrics.maxWidth / canvasWidth) * 100}% - 2px)`,
|
left: `${(position.cellX + position.maxWidth) * zoom - 2}px`,
|
||||||
top: `calc(${(position.cellY / canvasHeight) * 100}% + ${(gridMetrics.maxHeight / canvasHeight) * 100}% - 2px)`,
|
top: `${(position.cellY + position.maxHeight) * zoom - 2}px`,
|
||||||
transform: 'translate(-100%, -100%)',
|
transform: 'translate(-100%, -100%)',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@@ -91,23 +104,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context Menu -->
|
<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' }">
|
||||||
<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-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">
|
||||||
<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 text-blue-600 dark:text-blue-400"></i>
|
||||||
<i class="fas fa-plus"></i>
|
<span>Add Sprite</span>
|
||||||
Add sprite
|
|
||||||
</button>
|
</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">
|
<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"></i>
|
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
|
||||||
Replace sprite
|
<span>Replace Sprite</span>
|
||||||
</button>
|
</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>
|
<i class="fas fa-trash"></i>
|
||||||
Remove sprite
|
<span>Remove Sprite</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden file input for replace functionality -->
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -186,6 +198,7 @@
|
|||||||
calculateMaxDimensions,
|
calculateMaxDimensions,
|
||||||
} = useDragSprite({
|
} = useDragSprite({
|
||||||
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
||||||
|
layers: toRef(props, 'layers'),
|
||||||
columns: toRef(props, 'columns'),
|
columns: toRef(props, 'columns'),
|
||||||
zoom,
|
zoom,
|
||||||
allowCellSwap,
|
allowCellSwap,
|
||||||
@@ -208,6 +221,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
|
const showOffsetLabels = ref(true);
|
||||||
const showContextMenu = ref(false);
|
const showContextMenu = ref(false);
|
||||||
const contextMenuX = ref(0);
|
const contextMenuX = ref(0);
|
||||||
const contextMenuY = ref(0);
|
const contextMenuY = ref(0);
|
||||||
@@ -282,10 +296,6 @@
|
|||||||
// Grid metrics used to position offset labels relative to cell size
|
// Grid metrics used to position offset labels relative to cell size
|
||||||
const gridMetrics = computed(() => calculateMaxDimensions());
|
const gridMetrics = computed(() => calculateMaxDimensions());
|
||||||
|
|
||||||
// Reactive canvas dimensions to ensure label positions update when canvas size changes
|
|
||||||
const canvasWidth = ref(0);
|
|
||||||
const canvasHeight = ref(0);
|
|
||||||
|
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
if (!canvasRef.value) return;
|
if (!canvasRef.value) return;
|
||||||
|
|
||||||
@@ -388,9 +398,6 @@
|
|||||||
const newCanvasWidth = maxWidth * props.columns;
|
const newCanvasWidth = maxWidth * props.columns;
|
||||||
const newCanvasHeight = maxHeight * rows;
|
const newCanvasHeight = maxHeight * rows;
|
||||||
canvas2D.setCanvasSize(newCanvasWidth, newCanvasHeight);
|
canvas2D.setCanvasSize(newCanvasWidth, newCanvasHeight);
|
||||||
// Update reactive dimensions for template-driven elements (like labels)
|
|
||||||
if (canvasWidth.value !== newCanvasWidth) canvasWidth.value = newCanvasWidth;
|
|
||||||
if (canvasHeight.value !== newCanvasHeight) canvasHeight.value = newCanvasHeight;
|
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
canvas2D.clear();
|
canvas2D.clear();
|
||||||
@@ -414,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) {
|
if (showAllSprites.value) {
|
||||||
const total = Math.max(...props.layers.map(l => (l.visible ? l.sprites.length : 0)));
|
const visibleLayers = props.layers.filter(l => l.visible);
|
||||||
for (let cellIndex = 0; cellIndex < total; cellIndex++) {
|
const maxSprites = Math.max(...visibleLayers.map(l => l.sprites.length), 0);
|
||||||
|
|
||||||
|
for (let cellIndex = 0; cellIndex < maxSprites; cellIndex++) {
|
||||||
const cellCol = cellIndex % props.columns;
|
const cellCol = cellIndex % props.columns;
|
||||||
const cellRow = Math.floor(cellIndex / props.columns);
|
const cellRow = Math.floor(cellIndex / props.columns);
|
||||||
const cellX = Math.floor(cellCol * maxWidth);
|
const cellX = Math.floor(cellCol * maxWidth);
|
||||||
const cellY = Math.floor(cellRow * maxHeight);
|
const cellY = Math.floor(cellRow * maxHeight);
|
||||||
|
|
||||||
props.layers.forEach(layer => {
|
// Draw all sprites from all visible layers ghosted in this cell
|
||||||
if (!layer.visible) return;
|
visibleLayers.forEach(layer => {
|
||||||
const sprite = layer.sprites[cellIndex];
|
layer.sprites.forEach((sprite, spriteIndex) => {
|
||||||
if (sprite) canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.35);
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="spritesheet-preview w-full">
|
<div class="spritesheet-preview w-full">
|
||||||
<!-- Main Layout: Canvas Left, Controls Right -->
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
<!-- Canvas Area (Left/Main) -->
|
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<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
|
<canvas
|
||||||
@@ -32,10 +30,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls Sidebar (Right) -->
|
|
||||||
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
<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">
|
<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">
|
<div class="space-y-3">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Playback</h3>
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
@@ -214,14 +210,20 @@
|
|||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
|
|
||||||
const compositeFrames = computed<Sprite[]>(() => {
|
const compositeFrames = computed<Sprite[]>(() => {
|
||||||
const v = getVisibleLayers();
|
// Show frames from the active layer for the thumbnail list
|
||||||
const len = maxFrames();
|
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
const arr: Sprite[] = [];
|
if (!activeLayer) {
|
||||||
for (let i = 0; i < len; i++) {
|
// Fallback to first visible layer if no active layer
|
||||||
const s = v.find(l => l.sprites[i])?.sprites[i];
|
const v = getVisibleLayers();
|
||||||
if (s) arr.push(s);
|
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>(() => {
|
const currentFrameSprite = computed<Sprite | null>(() => {
|
||||||
@@ -283,6 +285,7 @@
|
|||||||
const frameIndex = currentFrameIndex.value;
|
const frameIndex = currentFrameIndex.value;
|
||||||
|
|
||||||
if (showAllSprites.value) {
|
if (showAllSprites.value) {
|
||||||
|
// When comparing sprites, show all frames from all visible layers (dimmed)
|
||||||
const len = maxFrames();
|
const len = maxFrames();
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
||||||
@@ -294,6 +297,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always draw current frame from all visible layers
|
||||||
visibleLayers.forEach(layer => {
|
visibleLayers.forEach(layer => {
|
||||||
const sprite = layer.sprites[frameIndex];
|
const sprite = layer.sprites[frameIndex];
|
||||||
if (!sprite) return;
|
if (!sprite) return;
|
||||||
@@ -421,6 +425,7 @@
|
|||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
||||||
|
watch(() => props.activeLayerId, drawPreviewCanvas);
|
||||||
watch(currentFrameIndex, drawPreviewCanvas);
|
watch(currentFrameIndex, drawPreviewCanvas);
|
||||||
watch(zoom, drawPreviewCanvas);
|
watch(zoom, drawPreviewCanvas);
|
||||||
watch(isDraggable, drawPreviewCanvas);
|
watch(isDraggable, drawPreviewCanvas);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<template>
|
<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">
|
<button
|
||||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
@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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
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 { getMaxDimensions } from './useSprites';
|
||||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export interface SpritePosition {
|
|||||||
|
|
||||||
export interface DragSpriteOptions {
|
export interface DragSpriteOptions {
|
||||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||||
|
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
|
||||||
columns: Ref<number> | number;
|
columns: Ref<number> | number;
|
||||||
zoom?: Ref<number>;
|
zoom?: Ref<number>;
|
||||||
allowCellSwap?: Ref<boolean>;
|
allowCellSwap?: Ref<boolean>;
|
||||||
@@ -46,6 +47,7 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
|
|
||||||
// Helper to get reactive values
|
// Helper to get reactive values
|
||||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
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 getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||||
const getZoom = () => options.zoom?.value ?? 1;
|
const getZoom = () => options.zoom?.value ?? 1;
|
||||||
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
||||||
@@ -73,7 +75,6 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
const lastMaxHeight = ref(1);
|
const lastMaxHeight = ref(1);
|
||||||
|
|
||||||
const calculateMaxDimensions = () => {
|
const calculateMaxDimensions = () => {
|
||||||
const sprites = getSprites();
|
|
||||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||||
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
||||||
|
|
||||||
@@ -87,8 +88,13 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
return { maxWidth, maxHeight, negativeSpacing };
|
return { maxWidth, maxHeight, negativeSpacing };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, calculate based on sprite dimensions
|
// Get all sprites to calculate dimensions from
|
||||||
const base = getMaxDimensions(sprites);
|
// 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
|
// When switching back from manual mode, reset to actual sprite dimensions
|
||||||
const baseMaxWidth = Math.max(1, base.maxWidth);
|
const baseMaxWidth = Math.max(1, base.maxWidth);
|
||||||
const baseMaxHeight = Math.max(1, base.maxHeight);
|
const baseMaxHeight = Math.max(1, base.maxHeight);
|
||||||
@@ -96,7 +102,7 @@ export function useDragSprite(options: DragSpriteOptions) {
|
|||||||
lastMaxHeight.value = baseMaxHeight;
|
lastMaxHeight.value = baseMaxHeight;
|
||||||
|
|
||||||
// Calculate negative spacing using shared composable
|
// Calculate negative spacing using shared composable
|
||||||
const negativeSpacing = calculateNegativeSpacing(sprites, negativeSpacingEnabled);
|
const negativeSpacing = calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled);
|
||||||
|
|
||||||
// Add negative spacing to expand each cell
|
// Add negative spacing to expand each cell
|
||||||
const maxWidth = baseMaxWidth + negativeSpacing;
|
const maxWidth = baseMaxWidth + negativeSpacing;
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export const useLayers = () => {
|
|||||||
cellWidth = settingsStore.manualCellWidth;
|
cellWidth = settingsStore.manualCellWidth;
|
||||||
cellHeight = settingsStore.manualCellHeight;
|
cellHeight = settingsStore.manualCellHeight;
|
||||||
} else {
|
} else {
|
||||||
// Use auto-calculated dimensions based on sprite sizes
|
// Use auto-calculated dimensions based on ALL visible layers (not just active layer)
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||||
cellWidth = maxWidth;
|
cellWidth = maxWidth;
|
||||||
cellHeight = maxHeight;
|
cellHeight = maxHeight;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user