[FEAT] Format code

This commit is contained in:
2026-01-01 18:46:46 +01:00
parent 221dcb7072
commit 65bdc2974f
17 changed files with 561 additions and 528 deletions

View File

@@ -227,7 +227,7 @@
(e: 'removeSprite', id: string): void; (e: 'removeSprite', id: string): void;
(e: 'removeSprites', ids: string[]): void; (e: 'removeSprites', ids: string[]): void;
(e: 'replaceSprite', id: string, file: File): void; (e: 'replaceSprite', id: string, file: File): void;
(e: 'addSprite', file: File): void; (e: 'addSprite', file: File, index?: number): void;
(e: 'addSpriteWithResize', file: File): void; (e: 'addSpriteWithResize', file: File): void;
(e: 'rotateSprite', id: string, angle: number): void; (e: 'rotateSprite', id: string, angle: number): void;
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void; (e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
@@ -263,6 +263,7 @@
handleTouchStart, handleTouchStart,
handleTouchMove, handleTouchMove,
findSpriteAtPosition, findSpriteAtPosition,
findCellAtPosition,
calculateMaxDimensions, calculateMaxDimensions,
} = useDragSprite({ } = useDragSprite({
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []), sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
@@ -293,6 +294,7 @@
const showContextMenu = ref(false); const showContextMenu = ref(false);
const contextMenuX = ref(0); const contextMenuX = ref(0);
const contextMenuY = ref(0); const contextMenuY = ref(0);
const contextMenuIndex = ref<number | null>(null);
const contextMenuSpriteId = ref<string | null>(null); const contextMenuSpriteId = ref<string | null>(null);
const selectedSpriteIds = ref<Set<string>>(new Set()); const selectedSpriteIds = ref<Set<string>>(new Set());
const replacingSpriteId = ref<string | null>(null); const replacingSpriteId = ref<string | null>(null);
@@ -383,6 +385,7 @@
if (!pos) return; if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y); const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
contextMenuIndex.value = findCellAtPosition(pos.x, pos.y)?.index ?? null;
contextMenuSpriteId.value = clickedSprite?.id || null; contextMenuSpriteId.value = clickedSprite?.id || null;
if (clickedSprite) { if (clickedSprite) {
@@ -528,14 +531,19 @@
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
if (replacingSpriteId.value) { if (replacingSpriteId.value) {
emit('replaceSprite', replacingSpriteId.value, file); emit('replaceSprite', replacingSpriteId.value, file);
} else {
if (contextMenuIndex.value !== null) {
emit('addSprite', file, contextMenuIndex.value);
} else { } else {
emit('addSprite', file); emit('addSprite', file);
} }
}
} else { } else {
alert('Please select an image file.'); alert('Please select an image file.');
} }
} }
replacingSpriteId.value = null; replacingSpriteId.value = null;
contextMenuIndex.value = null;
input.value = ''; input.value = '';
}; };

View File

@@ -16,48 +16,24 @@
<form @submit.prevent="handleSubmit" class="space-y-4"> <form @submit.prevent="handleSubmit" class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input <input v-model="email" type="email" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="you@example.com" />
v-model="email"
type="email"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
placeholder="you@example.com"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input <input v-model="password" type="password" required minlength="8" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="••••••••" />
v-model="password"
type="password"
required
minlength="8"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
placeholder="••••••••"
/>
</div> </div>
<div v-if="!isLogin"> <div v-if="!isLogin">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input <input v-model="passwordConfirm" type="password" required minlength="8" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="••••••••" />
v-model="passwordConfirm"
type="password"
required
minlength="8"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
placeholder="••••••••"
/>
</div> </div>
<div v-if="error" class="text-red-500 text-sm text-center bg-red-50 dark:bg-red-900/20 p-2 rounded"> <div v-if="error" class="text-red-500 text-sm text-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
{{ error }} {{ error }}
</div> </div>
<button <button type="submit" :disabled="loading" class="w-full btn btn-primary py-2.5 flex justify-center items-center">
type="submit"
:disabled="loading"
class="w-full btn btn-primary py-2.5 flex justify-center items-center"
>
<i v-if="loading" class="fas fa-spinner fa-spin mr-2"></i> <i v-if="loading" class="fas fa-spinner fa-spin mr-2"></i>
{{ isLogin ? 'Sign In' : 'Create Account' }} {{ isLogin ? 'Sign In' : 'Create Account' }}
</button> </button>

