Files
spritesheet-generator/src/components/SpriteCanvas.vue

579 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 -->
<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"
/>
<!-- Frame IDs -->
<div
v-if="showFrameIds"
v-for="cellIndex in totalCells"
:key="`frame-id-${cellIndex}`"
class="absolute pointer-events-none font-mono text-[10px] leading-none text-gray-500 dark:text-gray-400 bg-white/80 dark:bg-gray-800/90 px-1.5 py-1 rounded-br-lg border-r border-b border-gray-200 dark:border-gray-600 shadow-sm z-0"
:style="{
left: `${Math.round(getCellPosition(cellIndex - 1).x)}px`,
top: `${Math.round(getCellPosition(cellIndex - 1).y)}px`,
}"
>
{{ cellIndex - 1 }}
</div>
<!-- Offset labels (Coordinates) -->
<div
v-if="showOffsetLabels"
v-for="position in spritePositions"
:key="`label-${position.id}`"
class="absolute text-[10px] sm:text-xs leading-none font-mono font-medium text-indigo-600 dark:text-indigo-400 bg-white/95 dark:bg-gray-900/95 px-1.5 py-0.5 rounded shadow-sm border border-indigo-200 dark:border-indigo-900 pointer-events-none z-10"
:style="{
left: `${Math.round(position.cellX + position.maxWidth)}px`,
top: `${Math.round(position.cellY + position.maxHeight)}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;
showFrameIds: 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>