[FEAT] Canvas > IMG

This commit is contained in:
2025-11-23 15:40:04 +01:00
parent 4ec82317ea
commit 7f8e26255f
2 changed files with 324 additions and 383 deletions

View File

@@ -35,7 +35,7 @@
</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" />
<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>
</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">
@@ -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="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
ref="gridContainerRef"
:style="{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
width: `${gridDimensions.width}px`,
height: `${gridDimensions.height}px`,
position: 'relative'
}"
class="inline-block"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="stopDrag"
@contextmenu.prevent
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="onDragLeave"
@drop="handleDrop"
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
>
<!-- Grid cells -->
<div
v-for="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"
v-for="cellIndex in totalCells"
:key="`cell-${cellIndex - 1}`"
class="absolute"
: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%)',
left: `${getCellPosition(cellIndex - 1).x}px`,
top: `${getCellPosition(cellIndex - 1).y}px`,
width: `${gridMetrics.maxWidth}px`,
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>
@@ -130,7 +217,6 @@
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';
@@ -155,21 +241,7 @@
// 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 gridContainerRef = ref<HTMLDivElement | null>(null);
const {
zoom,
@@ -185,6 +257,17 @@
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 {
isDragging,
activeSpriteId,
@@ -208,13 +291,15 @@
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
getMousePosition,
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
onDraw: requestDraw,
onDraw: () => {},
});
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
const visibleLayers = computed(() => props.layers.filter(l => l.visible));
const activeSpriteSprite = computed(() => activeSprites.value.find(s => s.id === activeSpriteId.value));
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
sprites: activeSprites,
@@ -232,24 +317,79 @@
const fileInput = ref<HTMLInputElement | null>(null);
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 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;
@@ -260,18 +400,15 @@
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
isCustomMode.value = true;
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;
@@ -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) => {
if (!canvasRef.value) return;
if (!gridContainerRef.value) return;
// Hide context menu if open
showContextMenu.value = false;
@@ -307,13 +432,12 @@
// Handle right-click for context menu
if ('button' in event && (event as MouseEvent).button === 2) {
event.preventDefault();
const pos = canvas2D.getMousePosition(event, zoom.value);
const pos = getMousePosition(event, zoom.value);
if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
contextMenuSpriteId.value = clickedSprite?.id || null;
// Position context menu at cursor - use clientX/clientY with fixed positioning
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
@@ -321,14 +445,13 @@
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;
// Delegate to composable for actual drag handling
dragStart(event);
};
// Wrapper for drag move
const drag = (event: MouseEvent) => {
dragMove(event);
};
@@ -343,10 +466,8 @@
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;
}
@@ -355,7 +476,6 @@
const addSprite = () => {
if (fileInput.value) {
fileInput.value.click();
// Hide context menu immediately
showContextMenu.value = false;
contextMenuSpriteId.value = null;
}
@@ -370,14 +490,12 @@
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 = '';
};
@@ -387,161 +505,26 @@
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);
handleDragLeave(event, gridContainerRef.value);
};
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 for background color changes
watch(
() => props.layers,
() => {
attachImageListeners();
requestDraw();
},
{ deep: true }
() => settingsStore.backgroundColor,
async () => {
await nextTick();
}
);
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>

View File

