[FEAT] Re-organize files

This commit is contained in:
2026-01-02 00:47:41 +01:00
parent b7f90e69a1
commit 79f9e3f7b2
7 changed files with 733 additions and 602 deletions

View File

@@ -1,9 +1,9 @@
<template>
<div class="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950 transition-colors duration-300 font-sans" :class="{ 'lg:h-screen': layers.some(l => l.sprites.length) && $route.name === 'home' }">
<div class="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950 transition-colors duration-300 font-sans" :class="{ 'lg:h-screen': $route.name === 'editor' }">
<!-- Navbar -->
<Navbar @open-help="openHelpModal" />
<div class="flex flex-col flex-1 relative z-10 p-4 sm:p-6 lg:p-8 pt-6" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) && $route.name === 'home' }">
<div class="flex flex-col flex-1 relative z-10 p-4 sm:p-6 lg:p-8 pt-6" :class="{ 'lg:overflow-hidden': $route.name === 'editor' }">
<Breadcrumbs class="mb-6" />
<router-view v-slot="{ Component }">
@@ -43,9 +43,6 @@
import HelpModal from './components/HelpModal.vue';
import FeedbackModal from './components/FeedbackModal.vue';
import Breadcrumbs from './components/Breadcrumbs.vue';
import { useLayers } from './composables/useLayers';
const { layers } = useLayers();
const isHelpModalOpen = ref(false);
const isFeedbackModalOpen = ref(false);

View File

@@ -85,10 +85,11 @@
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import { useProjectManager } from '@/composables/useProjectManager';
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
const { isEditorActive } = useProjectManager();
const route = useRoute();
const isEditorActive = computed(() => route.name === 'editor');
defineProps<{
isOpen: boolean;

View File

@@ -47,12 +47,15 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useProjectStore } from '@/stores/useProjectStore';
import { useProjectManager } from '@/composables/useProjectManager';
import Tooltip from '@/components/utilities/Tooltip.vue';
defineEmits(['save-project', 'open-save-modal', 'open-project-list', 'open-new-project-modal']);
const route = useRoute();
const projectStore = useProjectStore();
const { isEditorActive } = useProjectManager();
const isEditorActive = computed(() => route.name === 'editor');
</script>

View File

