597 lines
24 KiB
Vue
597 lines
24 KiB
Vue
<template>
|
|
<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="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 text-xs w-4"></i>
|
|
<span>Add Sprite</span>
|
|
</button>
|
|
<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 text-xs w-4"></i>
|
|
<span>Rotate +90°</span>
|
|
</button>
|
|
<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 text-xs w-4"></i>
|
|
<span>Flip Horizontal</span>
|
|
</button>
|
|
<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 text-xs w-4"></i>
|
|
<span>Flip Vertical</span>
|
|
</button>
|
|
<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 text-xs w-4"></i>
|
|
<span>Replace Sprite</span>
|
|
</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>
|
|
<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 text-xs w-4"></i>
|
|
<span>{{ selectedSpriteIds.size > 1 ? `Remove ${selectedSpriteIds.size} Sprites` : 'Remove Sprite' }}</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="h-full w-full flex flex-col p-4">
|
|
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm overflow-auto max-h-[calc(100vh-340px)] min-h-[400px] w-full">
|
|
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
|
<div
|
|
ref="gridContainerRef"
|
|
:style="{
|
|
transform: `scale(${props.zoom})`,
|
|
transformOrigin: 'top left',
|
|
width: `${gridDimensions.width}px`,
|
|
height: `${gridDimensions.height}px`,
|
|
position: 'relative',
|
|
}"
|
|
class="inline-block"
|
|
@mousedown="startDrag"
|
|
@mousemove="drag"
|
|
@mouseup="stopDrag"
|
|
@mouseleave="stopDrag"
|
|
@touchstart="handleTouchStart"
|
|
@touchmove="handleTouchMove"
|
|
@touchend="stopDrag"
|
|
@contextmenu.prevent
|
|
@dragover="handleDragOver"
|
|
@dragenter="handleDragEnter"
|
|
@dragleave="onDragLeave"
|
|
@drop="handleDrop"
|
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
|
>
|
|
<!-- Grid cells -->
|
|
<div
|
|
v-for="cellIndex in totalCells"
|
|
:key="`cell-${cellIndex - 1}`"
|
|
class="absolute"
|
|
:style="{
|
|
left: `${getCellPosition(cellIndex - 1).x}px`,
|
|
top: `${getCellPosition(cellIndex - 1).y}px`,
|
|
width: `${gridMetrics.maxWidth}px`,
|
|
height: `${gridMetrics.maxHeight}px`,
|
|
backgroundColor: getCellBackground(),
|
|
backgroundImage: getCellBackgroundImage(),
|
|
backgroundSize: getCellBackgroundSize(),
|
|
backgroundPosition: getCellBackgroundPosition(),
|
|
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
|
|
}"
|
|
:class="{
|
|
'bg-blue-500 bg-opacity-20': highlightCell && highlightCell.col === (cellIndex - 1) % columns && highlightCell.row === Math.floor((cellIndex - 1) / columns),
|
|
}"
|
|
></div>
|
|
|
|
<!-- Background sprites (for compare mode) -->
|
|
<template v-if="showAllSprites">
|
|
<template v-for="layer in visibleLayers" :key="`bg-layer-${layer.id}`">
|
|
<template v-for="(sprite, spriteIndex) in layer.sprites" :key="`bg-${sprite.id}`">
|
|
<template v-for="cellIndex in totalCells" :key="`bg-${sprite.id}-${cellIndex}`">
|
|
<img
|
|
v-if="spriteIndex !== cellIndex - 1 && !(activeSpriteId === sprite.id && ghostSprite)"
|
|
:src="sprite.url"
|
|
class="absolute pointer-events-none"
|
|
:style="{
|
|
left: `${getCellPosition(cellIndex - 1).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
|
top: `${getCellPosition(cellIndex - 1).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
|
width: `${sprite.width}px`,
|
|
height: `${sprite.height}px`,
|
|
opacity: '0.25',
|
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
|
}"
|
|
draggable="false"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Layer sprites -->
|
|
<template v-for="layer in layers" :key="layer.id">
|
|
<template v-if="layer.visible">
|
|
<template v-for="(sprite, index) in layer.sprites" :key="sprite.id">
|
|
<img
|
|
v-if="!(activeSpriteId === sprite.id && ghostSprite)"
|
|
:src="sprite.url"
|
|
class="absolute cursor-move transition-transform duration-200"
|
|
:class="{ 'ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-900': showActiveBorder && selectedSpriteIds.has(sprite.id) }"
|
|
:style="{
|
|
left: `${getCellPosition(index).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
|
top: `${getCellPosition(index).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
|
width: `${sprite.width}px`,
|
|
height: `${sprite.height}px`,
|
|
opacity: layer.id === activeLayerId ? '1' : '0.85',
|
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
|
transform: `rotate(${sprite.rotation}deg) scale(${sprite.flipX ? -1 : 1}, ${sprite.flipY ? -1 : 1})`,
|
|
}"
|
|
:data-sprite-id="sprite.id"
|
|
:data-layer-id="layer.id"
|
|
draggable="false"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- Ghost sprite (while dragging) -->
|
|
<img
|
|
v-if="ghostSprite && activeSpriteId"
|
|
:src="activeSpriteSprite?.url"
|
|
class="absolute pointer-events-none"
|
|
:style="{
|
|
left: `${ghostSprite.x}px`,
|
|
top: `${ghostSprite.y}px`,
|
|
width: `${activeSpriteSprite?.width}px`,
|
|
height: `${activeSpriteSprite?.height}px`,
|
|
opacity: '0.6',
|
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
|
transform: `rotate(${activeSpriteSprite?.rotation || 0}deg) scale(${activeSpriteSprite?.flipX ? -1 : 1}, ${activeSpriteSprite?.flipY ? -1 : 1})`,
|
|
}"
|
|
draggable="false"
|
|
/>
|
|
|
|
<!-- Offset labels -->
|
|
<div
|
|
v-if="showOffsetLabels"
|
|
v-for="position in spritePositions"
|
|
:key="`label-${position.id}`"
|
|
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm pointer-events-none"
|
|
:style="{
|
|
left: `${position.cellX + position.maxWidth - 2}px`,
|
|
top: `${position.cellY + position.maxHeight - 2}px`,
|
|
transform: 'translate(-100%, -100%)',
|
|
}"
|
|
>
|
|
<span>{{ position.x }},{{ position.y }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
|
import type { Sprite } from '@/types/sprites';
|
|
import { useDragSprite } from '@/composables/useDragSprite';
|
|
import { useFileDrop } from '@/composables/useFileDrop';
|
|
|
|
import type { Layer } from '@/types/sprites';
|
|
|
|
const props = defineProps<{
|
|
layers: Layer[];
|
|
activeLayerId: string;
|
|
columns: number;
|
|
zoom: number;
|
|
isMultiSelectMode: boolean;
|
|
showActiveBorder: boolean;
|
|
allowCellSwap: boolean;
|
|
showAllSprites: boolean;
|
|
showOffsetLabels: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'updateSprite', id: string, x: number, y: number): void;
|
|
(e: 'updateSpriteCell', id: string, newIndex: number): void;
|
|
(e: 'removeSprite', id: string): void;
|
|
(e: 'removeSprites', ids: string[]): void;
|
|
(e: 'replaceSprite', id: string, file: File): void;
|
|
(e: 'addSprite', file: File): void;
|
|
(e: 'addSpriteWithResize', file: 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;
|
|
}>();
|
|
|
|
// Get settings from store
|
|
const settingsStore = useSettingsStore();
|
|
|
|
const gridContainerRef = ref<HTMLDivElement | null>(null);
|
|
|
|
const getMousePosition = (event: MouseEvent, z?: number) => {
|
|
if (!gridContainerRef.value) return null;
|
|
const currentZoom = z ?? props.zoom;
|
|
const rect = gridContainerRef.value.getBoundingClientRect();
|
|
const scaleX = gridContainerRef.value.offsetWidth / (rect.width / currentZoom);
|
|
const scaleY = gridContainerRef.value.offsetHeight / (rect.height / currentZoom);
|
|
return {
|
|
x: ((event.clientX - rect.left) / currentZoom) * scaleX,
|
|
y: ((event.clientY - rect.top) / currentZoom) * scaleY,
|
|
};
|
|
};
|
|
|
|
const {
|
|
isDragging,
|
|
activeSpriteId,
|
|
ghostSprite,
|
|
highlightCell,
|
|
spritePositions,
|
|
startDrag: dragStart,
|
|
drag: dragMove,
|
|
stopDrag,
|
|
handleTouchStart,
|
|
handleTouchMove,
|
|
findSpriteAtPosition,
|
|
calculateMaxDimensions,
|
|
} = useDragSprite({
|
|
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
|
layers: toRef(props, 'layers'),
|
|
columns: toRef(props, 'columns'),
|
|
zoom: toRef(props, 'zoom'),
|
|
allowCellSwap: toRef(props, 'allowCellSwap'),
|
|
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
|
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
|
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
|
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
|
getMousePosition,
|
|
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
|
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
|
onDraw: () => {},
|
|
});
|
|
|
|
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
|
|
const visibleLayers = computed(() => props.layers.filter(l => l.visible));
|
|
const activeSpriteSprite = computed(() => activeSprites.value.find(s => s.id === activeSpriteId.value));
|
|
|
|
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
|
sprites: activeSprites,
|
|
onAddSprite: file => emit('addSprite', file),
|
|
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
|
});
|
|
|
|
const showContextMenu = ref(false);
|
|
const contextMenuX = ref(0);
|
|
const contextMenuY = ref(0);
|
|
const contextMenuSpriteId = ref<string | null>(null);
|
|
const selectedSpriteIds = ref<Set<string>>(new Set());
|
|
const replacingSpriteId = ref<string | 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
|
|
watch(
|
|
() => props.isMultiSelectMode,
|
|
() => {
|
|
selectedSpriteIds.value.clear();
|
|
}
|
|
);
|
|
|
|
// Grid metrics
|
|
const gridMetrics = computed(() => calculateMaxDimensions());
|
|
|
|
const totalCells = computed(() => {
|
|
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
|
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
|
});
|
|
|
|
const gridDimensions = computed(() => {
|
|
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));
|
|
return {
|
|
width: gridMetrics.value.maxWidth * props.columns,
|
|
height: gridMetrics.value.maxHeight * rows,
|
|
};
|
|
});
|
|
|
|
const getCellPosition = (index: number) => {
|
|
const col = index % props.columns;
|
|
const row = Math.floor(index / props.columns);
|
|
return {
|
|
x: col * gridMetrics.value.maxWidth,
|
|
y: row * gridMetrics.value.maxHeight,
|
|
};
|
|
};
|
|
|
|
const getCellBackground = () => {
|
|
const bg = settingsStore.backgroundColor;
|
|
if (bg === 'transparent') {
|
|
return 'transparent';
|
|
}
|
|
return bg;
|
|
};
|
|
|
|
const getCellBackgroundImage = () => {
|
|
const bg = settingsStore.backgroundColor;
|
|
if (bg === 'transparent' && settingsStore.checkerboardEnabled) {
|
|
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
|
|
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';
|
|
};
|
|
|
|
const getCellBackgroundSize = () => {
|
|
const bg = settingsStore.backgroundColor;
|
|
if (bg === 'transparent') {
|
|
return '20px 20px';
|
|
}
|
|
return 'auto';
|
|
};
|
|
|
|
const getCellBackgroundPosition = () => {
|
|
const bg = settingsStore.backgroundColor;
|
|
if (bg === 'transparent') {
|
|
return '0 0, 0 10px, 10px -10px, -10px 0px';
|
|
}
|
|
return '0 0';
|
|
};
|
|
|
|
const startDrag = (event: MouseEvent) => {
|
|
// If the click originated from an interactive element (button, link, input), ignore drag handling
|
|
const target = event.target as HTMLElement;
|
|
if (target && target.closest('button, a, input, select, textarea')) {
|
|
return;
|
|
}
|
|
if (!gridContainerRef.value) return;
|
|
|
|
// Hide context menu if open
|
|
showContextMenu.value = false;
|
|
|
|
// Handle right-click for context menu
|
|
if ('button' in event && (event as MouseEvent).button === 2) {
|
|
event.preventDefault();
|
|
const pos = getMousePosition(event, props.zoom);
|
|
if (!pos) return;
|
|
|
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
|
contextMenuSpriteId.value = clickedSprite?.id || null;
|
|
|
|
if (clickedSprite) {
|
|
// If the right-clicked sprite is not in the selection, clear selection and select just this one
|
|
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
|
selectedSpriteIds.value.clear();
|
|
selectedSpriteIds.value.add(clickedSprite.id);
|
|
}
|
|
// If it IS in the selection, keep the current selection (so we can apply action to all)
|
|
} else {
|
|
// Right click on empty space
|
|
selectedSpriteIds.value.clear();
|
|
}
|
|
|
|
contextMenuX.value = event.clientX;
|
|
contextMenuY.value = event.clientY;
|
|
|
|
showContextMenu.value = true;
|
|
return;
|
|
}
|
|
|
|
// Ignore non-left mouse buttons
|
|
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
|
|
|
// Handle selection logic for left click
|
|
const pos = getMousePosition(event, props.zoom);
|
|
if (pos) {
|
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
|
if (clickedSprite) {
|
|
// Selection logic with multi-select mode check
|
|
if (event.ctrlKey || event.metaKey || props.isMultiSelectMode) {
|
|
// Toggle selection
|
|
if (selectedSpriteIds.value.has(clickedSprite.id)) {
|
|
selectedSpriteIds.value.delete(clickedSprite.id);
|
|
} else {
|
|
selectedSpriteIds.value.add(clickedSprite.id);
|
|
}
|
|
} else {
|
|
// Single select (but don't clear if dragging starts immediately?
|
|
// Usually standard behavior is to clear others unless shift/ctrl held)
|
|
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
|
selectedSpriteIds.value.clear();
|
|
selectedSpriteIds.value.add(clickedSprite.id);
|
|
}
|
|
}
|
|
} else {
|
|
// Clicked on empty space
|
|
selectedSpriteIds.value.clear();
|
|
}
|
|
}
|
|
|
|
// Delegate to composable for actual drag handling
|
|
dragStart(event);
|
|
};
|
|
|
|
const pendingDrag = ref(false);
|
|
const latestEvent = ref<MouseEvent | null>(null);
|
|
|
|
const drag = (event: MouseEvent) => {
|
|
// Store the latest event and schedule a single animation frame update
|
|
latestEvent.value = event;
|
|
if (!pendingDrag.value) {
|
|
pendingDrag.value = true;
|
|
requestAnimationFrame(() => {
|
|
if (latestEvent.value) {
|
|
dragMove(latestEvent.value);
|
|
}
|
|
pendingDrag.value = false;
|
|
latestEvent.value = null;
|
|
});
|
|
}
|
|
};
|
|
|
|
const removeSprite = () => {
|
|
if (selectedSpriteIds.value.size > 0) {
|
|
emit('removeSprites', Array.from(selectedSpriteIds.value));
|
|
selectedSpriteIds.value.clear();
|
|
showContextMenu.value = false;
|
|
contextMenuSpriteId.value = null;
|
|
} else if (contextMenuSpriteId.value) {
|
|
emit('removeSprite', contextMenuSpriteId.value);
|
|
showContextMenu.value = false;
|
|
contextMenuSpriteId.value = null;
|
|
}
|
|
};
|
|
|
|
const rotateSpriteInMenu = (angle: number) => {
|
|
if (selectedSpriteIds.value.size > 0) {
|
|
selectedSpriteIds.value.forEach(id => {
|
|
emit('rotateSprite', id, angle);
|
|
});
|
|
} else if (contextMenuSpriteId.value) {
|
|
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
|
}
|
|
showContextMenu.value = false;
|
|
};
|
|
|
|
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
|
if (selectedSpriteIds.value.size > 0) {
|
|
selectedSpriteIds.value.forEach(id => {
|
|
emit('flipSprite', id, direction);
|
|
});
|
|
} else if (contextMenuSpriteId.value) {
|
|
emit('flipSprite', contextMenuSpriteId.value, direction);
|
|
}
|
|
showContextMenu.value = false;
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
// Don't delete if editing text/input
|
|
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
|
|
|
|
if (selectedSpriteIds.value.size > 0) {
|
|
emit('removeSprites', Array.from(selectedSpriteIds.value));
|
|
selectedSpriteIds.value.clear();
|
|
}
|
|
}
|
|
};
|
|
|
|
const replaceSprite = () => {
|
|
if (contextMenuSpriteId.value && fileInput.value) {
|
|
replacingSpriteId.value = contextMenuSpriteId.value;
|
|
fileInput.value.click();
|
|
showContextMenu.value = false;
|
|
contextMenuSpriteId.value = null;
|
|
}
|
|
};
|
|
|
|
const addSprite = () => {
|
|
if (fileInput.value) {
|
|
fileInput.value.click();
|
|
showContextMenu.value = false;
|
|
contextMenuSpriteId.value = null;
|
|
}
|
|
};
|
|
|
|
const handleFileChange = (event: Event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
|
|
if (input.files && input.files.length > 0) {
|
|
const file = input.files[0];
|
|
if (file.type.startsWith('image/')) {
|
|
if (replacingSpriteId.value) {
|
|
emit('replaceSprite', replacingSpriteId.value, file);
|
|
} else {
|
|
emit('addSprite', file);
|
|
}
|
|
} else {
|
|
alert('Please select an image file.');
|
|
}
|
|
}
|
|
replacingSpriteId.value = null;
|
|
input.value = '';
|
|
};
|
|
|
|
const hideContextMenu = () => {
|
|
showContextMenu.value = false;
|
|
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) => {
|
|
handleDragLeave(event, gridContainerRef.value);
|
|
};
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('mouseup', stopDrag);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
// Watch for background color changes
|
|
</script>
|
|
|
|
<style scoped></style>
|