|
|
|
|
@@ -1,87 +1,100 @@
|
|
|
|
|
<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="requestDraw" />
|
|
|
|
|
<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>
|
|
|
|
|
<!-- Background color picker -->
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<label for="bg-color" class="dark:text-gray-200 text-sm sm:text-base">Background:</label>
|
|
|
|
|
<select id="bg-color" v-model="bgSelectValue" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 dark:text-gray-200 text-sm">
|
|
|
|
|
<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-8 h-8 border border-gray-300 dark:border-gray-600 rounded cursor-pointer" />
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
<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="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>
|
|
|
|
|
<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 -->
|
|
|
|
|
<div v-if="canvasRef" class="absolute inset-0 pointer-events-none">
|
|
|
|
|
<!-- 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="{
|
|
|
|
|
// Use the global cell size so labels line up with the actual grid cells
|
|
|
|
|
left: `calc(${(position.cellX / canvasWidth) * 100}% + ${(gridMetrics.maxWidth / canvasWidth) * 100}% - 2px)`,
|
|
|
|
|
top: `calc(${(position.cellY / canvasHeight) * 100}% + ${(gridMetrics.maxHeight / canvasHeight) * 100}% - 2px)`,
|
|
|
|
|
// 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%)',
|
|
|
|
|
}"
|
|
|
|
|
>
|
|
|
|
|
@@ -91,23 +104,22 @@
|
|
|
|
|
</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
|
|
|
|
|
<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-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 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>
|
|
|
|
|
<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">
|
|
|
|
|
<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>
|
|
|
|
|
Remove sprite
|
|
|
|
|
<span>Remove Sprite</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Hidden file input for replace functionality -->
|
|
|
|
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileChange" />
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
@@ -208,6 +220,7 @@
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const showAllSprites = ref(false);
|
|
|
|
|
const showOffsetLabels = ref(true);
|
|
|
|
|
const showContextMenu = ref(false);
|
|
|
|
|
const contextMenuX = ref(0);
|
|
|
|
|
const contextMenuY = ref(0);
|
|
|
|
|
@@ -282,10 +295,6 @@
|
|
|
|
|
// Grid metrics used to position offset labels relative to cell size
|
|
|
|
|
const gridMetrics = computed(() => calculateMaxDimensions());
|
|
|
|
|
|
|
|
|
|
// Reactive canvas dimensions to ensure label positions update when canvas size changes
|
|
|
|
|
const canvasWidth = ref(0);
|
|
|
|
|
const canvasHeight = ref(0);
|
|
|
|
|
|
|
|
|
|
const startDrag = (event: MouseEvent) => {
|
|
|
|
|
if (!canvasRef.value) return;
|
|
|
|
|
|
|
|
|
|
@@ -388,9 +397,6 @@
|
|
|
|
|
const newCanvasWidth = maxWidth * props.columns;
|
|
|
|
|
const newCanvasHeight = maxHeight * rows;
|
|
|
|
|
canvas2D.setCanvasSize(newCanvasWidth, newCanvasHeight);
|
|
|
|
|
// Update reactive dimensions for template-driven elements (like labels)
|
|
|
|
|
if (canvasWidth.value !== newCanvasWidth) canvasWidth.value = newCanvasWidth;
|
|
|
|
|
if (canvasHeight.value !== newCanvasHeight) canvasHeight.value = newCanvasHeight;
|
|
|
|
|
|
|
|
|
|
// Clear canvas
|
|
|
|
|
canvas2D.clear();
|
|
|
|
|
@@ -414,19 +420,29 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If showing all sprites, draw all sprites with transparency in each cell
|
|
|
|
|
// If showing all sprites, overlay all sprites from all visible layers ghosted 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 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);
|
|
|
|
|
|
|
|
|
|
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 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|