POC
This commit is contained in:
69
src/App.vue
69
src/App.vue
@@ -11,7 +11,6 @@
|
||||
<a href="#" @click.prevent="openHelpModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="help-link"> <i class="fas fa-question-circle"></i> Help </a>
|
||||
<a href="#" @click.prevent="openFeedbackModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="feedback-link"> <i class="fas fa-comment-dots"></i> Feedback </a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-soft dark:shadow-gray-900/30 p-4 sm:p-8 transition-colors duration-300">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-6 gap-3">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Upload sprites</h2>
|
||||
@@ -26,7 +25,7 @@
|
||||
</div>
|
||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||
<div v-if="!sprites.length" class="mt-8">
|
||||
<div v-if="!visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="mt-2 leading-relaxed space-y-2">
|
||||
<p>Create spritesheets for your game development and animation projects with our completely free, open-source spritesheet generator.</p>
|
||||
<p>This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
|
||||
@@ -37,7 +36,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sprites.length > 0" class="mt-8">
|
||||
<div v-if="visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Layers</span>
|
||||
<button @click="addLayer()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 rounded">Add</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="layer in layers" :key="layer.id" class="flex items-center gap-2 px-2 py-1 rounded border border-gray-200 dark:border-gray-600" :class="{ 'ring-2 ring-blue-500': layer.id === activeLayerId }">
|
||||
<button @click="activeLayerId = layer.id" class="px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">{{ layer.name }}</button>
|
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input type="checkbox" v-model="layer.visible" /> Visible
|
||||
</label>
|
||||
<button @click="moveLayer(layer.id, 'up')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↑</button>
|
||||
<button @click="moveLayer(layer.id, 'down')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↓</button>
|
||||
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-200 rounded">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-3 sm:gap-6 mb-6 sm:mb-8">
|
||||
<div class="flex items-center space-x-1">
|
||||
<label for="columns" class="text-gray-700 dark:text-gray-200 font-medium">Columns:</label>
|
||||
@@ -107,14 +123,13 @@
|
||||
<span>Preview animation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" @add-sprite-with-resize="addSpriteWithResize" />
|
||||
<sprite-canvas :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" @add-sprite-with-resize="addSpriteWithResize" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||||
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
</Modal>
|
||||
|
||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||
@@ -150,25 +165,41 @@
|
||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from './components/GifFpsModal.vue';
|
||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||
import { useSprites, getMaxDimensions } from './composables/useSprites';
|
||||
import { useExport } from './composables/useExport';
|
||||
import { useExportLayers } from './composables/useExportLayers';
|
||||
import { useLayers } from './composables/useLayers';
|
||||
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
|
||||
import { useSettingsStore } from './stores/useSettingsStore';
|
||||
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
|
||||
import type { SpriteFile } from './types/sprites';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { sprites, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites } = useSprites();
|
||||
const {
|
||||
layers,
|
||||
visibleLayers,
|
||||
activeLayer,
|
||||
activeLayerId,
|
||||
columns,
|
||||
updateSpritePosition,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
addSpriteWithResize,
|
||||
processImageFiles,
|
||||
alignSprites,
|
||||
addLayer,
|
||||
removeLayer,
|
||||
moveLayer,
|
||||
} = useLayers();
|
||||
|
||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExport(sprites, columns, toRef(settingsStore, 'negativeSpacingEnabled'));
|
||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'));
|
||||
|
||||
const cellSize = computed(() => {
|
||||
if (!sprites.value.length) return { width: 0, height: 0 };
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, settingsStore.negativeSpacingEnabled);
|
||||
return {
|
||||
width: maxWidth + negativeSpacing,
|
||||
height: maxHeight + negativeSpacing,
|
||||
};
|
||||
if (!layers.value.length) return { width: 0, height: 0 };
|
||||
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 isPreviewModalOpen = ref(false);
|
||||
const isHelpModalOpen = ref(false);
|
||||
@@ -216,7 +247,7 @@
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites to preview an animation.');
|
||||
return;
|
||||
}
|
||||
@@ -253,7 +284,7 @@
|
||||
};
|
||||
|
||||
const openGifFpsModal = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, toRef } from 'vue';
|
||||
import { ref, onMounted, watch, onUnmounted, toRef, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
@@ -108,8 +108,11 @@
|
||||
import { useDragSprite } from '@/composables/useDragSprite';
|
||||
import { useFileDrop } from '@/composables/useFileDrop';
|
||||
|
||||
import type { Layer } from '@/types/sprites';
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
layers: Layer[];
|
||||
activeLayerId: string;
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
@@ -158,7 +161,7 @@
|
||||
findSpriteAtPosition,
|
||||
calculateMaxDimensions,
|
||||
} = useDragSprite({
|
||||
sprites: toRef(props, 'sprites'),
|
||||
sprites: computed(() => (props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? [])),
|
||||
columns: toRef(props, 'columns'),
|
||||
zoom,
|
||||
allowCellSwap,
|
||||
@@ -169,8 +172,10 @@
|
||||
onDraw: drawCanvas,
|
||||
});
|
||||
|
||||
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
|
||||
|
||||
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
||||
sprites: props.sprites,
|
||||
sprites: activeSprites,
|
||||
onAddSprite: file => emit('addSprite', file),
|
||||
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
||||
});
|
||||
@@ -280,7 +285,8 @@
|
||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
|
||||
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
||||
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
|
||||
|
||||
// Clear canvas
|
||||
@@ -307,42 +313,42 @@
|
||||
|
||||
// If showing all sprites, draw all sprites with transparency in each cell
|
||||
if (showAllSprites.value) {
|
||||
for (let cellIndex = 0; cellIndex < props.sprites.length; cellIndex++) {
|
||||
const total = Math.max(...props.layers.map(l => (l.visible ? l.sprites.length : 0)));
|
||||
for (let cellIndex = 0; cellIndex < total; cellIndex++) {
|
||||
const cellCol = cellIndex % props.columns;
|
||||
const cellRow = Math.floor(cellIndex / props.columns);
|
||||
const cellX = Math.floor(cellCol * maxWidth);
|
||||
const cellY = Math.floor(cellRow * maxHeight);
|
||||
|
||||
// Draw all sprites with transparency in this cell
|
||||
// Position at bottom-right with negative spacing offset
|
||||
props.sprites.forEach((sprite, spriteIndex) => {
|
||||
if (spriteIndex !== cellIndex) {
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.3);
|
||||
}
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (sprite) canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.35);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Draw sprites normally
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
// Skip the active sprite if we're showing a ghost instead
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) {
|
||||
return;
|
||||
}
|
||||
// Draw layers in order; active layer will be interactable
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
layer.sprites.forEach((sprite, index) => {
|
||||
// Skip the active sprite if we're showing a ghost instead
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) return;
|
||||
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw sprite with negative spacing offset (bottom-right positioning)
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y);
|
||||
const alpha = layer.id === props.activeLayerId ? 1 : 0.85;
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, alpha);
|
||||
});
|
||||
});
|
||||
|
||||
// Draw ghost sprite if we're dragging between cells
|
||||
if (ghostSprite.value && activeSpriteId.value) {
|
||||
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
||||
const sprite = activeSprites.value.find(s => s.id === activeSpriteId.value);
|
||||
if (sprite) {
|
||||
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
|
||||
}
|
||||
@@ -362,7 +368,8 @@
|
||||
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
||||
|
||||
const attachImageListeners = () => {
|
||||
canvas2D.attachImageListeners(props.sprites, handleForceRedraw, imagesWithListeners);
|
||||
const sprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.attachImageListeners(sprites, handleForceRedraw, imagesWithListeners);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -120,10 +120,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Current frame offset display -->
|
||||
<div v-if="props.sprites[currentFrameIndex]" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<div v-if="currentFrameSprite" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Offset</span>
|
||||
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ props.sprites[currentFrameIndex].x }}, y: {{ props.sprites[currentFrameIndex].y }}</span>
|
||||
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ currentFrameSprite.x }}, y: {{ currentFrameSprite.y }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
<div class="rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800">
|
||||
<div class="max-h-[180px] overflow-y-auto">
|
||||
<div class="space-y-0.5 p-1">
|
||||
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
|
||||
<div v-for="(sprite, index) in compositeFrames" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
|
||||
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-3.5 h-3.5 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
|
||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
@@ -157,17 +157,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions } from '@/composables/useSprites';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
layers: Layer[];
|
||||
activeLayerId: string;
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
@@ -192,8 +193,19 @@
|
||||
initial: 1,
|
||||
});
|
||||
|
||||
const getVisibleLayers = () => props.layers.filter(l => l.visible);
|
||||
const maxFrames = () => Math.max(0, ...getVisibleLayers().map(l => l.sprites.length));
|
||||
|
||||
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
||||
sprites: () => props.sprites,
|
||||
sprites: () => {
|
||||
const len = maxFrames();
|
||||
const frames: Sprite[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = getVisibleLayers().find(l => l.sprites[i])?.sprites[i];
|
||||
if (s) frames.push(s);
|
||||
}
|
||||
return frames;
|
||||
},
|
||||
onDraw: drawPreviewCanvas,
|
||||
});
|
||||
|
||||
@@ -201,6 +213,23 @@
|
||||
const isDraggable = ref(false);
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
const compositeFrames = computed<Sprite[]>(() => {
|
||||
const v = getVisibleLayers();
|
||||
const len = maxFrames();
|
||||
const arr: Sprite[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = v.find(l => l.sprites[i])?.sprites[i];
|
||||
if (s) arr.push(s);
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
const currentFrameSprite = computed<Sprite | null>(() => {
|
||||
const layer = props.layers.find(l => l.id === props.activeLayerId);
|
||||
if (!layer) return null;
|
||||
return layer.sprites[currentFrameIndex.value] || null;
|
||||
});
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
@@ -211,13 +240,13 @@
|
||||
// Canvas drawing
|
||||
|
||||
function drawPreviewCanvas() {
|
||||
if (!previewCanvasRef.value || !canvas2D.ctx.value || props.sprites.length === 0) return;
|
||||
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
|
||||
|
||||
const currentSprite = props.sprites[currentFrameIndex.value];
|
||||
if (!currentSprite) return;
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(props.sprites, settingsStore.negativeSpacingEnabled);
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const allSprites = visibleLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
@@ -233,17 +262,25 @@
|
||||
// Draw grid background (cell)
|
||||
canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
|
||||
|
||||
// Draw all sprites with transparency if enabled
|
||||
if (showAllSprites.value && props.sprites.length > 1) {
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
||||
const frameIndex = currentFrameIndex.value;
|
||||
|
||||
if (showAllSprites.value) {
|
||||
const len = maxFrames();
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[i];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Draw current sprite with negative spacing offset
|
||||
canvas2D.drawImage(currentSprite.img, negativeSpacing + currentSprite.x, negativeSpacing + currentSprite.y);
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[frameIndex];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y);
|
||||
});
|
||||
|
||||
// Draw cell border
|
||||
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
|
||||
@@ -260,18 +297,22 @@
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
|
||||
const sprite = props.sprites[currentFrameIndex.value];
|
||||
const negativeSpacing = calculateNegativeSpacing(props.sprites, settingsStore.negativeSpacingEnabled);
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
const vLayers = getVisibleLayers();
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
|
||||
// Check if click is on sprite (accounting for negative spacing offset)
|
||||
const spriteCanvasX = negativeSpacing + sprite.x;
|
||||
const spriteCanvasY = negativeSpacing + sprite.y;
|
||||
if (sprite && mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) {
|
||||
if (activeSprite) {
|
||||
const spriteCanvasX = negativeSpacing + activeSprite.x;
|
||||
const spriteCanvasY = negativeSpacing + activeSprite.y;
|
||||
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + activeSprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + activeSprite.height) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = sprite.id;
|
||||
activeSpriteId.value = activeSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,11 +329,13 @@
|
||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||
|
||||
const sprite = props.sprites[currentFrameIndex.value];
|
||||
if (!sprite || sprite.id !== activeSpriteId.value) return;
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(props.sprites, settingsStore.negativeSpacingEnabled);
|
||||
const vLayers = getVisibleLayers();
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(vLayers);
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
@@ -301,8 +344,8 @@
|
||||
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
||||
|
||||
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
drawPreviewCanvas();
|
||||
@@ -359,13 +402,14 @@
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
canvas2D.ensureIntegerPositions(props.sprites);
|
||||
const allSprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.ensureIntegerPositions(allSprites);
|
||||
canvas2D.applySmoothing();
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
|
||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
||||
watch(currentFrameIndex, drawPreviewCanvas);
|
||||
watch(zoom, drawPreviewCanvas);
|
||||
watch(isDraggable, drawPreviewCanvas);
|
||||
@@ -375,7 +419,7 @@
|
||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
||||
|
||||
// Initial draw
|
||||
if (props.sprites.length > 0) {
|
||||
if (props.layers.some(l => l.sprites.length > 0)) {
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
</script>
|
||||
|
||||
246
src/composables/useExportLayers.ts
Normal file
246
src/composables/useExportLayers.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { Ref } from 'vue';
|
||||
import GIF from 'gif.js';
|
||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
||||
import JSZip from 'jszip';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from './useLayers';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
|
||||
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
|
||||
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||
|
||||
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
||||
const vLayers = getVisibleLayers();
|
||||
vLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (!sprite) return;
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
});
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
const rows = Math.ceil(maxLen / columns.value);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth * columns.value;
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
for (let index = 0; index < maxLen; index++) {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
const cellX = Math.floor(col * cellWidth);
|
||||
const cellY = Math.floor(row * cellHeight);
|
||||
|
||||
const cellCanvas = document.createElement('canvas');
|
||||
const cellCtx = cellCanvas.getContext('2d');
|
||||
if (!cellCtx) return;
|
||||
cellCanvas.width = cellWidth;
|
||||
cellCanvas.height = cellHeight;
|
||||
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
|
||||
ctx.drawImage(cellCanvas, cellX, cellY);
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
link.click();
|
||||
};
|
||||
|
||||
const exportSpritesheetJSON = async () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Nothing to export. Please add sprites first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const layersData = await Promise.all(
|
||||
layersRef.value.map(async layer => {
|
||||
const sprites = await Promise.all(
|
||||
layer.sprites.map(async sprite => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
return { id: sprite.id, width: sprite.width, height: sprite.height, x: sprite.x, y: sprite.y, base64, name: sprite.file.name };
|
||||
})
|
||||
);
|
||||
return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: sprites.filter(Boolean) };
|
||||
})
|
||||
);
|
||||
|
||||
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData };
|
||||
const jsonString = JSON.stringify(json, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'spritesheet.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||
const text = await jsonFile.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
const loadSprite = (spriteData: any) =>
|
||||
new Promise<Sprite>(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||
const file = new File([blob], fileName, { type: mimeType });
|
||||
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0 });
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
|
||||
if (typeof data.columns === 'number') columns.value = data.columns;
|
||||
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
|
||||
|
||||
if (Array.isArray(data.layers)) {
|
||||
const newLayers: Layer[] = [];
|
||||
for (const layerData of data.layers) {
|
||||
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
|
||||
newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites });
|
||||
}
|
||||
layersRef.value = newLayers;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.sprites)) {
|
||||
const sprites: Sprite[] = await Promise.all(data.sprites.map((s: any) => loadSprite(s)));
|
||||
layersRef.value = [
|
||||
{ id: crypto.randomUUID(), name: 'Base', visible: true, locked: false, sprites },
|
||||
{ id: crypto.randomUUID(), name: 'Clothes', visible: true, locked: false, sprites: [] },
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Invalid JSON format');
|
||||
};
|
||||
|
||||
const downloadAsGif = (fps: number) => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const gif = new GIF({ workers: 2, quality: 10, width: cellWidth, height: cellHeight, workerScript: gifWorkerUrl });
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing);
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
}
|
||||
|
||||
gif.on('finished', (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'animation.gif';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
gif.render();
|
||||
};
|
||||
|
||||
const downloadAsZip = async () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Please upload or import sprites before downloading a ZIP.');
|
||||
return;
|
||||
}
|
||||
const zip = new JSZip();
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing);
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
const buf = new ArrayBuffer(binary.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let j = 0; j < binary.length; j++) view[j] = binary.charCodeAt(j);
|
||||
zip.file(`frames/frame_${String(i + 1).padStart(3, '0')}.png`, view);
|
||||
}
|
||||
|
||||
const jsonFolder = zip.folder('export')!;
|
||||
const jsonBlobPromise = (async () => {
|
||||
const layersPayload = await Promise.all(
|
||||
layersRef.value.map(async layer => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: layer.locked,
|
||||
sprites: await Promise.all(
|
||||
layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, name: s.file.name }))
|
||||
),
|
||||
}))
|
||||
);
|
||||
const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersPayload };
|
||||
const metaStr = JSON.stringify(meta, null, 2);
|
||||
jsonFolder.file('spritesheet.meta.json', metaStr);
|
||||
})();
|
||||
await jsonBlobPromise;
|
||||
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(content);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sprites.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
|
||||
export interface FileDropOptions {
|
||||
sprites: Ref<Sprite[]> | Sprite[];
|
||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||
onAddSprite: (file: File) => void;
|
||||
onAddSpriteWithResize: (file: File) => void;
|
||||
}
|
||||
|
||||
205
src/composables/useLayers.ts
Normal file
205
src/composables/useLayers.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
|
||||
|
||||
export const createEmptyLayer = (name: string): Layer => ({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
sprites: [],
|
||||
visible: true,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
export const useLayers = () => {
|
||||
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
||||
const activeLayerId = ref<string>(layers.value[0].id);
|
||||
const columns = ref(4);
|
||||
|
||||
watch(columns, val => {
|
||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
||||
if (safe !== columns.value) columns.value = safe;
|
||||
});
|
||||
|
||||
const activeLayer = computed(() => layers.value.find(l => l.id === activeLayerId.value) || layers.value[0]);
|
||||
|
||||
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i !== -1) {
|
||||
l.sprites[i].x = Math.floor(x);
|
||||
l.sprites[i].y = Math.floor(y);
|
||||
}
|
||||
};
|
||||
|
||||
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||
const l = activeLayer.value;
|
||||
if (!l || !l.sprites.length) return;
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
|
||||
l.sprites = l.sprites.map(sprite => {
|
||||
let x = sprite.x;
|
||||
let y = sprite.y;
|
||||
switch (position) {
|
||||
case 'left':
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
});
|
||||
};
|
||||
|
||||
const updateSpriteCell = (id: string, newIndex: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const currentIndex = l.sprites.findIndex(s => s.id === id);
|
||||
if (currentIndex === -1 || currentIndex === newIndex) return;
|
||||
const next = [...l.sprites];
|
||||
if (newIndex < next.length) {
|
||||
const moving = { ...next[currentIndex] };
|
||||
const target = { ...next[newIndex] };
|
||||
next[currentIndex] = target;
|
||||
next[newIndex] = moving;
|
||||
} else {
|
||||
const [moved] = next.splice(currentIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
}
|
||||
l.sprites = next;
|
||||
};
|
||||
|
||||
const removeSprite = (id: string) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i === -1) return;
|
||||
const s = l.sprites[i];
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
l.sprites.splice(i, 1);
|
||||
};
|
||||
|
||||
const replaceSprite = (id: string, file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i === -1) return;
|
||||
const old = l.sprites[i];
|
||||
if (old.url && old.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(old.url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y };
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => addSpriteWithResize(file);
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const next: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
l.sprites = [...l.sprites, next];
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const processImageFiles = async (files: File[]) => {
|
||||
for (const f of files) addSpriteWithResize(f);
|
||||
};
|
||||
|
||||
const addLayer = (name?: string) => {
|
||||
const l = createEmptyLayer(name || `Layer ${layers.value.length + 1}`);
|
||||
layers.value.push(l);
|
||||
activeLayerId.value = l.id;
|
||||
};
|
||||
|
||||
const removeLayer = (id: string) => {
|
||||
if (layers.value.length === 1) return;
|
||||
const idx = layers.value.findIndex(l => l.id === id);
|
||||
if (idx === -1) return;
|
||||
layers.value.splice(idx, 1);
|
||||
if (activeLayerId.value === id) activeLayerId.value = layers.value[0].id;
|
||||
};
|
||||
|
||||
const moveLayer = (id: string, direction: 'up' | 'down') => {
|
||||
const idx = layers.value.findIndex(l => l.id === id);
|
||||
if (idx === -1) return;
|
||||
if (direction === 'up' && idx > 0) {
|
||||
const [l] = layers.value.splice(idx, 1);
|
||||
layers.value.splice(idx - 1, 0, l);
|
||||
}
|
||||
if (direction === 'down' && idx < layers.value.length - 1) {
|
||||
const [l] = layers.value.splice(idx, 1);
|
||||
layers.value.splice(idx + 1, 0, l);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
||||
|
||||
return {
|
||||
layers,
|
||||
visibleLayers,
|
||||
activeLayerId,
|
||||
activeLayer,
|
||||
columns,
|
||||
getMaxDimensions,
|
||||
updateSpritePosition,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
addSpriteWithResize,
|
||||
processImageFiles,
|
||||
alignSprites,
|
||||
addLayer,
|
||||
removeLayer,
|
||||
moveLayer,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMaxDimensionsAcrossLayers = (layers: Layer[]) => {
|
||||
const sprites = layers.flatMap(l => l.visible ? l.sprites : []);
|
||||
return getMaxDimensionsSingle(sprites);
|
||||
};
|
||||
@@ -16,3 +16,11 @@ export interface SpriteFile {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
id: string;
|
||||
name: string;
|
||||
sprites: Sprite[];
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user