View File

@@ -16,11 +16,7 @@
<div class="hidden md:flex items-center gap-5"> <div class="hidden md:flex items-center gap-5">
<!-- Auth & Projects --> <!-- Auth & Projects -->
<template v-if="authStore.user"> <template v-if="authStore.user">
<NavbarProjectActions <NavbarProjectActions @open-save-modal="openSaveModal" @open-new-project-modal="isNewProjectModalOpen = true" @open-project-list="isProjectListOpen = true" />
@open-save-modal="openSaveModal"
@open-new-project-modal="isNewProjectModalOpen = true"
@open-project-list="isProjectListOpen = true"
/>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div> <div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
@@ -29,9 +25,7 @@
</template> </template>
<div v-else> <div v-else>
<button @click="isAuthModalOpen = true" class="btn btn-primary btn-sm shadow-indigo-500/20 shadow-lg"> <button @click="isAuthModalOpen = true" class="btn btn-primary btn-sm shadow-indigo-500/20 shadow-lg">Login / Register</button>
Login / Register
</button>
</div> </div>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div> <div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
@@ -50,36 +44,16 @@
</div> </div>
<!-- Mobile Menu --> <!-- Mobile Menu -->
<NavbarMobileMenu <NavbarMobileMenu :is-open="isMobileMenuOpen" @close="isMobileMenuOpen = false" @open-help="$emit('open-help')" />
:is-open="isMobileMenuOpen"
@close="isMobileMenuOpen = false"
@open-help="$emit('open-help')"
/>
</nav> </nav>
<AuthModal <AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" />
:is-open="isAuthModalOpen" <ProjectList :is-open="isProjectListOpen" @close="isProjectListOpen = false" @open-project="handleOpenProject" />
@close="isAuthModalOpen = false" <SaveProjectModal :is-open="isSaveProjectModalOpen" :initial-name="projectStore.currentProject?.name" @close="isSaveProjectModalOpen = false" @save="handleSaveProject" />
/> <NewProjectModal :is-open="isNewProjectModalOpen" @close="isNewProjectModalOpen = false" @create="handleCreateNewProject" />
<ProjectList
:is-open="isProjectListOpen"
@close="isProjectListOpen = false"
@open-project="handleOpenProject"
/>
<SaveProjectModal
:is-open="isSaveProjectModalOpen"
:initial-name="projectStore.currentProject?.name"
@close="isSaveProjectModalOpen = false"
@save="handleSaveProject"
/>
<NewProjectModal
:is-open="isNewProjectModalOpen"
@close="isNewProjectModalOpen = false"
@create="handleCreateNewProject"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, toRef } from 'vue'; import { ref } from 'vue';
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';
@@ -87,9 +61,7 @@
import SaveProjectModal from '@/components/project/SaveProjectModal.vue'; import SaveProjectModal from '@/components/project/SaveProjectModal.vue';
import NewProjectModal from '@/components/project/NewProjectModal.vue'; import NewProjectModal from '@/components/project/NewProjectModal.vue';
import { useProjectStore, type Project } from '@/stores/useProjectStore'; import { useProjectStore, type Project } from '@/stores/useProjectStore';
import { useSettingsStore } from '@/stores/useSettingsStore'; import { useProjectManager } from '@/composables/useProjectManager';
import { useExportLayers } from '@/composables/useExportLayers';
import { useLayers, createEmptyLayer } from '@/composables/useLayers';
// Sub-components // Sub-components
import NavbarLogo from './navbar/NavbarLogo.vue'; import NavbarLogo from './navbar/NavbarLogo.vue';
@@ -109,66 +81,25 @@
const authStore = useAuthStore(); const authStore = useAuthStore();
const projectStore = useProjectStore(); const projectStore = useProjectStore();
const settingsStore = useSettingsStore(); const { createProject, openProject, saveProject } = useProjectManager();
const { layers, columns, activeLayerId } = useLayers();
const { generateProjectJSON, loadProjectData } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
const handleOpenProject = async (project: Project) => { const handleOpenProject = async (project: Project) => {
try { await openProject(project);
if (project.data) {
await loadProjectData(project.data);
}
projectStore.currentProject = project;
} catch (e) {
console.error("Failed to open project", e);
alert("Failed to open project data");
}
}; };
const openSaveModal = () => { const openSaveModal = () => {
isSaveProjectModalOpen.value = true; isSaveProjectModalOpen.value = true;
} };
const handleSaveProject = async (name: string) => { const handleSaveProject = async (name: string) => {
try { try {
const data = await generateProjectJSON(); await saveProject(name);
} catch {
if (projectStore.currentProject && projectStore.currentProject.name === name) { // Error handled in composable
await projectStore.updateProject(projectStore.currentProject.id, data);
} else {
await projectStore.createProject(name, data);
}
} catch (e) {
console.error(e);
alert("Failed to save project");
} }
}; };
const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => { const handleCreateNewProject = (config: { width: number; height: number; columns: number; rows: number }) => {
// 1. Reset Settings createProject(config);
settingsStore.setManualCellSize(config.width, config.height);
settingsStore.manualCellSizeEnabled = true;
// 2. Reset Layers
const newLayer = createEmptyLayer('Base');
layers.value = [newLayer];
activeLayerId.value = newLayer.id;
// 3. Set Columns
columns.value = config.columns;
// 4. Reset Project Store
projectStore.currentProject = null;
}; };
</script> </script>

