805 lines
27 KiB
Vue
805 lines
27 KiB
Vue
<template>
|
|
<!-- Make the outer container always pointer-events-none so clicks pass through -->
|
|
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none">
|
|
<!-- Apply pointer-events-auto ONLY to the modal itself so it can be interacted with -->
|
|
<div
|
|
class="bg-gray-800 rounded-lg overflow-auto scrollbar-hide shadow-lg pointer-events-auto relative"
|
|
:class="{ invisible: !isModalOpen, visible: isModalOpen }"
|
|
:style="{
|
|
transform: `translate3d(${position.x}px, ${position.y + (isModalOpen ? 0 : -20)}px, 0)`,
|
|
width: `${modalSize.width}px`,
|
|
height: `${modalSize.height}px`,
|
|
maxWidth: '90vw',
|
|
maxHeight: '90vh',
|
|
}"
|
|
>
|
|
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600 cursor-move" @mousedown="startDrag">
|
|
<div class="flex items-center gap-2 text-lg font-semibold select-none">
|
|
<i class="fas fa-film text-blue-500"></i>
|
|
<span>Animation Preview</span>
|
|
</div>
|
|
<button @click="closeModal" class="text-gray-400 hover:text-gray-200 text-xl">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
|
|
<div class="flex flex-wrap items-center gap-4 mb-6">
|
|
<div class="flex gap-2">
|
|
<button
|
|
@click="startAnimation"
|
|
:disabled="sprites.length === 0"
|
|
:class="{
|
|
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': !animation.isPlaying,
|
|
}"
|
|
class="flex items-center gap-2 border rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
<i class="fas fa-play"></i> Play
|
|
</button>
|
|
<button
|
|
@click="stopAnimation"
|
|
:disabled="sprites.length === 0"
|
|
:class="{
|
|
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': animation.isPlaying,
|
|
}"
|
|
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
|
>
|
|
<i class="fas fa-pause"></i> Pause
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 flex-grow">
|
|
<div class="flex justify-between text-sm text-gray-400">
|
|
<label for="frame-slider">Frame:</label>
|
|
<span>{{ currentFrameDisplay }}</span>
|
|
</div>
|
|
<input type="range" id="frame-slider" v-model="currentFrame" :min="0" :max="Math.max(0, sprites.length - 1)" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" @input="handleFrameChange" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 flex-grow">
|
|
<div class="flex justify-between text-sm text-gray-400">
|
|
<label for="framerate">Frame Rate:</label>
|
|
<span>{{ animation.frameRate }} FPS</span>
|
|
</div>
|
|
<input type="range" id="framerate" v-model.number="animation.frameRate" min="1" max="30" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" @input="handleFrameRateChange" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
|
<!-- Zoom controls -->
|
|
<div class="flex items-center gap-2">
|
|
<button @click="zoomOut" :disabled="previewZoom <= 1" class="flex items-center justify-center w-8 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">
|
|
<i class="fas fa-search-minus"></i>
|
|
</button>
|
|
<span class="text-sm text-gray-300">{{ Math.round(previewZoom * 100) }}%</span>
|
|
<button @click="zoomIn" :disabled="previewZoom >= 5" class="flex items-center justify-center w-8 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">
|
|
<i class="fas fa-search-plus"></i>
|
|
</button>
|
|
<button
|
|
@click="applyOffsetsToMainView"
|
|
:disabled="!hasSpriteOffset"
|
|
class="flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
|
title="Permanently apply offset to sprite position"
|
|
>
|
|
<i class="fas fa-save"></i>
|
|
Apply Offset
|
|
</button>
|
|
<button @click="resetZoom" :disabled="previewZoom === 1" class="flex items-center justify-center px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">Reset Zoom</button>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="flex items-center gap-4">
|
|
<!-- Show all sprites toggle -->
|
|
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
|
<input type="checkbox" v-model="showAllSprites" class="form-checkbox h-4 w-4 text-blue-500 rounded border-gray-600 bg-gray-700 focus:ring-blue-500" />
|
|
Show all frames
|
|
</label>
|
|
|
|
<!-- Reset sprite position button -->
|
|
<button
|
|
@click="resetSpritePosition"
|
|
:disabled="!hasSpriteOffset"
|
|
class="flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
|
|
title="Reset sprite to original position"
|
|
>
|
|
<i class="fas fa-crosshairs"></i>
|
|
Reset Position
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col justify-center items-center bg-gray-700 p-6 rounded mb-6 relative overflow-auto flex-grow">
|
|
<!-- Tooltip for dragging instructions -->
|
|
<div class="text-xs text-gray-400 mb-2" v-if="hasSpriteOffset || sprites.length > 0">
|
|
<span v-if="hasSpriteOffset">Sprite offset: {{ Math.round(spriteOffset.x) }}px, {{ Math.round(spriteOffset.y) }}px</span>
|
|
<span v-else>Click and drag the sprite to move it within the cell</span>
|
|
</div>
|
|
|
|
<div
|
|
class="canvas-container relative transition-transform duration-100 flex items-center justify-center"
|
|
:style="{
|
|
minWidth: `${store.cellSize.width * previewZoom}px`,
|
|
minHeight: `${store.cellSize.height * previewZoom}px`,
|
|
cursor: previewZoom > 1 ? (isViewportDragging ? 'grabbing' : 'grab') : 'default',
|
|
}"
|
|
@mousedown="startViewportDrag"
|
|
@wheel="handleCanvasWheel"
|
|
>
|
|
<div
|
|
class="sprite-wrapper"
|
|
:style="{
|
|
transform: `scale(${previewZoom}) translate(${viewportOffset.x}px, ${viewportOffset.y}px)`,
|
|
cursor: isCanvasDragging ? 'grabbing' : 'move',
|
|
}"
|
|
@mousedown.stop="startCanvasDrag"
|
|
title="Drag to move sprite within cell"
|
|
>
|
|
<canvas ref="animCanvas" class="block" style="image-rendering: pixelated"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resize handle - larger and more noticeable -->
|
|
<div class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize flex items-end justify-end bg-gradient-to-br from-transparent to-gray-700 hover:to-blue-500 transition-colors duration-200" @mousedown="startResize">
|
|
<i class="fas fa-grip-lines-diagonal text-gray-400 hover:text-white p-1"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
|
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
|
|
|
const store = useSpritesheetStore();
|
|
const animCanvas = ref<HTMLCanvasElement | null>(null);
|
|
|
|
const isModalOpen = computed(() => store.isModalOpen.value);
|
|
const sprites = computed(() => store.sprites.value);
|
|
const animation = computed(() => store.animation);
|
|
const previewBorder = computed(() => store.previewBorder);
|
|
|
|
const currentFrame = ref(0);
|
|
const position = ref({ x: 0, y: 0 });
|
|
const isDragging = ref(false);
|
|
const dragOffset = ref({ x: 0, y: 0 });
|
|
|
|
// New state for added features
|
|
const previewZoom = ref(1);
|
|
const showAllSprites = ref(false);
|
|
// Use a computed property to access and modify the store's sprite offset for the current frame
|
|
const spriteOffset = computed({
|
|
get: () => {
|
|
// Get the offset for the current frame
|
|
const frameOffset = store.getSpriteOffset(currentFrame.value);
|
|
// Update the current offset for UI display
|
|
store.currentSpriteOffset.x = frameOffset.x;
|
|
store.currentSpriteOffset.y = frameOffset.y;
|
|
return store.currentSpriteOffset;
|
|
},
|
|
set: val => {
|
|
// Update both the current offset and the frame-specific offset
|
|
store.currentSpriteOffset.x = val.x;
|
|
store.currentSpriteOffset.y = val.y;
|
|
|
|
// Get the frame-specific offset and update it
|
|
const frameOffset = store.getSpriteOffset(currentFrame.value);
|
|
frameOffset.x = val.x;
|
|
frameOffset.y = val.y;
|
|
},
|
|
});
|
|
const isCanvasDragging = ref(false);
|
|
const canvasDragStart = ref({ x: 0, y: 0 });
|
|
|
|
// Canvas viewport navigation state
|
|
const viewportOffset = ref({ x: 0, y: 0 });
|
|
const isViewportDragging = ref(false);
|
|
const viewportDragStart = ref({ x: 0, y: 0 });
|
|
|
|
// Modal size state for resize functionality
|
|
const modalSize = ref({ width: 800, height: 600 });
|
|
const isResizing = ref(false);
|
|
const initialSize = ref({ width: 0, height: 0 });
|
|
const resizeStart = ref({ x: 0, y: 0 });
|
|
|
|
const currentFrameDisplay = computed(() => {
|
|
const totalFrames = Math.max(1, sprites.value.length);
|
|
const frame = Math.min(currentFrame.value + 1, totalFrames);
|
|
return `${frame} / ${totalFrames}`;
|
|
});
|
|
|
|
// Computed property to check if sprite has been moved from original position
|
|
const hasSpriteOffset = computed(() => {
|
|
return spriteOffset.x !== 0 || spriteOffset.y !== 0;
|
|
});
|
|
|
|
const applyOffsetsToMainView = () => {
|
|
store.applyOffsetsToMainView();
|
|
store.showNotification('Offset permanently applied to sprite position');
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (!isModalOpen.value) return;
|
|
|
|
if (e.key === 'Escape') {
|
|
closeModal();
|
|
} else if (e.key === ' ' || e.key === 'Spacebar') {
|
|
// Toggle play/pause
|
|
if (animation.value.isPlaying) {
|
|
stopAnimation();
|
|
} else if (sprites.value.length > 0) {
|
|
startAnimation();
|
|
}
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
|
|
// Next frame
|
|
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
|
|
updateFrame();
|
|
} else if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
|
|
// Previous frame
|
|
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
|
|
updateFrame();
|
|
} else if (e.key === '+' || e.key === '=') {
|
|
// Zoom in
|
|
zoomIn();
|
|
e.preventDefault();
|
|
} else if (e.key === '-' || e.key === '_') {
|
|
// Zoom out
|
|
zoomOut();
|
|
e.preventDefault();
|
|
} else if (e.key === '0') {
|
|
// Reset zoom
|
|
resetZoom();
|
|
e.preventDefault();
|
|
} else if (e.key === 'r') {
|
|
if (e.shiftKey) {
|
|
// Shift+R: Reset sprite position only
|
|
resetSpritePosition();
|
|
} else {
|
|
// R: Reset both sprite position and viewport
|
|
// Reset the sprite offset for the current frame
|
|
const frameOffset = store.getSpriteOffset(currentFrame.value);
|
|
frameOffset.x = 0;
|
|
frameOffset.y = 0;
|
|
store.currentSpriteOffset.x = 0;
|
|
store.currentSpriteOffset.y = 0;
|
|
viewportOffset.value = { x: 0, y: 0 };
|
|
updateFrame();
|
|
store.showNotification('View and position reset');
|
|
}
|
|
e.preventDefault();
|
|
} else if (previewZoom.value > 1) {
|
|
// Arrow key navigation for panning when zoomed in
|
|
const panAmount = 10;
|
|
if (e.key === 'ArrowUp') {
|
|
viewportOffset.value.y += panAmount / previewZoom.value;
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowDown') {
|
|
viewportOffset.value.y -= panAmount / previewZoom.value;
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowLeft' && animation.value.isPlaying) {
|
|
viewportOffset.value.x += panAmount / previewZoom.value;
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowRight' && animation.value.isPlaying) {
|
|
viewportOffset.value.x -= panAmount / previewZoom.value;
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
const openModal = async () => {
|
|
if (sprites.value.length === 0) {
|
|
store.showNotification('Please add sprites first', 'error');
|
|
return;
|
|
}
|
|
|
|
// Center the modal in the viewport
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
position.value = {
|
|
x: (viewportWidth - modalSize.value.width) / 2,
|
|
y: (viewportHeight - modalSize.value.height) / 2,
|
|
};
|
|
|
|
// Reset zoom but keep sprite offset if it exists
|
|
previewZoom.value = 1;
|
|
viewportOffset.value = { x: 0, y: 0 };
|
|
|
|
// Reset modal size to default
|
|
modalSize.value = { width: 800, height: 600 };
|
|
|
|
// Reset to first frame
|
|
currentFrame.value = 0;
|
|
animation.value.currentFrame = 0;
|
|
|
|
await initializeCanvas();
|
|
|
|
// Set modal open state if not already open
|
|
if (!store.isModalOpen.value) {
|
|
store.isModalOpen.value = true;
|
|
}
|
|
|
|
// Wait for next frame to ensure DOM is updated
|
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
|
|
// Set proper canvas size before rendering
|
|
updateCanvasSize();
|
|
|
|
// Force render the first frame
|
|
if (sprites.value.length > 0) {
|
|
// Get the frame-specific offset for the first frame
|
|
const frameOffset = store.getSpriteOffset(0);
|
|
|
|
// Update the current offset for UI display
|
|
store.currentSpriteOffset.x = frameOffset.x;
|
|
store.currentSpriteOffset.y = frameOffset.y;
|
|
|
|
// Render with the frame-specific offset
|
|
store.renderAnimationFrame(0, showAllSprites.value, frameOffset);
|
|
}
|
|
};
|
|
|
|
const updateCanvasSize = () => {
|
|
if (animCanvas.value && store.cellSize.width && store.cellSize.height) {
|
|
animCanvas.value.width = store.cellSize.width;
|
|
animCanvas.value.height = store.cellSize.height;
|
|
|
|
// Also update container size
|
|
updateCanvasContainerSize();
|
|
}
|
|
};
|
|
|
|
const closeModal = () => {
|
|
store.isModalOpen.value = false;
|
|
|
|
// Stop animation if it's playing
|
|
if (animation.value.isPlaying) {
|
|
stopAnimation();
|
|
}
|
|
};
|
|
|
|
const startAnimation = () => {
|
|
if (sprites.value.length === 0) return;
|
|
store.startAnimation();
|
|
};
|
|
|
|
const stopAnimation = () => {
|
|
store.stopAnimation();
|
|
};
|
|
|
|
const handleFrameChange = () => {
|
|
// Stop any running animation
|
|
if (animation.value.isPlaying) {
|
|
stopAnimation();
|
|
}
|
|
updateFrame();
|
|
};
|
|
|
|
const updateFrame = () => {
|
|
animation.value.currentFrame = currentFrame.value;
|
|
animation.value.manualUpdate = true;
|
|
|
|
// Get the frame-specific offset
|
|
const frameOffset = store.getSpriteOffset(currentFrame.value);
|
|
|
|
// Render with the frame-specific offset
|
|
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, frameOffset);
|
|
};
|
|
|
|
const handleFrameRateChange = () => {
|
|
// If animation is currently playing, restart it with the new frame rate
|
|
if (animation.value.isPlaying) {
|
|
stopAnimation();
|
|
startAnimation();
|
|
}
|
|
};
|
|
|
|
// Modal drag functionality
|
|
const startDrag = (e: MouseEvent) => {
|
|
// Don't allow drag if currently resizing
|
|
if (isResizing.value) return;
|
|
|
|
isDragging.value = true;
|
|
dragOffset.value = {
|
|
x: e.clientX - position.value.x,
|
|
y: e.clientY - position.value.y,
|
|
};
|
|
|
|
// Add temporary event listeners
|
|
window.addEventListener('mousemove', handleDrag);
|
|
window.addEventListener('mouseup', stopDrag);
|
|
};
|
|
|
|
const handleDrag = (e: MouseEvent) => {
|
|
if (!isDragging.value) return;
|
|
|
|
requestAnimationFrame(() => {
|
|
position.value = {
|
|
x: e.clientX - dragOffset.value.x,
|
|
y: e.clientY - dragOffset.value.y,
|
|
};
|
|
});
|
|
};
|
|
|
|
const stopDrag = () => {
|
|
isDragging.value = false;
|
|
window.removeEventListener('mousemove', handleDrag);
|
|
window.removeEventListener('mouseup', stopDrag);
|
|
};
|
|
|
|
// NEW: Modal resize functionality
|
|
const startResize = (e: MouseEvent) => {
|
|
isResizing.value = true;
|
|
initialSize.value = { ...modalSize.value };
|
|
resizeStart.value = { x: e.clientX, y: e.clientY };
|
|
|
|
// Add temporary event listeners
|
|
window.addEventListener('mousemove', handleResize);
|
|
window.addEventListener('mouseup', stopResize);
|
|
|
|
// Prevent default to avoid text selection
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleResize = (e: MouseEvent) => {
|
|
if (!isResizing.value) return;
|
|
|
|
const deltaX = e.clientX - resizeStart.value.x;
|
|
const deltaY = e.clientY - resizeStart.value.y;
|
|
|
|
requestAnimationFrame(() => {
|
|
// Calculate new size with minimum constraints
|
|
const newWidth = Math.max(400, initialSize.value.width + deltaX);
|
|
const newHeight = Math.max(400, initialSize.value.height + deltaY);
|
|
|
|
// Update modal size
|
|
modalSize.value = {
|
|
width: newWidth,
|
|
height: newHeight,
|
|
};
|
|
});
|
|
};
|
|
|
|
const stopResize = () => {
|
|
isResizing.value = false;
|
|
window.removeEventListener('mousemove', handleResize);
|
|
window.removeEventListener('mouseup', stopResize);
|
|
};
|
|
|
|
const initializeCanvas = async () => {
|
|
if (!animCanvas.value) {
|
|
console.error('PreviewModal: Animation canvas not found');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const context = animCanvas.value.getContext('2d');
|
|
if (!context) {
|
|
console.error('PreviewModal: Failed to get 2D context from animation canvas');
|
|
return;
|
|
}
|
|
|
|
store.animation.canvas = animCanvas.value;
|
|
store.animation.ctx = context;
|
|
} catch (error) {
|
|
console.error('PreviewModal: Error initializing animation canvas:', error);
|
|
}
|
|
};
|
|
|
|
// Zoom functions
|
|
const zoomIn = () => {
|
|
if (previewZoom.value < 5) {
|
|
previewZoom.value = Math.min(5, previewZoom.value + 0.5);
|
|
|
|
// Adjust container size after zoom change
|
|
nextTick(() => {
|
|
updateCanvasContainerSize();
|
|
});
|
|
}
|
|
};
|
|
|
|
const zoomOut = () => {
|
|
if (previewZoom.value > 1) {
|
|
previewZoom.value = Math.max(1, previewZoom.value - 0.5);
|
|
|
|
// Adjust container size after zoom change
|
|
nextTick(() => {
|
|
updateCanvasContainerSize();
|
|
});
|
|
}
|
|
};
|
|
|
|
const resetZoom = () => {
|
|
previewZoom.value = 1;
|
|
viewportOffset.value = { x: 0, y: 0 };
|
|
|
|
// Adjust container size after zoom change
|
|
nextTick(() => {
|
|
updateCanvasContainerSize();
|
|
});
|
|
};
|
|
|
|
// Reset sprite position to original
|
|
const resetSpritePosition = () => {
|
|
// Reset the sprite offset for the current frame to zero
|
|
const frameOffset = store.getSpriteOffset(currentFrame.value);
|
|
frameOffset.x = 0;
|
|
frameOffset.y = 0;
|
|
|
|
// Also update the current offset
|
|
store.currentSpriteOffset.x = 0;
|
|
store.currentSpriteOffset.y = 0;
|
|
|
|
// Update the frame to reflect the change
|
|
updateFrame();
|
|
|
|
// Show a notification
|
|
store.showNotification('Sprite position reset to original');
|
|
};
|
|
|
|
// Update canvas container size based on zoom level
|
|
const updateCanvasContainerSize = () => {
|
|
// This ensures the container grows with zoom while keeping the sprite visible
|
|
if (!store.cellSize.width || !store.cellSize.height) return;
|
|
|
|
// We'll update the container if needed through the reactive bindings
|
|
};
|
|
|
|
// Canvas drag functions for moving the sprite within its cell
|
|
const startCanvasDrag = (e: MouseEvent) => {
|
|
if (sprites.value.length === 0) return;
|
|
|
|
// Don't start sprite dragging if we're already dragging the viewport
|
|
if (isViewportDragging.value) return;
|
|
|
|
isCanvasDragging.value = true;
|
|
canvasDragStart.value = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
};
|
|
|
|
// Add temporary event listeners
|
|
window.addEventListener('mousemove', handleCanvasDrag);
|
|
window.addEventListener('mouseup', stopCanvasDrag);
|
|
|
|
// Prevent default to avoid text selection
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleCanvasDrag = (e: MouseEvent) => {
|
|
if (!isCanvasDragging.value) return;
|
|
|
|
const deltaX = e.clientX - canvasDragStart.value.x;
|
|
const deltaY = e.clientY - canvasDragStart.value.y;
|
|
|
|
// Update sprite offset with the delta, scaled by zoom level
|
|
// Use requestAnimationFrame for smoother dragging
|
|
requestAnimationFrame(() => {
|
|
// Get the frame-specific offset
|
|
const frameOffset = store.getSpriteOffset(currentFrame.value);
|
|
|
|
// Update both the current offset and the frame-specific offset
|
|
const newX = frameOffset.x + deltaX / previewZoom.value;
|
|
const newY = frameOffset.y + deltaY / previewZoom.value;
|
|
|
|
frameOffset.x = newX;
|
|
frameOffset.y = newY;
|
|
store.currentSpriteOffset.x = newX;
|
|
store.currentSpriteOffset.y = newY;
|
|
|
|
// Reset drag start position
|
|
canvasDragStart.value = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
};
|
|
|
|
// Update the frame with the new offset
|
|
updateFrame();
|
|
|
|
// Re-render the main view to reflect the changes
|
|
store.renderSpritesheetPreview();
|
|
});
|
|
};
|
|
|
|
const stopCanvasDrag = () => {
|
|
isCanvasDragging.value = false;
|
|
window.removeEventListener('mousemove', handleCanvasDrag);
|
|
window.removeEventListener('mouseup', stopCanvasDrag);
|
|
};
|
|
|
|
// Canvas viewport navigation functions
|
|
const startViewportDrag = (e: MouseEvent) => {
|
|
// Only enable viewport dragging when zoomed in
|
|
if (previewZoom.value <= 1 || isCanvasDragging.value) return;
|
|
|
|
isViewportDragging.value = true;
|
|
viewportDragStart.value = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
};
|
|
|
|
// Add temporary event listeners
|
|
window.addEventListener('mousemove', handleViewportDrag);
|
|
window.addEventListener('mouseup', stopViewportDrag);
|
|
|
|
// Prevent default to avoid text selection
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleViewportDrag = (e: MouseEvent) => {
|
|
if (!isViewportDragging.value) return;
|
|
|
|
const deltaX = e.clientX - viewportDragStart.value.x;
|
|
const deltaY = e.clientY - viewportDragStart.value.y;
|
|
|
|
// Update viewport offset with the delta, scaled by zoom level
|
|
viewportOffset.value = {
|
|
x: viewportOffset.value.x + deltaX / previewZoom.value,
|
|
y: viewportOffset.value.y + deltaY / previewZoom.value,
|
|
};
|
|
|
|
// Reset drag start position
|
|
viewportDragStart.value = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
};
|
|
};
|
|
|
|
const stopViewportDrag = () => {
|
|
isViewportDragging.value = false;
|
|
window.removeEventListener('mousemove', handleViewportDrag);
|
|
window.removeEventListener('mouseup', stopViewportDrag);
|
|
};
|
|
|
|
// Handle mouse wheel zooming and panning
|
|
const handleCanvasWheel = (e: WheelEvent) => {
|
|
// Prevent the default scroll behavior
|
|
e.preventDefault();
|
|
|
|
if (e.ctrlKey) {
|
|
// Ctrl + wheel = zoom in/out
|
|
if (e.deltaY < 0) {
|
|
zoomIn();
|
|
} else {
|
|
zoomOut();
|
|
}
|
|
} else {
|
|
// Just wheel = pan when zoomed in
|
|
if (previewZoom.value > 1) {
|
|
// Adjust pan amount by zoom level
|
|
const panFactor = 0.5 / previewZoom.value;
|
|
|
|
if (e.shiftKey) {
|
|
// Shift + wheel = horizontal pan
|
|
viewportOffset.value.x -= e.deltaY * panFactor;
|
|
} else {
|
|
// Regular wheel = vertical pan
|
|
viewportOffset.value.y -= e.deltaY * panFactor;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
initializeCanvas();
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('mousemove', handleDrag);
|
|
window.removeEventListener('mouseup', stopDrag);
|
|
window.removeEventListener('mousemove', handleCanvasDrag);
|
|
window.removeEventListener('mouseup', stopCanvasDrag);
|
|
window.removeEventListener('mousemove', handleResize);
|
|
window.removeEventListener('mouseup', stopResize);
|
|
window.removeEventListener('mousemove', handleViewportDrag);
|
|
window.removeEventListener('mouseup', stopViewportDrag);
|
|
});
|
|
|
|
// Keep currentFrame in sync with animation.currentFrame
|
|
watch(
|
|
() => animation.value.currentFrame,
|
|
newVal => {
|
|
currentFrame.value = newVal;
|
|
}
|
|
);
|
|
|
|
// Watch for changes in sprites to update the canvas when new sprites are added
|
|
watch(
|
|
() => sprites.value,
|
|
newSprites => {
|
|
if (isModalOpen.value && newSprites.length > 0) {
|
|
updateCanvasSize();
|
|
updateCanvasContainerSize();
|
|
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
// Watch zoom changes to update container size
|
|
watch(
|
|
() => previewZoom.value,
|
|
() => {
|
|
updateCanvasContainerSize();
|
|
}
|
|
);
|
|
|
|
// Watch for changes in border settings to update the preview
|
|
watch(
|
|
() => previewBorder.value,
|
|
() => {
|
|
if (isModalOpen.value && sprites.value.length > 0) {
|
|
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
// Watch for changes in showAllSprites to update the preview
|
|
watch(
|
|
() => showAllSprites.value,
|
|
() => {
|
|
if (isModalOpen.value && sprites.value.length > 0) {
|
|
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Expose openModal for external use
|
|
defineExpose({ openModal });
|
|
</script>
|
|
|
|
<style scoped>
|
|
input[type='range']::-webkit-slider-thumb {
|
|
appearance: none;
|
|
width: 15px;
|
|
height: 15px;
|
|
background: #0096ff;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type='range']::-moz-range-thumb {
|
|
width: 15px;
|
|
height: 15px;
|
|
background: #0096ff;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Prevent text selection while dragging */
|
|
.cursor-move {
|
|
user-select: none;
|
|
}
|
|
|
|
/* Hide scrollbar styles */
|
|
.scrollbar-hide {
|
|
-ms-overflow-style: none; /* IE and Edge */
|
|
scrollbar-width: none; /* Firefox */
|
|
}
|
|
|
|
.scrollbar-hide::-webkit-scrollbar {
|
|
display: none; /* Chrome, Safari and Opera */
|
|
}
|
|
|
|
/* Cursor styles */
|
|
.cursor-se-resize {
|
|
cursor: se-resize;
|
|
}
|
|
|
|
/* Canvas container styles */
|
|
.canvas-container {
|
|
margin: auto;
|
|
transition:
|
|
min-width 0.2s,
|
|
min-height 0.2s;
|
|
}
|
|
|
|
.sprite-wrapper {
|
|
transform-origin: center;
|
|
}
|
|
</style>
|