Files
spritesheet-generator/src/views/EditorView.vue

671 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>Coordinates
</button>
</Tooltip>
<Tooltip text="Show frame index number">
<button
@click="showFrameIds = !showFrameIds"
:class="showFrameIds ? '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-hashtag mr-2"></i>Frame IDs
</button>
</Tooltip>
<Tooltip text="Compare with ghost overlays">
<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>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"
:show-frame-ids="showFrameIds"
@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 showFrameIds = 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>