[FEAT] Canvas > IMG
This commit is contained in:
@@ -35,7 +35,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-4">
|
<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">
|
<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" />
|
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 rounded" />
|
||||||
<span class="text-sm font-medium dark:text-gray-200">Pixel Perfect</span>
|
<span class="text-sm font-medium dark:text-gray-200">Pixel Perfect</span>
|
||||||
</label>
|
</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">
|
<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">
|
||||||
@@ -82,38 +82,125 @@
|
|||||||
|
|
||||||
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto max-h-[calc(100vh-400px)] min-h-[500px] w-full">
|
<div class="relative bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-2xl shadow-lg overflow-auto max-h-[calc(100vh-400px)] min-h-[500px] w-full">
|
||||||
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
<div class="canvas-container touch-manipulation relative inline-block min-w-full">
|
||||||
<div :style="{ transform: `scale(${zoom})`, transformOrigin: 'top left' }" class="inline-block">
|
<div
|
||||||
<canvas
|
ref="gridContainerRef"
|
||||||
ref="canvasRef"
|
:style="{
|
||||||
@mousedown="startDrag"
|
transform: `scale(${zoom})`,
|
||||||
@mousemove="drag"
|
transformOrigin: 'top left',
|
||||||
@mouseup="stopDrag"
|
width: `${gridDimensions.width}px`,
|
||||||
@mouseleave="stopDrag"
|
height: `${gridDimensions.height}px`,
|
||||||
@touchstart="handleTouchStart"
|
position: 'relative'
|
||||||
@touchmove="handleTouchMove"
|
}"
|
||||||
@touchend="stopDrag"
|
class="inline-block"
|
||||||
@contextmenu.prevent
|
@mousedown="startDrag"
|
||||||
@dragover="handleDragOver"
|
@mousemove="drag"
|
||||||
@dragenter="handleDragEnter"
|
@mouseup="stopDrag"
|
||||||
@dragleave="onDragLeave"
|
@mouseleave="stopDrag"
|
||||||
@drop="handleDrop"
|
@touchstart="handleTouchStart"
|
||||||
class="block transition-all"
|
@touchmove="handleTouchMove"
|
||||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
@touchend="stopDrag"
|
||||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
@contextmenu.prevent
|
||||||
></canvas>
|
@dragover="handleDragOver"
|
||||||
</div>
|
@dragenter="handleDragEnter"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
<!-- Offset labels in corners (not scaled with zoom) -->
|
@drop="handleDrop"
|
||||||
<div v-if="canvasRef && showOffsetLabels" class="absolute inset-0 pointer-events-none">
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||||
|
>
|
||||||
|
<!-- Grid cells -->
|
||||||
<div
|
<div
|
||||||
v-for="position in spritePositions"
|
v-for="cellIndex in totalCells"
|
||||||
:key="position.id"
|
:key="`cell-${cellIndex - 1}`"
|
||||||
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"
|
class="absolute"
|
||||||
:style="{
|
:style="{
|
||||||
// Position at bottom-right corner of each cell, scaled by zoom
|
left: `${getCellPosition(cellIndex - 1).x}px`,
|
||||||
left: `${(position.cellX + position.maxWidth) * zoom - 2}px`,
|
top: `${getCellPosition(cellIndex - 1).y}px`,
|
||||||
top: `${(position.cellY + position.maxHeight) * zoom - 2}px`,
|
width: `${gridMetrics.maxWidth}px`,
|
||||||
transform: 'translate(-100%, -100%)',
|
height: `${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.img.src"
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${getCellPosition(cellIndex - 1).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||||
|
top: `${getCellPosition(cellIndex - 1).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||||
|
width: `${sprite.width}px`,
|
||||||
|
height: `${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.img.src"
|
||||||
|
class="absolute cursor-move"
|
||||||
|
:style="{
|
||||||
|
left: `${getCellPosition(index).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||||
|
top: `${getCellPosition(index).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||||
|
width: `${sprite.width}px`,
|
||||||
|
height: `${sprite.height}px`,
|
||||||
|
opacity: layer.id === activeLayerId ? '1' : '0.85',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto'
|
||||||
|
}"
|
||||||
|
: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?.img.src"
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${ghostSprite.x}px`,
|
||||||
|
top: `${ghostSprite.y}px`,
|
||||||
|
width: `${activeSpriteSprite?.width}px`,
|
||||||
|
height: `${activeSpriteSprite?.height}px`,
|
||||||
|
opacity: '0.6',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto'
|
||||||
|
}"
|
||||||
|
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: `${position.cellX + position.maxWidth - 2}px`,
|
||||||
|
top: `${position.cellY + position.maxHeight - 2}px`,
|
||||||
|
transform: 'translate(-100%, -100%)'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span>{{ position.x }},{{ position.y }}</span>
|
<span>{{ position.x }},{{ position.y }}</span>
|
||||||
@@ -130,7 +217,6 @@
|
|||||||
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
||||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
import type { Sprite } from '@/types/sprites';
|
import type { Sprite } from '@/types/sprites';
|
||||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
|
||||||
import { useZoom } from '@/composables/useZoom';
|
import { useZoom } from '@/composables/useZoom';
|
||||||
import { useDragSprite } from '@/composables/useDragSprite';
|
import { useDragSprite } from '@/composables/useDragSprite';
|
||||||
import { useFileDrop } from '@/composables/useFileDrop';
|
import { useFileDrop } from '@/composables/useFileDrop';
|
||||||
@@ -155,21 +241,7 @@
|
|||||||
// Get settings from store
|
// Get settings from store
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const gridContainerRef = ref<HTMLDivElement | 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 {
|
const {
|
||||||
zoom,
|
zoom,
|
||||||
@@ -185,6 +257,17 @@
|
|||||||
|
|
||||||
const allowCellSwap = ref(false);
|
const allowCellSwap = ref(false);
|
||||||
|
|
||||||
|
const getMousePosition = (event: MouseEvent, z: number) => {
|
||||||
|
if (!gridContainerRef.value) return null;
|
||||||
|
const rect = gridContainerRef.value.getBoundingClientRect();
|
||||||
|
const scaleX = gridContainerRef.value.offsetWidth / (rect.width / z);
|
||||||
|
const scaleY = gridContainerRef.value.offsetHeight / (rect.height / z);
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rect.left) / z) * scaleX,
|
||||||
|
y: ((event.clientY - rect.top) / z) * scaleY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDragging,
|
isDragging,
|
||||||
activeSpriteId,
|
activeSpriteId,
|
||||||
@@ -208,13 +291,15 @@
|
|||||||
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||||
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
|
||||||
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
|
||||||
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
getMousePosition,
|
||||||
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||||
onDraw: requestDraw,
|
onDraw: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
|
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({
|
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
||||||
sprites: activeSprites,
|
sprites: activeSprites,
|
||||||
@@ -232,24 +317,79 @@
|
|||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const customColor = ref('#ffffff');
|
const customColor = ref('#ffffff');
|
||||||
|
|
||||||
// Background select handling: keep the select stable on "custom"
|
// Grid metrics
|
||||||
|
const gridMetrics = computed(() => calculateMaxDimensions());
|
||||||
|
|
||||||
|
const totalCells = computed(() => {
|
||||||
|
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||||
|
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridDimensions = computed(() => {
|
||||||
|
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));
|
||||||
|
return {
|
||||||
|
width: gridMetrics.value.maxWidth * props.columns,
|
||||||
|
height: gridMetrics.value.maxHeight * rows,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCellPosition = (index: number) => {
|
||||||
|
const col = index % props.columns;
|
||||||
|
const row = Math.floor(index / props.columns);
|
||||||
|
return {
|
||||||
|
x: col * gridMetrics.value.maxWidth,
|
||||||
|
y: row * gridMetrics.value.maxHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackground = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
return 'transparent';
|
||||||
|
}
|
||||||
|
return bg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackgroundImage = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
|
||||||
|
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
||||||
|
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackgroundSize = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
return '20px 20px';
|
||||||
|
}
|
||||||
|
return 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellBackgroundPosition = () => {
|
||||||
|
const bg = settingsStore.backgroundColor;
|
||||||
|
if (bg === 'transparent') {
|
||||||
|
return '0 0, 0 10px, 10px -10px, -10px 0px';
|
||||||
|
}
|
||||||
|
return '0 0';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Background select handling
|
||||||
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
|
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);
|
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)) {
|
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
|
||||||
customColor.value = settingsStore.backgroundColor;
|
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 isCustomMode = ref(isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any));
|
||||||
|
|
||||||
const bgSelectValue = computed<string>({
|
const bgSelectValue = computed<string>({
|
||||||
get() {
|
get() {
|
||||||
// If user explicitly entered custom mode, stay in it regardless of color value
|
|
||||||
if (isCustomMode.value) {
|
if (isCustomMode.value) {
|
||||||
// Keep color picker synced
|
|
||||||
const val = settingsStore.backgroundColor;
|
const val = settingsStore.backgroundColor;
|
||||||
if (isHexColor(val)) {
|
if (isHexColor(val)) {
|
||||||
customColor.value = val;
|
customColor.value = val;
|
||||||
@@ -260,18 +400,15 @@
|
|||||||
const val = settingsStore.backgroundColor;
|
const val = settingsStore.backgroundColor;
|
||||||
if (presetBgColors.includes(val as any)) return val;
|
if (presetBgColors.includes(val as any)) return val;
|
||||||
if (isHexColor(val)) {
|
if (isHexColor(val)) {
|
||||||
// Keep the color picker open and sync its swatch
|
|
||||||
customColor.value = val;
|
customColor.value = val;
|
||||||
isCustomMode.value = true; // Auto-enable custom mode for non-preset hex colors
|
isCustomMode.value = true;
|
||||||
return 'custom';
|
return 'custom';
|
||||||
}
|
}
|
||||||
// Fallback
|
|
||||||
return 'transparent';
|
return 'transparent';
|
||||||
},
|
},
|
||||||
set(v: string) {
|
set(v: string) {
|
||||||
if (v === 'custom') {
|
if (v === 'custom') {
|
||||||
isCustomMode.value = true;
|
isCustomMode.value = true;
|
||||||
// Switch UI to custom mode but keep the stored value as a color
|
|
||||||
const fallback = '#ffffff';
|
const fallback = '#ffffff';
|
||||||
const current = settingsStore.backgroundColor;
|
const current = settingsStore.backgroundColor;
|
||||||
const fromStore = isHexColor(current) ? current : null;
|
const fromStore = isHexColor(current) ? current : null;
|
||||||
@@ -286,20 +423,8 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
if (!canvasRef.value) return;
|
if (!gridContainerRef.value) return;
|
||||||
|
|
||||||
// Hide context menu if open
|
// Hide context menu if open
|
||||||
showContextMenu.value = false;
|
showContextMenu.value = false;
|
||||||
@@ -307,13 +432,12 @@
|
|||||||
// Handle right-click for context menu
|
// Handle right-click for context menu
|
||||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const pos = canvas2D.getMousePosition(event, zoom.value);
|
const pos = getMousePosition(event, zoom.value);
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
|
|
||||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||||
|
|
||||||
// Position context menu at cursor - use clientX/clientY with fixed positioning
|
|
||||||
contextMenuX.value = event.clientX;
|
contextMenuX.value = event.clientX;
|
||||||
contextMenuY.value = event.clientY;
|
contextMenuY.value = event.clientY;
|
||||||
|
|
||||||
@@ -321,14 +445,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
// Ignore non-left mouse buttons
|
||||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||||
|
|
||||||
// Delegate to composable for actual drag handling
|
// Delegate to composable for actual drag handling
|
||||||
dragStart(event);
|
dragStart(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper for drag move
|
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
dragMove(event);
|
dragMove(event);
|
||||||
};
|
};
|
||||||
@@ -343,10 +466,8 @@
|
|||||||
|
|
||||||
const replaceSprite = () => {
|
const replaceSprite = () => {
|
||||||
if (contextMenuSpriteId.value && fileInput.value) {
|
if (contextMenuSpriteId.value && fileInput.value) {
|
||||||
// Store the sprite ID separately so it persists after context menu closes
|
|
||||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
// Hide context menu immediately since we've stored the ID
|
|
||||||
showContextMenu.value = false;
|
showContextMenu.value = false;
|
||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
}
|
}
|
||||||
@@ -355,7 +476,6 @@
|
|||||||
const addSprite = () => {
|
const addSprite = () => {
|
||||||
if (fileInput.value) {
|
if (fileInput.value) {
|
||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
// Hide context menu immediately
|
|
||||||
showContextMenu.value = false;
|
showContextMenu.value = false;
|
||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
}
|
}
|
||||||
@@ -370,14 +490,12 @@
|
|||||||
if (replacingSpriteId.value) {
|
if (replacingSpriteId.value) {
|
||||||
emit('replaceSprite', replacingSpriteId.value, file);
|
emit('replaceSprite', replacingSpriteId.value, file);
|
||||||
} else {
|
} else {
|
||||||
// Adding new sprite
|
|
||||||
emit('addSprite', file);
|
emit('addSprite', file);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Please select an image file.');
|
alert('Please select an image file.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up after file selection
|
|
||||||
replacingSpriteId.value = null;
|
replacingSpriteId.value = null;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
};
|
};
|
||||||
@@ -387,161 +505,26 @@
|
|||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper for drag leave to pass canvasRef
|
|
||||||
const onDragLeave = (event: DragEvent) => {
|
const onDragLeave = (event: DragEvent) => {
|
||||||
handleDragLeave(event, canvasRef.value);
|
handleDragLeave(event, gridContainerRef.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(() => {
|
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
|
// Hide context menu when clicking elsewhere
|
||||||
document.addEventListener('click', hideContextMenu);
|
document.addEventListener('click', hideContextMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
document.removeEventListener('click', hideContextMenu);
|
document.removeEventListener('click', hideContextMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler for force redraw event
|
// Watch for background color changes
|
||||||
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(
|
watch(
|
||||||
() => props.layers,
|
() => settingsStore.backgroundColor,
|
||||||
() => {
|
async () => {
|
||||||
attachImageListeners();
|
await nextTick();
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -2,21 +2,66 @@
|
|||||||
<div class="spritesheet-preview w-full h-full">
|
<div class="spritesheet-preview w-full h-full">
|
||||||
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
||||||
<div class="flex-1 min-w-0 flex flex-col">
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
<div class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200">
|
<div class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200" @mousemove="drag" @mouseup="stopDrag" @mouseleave="stopDrag" @touchmove="handleTouchMove" @touchend="stopDrag">
|
||||||
<canvas
|
<div
|
||||||
ref="previewCanvasRef"
|
ref="previewContainerRef"
|
||||||
@mousedown="startDrag"
|
class="relative touch-manipulation inline-block"
|
||||||
@mousemove="drag"
|
:style="{
|
||||||
@mouseup="stopDrag"
|
transform: `scale(${zoom})`,
|
||||||
@mouseleave="stopDrag"
|
transformOrigin: 'top left',
|
||||||
@touchstart="handleTouchStart"
|
width: `${cellDimensions.cellWidth}px`,
|
||||||
@touchmove="handleTouchMove"
|
height: `${cellDimensions.cellHeight}px`,
|
||||||
@touchend="stopDrag"
|
backgroundColor: '#f9fafb',
|
||||||
class="block touch-manipulation"
|
backgroundImage: getPreviewBackgroundImage(),
|
||||||
:class="{ 'cursor-move': isDraggable }"
|
backgroundSize: settingsStore.backgroundColor === 'transparent' ? '20px 20px' : 'auto',
|
||||||
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
|
backgroundPosition: settingsStore.backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
|
||||||
|
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
</canvas>
|
<!-- Background sprites (dimmed for comparison) -->
|
||||||
|
<template v-if="showAllSprites">
|
||||||
|
<template v-for="i in maxFrames()" :key="`bg-${i}`">
|
||||||
|
<template v-if="i !== currentFrameIndex && !hiddenFrames.includes(i)">
|
||||||
|
<template v-for="layer in getVisibleLayers()" :key="`${layer.id}-${i}`">
|
||||||
|
<img
|
||||||
|
v-if="layer.sprites[i]"
|
||||||
|
:src="layer.sprites[i].img.src"
|
||||||
|
class="absolute pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
left: `${cellDimensions.negativeSpacing + layer.sprites[i].x}px`,
|
||||||
|
top: `${cellDimensions.negativeSpacing + layer.sprites[i].y}px`,
|
||||||
|
width: `${layer.sprites[i].width}px`,
|
||||||
|
height: `${layer.sprites[i].height}px`,
|
||||||
|
opacity: '0.3',
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto'
|
||||||
|
}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Current frame sprites -->
|
||||||
|
<template v-for="layer in getVisibleLayers()" :key="layer.id">
|
||||||
|
<img
|
||||||
|
v-if="layer.sprites[currentFrameIndex]"
|
||||||
|
:src="layer.sprites[currentFrameIndex].img.src"
|
||||||
|
class="absolute"
|
||||||
|
:class="{ 'cursor-move': isDraggable }"
|
||||||
|
:style="{
|
||||||
|
left: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].x}px`,
|
||||||
|
top: `${cellDimensions.negativeSpacing + layer.sprites[currentFrameIndex].y}px`,
|
||||||
|
width: `${layer.sprites[currentFrameIndex].width}px`,
|
||||||
|
height: `${layer.sprites[currentFrameIndex].height}px`,
|
||||||
|
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto'
|
||||||
|
}"
|
||||||
|
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile zoom controls -->
|
<!-- Mobile zoom controls -->
|
||||||
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
|
<div class="absolute bottom-3 right-3 flex space-x-2 lg:hidden bg-white/80 dark:bg-gray-800/80 p-2 rounded-lg shadow-md">
|
||||||
@@ -114,7 +159,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @change="drawPreviewCanvas" />
|
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
|
||||||
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
|
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +207,6 @@
|
|||||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
import type { Layer, Sprite } from '@/types/sprites';
|
import type { Layer, Sprite } from '@/types/sprites';
|
||||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
|
||||||
import { useZoom } from '@/composables/useZoom';
|
import { useZoom } from '@/composables/useZoom';
|
||||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||||
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||||
@@ -178,14 +222,11 @@
|
|||||||
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Get settings from store
|
// Get settings from store
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
// Initialize composables
|
|
||||||
const canvas2D = useCanvas2D(previewCanvasRef);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
zoom,
|
zoom,
|
||||||
increase: increaseZoom,
|
increase: increaseZoom,
|
||||||
@@ -208,7 +249,7 @@
|
|||||||
}
|
}
|
||||||
return frames;
|
return frames;
|
||||||
},
|
},
|
||||||
onDraw: drawPreviewCanvas,
|
onDraw: () => {}, // No longer needed for canvas drawing
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preview state
|
// Preview state
|
||||||
@@ -239,17 +280,8 @@
|
|||||||
return layer.sprites[currentFrameIndex.value] || null;
|
return layer.sprites[currentFrameIndex.value] || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dragging state
|
// Computed cell dimensions
|
||||||
const isDragging = ref(false);
|
const cellDimensions = computed(() => {
|
||||||
const activeSpriteId = ref<string | null>(null);
|
|
||||||
const dragStartX = ref(0);
|
|
||||||
const dragStartY = ref(0);
|
|
||||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
|
||||||
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
|
||||||
|
|
||||||
// Canvas drawing
|
|
||||||
|
|
||||||
const getCellDimensions = () => {
|
|
||||||
const visibleLayers = getVisibleLayers();
|
const visibleLayers = getVisibleLayers();
|
||||||
// If manual cell size is enabled, use manual values
|
// If manual cell size is enabled, use manual values
|
||||||
if (settingsStore.manualCellSizeEnabled) {
|
if (settingsStore.manualCellSizeEnabled) {
|
||||||
@@ -269,115 +301,68 @@
|
|||||||
cellHeight: maxHeight + negativeSpacing,
|
cellHeight: maxHeight + negativeSpacing,
|
||||||
negativeSpacing,
|
negativeSpacing,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper for background image (dark mode friendly)
|
||||||
|
const getPreviewBackgroundImage = () => {
|
||||||
|
if (settingsStore.backgroundColor === 'transparent') {
|
||||||
|
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
|
||||||
|
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
function drawPreviewCanvas() {
|
// Dragging state
|
||||||
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
|
const isDragging = ref(false);
|
||||||
const visibleLayers = getVisibleLayers();
|
const activeSpriteId = ref<string | null>(null);
|
||||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
|
const activeLayerId = ref<string | null>(null);
|
||||||
|
const dragStartX = ref(0);
|
||||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
const dragStartY = ref(0);
|
||||||
|
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||||
// Apply pixel art optimization
|
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
||||||
canvas2D.applySmoothing();
|
|
||||||
|
|
||||||
// Set canvas size to fit one sprite cell (expanded with negative spacing)
|
|
||||||
canvas2D.setCanvasSize(cellWidth, cellHeight);
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
canvas2D.clear();
|
|
||||||
|
|
||||||
// Draw grid background (cell)
|
|
||||||
canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
|
|
||||||
|
|
||||||
const frameIndex = currentFrameIndex.value;
|
|
||||||
|
|
||||||
if (showAllSprites.value) {
|
|
||||||
// When comparing sprites, show all frames from all visible layers (dimmed)
|
|
||||||
const len = maxFrames();
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
|
||||||
visibleLayers.forEach(layer => {
|
|
||||||
const sprite = layer.sprites[i];
|
|
||||||
if (!sprite) return;
|
|
||||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always draw current frame from all visible layers
|
|
||||||
visibleLayers.forEach(layer => {
|
|
||||||
const sprite = layer.sprites[frameIndex];
|
|
||||||
if (!sprite) return;
|
|
||||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw cell border
|
|
||||||
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag functionality
|
// Drag functionality
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||||
if (!isDraggable.value || !previewCanvasRef.value) return;
|
if (!isDraggable.value || !previewContainerRef.value) return;
|
||||||
|
|
||||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||||
|
|
||||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||||
|
|
||||||
const { negativeSpacing } = getCellDimensions();
|
|
||||||
|
|
||||||
if (repositionAllLayers.value) {
|
if (repositionAllLayers.value) {
|
||||||
// Check if click is on any sprite from any visible layer
|
isDragging.value = true;
|
||||||
|
activeSpriteId.value = 'ALL_LAYERS'; // Special marker for all layers
|
||||||
|
dragStartX.value = mouseX;
|
||||||
|
dragStartY.value = mouseY;
|
||||||
|
|
||||||
|
// Store initial positions for all sprites in this frame from all visible layers
|
||||||
|
allSpritesPosBeforeDrag.value.clear();
|
||||||
const visibleLayers = getVisibleLayers();
|
const visibleLayers = getVisibleLayers();
|
||||||
for (const layer of visibleLayers) {
|
visibleLayers.forEach(layer => {
|
||||||
const sprite = layer.sprites[currentFrameIndex.value];
|
const s = layer.sprites[currentFrameIndex.value];
|
||||||
if (!sprite) continue;
|
if (s) {
|
||||||
|
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
|
||||||
const spriteCanvasX = negativeSpacing + sprite.x;
|
|
||||||
const spriteCanvasY = negativeSpacing + sprite.y;
|
|
||||||
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) {
|
|
||||||
isDragging.value = true;
|
|
||||||
activeSpriteId.value = 'ALL_LAYERS'; // Special marker for all layers
|
|
||||||
dragStartX.value = mouseX;
|
|
||||||
dragStartY.value = mouseY;
|
|
||||||
|
|
||||||
// Store initial positions for all sprites in this frame from all visible layers
|
|
||||||
allSpritesPosBeforeDrag.value.clear();
|
|
||||||
visibleLayers.forEach(layer => {
|
|
||||||
const s = layer.sprites[currentFrameIndex.value];
|
|
||||||
if (s) {
|
|
||||||
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
} else {
|
} else {
|
||||||
// Only check active layer sprite
|
isDragging.value = true;
|
||||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
activeSpriteId.value = sprite.id;
|
||||||
if (activeSprite) {
|
activeLayerId.value = layerId;
|
||||||
const spriteCanvasX = negativeSpacing + activeSprite.x;
|
dragStartX.value = mouseX;
|
||||||
const spriteCanvasY = negativeSpacing + activeSprite.y;
|
dragStartY.value = mouseY;
|
||||||
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + activeSprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + activeSprite.height) {
|
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||||
isDragging.value = true;
|
|
||||||
activeSpriteId.value = activeSprite.id;
|
|
||||||
dragStartX.value = mouseX;
|
|
||||||
dragStartY.value = mouseY;
|
|
||||||
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
if (!isDragging.value || !activeSpriteId.value || !previewCanvasRef.value) return;
|
if (!isDragging.value || !activeSpriteId.value || !previewContainerRef.value) return;
|
||||||
|
|
||||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||||
|
|
||||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||||
@@ -385,7 +370,7 @@
|
|||||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||||
|
|
||||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||||
|
|
||||||
if (activeSpriteId.value === 'ALL_LAYERS') {
|
if (activeSpriteId.value === 'ALL_LAYERS') {
|
||||||
// Move all sprites in current frame from all visible layers
|
// Move all sprites in current frame from all visible layers
|
||||||
@@ -407,10 +392,9 @@
|
|||||||
|
|
||||||
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
|
||||||
});
|
});
|
||||||
drawPreviewCanvas();
|
|
||||||
} else {
|
} else {
|
||||||
// Move only the active layer sprite
|
// Move only the active layer sprite
|
||||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
|
||||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||||
|
|
||||||
// Calculate new position with constraints and round to integers
|
// Calculate new position with constraints and round to integers
|
||||||
@@ -422,16 +406,16 @@
|
|||||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||||
|
|
||||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||||
drawPreviewCanvas();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopDrag = () => {
|
const stopDrag = () => {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
activeSpriteId.value = null;
|
activeSpriteId.value = null;
|
||||||
|
activeLayerId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent, sprite: Sprite, layerId: string) => {
|
||||||
if (!isDraggable.value) return;
|
if (!isDraggable.value) return;
|
||||||
|
|
||||||
if (event.touches.length === 1) {
|
if (event.touches.length === 1) {
|
||||||
@@ -440,7 +424,7 @@
|
|||||||
clientX: touch.clientX,
|
clientX: touch.clientX,
|
||||||
clientY: touch.clientY,
|
clientY: touch.clientY,
|
||||||
});
|
});
|
||||||
startDrag(mouseEvent);
|
startDrag(mouseEvent, sprite, layerId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -463,44 +447,18 @@
|
|||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
canvas2D.initContext();
|
// No longer need to initialize canvas or draw
|
||||||
drawPreviewCanvas();
|
|
||||||
|
|
||||||
// Listen for forceRedraw event from App.vue
|
|
||||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAnimation();
|
stopAnimation();
|
||||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler for force redraw event
|
// Watchers - most canvas-related watchers removed
|
||||||
const handleForceRedraw = () => {
|
// Keep layer watchers to ensure reactivity
|
||||||
const allSprites = props.layers.flatMap(l => l.sprites);
|
watch(() => props.layers, () => {}, { deep: true });
|
||||||
canvas2D.ensureIntegerPositions(allSprites);
|
watch(() => props.activeLayerId, () => {});
|
||||||
canvas2D.applySmoothing();
|
watch(currentFrameIndex, () => {});
|
||||||
drawPreviewCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Watchers
|
|
||||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
|
||||||
watch(() => props.activeLayerId, drawPreviewCanvas);
|
|
||||||
watch(currentFrameIndex, drawPreviewCanvas);
|
|
||||||
watch(zoom, drawPreviewCanvas);
|
|
||||||
watch(isDraggable, drawPreviewCanvas);
|
|
||||||
watch(showAllSprites, drawPreviewCanvas);
|
|
||||||
watch(hiddenFrames, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.manualCellSizeEnabled, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.manualCellWidth, drawPreviewCanvas);
|
|
||||||
watch(() => settingsStore.manualCellHeight, drawPreviewCanvas);
|
|
||||||
|
|
||||||
// Initial draw
|
|
||||||
if (props.layers.some(l => l.sprites.length > 0)) {
|
|
||||||
drawPreviewCanvas();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user