Files
spritesheet-generator/src/components/SpritesheetSplitter.vue
2025-12-15 01:45:07 +01:00

234 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Modal :is-open="isOpen" @close="cancel" title="Split spritesheet">
<div class="space-y-6">
<!-- Preview Image -->
<div class="flex items-center justify-center">
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-48 sm:max-h-64 border border-gray-300 dark:border-gray-600 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
<!-- Detection Mode -->
<div class="space-y-4">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Mode </label>
<div class="grid grid-cols-2 gap-3">
<button @click="detectionMode = 'grid'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'grid' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
<div class="font-medium text-gray-900 dark:text-gray-100">Grid</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Split by cell size</div>
</button>
<button @click="detectionMode = 'auto'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'auto' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
<div class="font-medium text-gray-900 dark:text-gray-100">Auto-detect</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Find individual sprites</div>
</button>
</div>
</div>
<!-- Grid Mode Options -->
<div v-if="detectionMode === 'grid'" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label for="cell-width" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Cell Width (px) </label>
<input type="number" id="cell-width" v-model.number="cellWidth" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
</div>
<div class="space-y-1">
<label for="cell-height" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Cell Height (px) </label>
<input type="number" id="cell-height" v-model.number="cellHeight" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
</div>
</div>
<div class="flex items-center">
<input type="checkbox" id="preserve-cell-size" v-model="preserveCellSize" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label for="preserve-cell-size" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Preserve cell size (keep transparent padding) </label>
</div>
<div v-if="imageElement" class="text-xs text-gray-500 dark:text-gray-400">Image: {{ imageElement.width }}×{{ imageElement.height }}px {{ gridCols }}×{{ gridRows }} cells</div>
</div>
<!-- Auto-detect Options -->
<div v-if="detectionMode === 'auto'" class="space-y-2">
<label for="sensitivity" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Sensitivity </label>
<input type="range" id="sensitivity" v-model.number="sensitivity" min="1" max="100" class="w-full dark:accent-blue-400" />
<div class="text-xs text-gray-500 dark:text-gray-400 flex justify-between">
<span>Low (more sprites)</span>
<span>High (fewer sprites)</span>
</div>
</div>
<!-- Common Options -->
<div class="flex items-center">
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label for="remove-empty" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Remove empty sprites </label>
</div>
</div>
<!-- Preview -->
<div v-if="previewSprites.length > 0 || isProcessing" class="space-y-2">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
<span v-if="isProcessing">Detecting sprites...</span>
<span v-else>Preview ({{ previewSprites.length }} sprites)</span>
</h3>
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-80 overflow-y-auto p-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 flex items-center justify-center aspect-square">
<img :src="sprite.url" alt="Sprite" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button
@click="cancel"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
@click="confirm"
:disabled="previewSprites.length === 0 || isProcessing"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{{ isProcessing ? 'Processing...' : 'Split spritesheet' }}
</button>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from 'vue';
import Modal from './utilities/Modal.vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import { useSpritesheetSplitter } from '@/composables/useSpritesheetSplitter';
import type { SpriteFile } from '@/types/sprites';
import type { DetectionMode, SpritePreview } from '@/types/spritesheet';
const props = defineProps<{
isOpen: boolean;
imageUrl: string;
imageFile: File | null | undefined;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'split', sprites: SpriteFile[]): void;
}>();
const settingsStore = useSettingsStore();
const splitter = useSpritesheetSplitter();
// State
const detectionMode = ref<DetectionMode>('grid');
const cellWidth = ref(64);
const cellHeight = ref(64);
const sensitivity = ref(50);
const removeEmpty = ref(true);
const preserveCellSize = ref(false);
const previewSprites = ref<SpritePreview[]>([]);
const isProcessing = ref(false);
const imageElement = ref<HTMLImageElement | null>(null);
// Computed
const gridCols = computed(() => (imageElement.value && cellWidth.value > 0 ? Math.floor(imageElement.value.width / cellWidth.value) : 0));
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
// Load image and set initial cell size
watch(
() => props.imageUrl,
url => {
if (!url) return;
const img = new Image();
img.onload = () => {
imageElement.value = img;
// Set suggested cell size
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
cellWidth.value = suggested.width;
cellHeight.value = suggested.height;
generatePreview();
};
img.src = url;
},
{ immediate: true }
);
// Regenerate preview when options change
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
if (imageElement.value) {
generatePreview();
}
});
// Generate preview
async function generatePreview() {
if (!imageElement.value) return;
isProcessing.value = true;
previewSprites.value = [];
try {
const img = imageElement.value;
if (detectionMode.value === 'grid') {
previewSprites.value = await splitter.splitByGrid(img, {
cellWidth: cellWidth.value,
cellHeight: cellHeight.value,
preserveCellSize: preserveCellSize.value,
removeEmpty: removeEmpty.value,
});
} else {
previewSprites.value = await splitter.detectSprites(img, {
sensitivity: sensitivity.value,
removeEmpty: removeEmpty.value,
});
}
} catch (error) {
console.error('Error generating preview:', error);
} finally {
isProcessing.value = false;
}
}
// Actions
function cancel() {
emit('close');
}
async function confirm() {
if (previewSprites.value.length === 0) return;
isProcessing.value = true;
try {
const spriteFiles: SpriteFile[] = [];
for (let i = 0; i < previewSprites.value.length; i++) {
const sprite = previewSprites.value[i];
const response = await fetch(sprite.url);
const blob = await response.blob();
const file = new File([blob], `sprite_${i + 1}.png`, { type: 'image/png' });
spriteFiles.push({
file,
x: sprite.x,
y: sprite.y,
width: sprite.width,
height: sprite.height,
});
}
emit('split', spriteFiles);
emit('close');
} catch (error) {
console.error('Error creating sprite files:', error);
} finally {
isProcessing.value = false;
}
}
onUnmounted(() => {
splitter.cleanup();
});
</script>