[FEAT] Add back to project btn, extract GIFS into frames.

This commit is contained in:
2026-01-05 01:15:01 +01:00
parent 8e71d7379a
commit 77ae4bb429
8 changed files with 117 additions and 9 deletions

View File

@@ -1,5 +1,8 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [2.5.0] - 2026-01-05
- Uploading GIFS will ask you if you want to extract them to individual frames
## [2.4.0] - 2026-01-03 ## [2.4.0] - 2026-01-03
- Add pixel editor - Add pixel editor

View File

@@ -10,7 +10,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Mode </label> <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"> <div class="grid grid-cols-2 gap-3" :class="{ 'grid-cols-3': isGif }">
<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']"> <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="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> <div class="text-xs text-gray-500 dark:text-gray-400">Split by cell size</div>
@@ -19,6 +19,10 @@
<div class="font-medium text-gray-900 dark:text-gray-100">Auto-detect</div> <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> <div class="text-xs text-gray-500 dark:text-gray-400">Find individual sprites</div>
</button> </button>
<button v-if="isGif" @click="detectionMode = 'gif'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'gif' ? '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">Frame-by-frame</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Extract GIF frames</div>
</button>
</div> </div>
</div> </div>
@@ -129,6 +133,8 @@
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0)); const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
const isGif = computed(() => props.imageFile?.type === 'image/gif' || props.imageUrl.toLowerCase().endsWith('.gif'));
watch( watch(
() => props.imageUrl, () => props.imageUrl,
url => { url => {
@@ -142,6 +148,12 @@
cellWidth.value = suggested.width; cellWidth.value = suggested.width;
cellHeight.value = suggested.height; cellHeight.value = suggested.height;
if (isGif.value) {
detectionMode.value = 'gif';
} else if (detectionMode.value === 'gif') {
detectionMode.value = 'grid';
}
generatePreview(); generatePreview();
}; };
img.src = url; img.src = url;
@@ -171,11 +183,13 @@
preserveCellSize: preserveCellSize.value, preserveCellSize: preserveCellSize.value,
removeEmpty: removeEmpty.value, removeEmpty: removeEmpty.value,
}); });
} else { } else if (detectionMode.value === 'auto') {
previewSprites.value = await splitter.detectSprites(img, { previewSprites.value = await splitter.detectSprites(img, {
sensitivity: sensitivity.value, sensitivity: sensitivity.value,
removeEmpty: removeEmpty.value, removeEmpty: removeEmpty.value,
}); });
} else if (detectionMode.value === 'gif' && props.imageFile) {
previewSprites.value = await splitter.extractGifFrames(props.imageFile);
} }
} catch (error) { } catch (error) {
console.error('Error generating preview:', error); console.error('Error generating preview:', error);

View File

@@ -7,8 +7,14 @@
<NavbarLogo /> <NavbarLogo />
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<div class="hidden md:flex items-center"> <div class="hidden md:flex items-center gap-4">
<NavbarLinks /> <NavbarLinks />
<!-- Back to Editor Button -->
<button v-if="showBackToEditor" @click="goBackToEditor" class="btn btn-sm btn-primary shadow-indigo-500/20 shadow-lg">
<i class="fas fa-arrow-left mr-2"></i>
Back to Editor
</button>
</div> </div>
</div> </div>
@@ -54,6 +60,7 @@
@open-new-project-modal="isNewProjectModalOpen = true" @open-new-project-modal="isNewProjectModalOpen = true"
@open-project-list="isProjectListOpen = true" @open-project-list="isProjectListOpen = true"
@open-auth-modal="isAuthModalOpen = true" @open-auth-modal="isAuthModalOpen = true"
@back-to-editor="goBackToEditor"
/> />
</nav> </nav>
<AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" /> <AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" />
@@ -64,6 +71,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import DarkModeToggle from '../utilities/DarkModeToggle.vue'; import DarkModeToggle from '../utilities/DarkModeToggle.vue';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import AuthModal from '@/components/auth/AuthModal.vue'; import AuthModal from '@/components/auth/AuthModal.vue';
@@ -91,11 +99,17 @@
const saveMode = ref<'save' | 'save-as'>('save'); const saveMode = ref<'save' | 'save-as'>('save');
const isSaving = ref(false); const isSaving = ref(false);
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const projectStore = useProjectStore(); const projectStore = useProjectStore();
const { createProject, openProject, saveProject, saveAsProject } = useProjectManager(); const { createProject, openProject, saveProject, saveAsProject } = useProjectManager();
const { addToast } = useToast(); const { addToast } = useToast();
const showBackToEditor = computed(() => {
return route.name !== 'editor' && !!projectStore.currentProject;
});
const handleOpenProject = async (project: Project) => { const handleOpenProject = async (project: Project) => {
await openProject(project); await openProject(project);
}; };
@@ -151,4 +165,8 @@
const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => { const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => {
createProject(config); createProject(config);
}; };
const goBackToEditor = () => {
router.push({ name: 'editor' });
};
</script> </script>

View File

@@ -34,6 +34,20 @@
Login / Register Login / Register
</button> </button>
</div> </div>
<!-- Back to Editor Button -->
<button
v-if="showBackToEditor"
@click="
$emit('back-to-editor');
$emit('close');
"
class="mx-3 mb-3 btn btn-primary w-auto flex items-center gap-2"
>
<i class="fas fa-arrow-left"></i>
Back to Editor
</button>
<router-link <router-link
v-for="link in links" v-for="link in links"
:key="link.path" :key="link.path"
@@ -127,6 +141,13 @@
const route = useRoute(); const route = useRoute();
const isEditorActive = computed(() => route.name === 'editor'); const isEditorActive = computed(() => route.name === 'editor');
import { useProjectStore } from '@/stores/useProjectStore';
const projectStore = useProjectStore();
const showBackToEditor = computed(() => {
return route.name !== 'editor' && !!projectStore.currentProject;
});
const links = [ const links = [
{ name: 'Home', path: '/', icon: 'fas fa-home' }, { name: 'Home', path: '/', icon: 'fas fa-home' },
{ name: 'Blog', path: '/blog', icon: 'fas fa-newspaper' }, { name: 'Blog', path: '/blog', icon: 'fas fa-newspaper' },
@@ -147,7 +168,7 @@
isSaving?: boolean; isSaving?: boolean;
}>(); }>();
defineEmits(['close', 'open-help', 'save-project', 'open-save-modal', 'open-new-project-modal', 'open-project-list', 'open-auth-modal']); defineEmits(['close', 'open-help', 'save-project', 'open-save-modal', 'open-new-project-modal', 'open-project-list', 'open-auth-modal', 'back-to-editor']);
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
const authStore = useAuthStore(); const authStore = useAuthStore();

View File

@@ -300,11 +300,65 @@ export function useSpritesheetSplitter() {
return { width: cellWidth, height: cellHeight }; return { width: cellWidth, height: cellHeight };
} }
/**
* Extract frames from a GIF file using ImageDecoder API
*/
async function extractGifFrames(file: File): Promise<SpritePreview[]> {
if (!('ImageDecoder' in window)) {
console.warn('ImageDecoder API not supported');
return [];
}
try {
const arrayBuffer = await file.arrayBuffer();
const decoder = new ImageDecoder({ data: new DataView(arrayBuffer), type: 'image/gif' });
const sprites: SpritePreview[] = [];
let frameIndex = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const result = await decoder.decode({ frameIndex });
const frame = result.image;
const canvas = document.createElement('canvas');
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(frame, 0, 0);
sprites.push({
url: canvas.toDataURL('image/png'),
x: 0,
y: 0,
width: canvas.width,
height: canvas.height,
isEmpty: false,
});
}
frame.close(); // Important to release resources
frameIndex++;
} catch (err) {
// End of frames (RangeError) or other error
break;
}
}
return sprites;
} catch (error) {
console.error('Error extracting GIF frames:', error);
return [];
}
}
return { return {
isProcessing, isProcessing,
previewSprites, previewSprites,
splitByGrid, splitByGrid,
detectSprites, detectSprites,
extractGifFrames,
getSuggestedCellSize, getSuggestedCellSize,
cleanup, cleanup,
}; };