View File

@@ -3,20 +3,13 @@
<div class="flex items-center gap-2 group"> <div class="flex items-center gap-2 group">
<div class="text-right hidden lg:block"> <div class="text-right hidden lg:block">
<p class="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500 mb-[-2px]">Current Project</p> <p class="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500 mb-[-2px]">Current Project</p>
<button <button @click="$emit('open-save-modal')" class="text-sm font-bold text-gray-800 dark:text-gray-200 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors truncate max-w-[160px]" title="Rename Project">
@click="$emit('open-save-modal')"
class="text-sm font-bold text-gray-800 dark:text-gray-200 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors truncate max-w-[160px]"
title="Rename Project"
>
{{ projectStore.currentProject?.name || 'Untitled Project' }} {{ projectStore.currentProject?.name || 'Untitled Project' }}
</button> </button>
</div> </div>
<!-- Mobile/Tablet simplified view --> <!-- Mobile/Tablet simplified view -->
<button <button @click="$emit('open-save-modal')" class="lg:hidden text-sm font-bold text-gray-800 dark:text-gray-200 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors truncate max-w-[120px]">
@click="$emit('open-save-modal')"
class="lg:hidden text-sm font-bold text-gray-800 dark:text-gray-200 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors truncate max-w-[120px]"
>
{{ projectStore.currentProject?.name || 'Untitled' }} {{ projectStore.currentProject?.name || 'Untitled' }}
</button> </button>
</div> </div>

View File

@@ -1,10 +1,7 @@
<template> <template>
<div v-if="authStore.user" class="flex items-center gap-4"> <div v-if="authStore.user" class="flex items-center gap-4">
<div class="relative" ref="userMenuRef"> <div class="relative" ref="userMenuRef">
<button <button @click="isUserMenuOpen = !isUserMenuOpen" class="w-9 h-9 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-bold text-sm flex items-center justify-center shadow-md hover:shadow-lg transition-all border-2 border-white dark:border-gray-800">
@click="isUserMenuOpen = !isUserMenuOpen"
class="w-9 h-9 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white font-bold text-sm flex items-center justify-center shadow-md hover:shadow-lg transition-all border-2 border-white dark:border-gray-800"
>
{{ userInitials }} {{ userInitials }}
</button> </button>
@@ -22,10 +19,7 @@
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" :title="authStore.user.email">{{ authStore.user.email }}</p> <p class="text-sm font-medium text-gray-900 dark:text-white truncate" :title="authStore.user.email">{{ authStore.user.email }}</p>
</div> </div>
<div class="p-1"> <div class="p-1">
<button <button @click="handleLogout" class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors flex items-center gap-2">
@click="handleLogout"
class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors flex items-center gap-2"
>
<i class="fas fa-sign-out-alt"></i> Sign out <i class="fas fa-sign-out-alt"></i> Sign out
</button> </button>
</div> </div>
@@ -34,9 +28,7 @@
</div> </div>
</div> </div>
<div v-else> <div v-else>
<button @click="$emit('open-auth-modal')" class="btn btn-primary btn-sm shadow-indigo-500/20 shadow-lg"> <button @click="$emit('open-auth-modal')" class="btn btn-primary btn-sm shadow-indigo-500/20 shadow-lg">Login / Register</button>
Login / Register
</button>
</div> </div>
</template> </template>

