[FEAT] Add back to project btn, extract GIFS into frames.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user