417 lines
15 KiB
Vue
417 lines
15 KiB
Vue
<template>
|
|
<div class="p-2 bg-cyan-600 rounded w-full my-4">
|
|
<p>Developer's tip: Right click a sprite to open the context menu and add, replace or remove sprites.</p>
|
|
</div>
|
|
<div class="space-y-4">
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
|
<div class="flex items-center">
|
|
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2 w-4 h-4" @change="drawCanvas" />
|
|
<label for="pixel-perfect" class="dark:text-gray-200 text-sm sm:text-base">Pixel perfect rendering</label>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="mr-2 w-4 h-4" />
|
|
<label for="allow-cell-swap" class="dark:text-gray-200 text-sm sm:text-base">Allow moving between cells</label>
|
|
</div>
|
|
<!-- Add new checkbox for showing all sprites -->
|
|
<div class="flex items-center">
|
|
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
|
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
|
</div>
|
|
<!-- Negative spacing control -->
|
|
<div class="flex items-center">
|
|
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
|
|
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto">
|
|
<!-- Zoom controls - visible on all screen sizes and positioned outside cells -->
|
|
<div class="relative flex space-x-2 bg-white/90 dark:bg-gray-800/90 p-2 rounded-lg shadow-md z-20">
|
|
<button @click="zoomIn" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
|
<i class="fas fa-plus"></i>
|
|
</button>
|
|
<button @click="zoomOut" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
|
<i class="fas fa-minus"></i>
|
|
</button>
|
|
<button @click="resetZoom" class="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors">
|
|
<i class="fas fa-expand"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="canvas-container touch-manipulation relative" :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }">
|
|
<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="w-full transition-all"
|
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
|
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
|
></canvas>
|
|
|
|
<!-- Offset labels in corners -->
|
|
<div v-if="canvasRef" 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="{
|
|
left: `calc(${(position.cellX / canvasRef.width) * 100}% + ${(position.maxWidth / canvasRef.width) * 100}% - 2px)`,
|
|
top: `calc(${(position.cellY / canvasRef.height) * 100}% + ${(position.maxHeight / canvasRef.height) * 100}% - 2px)`,
|
|
transform: 'translate(-100%, -100%)',
|
|
}"
|
|
>
|
|
<span>{{ position.x }},{{ position.y }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Context Menu -->
|
|
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg z-50 py-1" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
|
<button @click="addSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
|
<i class="fas fa-plus"></i>
|
|
Add sprite
|
|
</button>
|
|
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 flex items-center gap-2">
|
|
<i class="fas fa-exchange-alt"></i>
|
|
Replace sprite
|
|
</button>
|
|
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 dark:text-red-400 flex items-center gap-2">
|
|
<i class="fas fa-trash"></i>
|
|
Remove sprite
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Hidden file input for replace functionality -->
|
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, onUnmounted, toRef, computed } 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);
|
|
|
|
// 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 ?? [])),
|
|
columns: toRef(props, 'columns'),
|
|
zoom,
|
|
allowCellSwap,
|
|
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
|
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
|
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
|
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
|
onDraw: drawCanvas,
|
|
});
|
|
|
|
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 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 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));
|
|
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
|
|
|
|
// 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, draw all sprites with transparency in each cell
|
|
if (showAllSprites.value) {
|
|
const total = Math.max(...props.layers.map(l => (l.visible ? l.sprites.length : 0)));
|
|
for (let cellIndex = 0; cellIndex < total; 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);
|
|
|
|
props.layers.forEach(layer => {
|
|
if (!layer.visible) return;
|
|
const sprite = layer.sprites[cellIndex];
|
|
if (sprite) canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.35);
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
drawCanvas();
|
|
|
|
// 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 = () => {
|
|
canvas2D.ensureIntegerPositions(props.sprites);
|
|
canvas2D.applySmoothing();
|
|
drawCanvas();
|
|
};
|
|
|
|
watch(
|
|
() => props.sprites,
|
|
() => {
|
|
attachImageListeners();
|
|
drawCanvas();
|
|
},
|
|
{ deep: true }
|
|
);
|
|
watch(() => props.columns, drawCanvas);
|
|
watch(() => settingsStore.pixelPerfect, drawCanvas);
|
|
watch(() => settingsStore.darkMode, drawCanvas);
|
|
watch(() => settingsStore.negativeSpacingEnabled, drawCanvas);
|
|
watch(showAllSprites, drawCanvas);
|
|
</script>
|
|
|
|
<style scoped></style>
|