[FEAT] Move sprite with arrow in preview, UI enhancement btns
This commit is contained in:
@@ -1,32 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||||
<button @click="addSprite" class="w-full px-5 py-3 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
<button @click="addSprite" class="w-full px-3 py-1.5 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
|
<i class="fas fa-plus text-blue-600 dark:text-blue-400 text-xs w-4"></i>
|
||||||
<span>Add Sprite</span>
|
<span>Add Sprite</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="contextMenuSpriteId" @click="rotateSpriteInMenu(90)" class="w-full px-5 py-3 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
<button v-if="contextMenuSpriteId" @click="rotateSpriteInMenu(90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
<i class="fas fa-redo text-green-600 dark:text-green-400"></i>
|
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
|
||||||
<span>Rotate +90°</span>
|
<span>Rotate +90°</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('horizontal')" class="w-full px-5 py-3 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400"></i>
|
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
||||||
<span>Flip Horizontal</span>
|
<span>Flip Horizontal</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('vertical')" class="w-full px-5 py-3 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400"></i>
|
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
||||||
<span>Flip Vertical</span>
|
<span>Flip Vertical</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-5 py-3 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-3 py-1.5 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
|
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400 text-xs w-4"></i>
|
||||||
<span>Replace Sprite</span>
|
<span>Replace Sprite</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="contextMenuSpriteId" @click="openCopyToFrameModal" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
||||||
|
<span>Copy to Frame...</span>
|
||||||
|
</button>
|
||||||
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-5 py-3 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-3 transition-colors font-medium">
|
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-3 py-1.5 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-2 transition-colors text-sm">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash text-xs w-4"></i>
|
||||||
<span>{{ selectedSpriteIds.size > 1 ? `Remove ${selectedSpriteIds.size} Sprites` : 'Remove Sprite' }}</span>
|
<span>{{ selectedSpriteIds.size > 1 ? `Remove ${selectedSpriteIds.size} Sprites` : 'Remove Sprite' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy to Frame Modal -->
|
||||||
|
<div v-if="showCopyToFrameModal" class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center" @click.self="closeCopyToFrameModal">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-6 min-w-[320px] max-w-md" @click.stop>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">Copy Sprite to Frame</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target Frame</label>
|
||||||
|
<select v-model.number="copyTargetFrame" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option v-for="i in maxFrameCount" :key="i" :value="i - 1">Frame {{ i }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target Layer</label>
|
||||||
|
<select v-model="copyTargetLayerId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option v-for="layer in props.layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button @click="closeCopyToFrameModal" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Cancel</button>
|
||||||
|
<button @click="confirmCopyToFrame" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<div class="h-full w-full flex flex-col p-4">
|
<div class="h-full w-full flex flex-col p-4">
|
||||||
@@ -197,6 +229,7 @@
|
|||||||
(e: 'addSpriteWithResize', file: File): void;
|
(e: 'addSpriteWithResize', file: File): void;
|
||||||
(e: 'rotateSprite', id: string, angle: number): void;
|
(e: 'rotateSprite', id: string, angle: number): void;
|
||||||
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||||
|
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Get settings from store
|
// Get settings from store
|
||||||
@@ -263,6 +296,17 @@
|
|||||||
const replacingSpriteId = ref<string | null>(null);
|
const replacingSpriteId = ref<string | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Copy to frame modal state
|
||||||
|
const showCopyToFrameModal = ref(false);
|
||||||
|
const copyTargetFrame = ref(0);
|
||||||
|
const copyTargetLayerId = ref(props.activeLayerId);
|
||||||
|
const copySpriteId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const maxFrameCount = computed(() => {
|
||||||
|
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
||||||
|
return maxLen + 1; // Allow copying to one frame beyond current max
|
||||||
|
});
|
||||||
|
|
||||||
// Clear selection when toggling multi-select mode
|
// Clear selection when toggling multi-select mode
|
||||||
watch(
|
watch(
|
||||||
() => props.isMultiSelectMode,
|
() => props.isMultiSelectMode,
|
||||||
@@ -510,6 +554,28 @@
|
|||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCopyToFrameModal = () => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
copySpriteId.value = contextMenuSpriteId.value;
|
||||||
|
copyTargetLayerId.value = props.activeLayerId;
|
||||||
|
copyTargetFrame.value = 0;
|
||||||
|
showCopyToFrameModal.value = true;
|
||||||
|
showContextMenu.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCopyToFrameModal = () => {
|
||||||
|
showCopyToFrameModal.value = false;
|
||||||
|
copySpriteId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCopyToFrame = () => {
|
||||||
|
if (copySpriteId.value) {
|
||||||
|
emit('copySpriteToFrame', copySpriteId.value, copyTargetLayerId.value, copyTargetFrame.value);
|
||||||
|
closeCopyToFrameModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onDragLeave = (event: DragEvent) => {
|
const onDragLeave = (event: DragEvent) => {
|
||||||
handleDragLeave(event, gridContainerRef.value);
|
handleDragLeave(event, gridContainerRef.value);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="spritesheet-preview w-full h-full">
|
<Teleport to="body">
|
||||||
|
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl z-50 py-1 min-w-[160px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||||
|
<button @click="rotateSpriteInMenu(90)" class="w-full px-3 py-1.5 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-redo text-green-600 dark:text-green-400 text-xs w-4"></i>
|
||||||
|
<span>Rotate +90°</span>
|
||||||
|
</button>
|
||||||
|
<button @click="flipSpriteInMenu('horizontal')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
||||||
|
<span>Flip Horizontal</span>
|
||||||
|
</button>
|
||||||
|
<button @click="flipSpriteInMenu('vertical')" class="w-full px-3 py-1.5 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400 text-xs w-4"></i>
|
||||||
|
<span>Flip Vertical</span>
|
||||||
|
</button>
|
||||||
|
<button @click="openCopyToFrameModal" class="w-full px-3 py-1.5 text-left hover:bg-cyan-50 dark:hover:bg-cyan-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||||
|
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
||||||
|
<span>Copy to Frame...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy to Frame Modal -->
|
||||||
|
<div v-if="showCopyToFrameModal" class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center" @click.self="closeCopyToFrameModal">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-6 min-w-[320px] max-w-md" @click.stop>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">Copy Sprite to Frame</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target Frame</label>
|
||||||
|
<select v-model.number="copyTargetFrame" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option v-for="i in maxFrameCount" :key="i" :value="i - 1">Frame {{ i }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target Layer</label>
|
||||||
|
<select v-model="copyTargetLayerId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option v-for="layer in props.layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button @click="closeCopyToFrameModal" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Cancel</button>
|
||||||
|
<button @click="confirmCopyToFrame" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div class="spritesheet-preview w-full h-full" @click="hideContextMenu">
|
||||||
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
|
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
|
||||||
<div class="flex-1 min-w-0 flex flex-col min-h-0">
|
<div class="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
<div
|
<div
|
||||||
@@ -71,6 +120,7 @@
|
|||||||
}"
|
}"
|
||||||
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
@contextmenu.prevent="openContextMenu($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -102,7 +152,7 @@
|
|||||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700 flex-shrink-0">
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700 flex-shrink-0">
|
||||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Playback</h3>
|
<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">
|
<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">
|
<button @click="togglePlayback" class="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 h-10 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">
|
<span v-if="isPlaying" class="flex items-center gap-2 font-medium">
|
||||||
<i class="fas fa-pause"></i>
|
<i class="fas fa-pause"></i>
|
||||||
Pause
|
Pause
|
||||||
@@ -112,10 +162,10 @@
|
|||||||
Play
|
Play
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="previousFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
<button @click="previousFrame" class="btn btn-secondary rounded-lg h-10 w-10" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||||
<i class="fas fa-step-backward"></i>
|
<i class="fas fa-step-backward"></i>
|
||||||
</button>
|
</button>
|
||||||
<button @click="nextFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
<button @click="nextFrame" class="btn btn-secondary rounded-lg h-10 w-10" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||||
<i class="fas fa-step-forward"></i>
|
<i class="fas fa-step-forward"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,11 +229,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="pl-4 border-l-2 border-gray-100 dark:border-gray-700 transition-all" :class="{ 'opacity-50 pointer-events-none': !isDraggable }">
|
<div class="pl-4 border-l-2 border-gray-100 dark:border-gray-700 transition-all space-y-2" :class="{ 'opacity-50 pointer-events-none': !isDraggable }">
|
||||||
<label class="flex items-center justify-between cursor-pointer group">
|
<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>
|
<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" />
|
<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>
|
</label>
|
||||||
|
<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">Arrow key movement</span>
|
||||||
|
<input type="checkbox" v-model="arrowKeyMovement" 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>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center justify-between cursor-pointer group">
|
<label class="flex items-center justify-between cursor-pointer group">
|
||||||
@@ -260,6 +314,9 @@
|
|||||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||||
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
||||||
(e: 'dropSprite', layerId: string, frameIndex: number, files: File[]): void;
|
(e: 'dropSprite', layerId: string, frameIndex: number, files: File[]): void;
|
||||||
|
(e: 'rotateSprite', id: string, angle: number): void;
|
||||||
|
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||||
|
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
@@ -295,9 +352,27 @@
|
|||||||
// Preview state
|
// Preview state
|
||||||
const isDraggable = ref(false);
|
const isDraggable = ref(false);
|
||||||
const repositionAllLayers = ref(false);
|
const repositionAllLayers = ref(false);
|
||||||
|
const arrowKeyMovement = ref(false);
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false);
|
||||||
|
|
||||||
|
// Context menu state
|
||||||
|
const showContextMenu = ref(false);
|
||||||
|
const contextMenuX = ref(0);
|
||||||
|
const contextMenuY = ref(0);
|
||||||
|
const contextMenuSpriteId = ref<string | null>(null);
|
||||||
|
const contextMenuLayerId = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Copy to frame modal state
|
||||||
|
const showCopyToFrameModal = ref(false);
|
||||||
|
const copyTargetFrame = ref(0);
|
||||||
|
const copyTargetLayerId = ref(props.activeLayerId);
|
||||||
|
|
||||||
|
const maxFrameCount = computed(() => {
|
||||||
|
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
||||||
|
return maxLen + 1; // Allow copying to one frame beyond current max
|
||||||
|
});
|
||||||
|
|
||||||
// Drag and drop for new sprites
|
// Drag and drop for new sprites
|
||||||
const onDragOver = () => {
|
const onDragOver = () => {
|
||||||
isDragOver.value = true;
|
isDragOver.value = true;
|
||||||
@@ -507,13 +582,79 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Arrow key movement handler
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!isDraggable.value || !arrowKeyMovement.value) return;
|
||||||
|
|
||||||
|
const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
|
||||||
|
if (!arrowKeys.includes(event.key)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
let deltaX = 0;
|
||||||
|
let deltaY = 0;
|
||||||
|
const step = event.shiftKey ? 10 : 1; // Hold Shift for larger steps
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
deltaY = -step;
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
deltaY = step;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
deltaX = -step;
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
deltaX = step;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||||
|
|
||||||
|
if (repositionAllLayers.value) {
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
let newX = Math.round(sprite.x + deltaX);
|
||||||
|
let newY = Math.round(sprite.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 activeLayer = props.layers.find(l => l.id === props.activeLayerId);
|
||||||
|
if (!activeLayer) return;
|
||||||
|
|
||||||
|
const sprite = activeLayer.sprites[currentFrameIndex.value];
|
||||||
|
if (!sprite) return;
|
||||||
|
|
||||||
|
let newX = Math.round(sprite.x + deltaX);
|
||||||
|
let newY = Math.round(sprite.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('updateSprite', sprite.id, newX, newY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// No longer need to initialize canvas or draw
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAnimation();
|
stopAnimation();
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watchers - most canvas-related watchers removed
|
// Watchers - most canvas-related watchers removed
|
||||||
@@ -528,6 +669,57 @@
|
|||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
watch(currentFrameIndex, () => {});
|
watch(currentFrameIndex, () => {});
|
||||||
|
|
||||||
|
// Context menu functions
|
||||||
|
const openContextMenu = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
contextMenuSpriteId.value = sprite.id;
|
||||||
|
contextMenuLayerId.value = layerId;
|
||||||
|
contextMenuX.value = event.clientX;
|
||||||
|
contextMenuY.value = event.clientY;
|
||||||
|
showContextMenu.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideContextMenu = () => {
|
||||||
|
showContextMenu.value = false;
|
||||||
|
contextMenuSpriteId.value = null;
|
||||||
|
contextMenuLayerId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateSpriteInMenu = (angle: number) => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
||||||
|
}
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
emit('flipSprite', contextMenuSpriteId.value, direction);
|
||||||
|
}
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCopyToFrameModal = () => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
copyTargetLayerId.value = contextMenuLayerId.value || props.activeLayerId;
|
||||||
|
copyTargetFrame.value = 0;
|
||||||
|
showCopyToFrameModal.value = true;
|
||||||
|
showContextMenu.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCopyToFrameModal = () => {
|
||||||
|
showCopyToFrameModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCopyToFrame = () => {
|
||||||
|
if (contextMenuSpriteId.value) {
|
||||||
|
emit('copySpriteToFrame', contextMenuSpriteId.value, copyTargetLayerId.value, copyTargetFrame.value);
|
||||||
|
closeCopyToFrameModal();
|
||||||
|
contextMenuSpriteId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -268,6 +268,68 @@ export const useLayers = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
|
||||||
|
// Find the source sprite in any layer
|
||||||
|
let sourceSprite: Sprite | undefined;
|
||||||
|
for (const layer of layers.value) {
|
||||||
|
sourceSprite = layer.sprites.find(s => s.id === spriteId);
|
||||||
|
if (sourceSprite) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceSprite) return;
|
||||||
|
|
||||||
|
// Find target layer
|
||||||
|
const targetLayer = layers.value.find(l => l.id === targetLayerId);
|
||||||
|
if (!targetLayer) return;
|
||||||
|
|
||||||
|
// Create a deep copy of the sprite with a new ID
|
||||||
|
const copiedSprite: Sprite = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file: sourceSprite.file,
|
||||||
|
img: sourceSprite.img,
|
||||||
|
url: sourceSprite.url,
|
||||||
|
width: sourceSprite.width,
|
||||||
|
height: sourceSprite.height,
|
||||||
|
x: sourceSprite.x,
|
||||||
|
y: sourceSprite.y,
|
||||||
|
rotation: sourceSprite.rotation,
|
||||||
|
flipX: sourceSprite.flipX,
|
||||||
|
flipY: sourceSprite.flipY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expand the sprites array if necessary with empty placeholder sprites
|
||||||
|
while (targetLayer.sprites.length < targetFrameIndex) {
|
||||||
|
targetLayer.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace or insert the sprite at the target index
|
||||||
|
if (targetFrameIndex < targetLayer.sprites.length) {
|
||||||
|
// Replace existing sprite at this frame
|
||||||
|
const old = targetLayer.sprites[targetFrameIndex];
|
||||||
|
if (old.url && old.url.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(old.url);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
targetLayer.sprites[targetFrameIndex] = copiedSprite;
|
||||||
|
} else {
|
||||||
|
// Add at the end
|
||||||
|
targetLayer.sprites.push(copiedSprite);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layers,
|
layers,
|
||||||
visibleLayers,
|
visibleLayers,
|
||||||
@@ -289,6 +351,7 @@ export const useLayers = () => {
|
|||||||
addLayer,
|
addLayer,
|
||||||
removeLayer,
|
removeLayer,
|
||||||
moveLayer,
|
moveLayer,
|
||||||
|
copySpriteToFrame,
|
||||||
hasSprites,
|
hasSprites,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -407,10 +407,21 @@
|
|||||||
@add-sprite="addSprite"
|
@add-sprite="addSprite"
|
||||||
@rotate-sprite="rotateSprite"
|
@rotate-sprite="rotateSprite"
|
||||||
@flip-sprite="flipSprite"
|
@flip-sprite="flipSprite"
|
||||||
|
@copy-sprite-to-frame="copySpriteToFrame"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
|
<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" />
|
<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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,7 +461,8 @@
|
|||||||
useHomeViewSEO();
|
useHomeViewSEO();
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite } = useLayers();
|
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(
|
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
|
||||||
layers,
|
layers,
|
||||||
|
|||||||
Reference in New Issue
Block a user