234 lines
9.9 KiB
Vue
234 lines
9.9 KiB
Vue
<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>
|