View File

@@ -15,7 +15,7 @@ if (typeof window !== 'undefined') {
if (storedDarkMode !== null) { if (storedDarkMode !== null) {
darkMode.value = storedDarkMode === 'true'; darkMode.value = storedDarkMode === 'true';
} else { } else {
darkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches; darkMode.value = true;
} }
} }

View File

@@ -22,7 +22,7 @@ export interface SpritePreview {
} }
/** Detection mode for sprite splitting */ /** Detection mode for sprite splitting */
export type DetectionMode = 'grid' | 'auto'; export type DetectionMode = 'grid' | 'auto' | 'gif';
/** Options for grid-based splitting */ /** Options for grid-based splitting */
export interface GridSplitOptions { export interface GridSplitOptions {

View File

@@ -9,9 +9,7 @@
<div class="px-5 py-4 border-b border-gray-200/50 dark:border-gray-700/50 flex items-center justify-between shrink-0 bg-gray-50/50 dark:bg-gray-800/10"> <div class="px-5 py-4 border-b border-gray-200/50 dark:border-gray-700/50 flex items-center justify-between shrink-0 bg-gray-50/50 dark:bg-gray-800/10">
<h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">Editor Tools</h2> <h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">Editor Tools</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button @click="closeProject" class="btn btn-secondary btn-sm w-8 h-8 p-0 justify-center border-gray-200 dark:border-gray-700 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 dark:text-gray-400" title="Close Project"> <button @click="closeProject" class="btn btn-secondary btn-sm border-gray-200 dark:border-gray-700 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 dark:text-gray-400" title="Close Project"><i class="fas fa-times mr-1"></i>Close</button>
<i class="fas fa-times"></i>
</button>
<button @click="openFileDialog" class="btn btn-primary btn-sm shadow-indigo-500/20" title="Add more sprites"><i class="fas fa-plus mr-1"></i> Add</button> <button @click="openFileDialog" class="btn btn-primary btn-sm shadow-indigo-500/20" title="Add more sprites"><i class="fas fa-plus mr-1"></i> Add</button>
</div> </div>
</div> </div>