505 lines
25 KiB
Vue
505 lines
25 KiB
Vue
<template>
|
|
<div class="spritesheet-preview w-full h-full">
|
|
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
|
<div class="flex-1 min-w-0 flex flex-col">
|
|
<div
|
|
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200"
|
|
@mousemove="drag"
|
|
@mouseup="stopDrag"
|
|
@mouseleave="stopDrag"
|
|
@touchmove="handleTouchMove"
|
|
@touchend="stopDrag"
|
|
>
|
|
<div
|
|
ref="previewContainerRef"
|
|
class="relative touch-manipulation inline-block"
|
|
:style="{
|
|
transform: `scale(${zoom})`,
|
|
transformOrigin: 'top left',
|
|
width: `${cellDimensions.cellWidth}px`,
|
|
height: `${cellDimensions.cellHeight}px`,
|
|
backgroundColor: '#f9fafb',
|
|
backgroundImage: getPreviewBackgroundImage(),
|
|
backgroundSize: settingsStore.backgroundColor === 'transparent' ? '20px 20px' : 'auto',
|
|
backgroundPosition: settingsStore.backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
|
|
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
|
|
}"
|
|
>
|
|
<!-- Background sprites (dimmed for comparison) -->
|
|
<template v-if="showAllSprites">
|
|
<template v-for="i in maxFrames()" :key="`bg-${i - 1}`">
|
|
<template v-if="i - 1 !== currentFrameIndex && !hiddenFrames.includes(i - 1)">
|
|
<template v-for="layer in getVisibleLayers()" :key="`${layer.id}-${i - 1}`">
|
|
<img
|
|
v-if="layer.sprites[i - 1]"
|
|
:src="layer.sprites[i - 1].url"
|
|
class="absolute pointer-events-none"
|
|
:style="{
|
|
left: `${cellDimensions.negativeSpacing + layer.sprites[i - 1].x}px`,
|
|
top: `${cellDimensions.negativeSpacing + layer.sprites[i - 1].y}px`,
|
|
width: `${layer.sprites[i - 1].width}px`,
|
|
height: `${layer.sprites[i - 1].height}px`,
|
|
opacity: '0.3',
|
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
|
}"
|
|
draggable="false"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Current frame sprites -->
|
|
<template v-for="layer in getVisibleLayers()" :key="layer.id">
|
|
<img
|
|
v-if="layer.sprites[currentFrameIndex]"
|
|
:src="layer.sprites[currentFrameIndex].url"
|
|
class="absolute"
|
|
:class="{ 'cursor-move': isDraggable }"
|
|
:style="{
|
|
left: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].x}px`,
|
|
top: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].y}px`,
|
|
width: `${layer.sprites[currentFrameIndex].width}px`,
|
|
height: `${layer.sprites[currentFrameIndex].height}px`,
|
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
|
}"
|
|
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
|
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
|
draggable="false"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Mobile zoom controls -->
|
|
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
|
|
<button @click="increaseZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
|
<i class="fas fa-plus"></i>
|
|
</button>
|
|
<button @click="decreaseZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
|
<i class="fas fa-minus"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
|
<!-- Playback Controls -->
|
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
|
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Playback</h3>
|
|
<div class="flex items-center gap-2">
|
|
<button @click="togglePlayback" class="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-lg transition-all cursor-pointer flex-1 shadow-sm active:scale-95">
|
|
<span v-if="isPlaying" class="flex items-center gap-2 font-medium">
|
|
<i class="fas fa-pause"></i>
|
|
Pause
|
|
</span>
|
|
<span v-else class="flex items-center gap-2 font-medium">
|
|
<i class="fas fa-play"></i>
|
|
Play
|
|
</span>
|
|
</button>
|
|
<button @click="previousFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
|
<i class="fas fa-step-backward"></i>
|
|
</button>
|
|
<button @click="nextFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
|
<i class="fas fa-step-forward"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Animation Settings -->
|
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5">
|
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
|
|
|
|
<!-- Frame Navigation -->
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between items-center">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Frame</span>
|
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ visibleFrameNumber }} / {{ visibleFramesCount }}</span>
|
|
</div>
|
|
<input type="range" min="0" :max="visibleFrames.length - 1" :value="visibleFrameIndex" @input="handleSliderInput" class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600" :disabled="isPlaying" :class="{ 'opacity-50': isPlaying }" />
|
|
</div>
|
|
|
|
<!-- FPS Control -->
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between items-center">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Speed (FPS)</span>
|
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ fps }}</span>
|
|
</div>
|
|
<input type="range" min="1" max="60" v-model.number="fps" class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- View Options -->
|
|
<div class="p-4 space-y-5">
|
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">View Options</h3>
|
|
|
|
<!-- Zoom Control -->
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between items-center">
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Zoom</span>
|
|
<span class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ Math.round(zoom * 100) }}%</span>
|
|
</div>
|
|
<input type="range" min="0.5" max="5" step="0.1" v-model.number="zoom" class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600" />
|
|
</div>
|
|
|
|
<!-- Toggles -->
|
|
<div class="space-y-3 pt-2">
|
|
<label class="flex items-center justify-between cursor-pointer group">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Pixel perfect</span>
|
|
<div class="relative inline-flex items-center cursor-pointer">
|
|
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="sr-only peer">
|
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer group">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Reposition mode</span>
|
|
<div class="relative inline-flex items-center cursor-pointer">
|
|
<input type="checkbox" v-model="isDraggable" class="sr-only peer">
|
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
|
</div>
|
|
</label>
|
|
|
|
<div class="pl-4 border-l-2 border-gray-100 dark:border-gray-700 transition-all" :class="{ 'opacity-50 pointer-events-none': !isDraggable }">
|
|
<label class="flex items-center justify-between cursor-pointer group">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400 group-hover:text-gray-800 dark:group-hover:text-gray-200 transition-colors">Apply to all layers</span>
|
|
<input type="checkbox" v-model="repositionAllLayers" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" :disabled="!isDraggable" />
|
|
</label>
|
|
</div>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer group">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Compare sprites</span>
|
|
<div class="relative inline-flex items-center cursor-pointer">
|
|
<input type="checkbox" v-model="showAllSprites" class="sr-only peer">
|
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Current frame offset display -->
|
|
<div v-if="currentFrameSprite" class="px-4 pb-4">
|
|
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Offset</span>
|
|
<span class="text-xs font-mono font-bold text-gray-700 dark:text-gray-200">X: {{ currentFrameSprite.x }} <span class="text-gray-300 dark:text-gray-600 mx-1">|</span> Y: {{ currentFrameSprite.y }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Frame Selection (when Compare sprites is enabled) -->
|
|
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Visible Frames</h3>
|
|
<div class="flex gap-1">
|
|
<button @click="showAllFrames" class="px-2 py-1 text-[10px] font-medium bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 rounded transition-colors">All</button>
|
|
<button @click="hideAllFrames" class="px-2 py-1 text-[10px] font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 rounded transition-colors">None</button>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700 p-2">
|
|
<div class="max-h-[150px] overflow-y-auto pr-1 custom-scrollbar">
|
|
<div class="space-y-1">
|
|
<div v-for="(sprite, index) in compositeFrames" :key="sprite.id" class="flex items-center gap-3 px-2 py-1.5 hover:bg-white dark:hover:bg-gray-700 cursor-pointer rounded-md transition-colors group" @click="toggleHiddenFrame(index)">
|
|
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" @click.stop @change="toggleHiddenFrame(index)" />
|
|
<div class="w-8 h-8 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded flex items-center justify-center overflow-hidden flex-shrink-0 shadow-sm">
|
|
<img :src="sprite.url" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
|
</div>
|
|
<span class="text-xs font-mono text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Frame {{ index + 1 }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
|
import type { Layer, Sprite } from '@/types/sprites';
|
|
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
|
import { useZoom } from '@/composables/useZoom';
|
|
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
|
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
|
|
|
const props = defineProps<{
|
|
layers: Layer[];
|
|
activeLayerId: string;
|
|
columns: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'updateSprite', id: string, x: number, y: number): void;
|
|
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
|
}>();
|
|
|
|
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
|
|
|
// Get settings from store
|
|
const settingsStore = useSettingsStore();
|
|
|
|
const {
|
|
zoom,
|
|
increase: increaseZoom,
|
|
decrease: decreaseZoom,
|
|
} = useZoom({
|
|
allowedValues: [0.5, 1, 2, 3, 4, 5],
|
|
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: () => {
|
|
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: () => {}, // No longer needed for canvas drawing
|
|
});
|
|
|
|
// Preview state
|
|
const isDraggable = ref(false);
|
|
const repositionAllLayers = ref(false);
|
|
const showAllSprites = ref(false);
|
|
|
|
const compositeFrames = computed<Sprite[]>(() => {
|
|
// Show frames from the active layer for the thumbnail list
|
|
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
|
if (!activeLayer) {
|
|
// Fallback to first visible layer if no active layer
|
|
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;
|
|
}
|
|
return activeLayer.sprites;
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
// Computed cell dimensions
|
|
const cellDimensions = computed(() => {
|
|
const visibleLayers = getVisibleLayers();
|
|
// If manual cell size is enabled, use manual values
|
|
if (settingsStore.manualCellSizeEnabled) {
|
|
return {
|
|
cellWidth: settingsStore.manualCellWidth,
|
|
cellHeight: settingsStore.manualCellHeight,
|
|
negativeSpacing: 0,
|
|
};
|
|
}
|
|
|
|
// Otherwise, calculate from sprite dimensions
|
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
|
const allSprites = visibleLayers.flatMap(l => l.sprites);
|
|
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
|
return {
|
|
cellWidth: maxWidth + negativeSpacing,
|
|
cellHeight: maxHeight + negativeSpacing,
|
|
negativeSpacing,
|
|
};
|
|
});
|
|
|
|
// Helper for background image (dark mode friendly)
|
|
const getPreviewBackgroundImage = () => {
|
|
if (settingsStore.backgroundColor === 'transparent' && settingsStore.checkerboardEnabled) {
|
|
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
|
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
|
}
|
|
return 'none';
|
|
};
|
|
|
|
// Dragging state
|
|
const isDragging = ref(false);
|
|
const activeSpriteId = ref<string | null>(null);
|
|
const activeLayerId = ref<string | null>(null);
|
|
const dragStartX = ref(0);
|
|
const dragStartY = ref(0);
|
|
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
|
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
|
|
|
// Drag functionality
|
|
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
|
if (!isDraggable.value || !previewContainerRef.value) return;
|
|
|
|
const rect = previewContainerRef.value.getBoundingClientRect();
|
|
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
|
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
|
|
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
|
|
|
if (repositionAllLayers.value) {
|
|
isDragging.value = true;
|
|
activeSpriteId.value = 'ALL_LAYERS'; // Special marker for all layers
|
|
dragStartX.value = mouseX;
|
|
dragStartY.value = mouseY;
|
|
|
|
// Store initial positions for all sprites in this frame from all visible layers
|
|
allSpritesPosBeforeDrag.value.clear();
|
|
const visibleLayers = getVisibleLayers();
|
|
visibleLayers.forEach(layer => {
|
|
const s = layer.sprites[currentFrameIndex.value];
|
|
if (s) {
|
|
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
|
|
}
|
|
});
|
|
} else {
|
|
isDragging.value = true;
|
|
activeSpriteId.value = sprite.id;
|
|
activeLayerId.value = layerId;
|
|
dragStartX.value = mouseX;
|
|
dragStartY.value = mouseY;
|
|
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
|
}
|
|
};
|
|
|
|
const drag = (event: MouseEvent) => {
|
|
if (!isDragging.value || !activeSpriteId.value || !previewContainerRef.value) return;
|
|
|
|
const rect = previewContainerRef.value.getBoundingClientRect();
|
|
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
|
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
|
|
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
|
|
|
const deltaX = Math.round(mouseX - dragStartX.value);
|
|
const deltaY = Math.round(mouseY - dragStartY.value);
|
|
|
|
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
|
|
|
if (activeSpriteId.value === 'ALL_LAYERS') {
|
|
// Move all sprites in current frame from all visible layers
|
|
const visibleLayers = getVisibleLayers();
|
|
visibleLayers.forEach(layer => {
|
|
const sprite = layer.sprites[currentFrameIndex.value];
|
|
if (!sprite) return;
|
|
|
|
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
|
|
if (!originalPos) return;
|
|
|
|
// Calculate new position with constraints
|
|
let newX = Math.round(originalPos.x + deltaX);
|
|
let newY = Math.round(originalPos.y + deltaY);
|
|
|
|
// Constrain movement within expanded cell
|
|
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
|
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
|
|
|
|
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
|
});
|
|
} else {
|
|
// Move only the active layer sprite
|
|
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
|
|
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
|
|
|
// Calculate new position with constraints and round to integers
|
|
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
|
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 - activeSprite.width, newX));
|
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
|
|
|
emit('updateSprite', activeSpriteId.value, newX, newY);
|
|
}
|
|
};
|
|
|
|
const stopDrag = () => {
|
|
isDragging.value = false;
|
|
activeSpriteId.value = null;
|
|
activeLayerId.value = null;
|
|
};
|
|
|
|
const handleTouchStart = (event: TouchEvent, sprite: Sprite, layerId: string) => {
|
|
if (!isDraggable.value) return;
|
|
|
|
if (event.touches.length === 1) {
|
|
const touch = event.touches[0];
|
|
const mouseEvent = new MouseEvent('mousedown', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
});
|
|
startDrag(mouseEvent, sprite, layerId);
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (event: TouchEvent) => {
|
|
if (!isDraggable.value) return;
|
|
|
|
if (isDragging.value) {
|
|
event.preventDefault(); // Only prevent default when actually dragging
|
|
}
|
|
|
|
if (event.touches.length === 1) {
|
|
const touch = event.touches[0];
|
|
const mouseEvent = new MouseEvent('mousemove', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY,
|
|
});
|
|
drag(mouseEvent);
|
|
}
|
|
};
|
|
|
|
// Lifecycle hooks
|
|
onMounted(() => {
|
|
// No longer need to initialize canvas or draw
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopAnimation();
|
|
});
|
|
|
|
// Watchers - most canvas-related watchers removed
|
|
// Keep layer watchers to ensure reactivity
|
|
watch(
|
|
() => props.layers,
|
|
() => {},
|
|
{ deep: true }
|
|
);
|
|
watch(
|
|
() => props.activeLayerId,
|
|
() => {}
|
|
);
|
|
watch(currentFrameIndex, () => {});
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Custom styling for range inputs */
|
|
input[type='range']::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: #3b82f6;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type='range']::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: #3b82f6;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
</style>
|