@@ -2,21 +2,66 @@
<div class="spritesheet-preview w-full 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="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">
<canvas
ref="previewCanvasRef"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="stopDrag"
class="block touch-manipulation"
:class="{ 'cursor-move': isDraggable }"
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
<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">
<div
ref="previewContainerRef"
class="relative touch-manipulation inline-block"
:style="{
transform: `scale(${zoom})`,
transformOrigin: 'top left',
width: `${cellDimensions.cellWidth}px`,
height: `${cellDimensions.cellHeight}px`,
backgroundColor: '#f9fafb',
backgroundImage: getPreviewBackgroundImage(),
backgroundSize: settingsStore.backgroundColor === 'transparent' ? '20px 20px' : 'auto',
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 -->
<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 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>
</label>
</div>
@@ -162,7 +207,6 @@
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useCanvas2D } from '@/composables/useCanvas2D';
import { useZoom } from '@/composables/useZoom';
import { useAnimationFrames } from '@/composables/useAnimationFrames';
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
@@ -178,14 +222,11 @@
(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
const settingsStore = useSettingsStore();
// Initialize composables
const canvas2D = useCanvas2D(previewCanvasRef);
const {
zoom,
increase: increaseZoom,
@@ -208,7 +249,7 @@
}
return frames;
},
onDraw: drawPreviewCanvas,
onDraw: () => {}, // No longer needed for canvas drawing
});
// Preview state
@@ -239,17 +280,8 @@
return layer.sprites[currentFrameIndex.value] || null;
});
// Dragging state
const isDragging = ref(false);
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 = () => {
// Computed cell dimensions
const cellDimensions = computed(() => {
const visibleLayers = getVisibleLayers();
// If manual cell size is enabled, use manual values
if (settingsStore.manualCellSizeEnabled) {
@@ -269,115 +301,68 @@
cellHeight: maxHeight + 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() {
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
const visibleLayers = getVisibleLayers();
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
// Apply pixel art optimization
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);
}
// Dragging state
const isDragging = ref(false);
const activeSpriteId = ref<string | null>(null);
const activeLayerId = 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());
// Drag functionality
const startDrag = (event: MouseEvent) => {
if (!isDraggable.value || !previewCanvasRef.value) return;
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
if (!isDraggable.value || !previewContainerRef.value) return;
const rect = previewCanvasRef.value.getBoundingClientRect();
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
const rect = previewContainerRef.value.getBoundingClientRect();
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
const { negativeSpacing } = getCellDimensions();
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();
for (const layer of visibleLayers) {
const sprite = layer.sprites[currentFrameIndex.value];
if (!sprite) continue;
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;
visibleLayers.forEach(layer => {
const s = layer.sprites[currentFrameIndex.value];
if (s) {
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
}
}
});
} else {
// Only check active layer sprite
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
if (activeSprite) {
const spriteCanvasX = negativeSpacing + activeSprite.x;
const spriteCanvasY = negativeSpacing + activeSprite.y;
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + activeSprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + activeSprite.height) {
isDragging.value = true;
activeSpriteId.value = activeSprite.id;
dragStartX.value = mouseX;
dragStartY.value = mouseY;
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
}
}
isDragging.value = true;
activeSpriteId.value = sprite.id;
activeLayerId.value = layerId;
dragStartX.value = mouseX;
dragStartY.value = mouseY;
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
}
};
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 scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
const rect = previewContainerRef.value.getBoundingClientRect();
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
@@ -385,7 +370,7 @@
const deltaX = Math.round(mouseX - dragStartX.value);
const deltaY = Math.round(mouseY - dragStartY.value);
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
if (activeSpriteId.value === 'ALL_LAYERS') {
// Move all sprites in current frame from all visible layers
@@ -407,10 +392,9 @@
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
});
drawPreviewCanvas();
} else {
// 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;
// Calculate new position with constraints and round to integers
@@ -422,16 +406,16 @@
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
emit('updateSprite', activeSpriteId.value, newX, newY);
drawPreviewCanvas();
}
};
const stopDrag = () => {
isDragging.value = false;
activeSpriteId.value = null;
activeLayerId.value = null;
};
const handleTouchStart = (event: TouchEvent) => {
const handleTouchStart = (event: TouchEvent, sprite: Sprite, layerId: string) => {
if (!isDraggable.value) return;
if (event.touches.length === 1) {
@@ -440,7 +424,7 @@
clientX: touch.clientX,
clientY: touch.clientY,
});
startDrag(mouseEvent);
startDrag(mouseEvent, sprite, layerId);
}
};
@@ -463,44 +447,18 @@
// Lifecycle hooks
onMounted(() => {
canvas2D.initContext();
drawPreviewCanvas();
// Listen for forceRedraw event from App.vue
window.addEventListener('forceRedraw', handleForceRedraw);
// No longer need to initialize canvas or draw
});
onUnmounted(() => {
stopAnimation();
window.removeEventListener('forceRedraw', handleForceRedraw);
});
// Handler for force redraw event
const handleForceRedraw = () => {
const allSprites = props.layers.flatMap(l => l.sprites);
canvas2D.ensureIntegerPositions(allSprites);
canvas2D.applySmoothing();
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();
}
// Watchers - most canvas-related watchers removed
// Keep layer watchers to ensure reactivity
watch(() => props.layers, () => {}, { deep: true });
watch(() => props.activeLayerId, () => {});
watch(currentFrameIndex, () => {});
</script>
<style scoped>