564 lines
23 KiB
Vue
564 lines
23 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 -->
|
|
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
|
|
</Teleport>
|
|
|
|
<div class="h-full w-full flex flex-col">
|
|
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm overflow-auto h-full 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: `${Math.round(getCellPosition(cellIndex - 1).x)}px`,
|
|
top: `${Math.round(getCellPosition(cellIndex - 1).y)}px`,
|
|
width: `${Math.round(gridMetrics.maxWidth)}px`,
|
|
height: `${Math.round(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: `${Math.round(getCellPosition(cellIndex - 1).x + gridMetrics.negativeSpacing + sprite.x)}px`,
|
|
top: `${Math.round(getCellPosition(cellIndex - 1).y + gridMetrics.negativeSpacing + sprite.y)}px`,
|
|
width: `${Math.round(sprite.width)}px`,
|
|
height: `${Math.round(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: `${Math.round(getCellPosition(index).x + gridMetrics.negativeSpacing + sprite.x)}px`,
|
|
top: `${Math.round(getCellPosition(index).y + gridMetrics.negativeSpacing + sprite.y)}px`,
|
|
width: `${Math.round(sprite.width)}px`,
|
|
height: `${Math.round(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: `${Math.round(ghostSprite.x)}px`,
|
|
top: `${Math.round(ghostSprite.y)}px`,
|
|
width: `${Math.round(activeSpriteSprite?.width || 0)}px`,
|
|
height: `${Math.round(activeSpriteSprite?.height || 0)}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: `${Math.round(position.cellX + position.maxWidth - 2)}px`,
|
|
top: `${Math.round(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 { useGridMetrics } from '@/composables/useGridMetrics';
|
|
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
|
|
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
|
|
|
|
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, index?: number): 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,
|
|
findCellAtPosition,
|
|
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 contextMenuIndex = ref<number | null>(null);
|
|
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 copyTargetLayerId = ref(props.activeLayerId);
|
|
const copySpriteId = ref<string | null>(null);
|
|
|
|
// Clear selection when toggling multi-select mode
|
|
watch(
|
|
() => props.isMultiSelectMode,
|
|
() => {
|
|
selectedSpriteIds.value.clear();
|
|
}
|
|
);
|
|
|
|
// Use the new useGridMetrics composable for consistent calculations
|
|
const { gridMetrics: gridMetricsRef, getCellPosition: getCellPositionHelper } = useGridMetrics({
|
|
layers: toRef(props, 'layers'),
|
|
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
|
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
|
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
|
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
|
});
|
|
|
|
const gridMetrics = gridMetricsRef;
|
|
|
|
const totalCells = computed(() => {
|
|
// Use all layers regardless of visibility to keep canvas size stable
|
|
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
|
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
|
});
|
|
|
|
const gridDimensions = computed(() => {
|
|
// Use all layers regardless of visibility to keep canvas size stable
|
|
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
|
|
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
|
return {
|
|
width: Math.round(gridMetrics.value.maxWidth * props.columns),
|
|
height: Math.round(gridMetrics.value.maxHeight * rows),
|
|
};
|
|
});
|
|
|
|
const getCellPosition = (index: number) => {
|
|
return getCellPositionHelper(index, props.columns, gridMetrics.value);
|
|
};
|
|
|
|
// Use the new useBackgroundStyles composable for consistent background styling
|
|
const {
|
|
backgroundColor: cellBackgroundColor,
|
|
backgroundImage: cellBackgroundImage,
|
|
backgroundSize: cellBackgroundSize,
|
|
backgroundPosition: cellBackgroundPosition,
|
|
} = useBackgroundStyles({
|
|
backgroundColor: toRef(settingsStore, 'backgroundColor'),
|
|
checkerboardEnabled: toRef(settingsStore, 'checkerboardEnabled'),
|
|
darkMode: toRef(settingsStore, 'darkMode'),
|
|
});
|
|
|
|
const getCellBackground = () => cellBackgroundColor.value;
|
|
const getCellBackgroundImage = () => cellBackgroundImage.value;
|
|
const getCellBackgroundSize = () => cellBackgroundSize.value;
|
|
const getCellBackgroundPosition = () => cellBackgroundPosition.value;
|
|
|
|
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);
|
|
contextMenuIndex.value = findCellAtPosition(pos.x, pos.y)?.index ?? null;
|
|
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 {
|
|
if (contextMenuIndex.value !== null) {
|
|
emit('addSprite', file, contextMenuIndex.value);
|
|
} else {
|
|
emit('addSprite', file);
|
|
}
|
|
}
|
|
} else {
|
|
alert('Please select an image file.');
|
|
}
|
|
}
|
|
replacingSpriteId.value = null;
|
|
contextMenuIndex.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;
|
|
showCopyToFrameModal.value = true;
|
|
showContextMenu.value = false;
|
|
}
|
|
};
|
|
|
|
const closeCopyToFrameModal = () => {
|
|
showCopyToFrameModal.value = false;
|
|
copySpriteId.value = null;
|
|
};
|
|
|
|
const confirmCopyToFrame = (targetLayerId: string, targetFrameIndex: number) => {
|
|
if (copySpriteId.value) {
|
|
emit('copySpriteToFrame', copySpriteId.value, targetLayerId, targetFrameIndex);
|
|
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>
|