Files
spritesheet-generator/src/components/SpriteCanvas.vue
2025-11-23 01:48:05 +01:00

543 lines
22 KiB
Vue

<template>
<div class="space-y-6">
<div class="bg-gradient-to-r from-cyan-500 to-blue-500 dark:from-cyan-600 dark:to-blue-600 rounded-xl p-4 shadow-lg border border-cyan-400/50 dark:border-cyan-500/50">
<div class="flex items-start gap-3">
<i class="fas fa-lightbulb text-yellow-300 text-xl mt-0.5"></i>
<div>
<h4 class="font-semibold text-white mb-1">Pro Tip</h4>
<p class="text-cyan-50 text-sm">Right-click any sprite to open the context menu for quick actions: add, replace, or remove sprites.</p>
</div>
</div>
</div>
<section>
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
<i class="fas fa-cog text-blue-600 dark:text-blue-400"></i>
Canvas Options
</h3>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 rounded" @change="requestDraw" />
<span class="text-sm font-medium dark:text-gray-200">Pixel Perfect</span>
</label>
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="w-4 h-4 rounded" />
<span class="text-sm font-medium dark:text-gray-200">Cell Swapping</span>
</label>
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="w-4 h-4 rounded" />
<span class="text-sm font-medium dark:text-gray-200">Compare Sprites</span>
</label>
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="w-4 h-4 rounded" />
<span class="text-sm font-medium dark:text-gray-200">Negative Spacing</span>
</label>
<div class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
<label for="bg-color" class="text-sm font-medium dark:text-gray-200">Background:</label>
<select id="bg-color" v-model="bgSelectValue" class="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all">
<option value="transparent">Transparent</option>
<option value="#ffffff">White</option>
<option value="#000000">Black</option>
<option value="#f9fafb">Light Gray</option>
<option value="custom">Custom</option>
</select>
<input v-if="bgSelectValue === 'custom'" type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="w-10 h-10 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" />
</div>
<div class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium dark:text-gray-200 mr-1">Zoom:</span>
<button @click="zoomIn" class="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded transition-all" title="Zoom In">
<i class="fas fa-plus text-xs"></i>
</button>
<button @click="zoomOut" class="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded transition-all" title="Zoom Out">
<i class="fas fa-minus text-xs"></i>
</button>
<button @click="resetZoom" class="p-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded transition-all" title="Reset Zoom">
<i class="fas fa-expand text-xs"></i>
</button>
</div>
<label class="flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition-all">
<input id="show-offset-labels" type="checkbox" v-model="showOffsetLabels" class="w-4 h-4 rounded" />
<span class="text-sm font-medium dark:text-gray-200">Show Offset Labels</span>
</label>
</div>
</section>
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto">
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
<div :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }" class="inline-block">
<canvas
ref="canvasRef"
@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="block transition-all"
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
></canvas>
</div>
<!-- Offset labels in corners (not scaled with zoom) -->
<div v-if="canvasRef && showOffsetLabels" class="absolute inset-0 pointer-events-none">
<div
v-for="position in spritePositions"
:key="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"
:style="{
// Position at bottom-right corner of each cell, scaled by zoom
left: `${(position.cellX + position.maxWidth) * zoom - 2}px`,
top: `${(position.cellY + position.maxHeight) * zoom - 2}px`,
transform: 'translate(-100%, -100%)',
}"
>
<span>{{ position.x }},{{ position.y }}</span>
</div>
</div>
</div>
</div>
<div v-if="showContextMenu" 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' }">
<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">
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
<span>Add Sprite</span>
</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">
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
<span>Replace Sprite</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-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">
<i class="fas fa-trash"></i>
<span>Remove Sprite</span>
</button>
</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 { useCanvas2D } from '@/composables/useCanvas2D';
import { useZoom } from '@/composables/useZoom';
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;
}>();
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: 'replaceSprite', id: string, file: File): void;
(e: 'addSprite', file: File): void;
(e: 'addSpriteWithResize', file: File): void;
}>();
// Get settings from store
const settingsStore = useSettingsStore();
const canvasRef = ref<HTMLCanvasElement | null>(null);
// rAF-based draw scheduler to coalesce multiple draw requests into a single frame
// Define before usage to avoid TDZ issues when passing into composables during setup
let rafId: number | null = null;
function requestDraw() {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
drawCanvas();
});
}
// Initialize composables
const canvas2D = useCanvas2D(canvasRef);
const {
zoom,
increase: zoomIn,
decrease: zoomOut,
reset: resetZoom,
} = useZoom({
min: 0.5,
max: 3,
step: 0.25,
initial: 1,
});
const allowCellSwap = ref(false);
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,
allowCellSwap,
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
onDraw: requestDraw,
});
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
sprites: activeSprites,
onAddSprite: file => emit('addSprite', file),
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
});
const showAllSprites = ref(false);
const showOffsetLabels = ref(true);
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
const contextMenuSpriteId = ref<string | null>(null);
const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const customColor = ref('#ffffff');
// Background select handling: keep the select stable on "custom"
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
// Ensure customColor mirrors the current stored custom color (only when needed)
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
customColor.value = settingsStore.backgroundColor;
}
// Track if user is in custom mode to keep picker visible
// Initialize to true if current color is custom
const isCustomMode = ref(isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any));
const bgSelectValue = computed<string>({
get() {
// If user explicitly entered custom mode, stay in it regardless of color value
if (isCustomMode.value) {
// Keep color picker synced
const val = settingsStore.backgroundColor;
if (isHexColor(val)) {
customColor.value = val;
}
return 'custom';
}
const val = settingsStore.backgroundColor;
if (presetBgColors.includes(val as any)) return val;
if (isHexColor(val)) {
// Keep the color picker open and sync its swatch
customColor.value = val;
isCustomMode.value = true; // Auto-enable custom mode for non-preset hex colors
return 'custom';
}
// Fallback
return 'transparent';
},
set(v: string) {
if (v === 'custom') {
isCustomMode.value = true;
// Switch UI to custom mode but keep the stored value as a color
const fallback = '#ffffff';
const current = settingsStore.backgroundColor;
const fromStore = isHexColor(current) ? current : null;
const fromLocal = isHexColor(customColor.value) ? customColor.value : null;
const color = fromStore || fromLocal || fallback;
customColor.value = color;
settingsStore.setBackgroundColor(color);
} else {
isCustomMode.value = false;
settingsStore.setBackgroundColor(v);
}
},
});
// Ensure canvas redraw and UI flush after background changes
watch(
() => settingsStore.backgroundColor,
async () => {
await nextTick();
requestDraw();
}
);
// Grid metrics used to position offset labels relative to cell size
const gridMetrics = computed(() => calculateMaxDimensions());
const startDrag = (event: MouseEvent) => {
if (!canvasRef.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 = canvas2D.getMousePosition(event, zoom.value);
if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
contextMenuSpriteId.value = clickedSprite?.id || null;
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
showContextMenu.value = true;
return;
}
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
if ('button' in event && (event as MouseEvent).button !== 0) return;
// Delegate to composable for actual drag handling
dragStart(event);
};
// Wrapper for drag move
const drag = (event: MouseEvent) => {
dragMove(event);
};
const removeSprite = () => {
if (contextMenuSpriteId.value) {
emit('removeSprite', contextMenuSpriteId.value);
showContextMenu.value = false;
contextMenuSpriteId.value = null;
}
};
const replaceSprite = () => {
if (contextMenuSpriteId.value && fileInput.value) {
// Store the sprite ID separately so it persists after context menu closes
replacingSpriteId.value = contextMenuSpriteId.value;
fileInput.value.click();
// Hide context menu immediately since we've stored the ID
showContextMenu.value = false;
contextMenuSpriteId.value = null;
}
};
const addSprite = () => {
if (fileInput.value) {
fileInput.value.click();
// Hide context menu immediately
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 {
// Adding new sprite
emit('addSprite', file);
}
} else {
alert('Please select an image file.');
}
}
// Clean up after file selection
replacingSpriteId.value = null;
input.value = '';
};
const hideContextMenu = () => {
showContextMenu.value = false;
contextMenuSpriteId.value = null;
};
// Wrapper for drag leave to pass canvasRef
const onDragLeave = (event: DragEvent) => {
handleDragLeave(event, canvasRef.value);
};
function drawCanvas() {
if (!canvasRef.value || !canvas2D.ctx.value) return;
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
// Set canvas size
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));
const newCanvasWidth = maxWidth * props.columns;
const newCanvasHeight = maxHeight * rows;
canvas2D.setCanvasSize(newCanvasWidth, newCanvasHeight);
// Clear canvas
canvas2D.clear();
// Apply pixel art optimization
canvas2D.applySmoothing();
// Draw background for each cell
for (let col = 0; col < props.columns; col++) {
for (let row = 0; row < rows; row++) {
const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight);
// Draw cell background
canvas2D.fillCellBackground(cellX, cellY, maxWidth, maxHeight);
// Highlight the target cell if specified
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
canvas2D.fillRect(cellX, cellY, maxWidth, maxHeight, 'rgba(59, 130, 246, 0.2)');
}
}
}
// If showing all sprites, overlay all sprites from all visible layers ghosted in each cell
if (showAllSprites.value) {
const visibleLayers = props.layers.filter(l => l.visible);
const maxSprites = Math.max(...visibleLayers.map(l => l.sprites.length), 0);
for (let cellIndex = 0; cellIndex < maxSprites; cellIndex++) {
const cellCol = cellIndex % props.columns;
const cellRow = Math.floor(cellIndex / props.columns);
const cellX = Math.floor(cellCol * maxWidth);
const cellY = Math.floor(cellRow * maxHeight);
// Draw all sprites from all visible layers ghosted in this cell
visibleLayers.forEach(layer => {
layer.sprites.forEach((sprite, spriteIndex) => {
// Skip drawing the sprite that belongs in this cell (it's already drawn below)
if (spriteIndex === cellIndex) return;
// Skip the active sprite if we're dragging it
if (activeSpriteId.value === sprite.id && ghostSprite.value) return;
if (sprite && sprite.img) {
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.25);
}
});
});
}
}
// Draw layers in order; active layer will be interactable
props.layers.forEach(layer => {
if (!layer.visible) return;
layer.sprites.forEach((sprite, index) => {
// Skip the active sprite if we're showing a ghost instead
if (activeSpriteId.value === sprite.id && ghostSprite.value) return;
const col = index % props.columns;
const row = Math.floor(index / props.columns);
const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight);
const alpha = layer.id === props.activeLayerId ? 1 : 0.85;
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, alpha);
});
});
// Draw ghost sprite if we're dragging between cells
if (ghostSprite.value && activeSpriteId.value) {
const sprite = activeSprites.value.find(s => s.id === activeSpriteId.value);
if (sprite) {
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
}
}
// Draw grid lines on top of everything
for (let col = 0; col < props.columns; col++) {
for (let row = 0; row < rows; row++) {
const cellX = Math.floor(col * maxWidth);
const cellY = Math.floor(row * maxHeight);
canvas2D.strokeGridCell(cellX, cellY, maxWidth, maxHeight);
}
}
}
// Track which images already have listeners
const imagesWithListeners = new WeakSet<HTMLImageElement>();
const attachImageListeners = () => {
const sprites = props.layers.flatMap(l => l.sprites);
canvas2D.attachImageListeners(sprites, handleForceRedraw, imagesWithListeners);
};
onMounted(() => {
canvas2D.initContext();
requestDraw();
// Attach listeners for current sprites
attachImageListeners();
// Listen for forceRedraw event from App.vue
window.addEventListener('forceRedraw', handleForceRedraw);
// Hide context menu when clicking elsewhere
document.addEventListener('click', hideContextMenu);
});
onUnmounted(() => {
window.removeEventListener('forceRedraw', handleForceRedraw);
document.removeEventListener('click', hideContextMenu);
});
// Handler for force redraw event
const handleForceRedraw = () => {
// Ensure integer positioning for crisp rendering on the active layer
canvas2D.ensureIntegerPositions(activeSprites.value);
canvas2D.applySmoothing();
requestDraw();
};
// Re-attach listeners and redraw whenever layers/sprites change
watch(
() => props.layers,
() => {
attachImageListeners();
requestDraw();
},
{ deep: true }
);
watch(() => props.columns, requestDraw);
watch(() => settingsStore.pixelPerfect, requestDraw);
watch(() => settingsStore.darkMode, requestDraw);
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
watch(() => settingsStore.backgroundColor, requestDraw);
watch(() => settingsStore.manualCellSizeEnabled, requestDraw);
watch(() => settingsStore.manualCellWidth, requestDraw);
watch(() => settingsStore.manualCellHeight, requestDraw);
watch(showAllSprites, requestDraw);
</script>
<style scoped></style>