[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

@@ -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>