367 lines
20 KiB
Vue
367 lines
20 KiB
Vue
<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>
|
||
</div>
|
||
|
||
<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>
|
||
</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>
|
||
|
||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||
</Modal>
|
||
|
||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
|
||
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
||
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||
|
||
<!-- One-time feedback popup -->
|
||
<div v-if="showFeedbackPopup" class="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50">
|
||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md mx-4 shadow-xl border border-gray-600">
|
||
<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>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, toRef, computed } from 'vue';
|
||
import FileUploader from './components/FileUploader.vue';
|
||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||
import Modal from './components/utilities/Modal.vue';
|
||
import SpritePreview from './components/SpritePreview.vue';
|
||
import HelpModal from './components/HelpModal.vue';
|
||
import FeedbackModal from './components/FeedbackModal.vue';
|
||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||
import GifFpsModal from './components/GifFpsModal.vue';
|
||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||
import { useExportLayers } from './composables/useExportLayers';
|
||
import { useLayers } from './composables/useLayers';
|
||
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
|
||
import { useSettingsStore } from './stores/useSettingsStore';
|
||
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
|
||
import type { SpriteFile } from './types/sprites';
|
||
|
||
const settingsStore = useSettingsStore();
|
||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, 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'),
|
||
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);
|
||
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
|
||
});
|
||
const isPreviewModalOpen = ref(false);
|
||
const isHelpModalOpen = ref(false);
|
||
const isFeedbackModalOpen = ref(false);
|
||
const isSpritesheetSplitterOpen = ref(false);
|
||
const isGifFpsModalOpen = ref(false);
|
||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||
const spritesheetImageUrl = ref('');
|
||
const spritesheetImageFile = ref<File | null>(null);
|
||
const showFeedbackPopup = ref(false);
|
||
|
||
const handleSpritesUpload = async (files: File[]) => {
|
||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||
|
||
if (jsonFile) {
|
||
try {
|
||
await importSpritesheetJSON(jsonFile);
|
||
} catch (error) {
|
||
console.error('Error importing JSON:', error);
|
||
alert('Failed to import JSON file. Please check the file format.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
||
const file = files[0];
|
||
const url = URL.createObjectURL(file);
|
||
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
|
||
spritesheetImageUrl.value = url;
|
||
spritesheetImageFile.value = file;
|
||
isSpritesheetSplitterOpen.value = true;
|
||
return;
|
||
}
|
||
|
||
processImageFiles([file]);
|
||
};
|
||
img.src = url;
|
||
return;
|
||
}
|
||
|
||
processImageFiles(files);
|
||
};
|
||
|
||
const openPreviewModal = () => {
|
||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||
alert('Please upload or import sprites to preview an animation.');
|
||
return;
|
||
}
|
||
isPreviewModalOpen.value = true;
|
||
};
|
||
|
||
const closePreviewModal = () => {
|
||
isPreviewModalOpen.value = false;
|
||
};
|
||
|
||
const openHelpModal = () => {
|
||
isHelpModalOpen.value = true;
|
||
};
|
||
|
||
const closeHelpModal = () => {
|
||
isHelpModalOpen.value = false;
|
||
};
|
||
|
||
const openFeedbackModal = () => {
|
||
isFeedbackModalOpen.value = true;
|
||
};
|
||
|
||
const closeFeedbackModal = () => {
|
||
isFeedbackModalOpen.value = false;
|
||
};
|
||
|
||
const closeSpritesheetSplitter = () => {
|
||
isSpritesheetSplitterOpen.value = false;
|
||
if (spritesheetImageUrl.value) {
|
||
URL.revokeObjectURL(spritesheetImageUrl.value);
|
||
spritesheetImageUrl.value = '';
|
||
}
|
||
spritesheetImageFile.value = null;
|
||
};
|
||
|
||
const openGifFpsModal = () => {
|
||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||
alert('Please upload or import sprites before generating a GIF.');
|
||
return;
|
||
}
|
||
isGifFpsModalOpen.value = true;
|
||
};
|
||
|
||
const closeGifFpsModal = () => {
|
||
isGifFpsModalOpen.value = false;
|
||
};
|
||
|
||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||
processImageFiles(spriteFiles.map(s => s.file));
|
||
};
|
||
|
||
const openJSONImportDialog = () => {
|
||
jsonFileInput.value?.click();
|
||
};
|
||
|
||
const handleJSONFileChange = async (event: Event) => {
|
||
const input = event.target as HTMLInputElement;
|
||
if (input.files && input.files.length > 0) {
|
||
const jsonFile = input.files[0];
|
||
try {
|
||
await importSpritesheetJSON(jsonFile);
|
||
} catch (error) {
|
||
console.error('Error importing JSON:', error);
|
||
alert('Failed to import JSON file. Please check the file format.');
|
||
}
|
||
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
|
||
if (!hasShownFeedbackPopup) {
|
||
setTimeout(() => {
|
||
showFeedbackPopup.value = true;
|
||
}, 3000);
|
||
}
|
||
});
|
||
|
||
const handleFeedbackPopupResponse = (showModal: boolean) => {
|
||
showFeedbackPopup.value = false;
|
||
localStorage.setItem('hasShownFeedbackPopup', 'true');
|
||
|
||
if (showModal) {
|
||
openFeedbackModal();
|
||
}
|
||
};
|
||
</script>
|