View File

@@ -4,31 +4,18 @@
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm border border-gray-200 dark:border-gray-700"> <div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">New Project</h2> <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">New Project</h2>
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-2 gap-4 mb-4">
<div> <div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Width</label> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Width</label>
<div class="relative"> <div class="relative">
<input <input v-model.number="width" type="number" min="1" required class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
v-model.number="width"
type="number"
min="1"
required
class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<span class="absolute right-3 top-2 text-gray-400 text-xs">px</span> <span class="absolute right-3 top-2 text-gray-400 text-xs">px</span>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Height</label> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Height</label>
<div class="relative"> <div class="relative">
<input <input v-model.number="height" type="number" min="1" required class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
v-model.number="height"
type="number"
min="1"
required
class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<span class="absolute right-3 top-2 text-gray-400 text-xs">px</span> <span class="absolute right-3 top-2 text-gray-400 text-xs">px</span>
</div> </div>
</div> </div>
@@ -37,25 +24,11 @@
<div class="grid grid-cols-2 gap-4 mb-6"> <div class="grid grid-cols-2 gap-4 mb-6">
<div> <div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Columns</label> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Columns</label>
<input <input v-model.number="columns" type="number" min="1" max="20" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
v-model.number="columns"
type="number"
min="1"
max="20"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Rows</label> <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Rows</label>
<input <input v-model.number="rows" type="number" min="1" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" title="Rows are dynamic but this sets an initial expectation" />
v-model.number="rows"
type="number"
min="1"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
title="Rows are dynamic but this sets an initial expectation"
/>
</div> </div>
</div> </div>
@@ -91,14 +64,17 @@ const isValid = computed(() => {
}); });
// Reset to defaults when opened // Reset to defaults when opened
watch(() => props.isOpen, (val) => { watch(
() => props.isOpen,
val => {
if (val) { if (val) {
width.value = 64; width.value = 64;
height.value = 64; height.value = 64;
columns.value = 8; columns.value = 8;
rows.value = 8; rows.value = 8;
} }
}); }
);
const close = () => emit('close'); const close = () => emit('close');
const handleSubmit = () => { const handleSubmit = () => {
@@ -107,7 +83,7 @@ const handleSubmit = () => {
width: width.value, width: width.value,
height: height.value, height: height.value,
columns: columns.value, columns: columns.value,
rows: rows.value rows: rows.value,
}); });
close(); close();
} }

View File