@@ -1,16 +1,15 @@
import { ref, toRef, watch } from 'vue';
import { useLayers, createEmptyLayer, getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useRouter } from 'vue-router';
import { toRef } from 'vue';
import { useLayers, createEmptyLayer } 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 router = useRouter();
const settingsStore = useSettingsStore();
const projectStore = useProjectStore();
const { layers, columns, activeLayerId, visibleLayers } = useLayers();
const { layers, columns, activeLayerId } = useLayers();
const { generateProjectJSON, loadProjectData } = useExportLayers(
layers,
@@ -23,15 +22,6 @@ export const useProjectManager = () => {
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);
@@ -48,8 +38,8 @@ export const useProjectManager = () => {
// 4. Reset Project Store
projectStore.currentProject = null;
// 5. Force Editor Active
isEditorActive.value = true;
// 5. Navigate to Editor
router.push('/editor');
};
const openProject = async (project: Project) => {
@@ -58,7 +48,7 @@ export const useProjectManager = () => {
await loadProjectData(project.data);
}
projectStore.currentProject = project;
isEditorActive.value = true;
router.push({ name: 'editor', params: { id: project.id } });
} catch (e) {
console.error('Failed to open project', e);
alert('Failed to open project data');
@@ -75,6 +65,10 @@ export const useProjectManager = () => {
} else {
// Create new project if none exists
await projectStore.createProject(name, data);
// After creating, we should update route to include ID so subsequent saves update it
if (projectStore.currentProject) {
router.replace({ name: 'editor', params: { id: projectStore.currentProject.id } });
}
}
} catch (e) {
console.error(e);
@@ -88,6 +82,10 @@ export const useProjectManager = () => {
const data = await generateProjectJSON();
// Always create new
await projectStore.createProject(name, data);
// Navigate to new project
if (projectStore.currentProject) {
router.push({ name: 'editor', params: { id: projectStore.currentProject.id } });
}
} catch (e) {
console.error(e);
alert('Failed to save project as new');
@@ -95,10 +93,6 @@ export const useProjectManager = () => {
}
};
const closeEditor = () => {
isEditorActive.value = false;
};
const closeProject = () => {
// Reset Layers
const newLayer = createEmptyLayer('Base');
@@ -111,12 +105,15 @@ export const useProjectManager = () => {
// Reset Project Store
projectStore.currentProject = null;
// Close Editor
isEditorActive.value = false;
// Navigate Home
router.push('/');
};
const closeEditor = () => {
closeProject();
};
return {
isEditorActive,
createProject,
openProject,
saveProject,

View File

@@ -44,6 +44,11 @@ const router = createRouter({
name: 'share',
component: () => import('../views/ShareView.vue'),
},
{
path: '/editor/:id?',
name: 'editor',
component: () => import('../views/EditorView.vue'),
},
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {

659
src/views/EditorView.vue Normal file
View File

@@ -0,0 +1,659 @@
<template>
<main class="flex flex-col flex-1 h-full min-h-0 relative">
<!-- Main Editor Interface -->
<div class="flex flex-col lg:flex-row gap-6 h-full min-h-[600px] lg:overflow-hidden">
<!-- Sidebar Controls -->
<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">
<!-- Sidebar Header -->
<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>
<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">
<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>
</div>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto overflow-x-hidden p-5 space-y-6 scrollbar-thin">
<!-- Layers -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Layers</h3>
<button @click="addLayer()" class="text-xs p-1 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded transition-colors" title="Add Layer"><i class="fas fa-layer-group mr-1"></i> New</button>
</div>
<div class="space-y-2">
<div
v-for="layer in layers"
:key="layer.id"
class="group flex items-center gap-2 p-2 rounded-lg border transition-all duration-200"
:class="activeLayerId === layer.id ? 'bg-white dark:bg-gray-800 border-indigo-500 ring-1 ring-indigo-500 shadow-sm' : 'bg-gray-50 dark:bg-gray-800/40 border-transparent hover:border-gray-200 dark:hover:border-gray-700'"
>
<!-- Visibility Toggle -->
<button @click.stop="layer.visible = !layer.visible" class="w-6 h-6 flex items-center justify-center rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
<i :class="layer.visible ? 'fas fa-eye' : 'fas fa-eye-slash'" class="text-xs"></i>
</button>
<!-- Layer Name -->
<div class="flex-1 min-w-0">
<input
v-if="editingLayerId === layer.id"
v-model="editingLayerName"
@blur="finishEditingLayer"
@keyup.enter="finishEditingLayer"
@keyup.esc="cancelEditingLayer"
ref="layerNameInput"
class="w-full text-sm bg-transparent border-b border-indigo-500 outline-none p-0 text-gray-900 dark:text-gray-100"
/>
<button v-else @click="activeLayerId = layer.id" class="w-full text-left text-sm font-medium truncate" :class="activeLayerId === layer.id ? 'text-gray-900 dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'">
{{ layer.name }} <span class="text-xs opacity-50 font-normal ml-1">({{ layer.sprites.length }})</span>
</button>
</div>
<!-- Actions -->
<div class="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="startEditingLayer(layer.id, layer.name)" class="p-1.5 text-gray-400 hover:text-indigo-500 transition-colors" title="Rename"><i class="fas fa-pen text-[10px]"></i></button>
<button @click="moveLayer(layer.id, 'up')" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" title="Move Up"><i class="fas fa-chevron-up text-[10px]"></i></button>
<button @click="moveLayer(layer.id, 'down')" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" title="Move Down"><i class="fas fa-chevron-down text-[10px]"></i></button>
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="p-1.5 text-gray-400 hover:text-red-500 transition-colors" title="Delete"><i class="fas fa-trash text-[10px]"></i></button>
</div>
</div>
</div>
</section>
<!-- Alignment Tools (Always visible) -->
<section>
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Alignment</h3>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-3 gap-2">
<Tooltip text="Align sprites to the left edge">
<button @click="alignSprites('left')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-left"></i>Left</button>
</Tooltip>
<Tooltip text="Center sprites horizontally">
<button @click="alignSprites('center')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrows-left-right"></i>Center</button>
</Tooltip>
<Tooltip text="Align sprites to the right edge">
<button @click="alignSprites('right')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-right"></i>Right</button>
</Tooltip>
<Tooltip text="Align sprites to the top edge">
<button @click="alignSprites('top')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-up"></i>Top</button>
</Tooltip>
<Tooltip text="Center sprites vertically">
<button @click="alignSprites('middle')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrows-up-down"></i>Middle</button>
</Tooltip>
<Tooltip text="Align sprites to the bottom edge">
<button @click="alignSprites('bottom')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-down"></i>Bottom</button>
</Tooltip>
</div>
</section>
<!-- Canvas Grid Settings (Editor only) -->
<section v-if="activeTab === 'canvas'">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Grid Layout</h3>
<div class="card p-3 bg-gray-50/50 dark:bg-gray-800/40 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-300">Columns</label>
<input type="number" v-model.number="columns" min="1" max="20" class="input-field w-16 text-center" />
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-300">Force Size</label>
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
</div>
<div v-if="settingsStore.manualCellSizeEnabled" class="flex items-center gap-1">
<input v-model.number="settingsStore.manualCellWidth" class="input-field w-14 text-center px-1" placeholder="W" />
<span class="text-gray-400 text-xs">x</span>
<input v-model.number="settingsStore.manualCellHeight" class="input-field w-14 text-center px-1" placeholder="H" />
</div>
<span v-else class="text-xs font-mono text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">{{ cellSize.width }}×{{ cellSize.height }}px</span>
</div>
</div>
</section>
<!-- View Options (Editor only) -->
<section v-if="activeTab === 'canvas'">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">View Options</h3>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-2 gap-2">
<Tooltip text="Disable anti-aliasing for crisp pixel art rendering">
<button
@click="settingsStore.pixelPerfect = !settingsStore.pixelPerfect"
:class="settingsStore.pixelPerfect ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-th mr-2"></i>Pixel
</button>
</Tooltip>
<Tooltip text="Show checkerboard background">
<button
@click="settingsStore.checkerboardEnabled = !settingsStore.checkerboardEnabled"
:class="settingsStore.checkerboardEnabled ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-chess-board mr-2"></i>Grid
</button>
</Tooltip>
<Tooltip text="Show selection borders">
<button
@click="showActiveBorder = !showActiveBorder"
:class="showActiveBorder ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-vector-square mr-2"></i>Borders
</button>
</Tooltip>
<Tooltip text="Show sprite coordinates">
<button
@click="showOffsetLabels = !showOffsetLabels"
:class="showOffsetLabels ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-tag mr-2"></i>Labels
</button>
</Tooltip>
<Tooltip text="Compare with ghost overlays" class="col-span-2">
<button
@click="showAllSprites = !showAllSprites"
:class="showAllSprites ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-clone mr-2"></i>Ghost compare
</button>
</Tooltip>
</div>
</section>
<!-- Tools (Editor only) -->
<section v-if="activeTab === 'canvas'">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Tools</h3>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 space-y-2">
<div class="flex gap-2">
<Tooltip text="Select multiple sprites" class="flex-1">
<button
@click="isMultiSelectMode = !isMultiSelectMode"
:class="isMultiSelectMode ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-object-group mr-2"></i>Multi
</button>
</Tooltip>
<Tooltip text="Swap cell positions" class="flex-1">
<button
@click="allowCellSwap = !allowCellSwap"
:class="allowCellSwap ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-exchange-alt mr-2"></i>Swap
</button>
</Tooltip>
</div>
<div class="flex items-center justify-between px-2 pt-1 border-t border-gray-100 dark:border-gray-700/50">
<span class="text-xs text-gray-600 dark:text-gray-400">Negative Spacing</span>
<button
@click="settingsStore.negativeSpacingEnabled = !settingsStore.negativeSpacingEnabled"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="settingsStore.negativeSpacingEnabled ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700'"
>
<span class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform" :class="settingsStore.negativeSpacingEnabled ? 'translate-x-5' : 'translate-x-1'" />
</button>
</div>
</div>
</section>
</div>
<!-- Sidebar Footer (Export) -->
<div class="p-4 border-t border-gray-200/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/10 space-y-2 shrink-0">
<div class="grid grid-cols-2 gap-2">
<button @click="downloadSpritesheet" class="btn btn-secondary btn-sm justify-start" title="Download PNG"><i class="fas fa-image text-indigo-500 w-4"></i> PNG</button>
<button @click="exportSpritesheetJSON" class="btn btn-secondary btn-sm justify-start" title="Download JSON"><i class="fas fa-code text-indigo-500 w-4"></i> JSON</button>
<button @click="openGifFpsModal" class="btn btn-secondary btn-sm justify-start" title="Export GIF"><i class="fas fa-film text-pink-500 w-4"></i> GIF</button>
<button @click="downloadAsZip" class="btn btn-secondary btn-sm justify-start" title="Download ZIP"><i class="fas fa-file-archive text-yellow-500 w-4"></i> ZIP</button>
</div>
<button @click="openShareModal" class="btn btn-secondary w-full justify-start"><i class="fas fa-share-alt mr-2 text-indigo-500"></i> Share project</button>
</div>
</div>
</aside>
<!-- Main Canvas Area -->
<div class="flex-1 flex flex-col min-h-0 glass-panel rounded-xl border border-gray-200 dark:border-gray-700/60 shadow-lg overflow-hidden">
<!-- Tabs / Toolbar -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/20">
<div class="flex items-center gap-3">
<div class="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
@click="activeTab = 'canvas'"
:class="activeTab === 'canvas' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
>
<i class="fas fa-th mr-2"></i>Editor
</button>
<button
@click="activeTab = 'preview'"
:class="activeTab === 'preview' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
>
<i class="fas fa-play mr-2"></i>Preview
</button>
</div>
<!-- Background Color (Compact) -->
<div class="flex items-center gap-2 pl-3 border-l border-gray-200 dark:border-gray-700">
<span class="text-xs font-medium text-gray-500 uppercase">Bg</span>
<div class="flex items-center gap-1">
<select v-model="bgSelectValue" class="text-xs dark:text-gray-300 focus:ring-0 cursor-pointer pr-8">
<option value="transparent">None</option>
<option value="#ffffff">White</option>
<option value="#000000">Black</option>
<option value="#f9fafb">Gray</option>
<option value="custom">Custom</option>
</select>
<div v-if="bgSelectValue === 'custom'" class="relative w-5 h-5 rounded-full overflow-hidden border border-gray-300 dark:border-gray-600 shadow-sm">
<input type="color" v-model="customColor" @input="updateCustomColor" class="absolute -top-1 -left-1 w-8 h-8 cursor-pointer p-0 border-0 opacity-0" />
<div class="w-full h-full" :style="{ backgroundColor: customColor }"></div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'canvas'" class="flex items-center gap-2">
<div class="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button @click="zoomOut" class="p-1.5 hover:bg-white dark:hover:bg-gray-700 rounded text-gray-500 transition-colors"><i class="fas fa-minus text-xs"></i></button>
<span class="text-xs font-mono w-12 text-center text-gray-600 dark:text-gray-300">{{ Math.round(zoom * 100) }}%</span>
<button @click="zoomIn" class="p-1.5 hover:bg-white dark:hover:bg-gray-700 rounded text-gray-500 transition-colors"><i class="fas fa-plus text-xs"></i></button>
</div>
</div>
</div>
<!-- Canvas Content -->
<div class="flex-1 overflow-hidden relative bg-white dark:bg-gray-900/50">
<!-- Grid Background Pattern -->
<div
class="absolute inset-0 opacity-5 pointer-events-none bg-gray-500"
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2220%22 height=%2220%22><circle cx=%2210%22 cy=%2210%22 r=%221%22 fill=%22%236b7280%22/></svg>'); background-size: 20px 20px"
></div>
<div class="h-full overflow-auto custom-scrollbar p-4">
<div v-if="activeTab === 'canvas'" class="h-full flex flex-col justify-center">
<sprite-canvas
:layers="layers"
:active-layer-id="activeLayerId"
:columns="columns"
:zoom="zoom"
:is-multi-select-mode="isMultiSelectMode"
:show-active-border="showActiveBorder"
:allow-cell-swap="allowCellSwap"
:show-all-sprites="showAllSprites"
:show-offset-labels="showOffsetLabels"
@update-sprite="updateSpritePosition"
@update-sprite-cell="updateSpriteCell"
@remove-sprite="removeSprite"
@remove-sprites="removeSprites"
@replace-sprite="replaceSprite"
@add-sprite="addSprite"
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
/>
</div>
<div v-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
<sprite-preview
:layers="layers"
:active-layer-id="activeLayerId"
:columns="columns"
@update-sprite="updateSpritePosition"
@update-sprite-in-layer="updateSpriteInLayer"
@drop-sprite="handleDropSprite"
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
@replace-sprite="replaceSprite"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Modals & Hidden Inputs -->
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
<ShareModal :is-open="isShareModalOpen" :share-function="shareFunction" @close="closeShareModal" />
<input ref="uploadInput" type="file" multiple accept="image/*" class="hidden" @change="handleUploadChange" />
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
</main>
</template>
<script setup lang="ts">
import { ref, toRef, computed, watch, nextTick, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import FileUploader from '@/components/FileUploader.vue';
import SpriteCanvas from '@/components/SpriteCanvas.vue';
import SpritePreview from '@/components/SpritePreview.vue';
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
import GifFpsModal from '@/components/GifFpsModal.vue';
import ShareModal from '@/components/ShareModal.vue';
import Tooltip from '@/components/utilities/Tooltip.vue';
import { useExportLayers } from '@/composables/useExportLayers';
import { useShare } from '@/composables/useShare';
import { useLayers } from '@/composables/useLayers';
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useSettingsStore } from '@/stores/useSettingsStore';
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
import { useZoom } from '@/composables/useZoom';
import { useProjectManager } from '@/composables/useProjectManager';
import { useProjectStore } from '@/stores/useProjectStore';
import type { SpriteFile } from '@/types/sprites';
const route = useRoute();
const router = useRouter();
const projectStore = useProjectStore();
const { closeProject, loadProjectData } = useProjectManager();
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite, copySpriteToFrame } =
useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
// Zoom Control
const {
zoom,
increase: zoomIn,
decrease: zoomOut,
reset: resetZoom,
} = useZoom({
min: 0.5,
max: 3,
step: 0.25,
initial: 1,
});
// View Options & Tools
const isMultiSelectMode = ref(false);
const showActiveBorder = ref(true);
const allowCellSwap = ref(false);
const showAllSprites = ref(false);
const showOffsetLabels = ref(false);
const customColor = ref('#ffffff');
const isCustomMode = ref(false);
// Background Color Logic
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
customColor.value = settingsStore.backgroundColor;
isCustomMode.value = true;
}
const bgSelectValue = computed<string>({
get() {
if (isCustomMode.value) return 'custom';
const val = settingsStore.backgroundColor;
if (presetBgColors.includes(val as any)) return val;
if (isHexColor(val)) {
customColor.value = val;
isCustomMode.value = true;
return 'custom';
}
return 'transparent';
},
set(v: string) {
if (v === 'custom') {
isCustomMode.value = true;
settingsStore.setBackgroundColor(customColor.value);
} else {
isCustomMode.value = false;
settingsStore.setBackgroundColor(v);
}
},
});
const updateCustomColor = () => {
settingsStore.setBackgroundColor(customColor.value);
};
const getCellSize = () => {
if (!visibleLayers.value.length) return { width: 0, height: 0 };
if (settingsStore.manualCellSizeEnabled) {
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
};
const cellSize = computed(getCellSize);
const activeTab = ref<'canvas' | 'preview'>('canvas');
const isSpritesheetSplitterOpen = ref(false);
const isGifFpsModalOpen = ref(false);
const isShareModalOpen = ref(false);
const uploadInput = ref<HTMLInputElement | null>(null);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const spritesheetImageUrl = ref('');
const spritesheetImageFile = ref<File | null>(null);
const editingLayerId = ref<string | null>(null);
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
// Upload Handlers
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
await handleJSONImport(jsonFile);
return;
}
if (files.length === 1 && files[0].type.startsWith('image/')) {
const file = files[0];
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
spritesheetImageUrl.value = url;
spritesheetImageFile.value = file;
isSpritesheetSplitterOpen.value = true;
return;
}
processImageFiles([file]);
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
};
img.src = url;
};
reader.readAsDataURL(file);
return;
}
processImageFiles(files);
};
const handleJSONImport = async (jsonFile: File) => {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
try {
URL.revokeObjectURL(spritesheetImageUrl.value);
} catch {}
}
spritesheetImageUrl.value = '';
spritesheetImageFile.value = null;
};
const openGifFpsModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
isGifFpsModalOpen.value = true;
};
const closeGifFpsModal = () => (isGifFpsModalOpen.value = false);
const { share } = useShare(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), toRef(settingsStore, 'backgroundColor'), toRef(settingsStore, 'manualCellSizeEnabled'), toRef(settingsStore, 'manualCellWidth'), toRef(settingsStore, 'manualCellHeight'));
const shareFunction = () => share();
const openShareModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before sharing.');
return;
}
isShareModalOpen.value = true;
};
const closeShareModal = () => (isShareModalOpen.value = false);
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
processImageFiles(spriteFiles.map(s => s.file));
};
const openFileDialog = () => uploadInput.value?.click();
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleJSONImport(input.files[0]);
input.value = '';
}
};
const handleUploadChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleSpritesUpload(Array.from(input.files));
input.value = '';
}
};
// Layer Editing
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
setTimeout(() => {
layerNameInput.value?.focus();
layerNameInput.value?.select();
}, 0);
};
const finishEditingLayer = () => {
if (editingLayerId.value && editingLayerName.value.trim()) {
const layer = layers.value.find(l => l.id === editingLayerId.value);
if (layer) layer.name = editingLayerName.value.trim();
}
editingLayerId.value = null;
editingLayerName.value = '';
};
const cancelEditingLayer = () => {
editingLayerId.value = null;
editingLayerName.value = '';
};
const handleDropSprite = (layerId: string, frameIndex: number, files: File[]) => {
const layer = layers.value.find(l => l.id === layerId);
if (!layer) return;
files.forEach((file, fileIdx) => {
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
const sprite = {
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
};
const insertIndex = frameIndex + fileIdx;
if (insertIndex < layer.sprites.length) {
layer.sprites = [...layer.sprites.slice(0, insertIndex), sprite, ...layer.sprites.slice(insertIndex + 1)];
} else {
while (layer.sprites.length < insertIndex) {
layer.sprites.push({
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,
});
}
layer.sprites = [...layer.sprites, sprite];
}
};
img.src = url;
};
reader.readAsDataURL(file);
});
};
onMounted(async () => {
const id = route.params.id as string;
if (id) {
if (projectStore.currentProject?.id !== id) {
// Only load if active project is different
await projectStore.loadProject(id);
if (projectStore.currentProject?.data) {
await loadProjectData(projectStore.currentProject.data);
}
}
}
});
watch(
() => route.params.id,
async newId => {
if (newId) {
if (projectStore.currentProject?.id !== newId) {
await projectStore.loadProject(newId as string);
if (projectStore.currentProject?.data) {
await loadProjectData(projectStore.currentProject.data);
}
}
} else {
// If navigated to /editor without ID, maybe clear logic?
// We likely want to keep state if user created new project.
}
}
);
</script>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<template>
<main class="flex flex-col flex-1 h-full min-h-0 relative">
<!-- Welcome / Empty State -->
<div v-if="!isEditorActive" class="flex-1 flex flex-col items-center justify-center pb-12">
<div class="flex-1 flex flex-col items-center justify-center pb-12">
<div class="w-full flex flex-col gap-8 lg:gap-12 items-start">
<!-- 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">
@@ -18,7 +18,7 @@
</div>
</div>
<!-- Bottom Section: Hero Text & Features -->
<!-- Bottom Section: Features -->
<div class="w-full grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
<!-- Hero Text -->
<div class="text-left">
@@ -120,359 +120,29 @@
</div>
</div>
<!-- Main Editor Interface -->
<div v-else-if="isEditorActive" class="flex flex-col lg:flex-row gap-6 h-full min-h-[600px] lg:overflow-hidden">
<!-- Sidebar Controls -->
<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">
<!-- Sidebar Header -->
<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>
<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">
<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>
</div>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto overflow-x-hidden p-5 space-y-6 scrollbar-thin">
<!-- Layers -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Layers</h3>
<button @click="addLayer()" class="text-xs p-1 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded transition-colors" title="Add Layer"><i class="fas fa-layer-group mr-1"></i> New</button>
</div>
<div class="space-y-2">
<div
v-for="layer in layers"
:key="layer.id"
class="group flex items-center gap-2 p-2 rounded-lg border transition-all duration-200"
:class="activeLayerId === layer.id ? 'bg-white dark:bg-gray-800 border-indigo-500 ring-1 ring-indigo-500 shadow-sm' : 'bg-gray-50 dark:bg-gray-800/40 border-transparent hover:border-gray-200 dark:hover:border-gray-700'"
>
<!-- Visibility Toggle -->
<button @click.stop="layer.visible = !layer.visible" class="w-6 h-6 flex items-center justify-center rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
<i :class="layer.visible ? 'fas fa-eye' : 'fas fa-eye-slash'" class="text-xs"></i>
</button>
<!-- Layer Name -->
<div class="flex-1 min-w-0">
<input
v-if="editingLayerId === layer.id"
v-model="editingLayerName"
@blur="finishEditingLayer"
@keyup.enter="finishEditingLayer"
@keyup.esc="cancelEditingLayer"
ref="layerNameInput"
class="w-full text-sm bg-transparent border-b border-indigo-500 outline-none p-0 text-gray-900 dark:text-gray-100"
/>
<button v-else @click="activeLayerId = layer.id" class="w-full text-left text-sm font-medium truncate" :class="activeLayerId === layer.id ? 'text-gray-900 dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'">
{{ layer.name }} <span class="text-xs opacity-50 font-normal ml-1">({{ layer.sprites.length }})</span>
</button>
</div>
<!-- Actions -->
<div class="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<button @click="startEditingLayer(layer.id, layer.name)" class="p-1.5 text-gray-400 hover:text-indigo-500 transition-colors" title="Rename"><i class="fas fa-pen text-[10px]"></i></button>
<button @click="moveLayer(layer.id, 'up')" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" title="Move Up"><i class="fas fa-chevron-up text-[10px]"></i></button>
<button @click="moveLayer(layer.id, 'down')" class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" title="Move Down"><i class="fas fa-chevron-down text-[10px]"></i></button>
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="p-1.5 text-gray-400 hover:text-red-500 transition-colors" title="Delete"><i class="fas fa-trash text-[10px]"></i></button>
</div>
</div>
</div>
</section>
<!-- Alignment Tools (Always visible) -->
<section>
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Alignment</h3>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-3 gap-2">
<Tooltip text="Align sprites to the left edge">
<button @click="alignSprites('left')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-left"></i>Left</button>
</Tooltip>
<Tooltip text="Center sprites horizontally">
<button @click="alignSprites('center')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrows-left-right"></i>Center</button>
</Tooltip>
<Tooltip text="Align sprites to the right edge">
<button @click="alignSprites('right')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-right"></i>Right</button>
</Tooltip>
<Tooltip text="Align sprites to the top edge">
<button @click="alignSprites('top')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-up"></i>Top</button>
</Tooltip>
<Tooltip text="Center sprites vertically">
<button @click="alignSprites('middle')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrows-up-down"></i>Middle</button>
</Tooltip>
<Tooltip text="Align sprites to the bottom edge">
<button @click="alignSprites('bottom')" class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-down"></i>Bottom</button>
</Tooltip>
</div>
</section>
<!-- Canvas Grid Settings (Editor only) -->
<section v-if="activeTab === 'canvas'">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Grid Layout</h3>
<div class="card p-3 bg-gray-50/50 dark:bg-gray-800/40 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-600 dark:text-gray-300">Columns</label>
<input type="number" v-model.number="columns" min="1" max="20" class="input-field w-16 text-center" />
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-300">Force Size</label>
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
</div>
<div v-if="settingsStore.manualCellSizeEnabled" class="flex items-center gap-1">
<input v-model.number="settingsStore.manualCellWidth" class="input-field w-14 text-center px-1" placeholder="W" />
<span class="text-gray-400 text-xs">x</span>
<input v-model.number="settingsStore.manualCellHeight" class="input-field w-14 text-center px-1" placeholder="H" />
</div>
<span v-else class="text-xs font-mono text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">{{ cellSize.width }}×{{ cellSize.height }}px</span>
</div>
</div>
</section>
<!-- View Options (Editor only) -->
<section v-if="activeTab === 'canvas'">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">View Options</h3>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-2 gap-2">
<Tooltip text="Disable anti-aliasing for crisp pixel art rendering">
<button
@click="settingsStore.pixelPerfect = !settingsStore.pixelPerfect"
:class="settingsStore.pixelPerfect ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-th mr-2"></i>Pixel
</button>
</Tooltip>
<Tooltip text="Show checkerboard background">
<button
@click="settingsStore.checkerboardEnabled = !settingsStore.checkerboardEnabled"
:class="settingsStore.checkerboardEnabled ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-chess-board mr-2"></i>Grid
</button>
</Tooltip>
<Tooltip text="Show selection borders">
<button
@click="showActiveBorder = !showActiveBorder"
:class="showActiveBorder ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-vector-square mr-2"></i>Borders
</button>
</Tooltip>
<Tooltip text="Show sprite coordinates">
<button
@click="showOffsetLabels = !showOffsetLabels"
:class="showOffsetLabels ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-tag mr-2"></i>Labels
</button>
</Tooltip>
<Tooltip text="Compare with ghost overlays" class="col-span-2">
<button
@click="showAllSprites = !showAllSprites"
:class="showAllSprites ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-clone mr-2"></i>Ghost compare
</button>
</Tooltip>
</div>
</section>
<!-- Tools (Editor only) -->
<section v-if="activeTab === 'canvas'">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Tools</h3>
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 space-y-2">
<div class="flex gap-2">
<Tooltip text="Select multiple sprites" class="flex-1">
<button
@click="isMultiSelectMode = !isMultiSelectMode"
:class="isMultiSelectMode ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-object-group mr-2"></i>Multi
</button>
</Tooltip>
<Tooltip text="Swap cell positions" class="flex-1">
<button
@click="allowCellSwap = !allowCellSwap"
:class="allowCellSwap ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="w-full flex items-center justify-start gap-2 p-2 rounded text-xs font-medium transition-colors"
>
<i class="fas fa-exchange-alt mr-2"></i>Swap
</button>
</Tooltip>
</div>
<div class="flex items-center justify-between px-2 pt-1 border-t border-gray-100 dark:border-gray-700/50">
<span class="text-xs text-gray-600 dark:text-gray-400">Negative Spacing</span>
<button
@click="settingsStore.negativeSpacingEnabled = !settingsStore.negativeSpacingEnabled"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="settingsStore.negativeSpacingEnabled ? 'bg-indigo-600' : 'bg-gray-200 dark:bg-gray-700'"
>
<span class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform" :class="settingsStore.negativeSpacingEnabled ? 'translate-x-5' : 'translate-x-1'" />
</button>
</div>
</div>
</section>
</div>
<!-- Sidebar Footer (Export) -->
<div class="p-4 border-t border-gray-200/50 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/10 space-y-2 shrink-0">
<div class="grid grid-cols-2 gap-2">
<button @click="downloadSpritesheet" class="btn btn-secondary btn-sm justify-start" title="Download PNG"><i class="fas fa-image text-indigo-500 w-4"></i> PNG</button>
<button @click="exportSpritesheetJSON" class="btn btn-secondary btn-sm justify-start" title="Download JSON"><i class="fas fa-code text-indigo-500 w-4"></i> JSON</button>
<button @click="openGifFpsModal" class="btn btn-secondary btn-sm justify-start" title="Export GIF"><i class="fas fa-film text-pink-500 w-4"></i> GIF</button>
<button @click="downloadAsZip" class="btn btn-secondary btn-sm justify-start" title="Download ZIP"><i class="fas fa-file-archive text-yellow-500 w-4"></i> ZIP</button>
</div>
<button @click="openShareModal" class="btn btn-secondary w-full justify-start"><i class="fas fa-share-alt mr-2 text-indigo-500"></i> Share project</button>
</div>
</div>
</aside>
<!-- Main Canvas Area -->
<div class="flex-1 flex flex-col min-h-0 glass-panel rounded-xl border border-gray-200 dark:border-gray-700/60 shadow-lg overflow-hidden">
<!-- Tabs / Toolbar -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/20">
<div class="flex items-center gap-3">
<div class="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
@click="activeTab = 'canvas'"
:class="activeTab === 'canvas' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
>
<i class="fas fa-th mr-2"></i>Editor
</button>
<button
@click="activeTab = 'preview'"
:class="activeTab === 'preview' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
>
<i class="fas fa-play mr-2"></i>Preview
</button>
</div>
<!-- Background Color (Compact) -->
<div class="flex items-center gap-2 pl-3 border-l border-gray-200 dark:border-gray-700">
<span class="text-xs font-medium text-gray-500 uppercase">Bg</span>
<div class="flex items-center gap-1">
<select v-model="bgSelectValue" class="text-xs dark:text-gray-300 focus:ring-0 cursor-pointer pr-8">
<option value="transparent">None</option>
<option value="#ffffff">White</option>
<option value="#000000">Black</option>
<option value="#f9fafb">Gray</option>
<option value="custom">Custom</option>
</select>
<div v-if="bgSelectValue === 'custom'" class="relative w-5 h-5 rounded-full overflow-hidden border border-gray-300 dark:border-gray-600 shadow-sm">
<input type="color" v-model="customColor" @input="updateCustomColor" class="absolute -top-1 -left-1 w-8 h-8 cursor-pointer p-0 border-0 opacity-0" />
<div class="w-full h-full" :style="{ backgroundColor: customColor }"></div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'canvas'" class="flex items-center gap-2">
<div class="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button @click="zoomOut" class="p-1.5 hover:bg-white dark:hover:bg-gray-700 rounded text-gray-500 transition-colors"><i class="fas fa-minus text-xs"></i></button>
<span class="text-xs font-mono w-12 text-center text-gray-600 dark:text-gray-300">{{ Math.round(zoom * 100) }}%</span>
<button @click="zoomIn" class="p-1.5 hover:bg-white dark:hover:bg-gray-700 rounded text-gray-500 transition-colors"><i class="fas fa-plus text-xs"></i></button>
</div>
</div>
</div>
<!-- Canvas Content -->
<div class="flex-1 overflow-hidden relative bg-white dark:bg-gray-900/50">
<!-- Grid Background Pattern -->
<div
class="absolute inset-0 opacity-5 pointer-events-none bg-gray-500"
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2220%22 height=%2220%22><circle cx=%2210%22 cy=%2210%22 r=%221%22 fill=%22%236b7280%22/></svg>'); background-size: 20px 20px"
></div>
<div class="h-full overflow-auto custom-scrollbar p-4">
<div v-if="activeTab === 'canvas'" class="h-full flex flex-col justify-center">
<sprite-canvas
:layers="layers"
:active-layer-id="activeLayerId"
:columns="columns"
:zoom="zoom"
:is-multi-select-mode="isMultiSelectMode"
:show-active-border="showActiveBorder"
:allow-cell-swap="allowCellSwap"
:show-all-sprites="showAllSprites"
:show-offset-labels="showOffsetLabels"
@update-sprite="updateSpritePosition"
@update-sprite-cell="updateSpriteCell"
@remove-sprite="removeSprite"
@remove-sprites="removeSprites"
@replace-sprite="replaceSprite"
@add-sprite="addSprite"
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
/>
</div>
<div v-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
<sprite-preview
:layers="layers"
:active-layer-id="activeLayerId"
:columns="columns"
@update-sprite="updateSpritePosition"
@update-sprite-in-layer="updateSpriteInLayer"
@drop-sprite="handleDropSprite"
@rotate-sprite="rotateSprite"
@flip-sprite="flipSprite"
@copy-sprite-to-frame="copySpriteToFrame"
@replace-sprite="replaceSprite"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Modals & Hidden Inputs -->
<!-- Modals -->
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
<ShareModal :is-open="isShareModalOpen" :share-function="shareFunction" @close="closeShareModal" />
<input ref="uploadInput" type="file" multiple accept="image/*" class="hidden" @change="handleUploadChange" />
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
</main>
</template>
<script setup lang="ts">
import { ref, toRef, computed, watch, nextTick } from 'vue';
import { ref, toRef } from 'vue';
import { useRouter } from 'vue-router';
import FileUploader from '@/components/FileUploader.vue';
import SpriteCanvas from '@/components/SpriteCanvas.vue';
import SpritePreview from '@/components/SpritePreview.vue';
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
import GifFpsModal from '@/components/GifFpsModal.vue';
import ShareModal from '@/components/ShareModal.vue';
import Tooltip from '@/components/utilities/Tooltip.vue';
import { useExportLayers } from '@/composables/useExportLayers';
import { useShare } from '@/composables/useShare';
import { useLayers } from '@/composables/useLayers';
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useSettingsStore } from '@/stores/useSettingsStore';
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
import { useHomeViewSEO } from './HomeView.seo';
import { useZoom } from '@/composables/useZoom';
import { useProjectManager } from '@/composables/useProjectManager';
import type { SpriteFile } from '@/types/sprites';
import { useLayers } from '@/composables/useLayers';
import { useSettingsStore } from '@/stores/useSettingsStore';
import { useExportLayers } from '@/composables/useExportLayers';
import tutVideo from '@/assets/tut2.mp4';
import type { SpriteFile } from '@/types/sprites';
useHomeViewSEO();
const { isEditorActive, closeProject } = useProjectManager();
const router = useRouter();
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite, copySpriteToFrame } =
useLayers();
const { layers, columns, processImageFiles, activeLayerId } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
const { importSpritesheetJSON } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
@@ -483,97 +153,37 @@
toRef(settingsStore, 'manualCellHeight')
);
// Zoom Control
const {
zoom,
increase: zoomIn,
decrease: zoomOut,
reset: resetZoom,
} = useZoom({
min: 0.5,
max: 3,
step: 0.25,
initial: 1,
});
// View Options & Tools
const isMultiSelectMode = ref(false);
const showActiveBorder = ref(true);
const allowCellSwap = ref(false);
const showAllSprites = ref(false);
const showOffsetLabels = ref(false);
const customColor = ref('#ffffff');
const isCustomMode = ref(false);
// Background Color Logic
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
customColor.value = settingsStore.backgroundColor;
isCustomMode.value = true;
}
const bgSelectValue = computed<string>({
get() {
if (isCustomMode.value) return 'custom';
const val = settingsStore.backgroundColor;
if (presetBgColors.includes(val as any)) return val;
if (isHexColor(val)) {
customColor.value = val;
isCustomMode.value = true;
return 'custom';
}
return 'transparent';
},
set(v: string) {
if (v === 'custom') {
isCustomMode.value = true;
settingsStore.setBackgroundColor(customColor.value);
} else {
isCustomMode.value = false;
settingsStore.setBackgroundColor(v);
}
},
});
const updateCustomColor = () => {
settingsStore.setBackgroundColor(customColor.value);
};
const getCellSize = () => {
if (!visibleLayers.value.length) return { width: 0, height: 0 };
if (settingsStore.manualCellSizeEnabled) {
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
};
const cellSize = computed(getCellSize);
const activeTab = ref<'canvas' | 'preview'>('canvas');
const isSpritesheetSplitterOpen = ref(false);
const isGifFpsModalOpen = ref(false);
const isShareModalOpen = ref(false);
const uploadInput = ref<HTMLInputElement | null>(null);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const spritesheetImageUrl = ref('');
const spritesheetImageFile = ref<File | null>(null);
const editingLayerId = ref<string | null>(null);
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
try {
URL.revokeObjectURL(spritesheetImageUrl.value);
} catch {}
}
spritesheetImageUrl.value = '';
spritesheetImageFile.value = null;
};
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
processImageFiles(spriteFiles.map(s => s.file));
router.push('/editor');
};
// Upload Handlers
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
await handleJSONImport(jsonFile);
try {
await importSpritesheetJSON(jsonFile);
router.push('/editor');
} catch (e) {
console.error(e);
alert('Failed to import JSON file');
}
return;
}
@@ -591,9 +201,7 @@
return;
}
processImageFiles([file]);
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
router.push('/editor');
};
img.src = url;
};
@@ -602,146 +210,7 @@
}
processImageFiles(files);
};
const handleJSONImport = async (jsonFile: File) => {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
try {
URL.revokeObjectURL(spritesheetImageUrl.value);
} catch {}
}
spritesheetImageUrl.value = '';
spritesheetImageFile.value = null;
};
const openGifFpsModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
isGifFpsModalOpen.value = true;
};
const closeGifFpsModal = () => (isGifFpsModalOpen.value = false);
const { share } = useShare(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), toRef(settingsStore, 'backgroundColor'), toRef(settingsStore, 'manualCellSizeEnabled'), toRef(settingsStore, 'manualCellWidth'), toRef(settingsStore, 'manualCellHeight'));
const shareFunction = () => share();
const openShareModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before sharing.');
return;
}
isShareModalOpen.value = true;
};
const closeShareModal = () => (isShareModalOpen.value = false);
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
processImageFiles(spriteFiles.map(s => s.file));
};
const openFileDialog = () => uploadInput.value?.click();
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleJSONImport(input.files[0]);
input.value = '';
}
};
const handleUploadChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleSpritesUpload(Array.from(input.files));
input.value = '';
}
};
// Layer Editing
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
setTimeout(() => {
layerNameInput.value?.focus();
layerNameInput.value?.select();
}, 0);
};
const finishEditingLayer = () => {
if (editingLayerId.value && editingLayerName.value.trim()) {
const layer = layers.value.find(l => l.id === editingLayerId.value);
if (layer) layer.name = editingLayerName.value.trim();
}
editingLayerId.value = null;
editingLayerName.value = '';
};
const cancelEditingLayer = () => {
editingLayerId.value = null;
editingLayerName.value = '';
};
const handleDropSprite = (layerId: string, frameIndex: number, files: File[]) => {
const layer = layers.value.find(l => l.id === layerId);
if (!layer) return;
files.forEach((file, fileIdx) => {
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
const sprite = {
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
};
const insertIndex = frameIndex + fileIdx;
if (insertIndex < layer.sprites.length) {
layer.sprites = [...layer.sprites.slice(0, insertIndex), sprite, ...layer.sprites.slice(insertIndex + 1)];
} else {
while (layer.sprites.length < insertIndex) {
layer.sprites.push({
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,
});
}
layer.sprites = [...layer.sprites, sprite];
}
};
img.src = url;
};
reader.readAsDataURL(file);
});
router.push('/editor');
};
</script>