@@ -20,12 +20,7 @@
</div> </div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div <div v-for="project in projects" :key="project.id" class="group p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-indigo-500 dark:hover:border-indigo-500 transition-all cursor-pointer relative" @click="selectProject(project)">
v-for="project in projects"
:key="project.id"
class="group p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-indigo-500 dark:hover:border-indigo-500 transition-all cursor-pointer relative"
@click="selectProject(project)"
>
<div class="flex items-start justify-between mb-2"> <div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="font-bold text-gray-900 dark:text-gray-100 truncate pr-2">{{ project.name }}</h3> <h3 class="font-bold text-gray-900 dark:text-gray-100 truncate pr-2">{{ project.name }}</h3>
@@ -69,11 +64,14 @@ const emit = defineEmits<{
const projectStore = useProjectStore(); const projectStore = useProjectStore();
const { projects, isLoading: loading } = toRefs(projectStore); const { projects, isLoading: loading } = toRefs(projectStore);
watch(() => props.isOpen, (isOpen) => { watch(
() => props.isOpen,
isOpen => {
if (isOpen) { if (isOpen) {
projectStore.fetchProjects(); projectStore.fetchProjects();
} }
}); }
);
onMounted(() => { onMounted(() => {
if (props.isOpen) { if (props.isOpen) {

View File

@@ -6,14 +6,7 @@
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input <input v-model="projectName" type="text" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="My Awesome Spritesheet" ref="inputRef" />
v-model="projectName"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
placeholder="My Awesome Spritesheet"
ref="inputRef"
/>
</div> </div>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button type="button" @click="close" class="btn btn-secondary">Cancel</button> <button type="button" @click="close" class="btn btn-secondary">Cancel</button>
@@ -43,7 +36,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
watch( watch(
() => props.isOpen, () => props.isOpen,
(isOpen) => { isOpen => {
if (isOpen) { if (isOpen) {
projectName.value = props.initialName || ''; projectName.value = props.initialName || '';
nextTick(() => { nextTick(() => {

View File

@@ -113,19 +113,16 @@ export function useDragSprite(options: DragSpriteOptions) {
}); });
const findCellAtPosition = (x: number, y: number): CellPosition | null => { const findCellAtPosition = (x: number, y: number): CellPosition | null => {
const sprites = getSprites();
const columns = getColumns(); const columns = getColumns();
const { maxWidth, maxHeight } = calculateMaxDimensions(); const { maxWidth, maxHeight } = calculateMaxDimensions();
const col = Math.floor(x / maxWidth); const col = Math.floor(x / maxWidth);
const row = Math.floor(y / maxHeight); const row = Math.floor(y / maxHeight);
const totalRows = Math.ceil(sprites.length / columns); // Allow dropping anywhere in the columns, assuming infinite rows effectively
if (col >= 0 && col < columns && row >= 0 && row < totalRows) { if (col >= 0 && col < columns && row >= 0) {
const index = row * columns + col; const index = row * columns + col;
if (index < sprites.length) {
return { col, row, index }; return { col, row, index };
} }
}
return null; return null;
}; };

View File

@@ -15,6 +15,20 @@ const layers = ref<Layer[]>([createEmptyLayer('Base')]);
const activeLayerId = ref<string>(layers.value[0].id); const activeLayerId = ref<string>(layers.value[0].id);
const columns = ref(4); const columns = ref(4);
const createEmptySprite = (): Sprite => ({
id: crypto.randomUUID(),
file: new File([], 'empty'),
img: new Image(),
url: '',
width: 0,
height: 0,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
});
export const useLayers = () => { export const useLayers = () => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -103,17 +117,66 @@ export const useLayers = () => {
if (!l) return; if (!l) return;
const currentIndex = l.sprites.findIndex(s => s.id === id); const currentIndex = l.sprites.findIndex(s => s.id === id);
if (currentIndex === -1 || currentIndex === newIndex) return; if (currentIndex === -1 || currentIndex === newIndex) return;
const next = [...l.sprites]; const next = [...l.sprites];
if (newIndex < next.length) {
const moving = { ...next[currentIndex] }; // Remove the moving sprite first
const target = { ...next[newIndex] }; const [moving] = next.splice(currentIndex, 1);
next[currentIndex] = target;
next[newIndex] = moving; // Determine the actual index to insert at, considering we removed one item
} else { // If the target index was greater than current index, it shifts down by 1 in the original array perspective?
const [moved] = next.splice(currentIndex, 1); // Actually simpler: we just want to put 'moving' at 'newIndex' in the final array.
next.splice(newIndex, 0, moved);
// If newIndex is beyond the current bounds (after removal), fill with placeholders
while (next.length < newIndex) {
next.push(createEmptySprite());
}
// Now insert
// If newIndex is within bounds, we might be swapping if there was something there
// But the DragSprite logic implies we are "moving to this cell".
// If there is existing content at newIndex, we should swap or splice?
// The previous implementation did a swap if newIndex < length (before removal).
// Let's stick to the "swap" logic if there's a sprite there, or "move" if we are reordering.
// Wait, Drag and Drop usually implies "insert here" or "swap with this".
// useDragSprite says: "if allowCellSwap... updateSpriteCell".
// The original logic:
// if (newIndex < next.length) -> swap
// else -> splice (move)
// Re-evaluating original logic:
// next has NOT had the item removed yet in the original logic 'if' block.
// Let's implement robust swap/move logic.
// 1. If target is empty placeholder -> just move there (replace placeholder).
// 2. If target has sprite -> swap.
// 3. If target is out of bounds -> pad and move.
if (newIndex < l.sprites.length) {
// Perform Swap
const target = l.sprites[newIndex];
const moving = l.sprites[currentIndex];
// Clone array
const newSprites = [...l.sprites];
newSprites[currentIndex] = target;
newSprites[newIndex] = moving;
l.sprites = newSprites;
} else {
// Move to previously empty/non-existent cell
const newSprites = [...l.sprites];
// Remove from old pos
const [moved] = newSprites.splice(currentIndex, 1);
// Pad
while (newSprites.length < newIndex) {
newSprites.push(createEmptySprite());
}
// Insert (or push if equal length)
newSprites.splice(newIndex, 0, moved);
l.sprites = newSprites;
} }
l.sprites = next;
}; };
const removeSprite = (id: string) => { const removeSprite = (id: string) => {
@@ -203,7 +266,7 @@ export const useLayers = () => {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
const addSprite = (file: File) => { const addSprite = (file: File, index?: number) => {
const l = activeLayer.value; const l = activeLayer.value;
if (!l) return; if (!l) return;
const reader = new FileReader(); const reader = new FileReader();
@@ -224,7 +287,28 @@ export const useLayers = () => {
flipX: false, flipX: false,
flipY: false, flipY: false,
}; };
l.sprites = [...l.sprites, next];
const currentSprites = [...l.sprites];
if (typeof index === 'number') {
// If index is provided, insert there (padding if needed)
while (currentSprites.length < index) {
currentSprites.push(createEmptySprite());
}
// If valid index, replace if empty or splice?
// "Adds it not in the one I selected".
// If I select a cell, I expect it to go there.
// If the cell is empty (placeholder), replace it.
// If the cell has a sprite, maybe insert/shift?
// Usually "Add" implies append, but context menu "Add sprite" on a cell implies "Put it here".
// Let's Insert (Shift others) for safety, or check if empty.
// But simpler: just splice it in.
currentSprites.splice(index, 0, next);
} else {
// No index, append to end
currentSprites.push(next);
}
l.sprites = currentSprites;
}; };
img.onerror = () => { img.onerror = () => {
console.error('Failed to load sprite image:', file.name); console.error('Failed to load sprite image:', file.name);
@@ -299,19 +383,7 @@ export const useLayers = () => {
// Expand the sprites array if necessary with empty placeholder sprites // Expand the sprites array if necessary with empty placeholder sprites
while (targetLayer.sprites.length < targetFrameIndex) { while (targetLayer.sprites.length < targetFrameIndex) {
targetLayer.sprites.push({ targetLayer.sprites.push(createEmptySprite());
id: crypto.randomUUID(),
file: new File([], 'empty'),
img: new Image(),
url: '',
width: 0,
height: 0,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
});
} }
// Replace or insert the sprite at the target index // Replace or insert the sprite at the target index

View File

@@ -0,0 +1,95 @@
import { ref, toRef, watch } from 'vue';
import { useLayers, createEmptyLayer, getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useSettingsStore } from '@/stores/useSettingsStore';
import { useProjectStore, type Project } from '@/stores/useProjectStore';
import { useExportLayers } from '@/composables/useExportLayers';
// Global state for editor visibility
const isEditorActive = ref(false);
export const useProjectManager = () => {
const settingsStore = useSettingsStore();
const projectStore = useProjectStore();
const { layers, columns, activeLayerId, visibleLayers } = useLayers();
const { generateProjectJSON, loadProjectData } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
// Watch for sprites to automatically open editor (legacy behavior support)
watch(
() => layers.value.some(l => l.sprites.length > 0),
hasSprites => {
if (hasSprites) isEditorActive.value = true;
},
{ immediate: true }
);
const createProject = (config: { width: number; height: number; columns: number; rows: number }) => {
// 1. Reset Settings
settingsStore.setManualCellSize(config.width, config.height);
settingsStore.manualCellSizeEnabled = true;
// 2. Reset Layers
const newLayer = createEmptyLayer('Base');
layers.value = [newLayer];
activeLayerId.value = newLayer.id;
// 3. Set Columns
columns.value = config.columns;
// 4. Reset Project Store
projectStore.currentProject = null;
// 5. Force Editor Active
isEditorActive.value = true;
};
const openProject = async (project: Project) => {
try {
if (project.data) {
await loadProjectData(project.data);
}
projectStore.currentProject = project;
isEditorActive.value = true;
} catch (e) {
console.error('Failed to open project', e);
alert('Failed to open project data');
}
};
const saveProject = async (name: string) => {
try {
const data = await generateProjectJSON();
if (projectStore.currentProject && projectStore.currentProject.name === name) {
await projectStore.updateProject(projectStore.currentProject.id, data);
} else {
await projectStore.createProject(name, data);
}
} catch (e) {
console.error(e);
alert('Failed to save project');
throw e; // Re-throw to let caller know
}
};
const closeEditor = () => {
isEditorActive.value = false;
};
return {
isEditorActive,
createProject,
openProject,
saveProject,
closeEditor,
};
};

View File

@@ -89,10 +89,10 @@ export const useProjectStore = defineStore('project', () => {
name: record.name, name: record.name,
data: record.data, data: record.data,
created: record.created, created: record.created,
updated: record.updated updated: record.updated,
}; };
} catch (error) { } catch (error) {
console.error("Failed to load project", error); console.error('Failed to load project', error);
throw error; throw error;
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@@ -125,6 +125,6 @@ export const useProjectStore = defineStore('project', () => {
createProject, createProject,
updateProject, updateProject,
loadProject, loadProject,
deleteProject deleteProject,
}; };
}); });

View File

@@ -1,7 +1,7 @@
<template> <template>
<main class="flex flex-col flex-1 h-full min-h-0 relative"> <main class="flex flex-col flex-1 h-full min-h-0 relative">
<!-- Welcome / Empty State --> <!-- Welcome / Empty State -->
<div v-if="!layers.some(l => l.sprites.length)" class="flex-1 flex flex-col items-center justify-center pb-12"> <div v-if="!isEditorActive" class="flex-1 flex flex-col items-center justify-center pb-12">
<div class="w-full max-w-[90rem] px-4 sm:px-6 lg:px-8 flex flex-col gap-8 lg:gap-12 items-start"> <div class="w-full max-w-[90rem] px-4 sm:px-6 lg:px-8 flex flex-col gap-8 lg:gap-12 items-start">
<!-- Top Row: Upload Field & Video Side by Side --> <!-- Top Row: Upload Field & Video Side by Side -->
<div class="w-full grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8"> <div class="w-full grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8">
@@ -121,7 +121,7 @@
</div> </div>
<!-- Main Editor Interface --> <!-- Main Editor Interface -->
<div v-else class="flex flex-col lg:flex-row gap-6 h-full min-h-[600px] lg:overflow-hidden"> <div v-else-if="isEditorActive" class="flex flex-col lg:flex-row gap-6 h-full min-h-[600px] lg:overflow-hidden">
<!-- Sidebar Controls --> <!-- Sidebar Controls -->
<aside class="flex flex-col w-full lg:w-[340px] gap-4 shrink-0 lg:overflow-hidden"> <aside class="flex flex-col w-full lg:w-[340px] gap-4 shrink-0 lg:overflow-hidden">
<div class="glass-panel rounded-xl flex flex-col h-full lg:overflow-hidden border border-gray-200 dark:border-gray-700/60 shadow-lg"> <div class="glass-panel rounded-xl flex flex-col h-full lg:overflow-hidden border border-gray-200 dark:border-gray-700/60 shadow-lg">
@@ -456,11 +456,13 @@
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing'; import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
import { useHomeViewSEO } from './HomeView.seo'; import { useHomeViewSEO } from './HomeView.seo';
import { useZoom } from '@/composables/useZoom'; import { useZoom } from '@/composables/useZoom';
import { useProjectManager } from '@/composables/useProjectManager';
import type { SpriteFile } from '@/types/sprites'; import type { SpriteFile } from '@/types/sprites';
import tutVideo from '@/assets/tut2.mp4'; import tutVideo from '@/assets/tut2.mp4';
useHomeViewSEO(); useHomeViewSEO();
const { isEditorActive } = useProjectManager();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite, copySpriteToFrame } = const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite, copySpriteToFrame } =
useLayers(); useLayers();