Compare commits
2 Commits
d571cb51cb
...
5c33e77595
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c33e77595 | |||
| 404ca9ce88 |
@@ -48,7 +48,7 @@
|
|||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
@dragover="handleDragOver"
|
@dragover="handleDragOver"
|
||||||
@dragenter="handleDragEnter"
|
@dragenter="handleDragEnter"
|
||||||
@dragleave="handleDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
class="w-full transition-all"
|
class="w-full transition-all"
|
||||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||||
@@ -95,16 +95,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed, onUnmounted } from 'vue';
|
import { ref, onMounted, watch, onUnmounted, toRef } 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 { getMaxDimensions } from '@/composables/useSprites';
|
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||||
|
import { useZoom } from '@/composables/useZoom';
|
||||||
interface CellPosition {
|
import { useDragSprite } from '@/composables/useDragSprite';
|
||||||
col: number;
|
import { useFileDrop } from '@/composables/useFileDrop';
|
||||||
row: number;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sprites: Sprite[];
|
sprites: Sprite[];
|
||||||
@@ -124,22 +121,53 @@
|
|||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
|
||||||
|
|
||||||
// State for tracking drag operations
|
// Initialize composables
|
||||||
const isDragging = ref(false);
|
const canvas2D = useCanvas2D(canvasRef);
|
||||||
const activeSpriteId = ref<string | null>(null);
|
|
||||||
const activeSpriteCellIndex = ref<number | null>(null);
|
const {
|
||||||
const dragStartX = ref(0);
|
zoom,
|
||||||
const dragStartY = ref(0);
|
increase: zoomIn,
|
||||||
const dragOffsetX = ref(0);
|
decrease: zoomOut,
|
||||||
const dragOffsetY = ref(0);
|
reset: resetZoom,
|
||||||
|
} = useZoom({
|
||||||
|
min: 0.5,
|
||||||
|
max: 3,
|
||||||
|
step: 0.25,
|
||||||
|
initial: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const allowCellSwap = ref(false);
|
const allowCellSwap = ref(false);
|
||||||
const currentHoverCell = ref<CellPosition | null>(null);
|
|
||||||
|
|
||||||
// Visual feedback refs
|
const {
|
||||||
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
isDragging,
|
||||||
const highlightCell = ref<CellPosition | null>(null);
|
activeSpriteId,
|
||||||
|
ghostSprite,
|
||||||
|
highlightCell,
|
||||||
|
spritePositions,
|
||||||
|
startDrag: dragStart,
|
||||||
|
drag: dragMove,
|
||||||
|
stopDrag,
|
||||||
|
handleTouchStart,
|
||||||
|
handleTouchMove,
|
||||||
|
findSpriteAtPosition,
|
||||||
|
calculateMaxDimensions,
|
||||||
|
} = useDragSprite({
|
||||||
|
sprites: toRef(props, 'sprites'),
|
||||||
|
columns: toRef(props, 'columns'),
|
||||||
|
zoom,
|
||||||
|
allowCellSwap,
|
||||||
|
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
||||||
|
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||||
|
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||||
|
onDraw: drawCanvas,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
||||||
|
sprites: props.sprites,
|
||||||
|
onAddSprite: file => emit('addSprite', file),
|
||||||
|
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
||||||
|
});
|
||||||
|
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
const showContextMenu = ref(false);
|
const showContextMenu = ref(false);
|
||||||
@@ -148,47 +176,6 @@
|
|||||||
const contextMenuSpriteId = ref<string | null>(null);
|
const contextMenuSpriteId = ref<string | null>(null);
|
||||||
const replacingSpriteId = ref<string | null>(null);
|
const replacingSpriteId = ref<string | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const isDragOver = ref(false);
|
|
||||||
|
|
||||||
const spritePositions = computed(() => {
|
|
||||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
|
||||||
|
|
||||||
return props.sprites.map((sprite, index) => {
|
|
||||||
const col = index % props.columns;
|
|
||||||
const row = Math.floor(index / props.columns);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: sprite.id,
|
|
||||||
canvasX: col * maxWidth + sprite.x,
|
|
||||||
canvasY: row * maxHeight + sprite.y,
|
|
||||||
cellX: col * maxWidth,
|
|
||||||
cellY: row * maxHeight,
|
|
||||||
width: sprite.width,
|
|
||||||
height: sprite.height,
|
|
||||||
maxWidth,
|
|
||||||
maxHeight,
|
|
||||||
col,
|
|
||||||
row,
|
|
||||||
index,
|
|
||||||
x: sprite.x,
|
|
||||||
y: sprite.y,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache last known max dimensions to avoid collapsing cells while images are loading
|
|
||||||
const lastMaxWidth = ref(1);
|
|
||||||
const lastMaxHeight = ref(1);
|
|
||||||
|
|
||||||
const calculateMaxDimensions = () => {
|
|
||||||
// Use shared utility for max dimensions, then apply local caching to avoid visual collapse
|
|
||||||
const base = getMaxDimensions(props.sprites);
|
|
||||||
const maxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
|
||||||
const maxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
|
||||||
lastMaxWidth.value = maxWidth;
|
|
||||||
lastMaxHeight.value = maxHeight;
|
|
||||||
return { maxWidth, maxHeight };
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
if (!canvasRef.value) return;
|
if (!canvasRef.value) return;
|
||||||
@@ -199,14 +186,10 @@
|
|||||||
// 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 rect = canvasRef.value.getBoundingClientRect();
|
const pos = canvas2D.getMousePosition(event, zoom.value);
|
||||||
const scaleX = canvasRef.value.width / rect.width;
|
if (!pos) return;
|
||||||
const scaleY = canvasRef.value.height / rect.height;
|
|
||||||
|
|
||||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
|
||||||
|
|
||||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
|
||||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||||
contextMenuX.value = event.clientX;
|
contextMenuX.value = event.clientX;
|
||||||
contextMenuY.value = event.clientY;
|
contextMenuY.value = event.clientY;
|
||||||
@@ -217,167 +200,13 @@
|
|||||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
||||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||||
|
|
||||||
const rect = canvasRef.value.getBoundingClientRect();
|
// Delegate to composable for actual drag handling
|
||||||
const scaleX = canvasRef.value.width / rect.width;
|
dragStart(event);
|
||||||
const scaleY = canvasRef.value.height / rect.height;
|
|
||||||
|
|
||||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
|
||||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
|
||||||
|
|
||||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
|
||||||
if (clickedSprite) {
|
|
||||||
isDragging.value = true;
|
|
||||||
activeSpriteId.value = clickedSprite.id;
|
|
||||||
dragStartX.value = mouseX;
|
|
||||||
dragStartY.value = mouseY;
|
|
||||||
|
|
||||||
// Find the sprite's position to calculate offset from mouse to sprite origin
|
|
||||||
const spritePosition = spritePositions.value.find(pos => pos.id === clickedSprite.id);
|
|
||||||
if (spritePosition) {
|
|
||||||
dragOffsetX.value = mouseX - spritePosition.canvasX;
|
|
||||||
dragOffsetY.value = mouseY - spritePosition.canvasY;
|
|
||||||
activeSpriteCellIndex.value = spritePosition.index;
|
|
||||||
|
|
||||||
// Store the starting cell position
|
|
||||||
const startCell = findCellAtPosition(mouseX, mouseY);
|
|
||||||
if (startCell) {
|
|
||||||
currentHoverCell.value = startCell;
|
|
||||||
highlightCell.value = null; // No highlight at the start
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
|
|
||||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
|
||||||
const col = Math.floor(x / maxWidth);
|
|
||||||
const row = Math.floor(y / maxHeight);
|
|
||||||
|
|
||||||
// Check if the cell position is valid
|
|
||||||
const totalRows = Math.ceil(props.sprites.length / props.columns);
|
|
||||||
if (col >= 0 && col < props.columns && row >= 0 && row < totalRows) {
|
|
||||||
const index = row * props.columns + col;
|
|
||||||
if (index < props.sprites.length) {
|
|
||||||
return { col, row, index };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrapper for drag move
|
||||||
const drag = (event: MouseEvent) => {
|
const drag = (event: MouseEvent) => {
|
||||||
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value || activeSpriteCellIndex.value === null) return;
|
dragMove(event);
|
||||||
|
|
||||||
const rect = canvasRef.value.getBoundingClientRect();
|
|
||||||
const scaleX = canvasRef.value.width / rect.width;
|
|
||||||
const scaleY = canvasRef.value.height / rect.height;
|
|
||||||
|
|
||||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
|
||||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
|
||||||
|
|
||||||
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
|
||||||
if (spriteIndex === -1) return;
|
|
||||||
|
|
||||||
// Find the cell the mouse is currently over
|
|
||||||
const hoverCell = findCellAtPosition(mouseX, mouseY);
|
|
||||||
currentHoverCell.value = hoverCell;
|
|
||||||
|
|
||||||
if (allowCellSwap.value && hoverCell) {
|
|
||||||
// If we're hovering over a different cell than the sprite's current cell
|
|
||||||
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
|
||||||
// Show a highlight for the target cell
|
|
||||||
highlightCell.value = hoverCell;
|
|
||||||
|
|
||||||
// Create a ghost sprite that follows the mouse
|
|
||||||
ghostSprite.value = {
|
|
||||||
id: activeSpriteId.value,
|
|
||||||
x: mouseX - dragOffsetX.value,
|
|
||||||
y: mouseY - dragOffsetY.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
drawCanvas();
|
|
||||||
} else {
|
|
||||||
// Same cell as the sprite's origin, just do regular movement
|
|
||||||
highlightCell.value = null;
|
|
||||||
ghostSprite.value = null;
|
|
||||||
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular in-cell movement
|
|
||||||
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
|
|
||||||
if (!activeSpriteId.value) return;
|
|
||||||
|
|
||||||
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
|
|
||||||
if (!position) return;
|
|
||||||
|
|
||||||
// Calculate new position based on mouse position and initial click offset
|
|
||||||
const newX = mouseX - position.cellX - dragOffsetX.value;
|
|
||||||
const newY = mouseY - position.cellY - dragOffsetY.value;
|
|
||||||
|
|
||||||
// Constrain within cell boundaries and ensure integer positions
|
|
||||||
const constrainedX = Math.floor(Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX)));
|
|
||||||
const constrainedY = Math.floor(Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY)));
|
|
||||||
|
|
||||||
emit('updateSprite', activeSpriteId.value, constrainedX, constrainedY);
|
|
||||||
drawCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopDrag = () => {
|
|
||||||
if (isDragging.value && allowCellSwap.value && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
|
||||||
// We've dragged from one cell to another
|
|
||||||
// Tell parent component to update the sprite's cell index
|
|
||||||
emit('updateSpriteCell', activeSpriteId.value, currentHoverCell.value.index);
|
|
||||||
|
|
||||||
// Also reset the sprite's position within the cell to 0,0
|
|
||||||
emit('updateSprite', activeSpriteId.value, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset all drag state
|
|
||||||
isDragging.value = false;
|
|
||||||
activeSpriteId.value = null;
|
|
||||||
activeSpriteCellIndex.value = null;
|
|
||||||
currentHoverCell.value = null;
|
|
||||||
highlightCell.value = null;
|
|
||||||
ghostSprite.value = null;
|
|
||||||
|
|
||||||
// Redraw without highlights
|
|
||||||
drawCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add zoom functionality for mobile
|
|
||||||
const zoom = ref(1);
|
|
||||||
const minZoom = 0.5;
|
|
||||||
const maxZoom = 3;
|
|
||||||
const zoomStep = 0.25;
|
|
||||||
|
|
||||||
const zoomIn = () => {
|
|
||||||
zoom.value = Math.min(maxZoom, zoom.value + zoomStep);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
zoom.value = Math.max(minZoom, zoom.value - zoomStep);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetZoom = () => {
|
|
||||||
zoom.value = 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Improved touch handling
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
|
||||||
// Don't prevent default to allow scrolling
|
|
||||||
if (event.touches.length === 1) {
|
|
||||||
if (!canvasRef.value) return;
|
|
||||||
const touch = event.touches[0];
|
|
||||||
const mouseEvent = {
|
|
||||||
clientX: touch.clientX,
|
|
||||||
clientY: touch.clientY,
|
|
||||||
preventDefault: () => {},
|
|
||||||
} as unknown as MouseEvent;
|
|
||||||
startDrag(mouseEvent);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSprite = () => {
|
const removeSprite = () => {
|
||||||
@@ -434,132 +263,25 @@
|
|||||||
contextMenuSpriteId.value = null;
|
contextMenuSpriteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Wrapper for drag leave to pass canvasRef
|
||||||
const handleDragOver = (event: DragEvent) => {
|
const onDragLeave = (event: DragEvent) => {
|
||||||
event.preventDefault();
|
handleDragLeave(event, canvasRef.value);
|
||||||
event.stopPropagation();
|
|
||||||
if (event.dataTransfer) {
|
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
|
||||||
}
|
|
||||||
isDragOver.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
function drawCanvas() {
|
||||||
event.preventDefault();
|
if (!canvasRef.value || !canvas2D.ctx.value) return;
|
||||||
event.stopPropagation();
|
|
||||||
isDragOver.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
// Only set to false if we're leaving the canvas entirely
|
|
||||||
const rect = canvasRef.value?.getBoundingClientRect();
|
|
||||||
if (rect && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) {
|
|
||||||
isDragOver.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
isDragOver.value = false;
|
|
||||||
|
|
||||||
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
alert('Please drop image files only.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each dropped file
|
|
||||||
for (const file of files) {
|
|
||||||
await processDroppedImage(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processDroppedImage = (file: File): Promise<void> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = e => {
|
|
||||||
if (e.target?.result) {
|
|
||||||
img.src = e.target.result as string;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
|
||||||
|
|
||||||
// Check if the dropped image is larger than current cells
|
|
||||||
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
|
|
||||||
// Emit event with resize flag
|
|
||||||
emit('addSpriteWithResize', file);
|
|
||||||
} else {
|
|
||||||
// Normal add
|
|
||||||
emit('addSprite', file);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
console.error('Failed to load image:', file.name);
|
|
||||||
reject(new Error('Failed to load image'));
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
|
||||||
// Only prevent default when we're actually dragging
|
|
||||||
if (isDragging.value) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.touches.length === 1) {
|
|
||||||
const touch = event.touches[0];
|
|
||||||
const mouseEvent = {
|
|
||||||
clientX: touch.clientX,
|
|
||||||
clientY: touch.clientY,
|
|
||||||
preventDefault: () => {},
|
|
||||||
} as unknown as MouseEvent;
|
|
||||||
drag(mouseEvent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const findSpriteAtPosition = (x: number, y: number) => {
|
|
||||||
// Search in reverse order to get the topmost sprite first
|
|
||||||
for (let i = spritePositions.value.length - 1; i >= 0; i--) {
|
|
||||||
const pos = spritePositions.value[i];
|
|
||||||
|
|
||||||
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
|
|
||||||
return props.sprites.find(s => s.id === pos.id) || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawCanvas = () => {
|
|
||||||
if (!canvasRef.value || !ctx.value) return;
|
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||||
|
|
||||||
// Set canvas size
|
// Set canvas size
|
||||||
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
|
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
|
||||||
canvasRef.value.width = maxWidth * props.columns;
|
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
|
||||||
canvasRef.value.height = maxHeight * rows;
|
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
canvas2D.clear();
|
||||||
|
|
||||||
// Disable image smoothing based on pixel perfect setting
|
// Apply pixel art optimization
|
||||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
canvas2D.applySmoothing();
|
||||||
|
|
||||||
// Draw background for each cell
|
// Draw background for each cell
|
||||||
for (let col = 0; col < props.columns; col++) {
|
for (let col = 0; col < props.columns; col++) {
|
||||||
@@ -568,13 +290,11 @@
|
|||||||
const cellY = Math.floor(row * maxHeight);
|
const cellY = Math.floor(row * maxHeight);
|
||||||
|
|
||||||
// Draw cell background
|
// Draw cell background
|
||||||
ctx.value.fillStyle = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
canvas2D.fillCellBackground(cellX, cellY, maxWidth, maxHeight);
|
||||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
|
||||||
|
|
||||||
// Highlight the target cell if specified
|
// Highlight the target cell if specified
|
||||||
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
||||||
ctx.value.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Light blue highlight
|
canvas2D.fillRect(cellX, cellY, maxWidth, maxHeight, 'rgba(59, 130, 246, 0.2)');
|
||||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,14 +308,11 @@
|
|||||||
const cellY = Math.floor(cellRow * maxHeight);
|
const cellY = Math.floor(cellRow * maxHeight);
|
||||||
|
|
||||||
// Draw all sprites with transparency in this cell
|
// Draw all sprites with transparency in this cell
|
||||||
ctx.value.globalAlpha = 0.3;
|
|
||||||
props.sprites.forEach((sprite, spriteIndex) => {
|
props.sprites.forEach((sprite, spriteIndex) => {
|
||||||
if (spriteIndex !== cellIndex) {
|
if (spriteIndex !== cellIndex) {
|
||||||
// Don't draw the cell's own sprite with transparency
|
canvas2D.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y, 0.3);
|
||||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ctx.value.globalAlpha = 1.0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,58 +329,38 @@
|
|||||||
const cellX = Math.floor(col * maxWidth);
|
const cellX = Math.floor(col * maxWidth);
|
||||||
const cellY = Math.floor(row * maxHeight);
|
const cellY = Math.floor(row * maxHeight);
|
||||||
|
|
||||||
// Draw sprite using integer positions for pixel-perfect rendering
|
// Draw sprite
|
||||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
canvas2D.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw ghost sprite if we're dragging between cells
|
// Draw ghost sprite if we're dragging between cells
|
||||||
if (ghostSprite.value && activeSpriteId.value) {
|
if (ghostSprite.value && activeSpriteId.value) {
|
||||||
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
||||||
if (sprite) {
|
if (sprite) {
|
||||||
// Semi-transparent ghost
|
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
|
||||||
ctx.value.globalAlpha = 0.6;
|
|
||||||
ctx.value.drawImage(sprite.img, Math.floor(ghostSprite.value.x), Math.floor(ghostSprite.value.y));
|
|
||||||
ctx.value.globalAlpha = 1.0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw grid lines on top of everything
|
// Draw grid lines on top of everything
|
||||||
ctx.value.strokeStyle = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
|
||||||
ctx.value.lineWidth = 1;
|
|
||||||
|
|
||||||
for (let col = 0; col < props.columns; col++) {
|
for (let col = 0; col < props.columns; col++) {
|
||||||
for (let row = 0; row < rows; row++) {
|
for (let row = 0; row < rows; row++) {
|
||||||
const cellX = Math.floor(col * maxWidth);
|
const cellX = Math.floor(col * maxWidth);
|
||||||
const cellY = Math.floor(row * maxHeight);
|
const cellY = Math.floor(row * maxHeight);
|
||||||
|
canvas2D.strokeGridCell(cellX, cellY, maxWidth, maxHeight);
|
||||||
// Draw grid lines
|
|
||||||
ctx.value.strokeRect(cellX, cellY, maxWidth, maxHeight);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Track which images already have listeners
|
// Track which images already have listeners
|
||||||
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
||||||
|
|
||||||
const attachImageListeners = () => {
|
const attachImageListeners = () => {
|
||||||
props.sprites.forEach(sprite => {
|
canvas2D.attachImageListeners(props.sprites, handleForceRedraw, imagesWithListeners);
|
||||||
const img = sprite.img as HTMLImageElement | undefined;
|
|
||||||
if (img && !imagesWithListeners.has(img)) {
|
|
||||||
imagesWithListeners.add(img);
|
|
||||||
if (!img.complete) {
|
|
||||||
// Redraw when the image loads or errors (to reflect updated dimensions)
|
|
||||||
img.addEventListener('load', handleForceRedraw, { once: true } as AddEventListenerOptions);
|
|
||||||
img.addEventListener('error', handleForceRedraw, { once: true } as AddEventListenerOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (canvasRef.value) {
|
canvas2D.initContext();
|
||||||
ctx.value = canvasRef.value.getContext('2d');
|
drawCanvas();
|
||||||
drawCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach listeners for current sprites
|
// Attach listeners for current sprites
|
||||||
attachImageListeners();
|
attachImageListeners();
|
||||||
@@ -682,17 +379,9 @@
|
|||||||
|
|
||||||
// Handler for force redraw event
|
// Handler for force redraw event
|
||||||
const handleForceRedraw = () => {
|
const handleForceRedraw = () => {
|
||||||
// Ensure we're using integer positions for pixel-perfect rendering
|
canvas2D.ensureIntegerPositions(props.sprites);
|
||||||
props.sprites.forEach(sprite => {
|
canvas2D.applySmoothing();
|
||||||
sprite.x = Math.floor(sprite.x);
|
drawCanvas();
|
||||||
sprite.y = Math.floor(sprite.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force a redraw with the correct image smoothing settings
|
|
||||||
if (ctx.value) {
|
|
||||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
|
||||||
drawCanvas();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -157,10 +157,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
import { ref, onMounted, watch, onUnmounted } 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 { getMaxDimensions } from '@/composables/useSprites';
|
import { getMaxDimensions } from '@/composables/useSprites';
|
||||||
|
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||||
|
import { useZoom } from '@/composables/useZoom';
|
||||||
|
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sprites: Sprite[];
|
sprites: Sprite[];
|
||||||
@@ -172,17 +175,30 @@
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
|
||||||
|
// Get settings from store
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
// Initialize composables
|
||||||
|
const canvas2D = useCanvas2D(previewCanvasRef);
|
||||||
|
|
||||||
|
const {
|
||||||
|
zoom,
|
||||||
|
increase: increaseZoom,
|
||||||
|
decrease: decreaseZoom,
|
||||||
|
} = useZoom({
|
||||||
|
allowedValues: [0.5, 1, 2, 3, 4, 5],
|
||||||
|
initial: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
||||||
|
sprites: () => props.sprites,
|
||||||
|
onDraw: drawPreviewCanvas,
|
||||||
|
});
|
||||||
|
|
||||||
// Preview state
|
// Preview state
|
||||||
const currentFrameIndex = ref(0);
|
|
||||||
const isPlaying = ref(false);
|
|
||||||
const fps = ref(12);
|
|
||||||
const zoom = ref(1);
|
|
||||||
const isDraggable = ref(false);
|
const isDraggable = ref(false);
|
||||||
const showAllSprites = ref(false);
|
const showAllSprites = ref(false);
|
||||||
const animationFrameId = ref<number | null>(null);
|
|
||||||
const lastFrameTime = ref(0);
|
|
||||||
|
|
||||||
// Dragging state
|
// Dragging state
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
@@ -191,128 +207,43 @@
|
|||||||
const dragStartY = ref(0);
|
const dragStartY = ref(0);
|
||||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
// Add this after other refs
|
|
||||||
const hiddenFrames = ref<number[]>([]);
|
|
||||||
|
|
||||||
// Get settings from store
|
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
|
||||||
// Add these computed properties
|
|
||||||
const visibleFrames = computed(() => props.sprites.filter((_, index) => !hiddenFrames.value.includes(index)));
|
|
||||||
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
|
||||||
const visibleFrameIndex = computed(() => {
|
|
||||||
return visibleFrames.value.findIndex((_, idx) => idx === visibleFrames.value.findIndex(s => s === props.sprites[currentFrameIndex.value]));
|
|
||||||
});
|
|
||||||
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
|
||||||
|
|
||||||
// Canvas drawing
|
// Canvas drawing
|
||||||
|
|
||||||
const drawPreviewCanvas = () => {
|
function drawPreviewCanvas() {
|
||||||
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
|
if (!previewCanvasRef.value || !canvas2D.ctx.value || props.sprites.length === 0) return;
|
||||||
|
|
||||||
const currentSprite = props.sprites[currentFrameIndex.value];
|
const currentSprite = props.sprites[currentFrameIndex.value];
|
||||||
if (!currentSprite) return;
|
if (!currentSprite) return;
|
||||||
|
|
||||||
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
|
||||||
|
|
||||||
// Apply pixel art optimization consistently from store
|
// Apply pixel art optimization
|
||||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
canvas2D.applySmoothing();
|
||||||
|
|
||||||
// Set canvas size to just fit one sprite cell
|
// Set canvas size to just fit one sprite cell
|
||||||
previewCanvasRef.value.width = maxWidth;
|
canvas2D.setCanvasSize(maxWidth, maxHeight);
|
||||||
previewCanvasRef.value.height = maxHeight;
|
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
ctx.value.clearRect(0, 0, previewCanvasRef.value.width, previewCanvasRef.value.height);
|
canvas2D.clear();
|
||||||
|
|
||||||
// Draw grid background (cell)
|
// Draw grid background (cell)
|
||||||
ctx.value.fillStyle = '#f9fafb';
|
canvas2D.fillRect(0, 0, maxWidth, maxHeight, '#f9fafb');
|
||||||
ctx.value.fillRect(0, 0, maxWidth, maxHeight);
|
|
||||||
|
|
||||||
// Keep pixel art optimization consistent throughout all drawing operations
|
|
||||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
|
||||||
|
|
||||||
// Draw all sprites with transparency if enabled
|
// Draw all sprites with transparency if enabled
|
||||||
if (showAllSprites.value && props.sprites.length > 1) {
|
if (showAllSprites.value && props.sprites.length > 1) {
|
||||||
ctx.value.globalAlpha = 0.3;
|
|
||||||
props.sprites.forEach((sprite, index) => {
|
props.sprites.forEach((sprite, index) => {
|
||||||
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
||||||
// Use Math.floor for pixel-perfect positioning
|
canvas2D.drawImage(sprite.img, sprite.x, sprite.y, 0.3);
|
||||||
ctx.value?.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ctx.value.globalAlpha = 1.0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw current sprite with integer positions for pixel-perfect rendering
|
// Draw current sprite
|
||||||
ctx.value.drawImage(currentSprite.img, Math.floor(currentSprite.x), Math.floor(currentSprite.y));
|
canvas2D.drawImage(currentSprite.img, currentSprite.x, currentSprite.y);
|
||||||
|
|
||||||
// Draw cell border
|
// Draw cell border
|
||||||
ctx.value.strokeStyle = '#e5e7eb';
|
canvas2D.strokeRect(0, 0, maxWidth, maxHeight, '#e5e7eb', 1);
|
||||||
ctx.value.lineWidth = 1;
|
}
|
||||||
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Animation control
|
|
||||||
const togglePlayback = () => {
|
|
||||||
isPlaying.value = !isPlaying.value;
|
|
||||||
|
|
||||||
if (isPlaying.value) {
|
|
||||||
startAnimation();
|
|
||||||
} else {
|
|
||||||
stopAnimation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startAnimation = () => {
|
|
||||||
lastFrameTime.value = performance.now();
|
|
||||||
animateFrame();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopAnimation = () => {
|
|
||||||
if (animationFrameId.value !== null) {
|
|
||||||
cancelAnimationFrame(animationFrameId.value);
|
|
||||||
animationFrameId.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const animateFrame = () => {
|
|
||||||
const now = performance.now();
|
|
||||||
const elapsed = now - lastFrameTime.value;
|
|
||||||
const frameTime = 1000 / fps.value;
|
|
||||||
|
|
||||||
if (elapsed >= frameTime) {
|
|
||||||
lastFrameTime.value = now - (elapsed % frameTime);
|
|
||||||
nextFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFrameId.value = requestAnimationFrame(animateFrame);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextFrame = () => {
|
|
||||||
if (visibleFrames.value.length === 0) return;
|
|
||||||
|
|
||||||
const currentVisibleIndex = visibleFrameIndex.value;
|
|
||||||
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
|
|
||||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
|
|
||||||
drawPreviewCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousFrame = () => {
|
|
||||||
if (visibleFrames.value.length === 0) return;
|
|
||||||
|
|
||||||
const currentVisibleIndex = visibleFrameIndex.value;
|
|
||||||
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
|
|
||||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
|
|
||||||
drawPreviewCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add this method to handle slider input
|
|
||||||
const handleSliderInput = (event: Event) => {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
const index = parseInt(target.value);
|
|
||||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[index]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Drag functionality
|
// Drag functionality
|
||||||
const startDrag = (event: MouseEvent) => {
|
const startDrag = (event: MouseEvent) => {
|
||||||
@@ -372,23 +303,6 @@
|
|||||||
activeSpriteId.value = null;
|
activeSpriteId.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add helper methods for mobile zoom controls
|
|
||||||
const increaseZoom = () => {
|
|
||||||
const zoomValues = [0.5, 1, 2, 3, 4];
|
|
||||||
const currentIndex = zoomValues.indexOf(Number(zoom.value));
|
|
||||||
if (currentIndex < zoomValues.length - 1) {
|
|
||||||
zoom.value = zoomValues[currentIndex + 1];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const decreaseZoom = () => {
|
|
||||||
const zoomValues = [0.5, 1, 2, 3, 4];
|
|
||||||
const currentIndex = zoomValues.indexOf(Number(zoom.value));
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
zoom.value = zoomValues[currentIndex - 1];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
if (!isDraggable.value) return;
|
if (!isDraggable.value) return;
|
||||||
|
|
||||||
@@ -421,10 +335,8 @@
|
|||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (previewCanvasRef.value) {
|
canvas2D.initContext();
|
||||||
ctx.value = previewCanvasRef.value.getContext('2d');
|
drawPreviewCanvas();
|
||||||
drawPreviewCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for forceRedraw event from App.vue
|
// Listen for forceRedraw event from App.vue
|
||||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||||
@@ -437,17 +349,9 @@
|
|||||||
|
|
||||||
// Handler for force redraw event
|
// Handler for force redraw event
|
||||||
const handleForceRedraw = () => {
|
const handleForceRedraw = () => {
|
||||||
// Ensure we're using integer positions for pixel-perfect rendering
|
canvas2D.ensureIntegerPositions(props.sprites);
|
||||||
props.sprites.forEach(sprite => {
|
canvas2D.applySmoothing();
|
||||||
sprite.x = Math.floor(sprite.x);
|
drawPreviewCanvas();
|
||||||
sprite.y = Math.floor(sprite.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force a redraw with the correct image smoothing settings
|
|
||||||
if (ctx.value) {
|
|
||||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
|
||||||
drawPreviewCanvas();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
@@ -463,42 +367,6 @@
|
|||||||
if (props.sprites.length > 0) {
|
if (props.sprites.length > 0) {
|
||||||
drawPreviewCanvas();
|
drawPreviewCanvas();
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleHiddenFrame = (index: number) => {
|
|
||||||
const currentIndex = hiddenFrames.value.indexOf(index);
|
|
||||||
if (currentIndex === -1) {
|
|
||||||
// Adding to hidden frames
|
|
||||||
hiddenFrames.value.push(index);
|
|
||||||
|
|
||||||
// If we're hiding the current frame, switch to the next visible frame
|
|
||||||
if (index === currentFrameIndex.value) {
|
|
||||||
const nextVisibleSprite = props.sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
|
||||||
if (nextVisibleSprite !== -1) {
|
|
||||||
currentFrameIndex.value = nextVisibleSprite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Removing from hidden frames
|
|
||||||
hiddenFrames.value.splice(currentIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force a redraw
|
|
||||||
drawPreviewCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
const showAllFrames = () => {
|
|
||||||
hiddenFrames.value = [];
|
|
||||||
drawPreviewCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideAllFrames = () => {
|
|
||||||
hiddenFrames.value = props.sprites.map((_, index) => index);
|
|
||||||
// Keep at least one frame visible
|
|
||||||
if (hiddenFrames.value.length > 0) {
|
|
||||||
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
|
||||||
}
|
|
||||||
drawPreviewCanvas();
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
178
src/composables/useAnimationFrames.ts
Normal file
178
src/composables/useAnimationFrames.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { ref, computed, type Ref, onUnmounted, toRef, isRef } from 'vue';
|
||||||
|
import type { Sprite } from '@/types/sprites';
|
||||||
|
|
||||||
|
export interface AnimationFramesOptions {
|
||||||
|
sprites: Ref<Sprite[]> | Sprite[] | (() => Sprite[]);
|
||||||
|
onDraw: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnimationFrames(options: AnimationFramesOptions) {
|
||||||
|
const { onDraw } = options;
|
||||||
|
|
||||||
|
// Convert sprites to a computed ref for reactivity
|
||||||
|
const spritesRef = computed(() => {
|
||||||
|
if (typeof options.sprites === 'function') {
|
||||||
|
return options.sprites();
|
||||||
|
}
|
||||||
|
if (isRef(options.sprites)) {
|
||||||
|
return options.sprites.value;
|
||||||
|
}
|
||||||
|
return options.sprites;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to get sprites array
|
||||||
|
const getSprites = () => spritesRef.value;
|
||||||
|
|
||||||
|
// State
|
||||||
|
const currentFrameIndex = ref(0);
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const fps = ref(12);
|
||||||
|
const hiddenFrames = ref<number[]>([]);
|
||||||
|
|
||||||
|
// Animation internals
|
||||||
|
const animationFrameId = ref<number | null>(null);
|
||||||
|
const lastFrameTime = ref(0);
|
||||||
|
|
||||||
|
// Computed properties for visible frames
|
||||||
|
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
|
||||||
|
|
||||||
|
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
||||||
|
|
||||||
|
const visibleFrameIndex = computed(() => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const currentSprite = sprites[currentFrameIndex.value];
|
||||||
|
return visibleFrames.value.findIndex(s => s === currentSprite);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
||||||
|
|
||||||
|
// Animation control
|
||||||
|
const animateFrame = () => {
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsed = now - lastFrameTime.value;
|
||||||
|
const frameTime = 1000 / fps.value;
|
||||||
|
|
||||||
|
if (elapsed >= frameTime) {
|
||||||
|
lastFrameTime.value = now - (elapsed % frameTime);
|
||||||
|
nextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId.value = requestAnimationFrame(animateFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAnimation = () => {
|
||||||
|
lastFrameTime.value = performance.now();
|
||||||
|
animateFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAnimation = () => {
|
||||||
|
if (animationFrameId.value !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId.value);
|
||||||
|
animationFrameId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayback = () => {
|
||||||
|
isPlaying.value = !isPlaying.value;
|
||||||
|
|
||||||
|
if (isPlaying.value) {
|
||||||
|
startAnimation();
|
||||||
|
} else {
|
||||||
|
stopAnimation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextFrame = () => {
|
||||||
|
if (visibleFrames.value.length === 0) return;
|
||||||
|
const sprites = getSprites();
|
||||||
|
|
||||||
|
const currentVisibleIndex = visibleFrameIndex.value;
|
||||||
|
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
|
||||||
|
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousFrame = () => {
|
||||||
|
if (visibleFrames.value.length === 0) return;
|
||||||
|
const sprites = getSprites();
|
||||||
|
|
||||||
|
const currentVisibleIndex = visibleFrameIndex.value;
|
||||||
|
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
|
||||||
|
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderInput = (event: Event) => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const index = parseInt(target.value);
|
||||||
|
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Frame visibility management
|
||||||
|
const toggleHiddenFrame = (index: number) => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const currentIndex = hiddenFrames.value.indexOf(index);
|
||||||
|
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
hiddenFrames.value.push(index);
|
||||||
|
|
||||||
|
// If hiding current frame, switch to next visible
|
||||||
|
if (index === currentFrameIndex.value) {
|
||||||
|
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
||||||
|
if (nextVisible !== -1) {
|
||||||
|
currentFrameIndex.value = nextVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hiddenFrames.value.splice(currentIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAllFrames = () => {
|
||||||
|
hiddenFrames.value = [];
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideAllFrames = () => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
hiddenFrames.value = sprites.map((_, index) => index);
|
||||||
|
|
||||||
|
// Keep at least one frame visible
|
||||||
|
if (hiddenFrames.value.length > 0) {
|
||||||
|
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
||||||
|
}
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAnimation();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
currentFrameIndex,
|
||||||
|
isPlaying,
|
||||||
|
fps,
|
||||||
|
hiddenFrames,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
visibleFrames,
|
||||||
|
visibleFramesCount,
|
||||||
|
visibleFrameIndex,
|
||||||
|
visibleFrameNumber,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
togglePlayback,
|
||||||
|
nextFrame,
|
||||||
|
previousFrame,
|
||||||
|
handleSliderInput,
|
||||||
|
toggleHiddenFrame,
|
||||||
|
showAllFrames,
|
||||||
|
hideAllFrames,
|
||||||
|
stopAnimation,
|
||||||
|
};
|
||||||
|
}
|
||||||
150
src/composables/useCanvas2D.ts
Normal file
150
src/composables/useCanvas2D.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { ref, type Ref } from 'vue';
|
||||||
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
|
import type { Sprite } from '@/types/sprites';
|
||||||
|
|
||||||
|
export interface Canvas2DOptions {
|
||||||
|
pixelPerfect?: Ref<boolean> | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?: Canvas2DOptions) {
|
||||||
|
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const initContext = () => {
|
||||||
|
if (canvasRef.value) {
|
||||||
|
ctx.value = canvasRef.value.getContext('2d');
|
||||||
|
applySmoothing();
|
||||||
|
}
|
||||||
|
return ctx.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySmoothing = () => {
|
||||||
|
if (ctx.value) {
|
||||||
|
const pixelPerfect = options?.pixelPerfect;
|
||||||
|
const isPixelPerfect = typeof pixelPerfect === 'boolean' ? pixelPerfect : (pixelPerfect?.value ?? settingsStore.pixelPerfect);
|
||||||
|
ctx.value.imageSmoothingEnabled = !isPixelPerfect;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
if (!canvasRef.value || !ctx.value) return;
|
||||||
|
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCanvasSize = (width: number, height: number) => {
|
||||||
|
if (canvasRef.value) {
|
||||||
|
canvasRef.value.width = width;
|
||||||
|
canvasRef.value.height = height;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillRect = (x: number, y: number, width: number, height: number, color: string) => {
|
||||||
|
if (!ctx.value) return;
|
||||||
|
ctx.value.fillStyle = color;
|
||||||
|
ctx.value.fillRect(Math.floor(x), Math.floor(y), width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const strokeRect = (x: number, y: number, width: number, height: number, color: string, lineWidth = 1) => {
|
||||||
|
if (!ctx.value) return;
|
||||||
|
ctx.value.strokeStyle = color;
|
||||||
|
ctx.value.lineWidth = lineWidth;
|
||||||
|
ctx.value.strokeRect(Math.floor(x), Math.floor(y), width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawImage = (img: HTMLImageElement | HTMLCanvasElement, x: number, y: number, alpha = 1) => {
|
||||||
|
if (!ctx.value) return;
|
||||||
|
const prevAlpha = ctx.value.globalAlpha;
|
||||||
|
ctx.value.globalAlpha = alpha;
|
||||||
|
ctx.value.drawImage(img, Math.floor(x), Math.floor(y));
|
||||||
|
ctx.value.globalAlpha = prevAlpha;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGlobalAlpha = (alpha: number) => {
|
||||||
|
if (ctx.value) {
|
||||||
|
ctx.value.globalAlpha = alpha;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGlobalAlpha = () => {
|
||||||
|
if (ctx.value) {
|
||||||
|
ctx.value.globalAlpha = 1.0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to ensure integer positions for pixel-perfect rendering
|
||||||
|
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
item.x = Math.floor(item.x);
|
||||||
|
item.y = Math.floor(item.y);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Centralized force redraw handler
|
||||||
|
const createForceRedrawHandler = <T extends { x: number; y: number }>(items: T[], drawCallback: () => void) => {
|
||||||
|
return () => {
|
||||||
|
ensureIntegerPositions(items);
|
||||||
|
applySmoothing();
|
||||||
|
drawCallback();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mouse position relative to canvas, accounting for zoom
|
||||||
|
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
|
||||||
|
if (!canvasRef.value) return null;
|
||||||
|
|
||||||
|
const rect = canvasRef.value.getBoundingClientRect();
|
||||||
|
const scaleX = canvasRef.value.width / (rect.width / zoom);
|
||||||
|
const scaleY = canvasRef.value.height / (rect.height / zoom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rect.left) / zoom) * scaleX,
|
||||||
|
y: ((event.clientY - rect.top) / zoom) * scaleY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to attach load/error listeners to images that aren't yet loaded
|
||||||
|
const attachImageListeners = (sprites: Sprite[], onLoad: () => void, tracked: WeakSet<HTMLImageElement>) => {
|
||||||
|
sprites.forEach(sprite => {
|
||||||
|
const img = sprite.img as HTMLImageElement | undefined;
|
||||||
|
if (img && !tracked.has(img)) {
|
||||||
|
tracked.add(img);
|
||||||
|
if (!img.complete) {
|
||||||
|
img.addEventListener('load', onLoad, { once: true });
|
||||||
|
img.addEventListener('error', onLoad, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill cell background with theme-aware color
|
||||||
|
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
||||||
|
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
||||||
|
fillRect(x, y, width, height, color);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stroke grid with theme-aware color
|
||||||
|
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
|
||||||
|
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||||
|
strokeRect(x, y, width, height, color, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
canvasRef,
|
||||||
|
initContext,
|
||||||
|
applySmoothing,
|
||||||
|
clear,
|
||||||
|
setCanvasSize,
|
||||||
|
fillRect,
|
||||||
|
strokeRect,
|
||||||
|
drawImage,
|
||||||
|
setGlobalAlpha,
|
||||||
|
resetGlobalAlpha,
|
||||||
|
ensureIntegerPositions,
|
||||||
|
createForceRedrawHandler,
|
||||||
|
getMousePosition,
|
||||||
|
attachImageListeners,
|
||||||
|
fillCellBackground,
|
||||||
|
strokeGridCell,
|
||||||
|
};
|
||||||
|
}
|
||||||
280
src/composables/useDragSprite.ts
Normal file
280
src/composables/useDragSprite.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||||
|
import type { Sprite } from '@/types/sprites';
|
||||||
|
import { getMaxDimensions } from './useSprites';
|
||||||
|
|
||||||
|
export interface CellPosition {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpritePosition {
|
||||||
|
id: string;
|
||||||
|
canvasX: number;
|
||||||
|
canvasY: number;
|
||||||
|
cellX: number;
|
||||||
|
cellY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
index: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragSpriteOptions {
|
||||||
|
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||||
|
columns: Ref<number> | number;
|
||||||
|
zoom?: Ref<number>;
|
||||||
|
allowCellSwap?: Ref<boolean>;
|
||||||
|
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||||
|
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||||
|
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||||
|
onDraw: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDragSprite(options: DragSpriteOptions) {
|
||||||
|
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
|
||||||
|
|
||||||
|
// Helper to get reactive values
|
||||||
|
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||||
|
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||||
|
const getZoom = () => options.zoom?.value ?? 1;
|
||||||
|
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const activeSpriteId = ref<string | null>(null);
|
||||||
|
const activeSpriteCellIndex = ref<number | null>(null);
|
||||||
|
const dragStartX = ref(0);
|
||||||
|
const dragStartY = ref(0);
|
||||||
|
const dragOffsetX = ref(0);
|
||||||
|
const dragOffsetY = ref(0);
|
||||||
|
const currentHoverCell = ref<CellPosition | null>(null);
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
||||||
|
const highlightCell = ref<CellPosition | null>(null);
|
||||||
|
|
||||||
|
// Cache for max dimensions
|
||||||
|
const lastMaxWidth = ref(1);
|
||||||
|
const lastMaxHeight = ref(1);
|
||||||
|
|
||||||
|
const calculateMaxDimensions = () => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const base = getMaxDimensions(sprites);
|
||||||
|
const maxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
||||||
|
const maxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
||||||
|
lastMaxWidth.value = maxWidth;
|
||||||
|
lastMaxHeight.value = maxHeight;
|
||||||
|
return { maxWidth, maxHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed sprite positions
|
||||||
|
const spritePositions = computed<SpritePosition[]>(() => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const columns = getColumns();
|
||||||
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||||
|
|
||||||
|
return sprites.map((sprite, index) => {
|
||||||
|
const col = index % columns;
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sprite.id,
|
||||||
|
canvasX: col * maxWidth + sprite.x,
|
||||||
|
canvasY: row * maxHeight + sprite.y,
|
||||||
|
cellX: col * maxWidth,
|
||||||
|
cellY: row * maxHeight,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
index,
|
||||||
|
x: sprite.x,
|
||||||
|
y: sprite.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const columns = getColumns();
|
||||||
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||||
|
const col = Math.floor(x / maxWidth);
|
||||||
|
const row = Math.floor(y / maxHeight);
|
||||||
|
|
||||||
|
const totalRows = Math.ceil(sprites.length / columns);
|
||||||
|
if (col >= 0 && col < columns && row >= 0 && row < totalRows) {
|
||||||
|
const index = row * columns + col;
|
||||||
|
if (index < sprites.length) {
|
||||||
|
return { col, row, index };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSpriteAtPosition = (x: number, y: number): Sprite | null => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const positions = spritePositions.value;
|
||||||
|
|
||||||
|
for (let i = positions.length - 1; i >= 0; i--) {
|
||||||
|
const pos = positions[i];
|
||||||
|
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
|
||||||
|
return sprites.find(s => s.id === pos.id) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (event: MouseEvent) => {
|
||||||
|
const pos = getMousePosition(event, getZoom());
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||||
|
if (clickedSprite) {
|
||||||
|
isDragging.value = true;
|
||||||
|
activeSpriteId.value = clickedSprite.id;
|
||||||
|
dragStartX.value = pos.x;
|
||||||
|
dragStartY.value = pos.y;
|
||||||
|
|
||||||
|
const spritePosition = spritePositions.value.find(p => p.id === clickedSprite.id);
|
||||||
|
if (spritePosition) {
|
||||||
|
dragOffsetX.value = pos.x - spritePosition.canvasX;
|
||||||
|
dragOffsetY.value = pos.y - spritePosition.canvasY;
|
||||||
|
activeSpriteCellIndex.value = spritePosition.index;
|
||||||
|
|
||||||
|
const startCell = findCellAtPosition(pos.x, pos.y);
|
||||||
|
if (startCell) {
|
||||||
|
currentHoverCell.value = startCell;
|
||||||
|
highlightCell.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
|
||||||
|
if (!activeSpriteId.value) return;
|
||||||
|
const sprites = getSprites();
|
||||||
|
const columns = getColumns();
|
||||||
|
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||||
|
|
||||||
|
// Use the sprite's current index in the array to calculate cell position
|
||||||
|
const cellCol = spriteIndex % columns;
|
||||||
|
const cellRow = Math.floor(spriteIndex / columns);
|
||||||
|
const cellX = cellCol * maxWidth;
|
||||||
|
const cellY = cellRow * maxHeight;
|
||||||
|
|
||||||
|
const newX = mouseX - cellX - dragOffsetX.value;
|
||||||
|
const newY = mouseY - cellY - dragOffsetY.value;
|
||||||
|
|
||||||
|
const constrainedX = Math.floor(Math.max(0, Math.min(maxWidth - sprites[spriteIndex].width, newX)));
|
||||||
|
const constrainedY = Math.floor(Math.max(0, Math.min(maxHeight - sprites[spriteIndex].height, newY)));
|
||||||
|
|
||||||
|
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const drag = (event: MouseEvent) => {
|
||||||
|
if (!isDragging.value || !activeSpriteId.value || activeSpriteCellIndex.value === null) return;
|
||||||
|
|
||||||
|
const pos = getMousePosition(event, getZoom());
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
const sprites = getSprites();
|
||||||
|
const spriteIndex = sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||||
|
if (spriteIndex === -1) return;
|
||||||
|
|
||||||
|
const hoverCell = findCellAtPosition(pos.x, pos.y);
|
||||||
|
currentHoverCell.value = hoverCell;
|
||||||
|
|
||||||
|
if (getAllowCellSwap() && hoverCell) {
|
||||||
|
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
||||||
|
highlightCell.value = hoverCell;
|
||||||
|
ghostSprite.value = {
|
||||||
|
id: activeSpriteId.value,
|
||||||
|
x: pos.x - dragOffsetX.value,
|
||||||
|
y: pos.y - dragOffsetY.value,
|
||||||
|
};
|
||||||
|
onDraw();
|
||||||
|
} else {
|
||||||
|
highlightCell.value = null;
|
||||||
|
ghostSprite.value = null;
|
||||||
|
handleInCellMovement(pos.x, pos.y, spriteIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleInCellMovement(pos.x, pos.y, spriteIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
if (isDragging.value && getAllowCellSwap() && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
||||||
|
if (onUpdateSpriteCell) {
|
||||||
|
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
|
||||||
|
}
|
||||||
|
onUpdateSprite(activeSpriteId.value, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging.value = false;
|
||||||
|
activeSpriteId.value = null;
|
||||||
|
activeSpriteCellIndex.value = null;
|
||||||
|
currentHoverCell.value = null;
|
||||||
|
highlightCell.value = null;
|
||||||
|
ghostSprite.value = null;
|
||||||
|
|
||||||
|
onDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch event handlers
|
||||||
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const mouseEvent = {
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY,
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as unknown as MouseEvent;
|
||||||
|
startDrag(mouseEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
if (isDragging.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const mouseEvent = {
|
||||||
|
clientX: touch.clientX,
|
||||||
|
clientY: touch.clientY,
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as unknown as MouseEvent;
|
||||||
|
drag(mouseEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isDragging,
|
||||||
|
activeSpriteId,
|
||||||
|
ghostSprite,
|
||||||
|
highlightCell,
|
||||||
|
spritePositions,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
startDrag,
|
||||||
|
drag,
|
||||||
|
stopDrag,
|
||||||
|
handleTouchStart,
|
||||||
|
handleTouchMove,
|
||||||
|
findSpriteAtPosition,
|
||||||
|
findCellAtPosition,
|
||||||
|
calculateMaxDimensions,
|
||||||
|
};
|
||||||
|
}
|
||||||
110
src/composables/useFileDrop.ts
Normal file
110
src/composables/useFileDrop.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref, type Ref } from 'vue';
|
||||||
|
import type { Sprite } from '@/types/sprites';
|
||||||
|
import { getMaxDimensions } from './useSprites';
|
||||||
|
|
||||||
|
export interface FileDropOptions {
|
||||||
|
sprites: Ref<Sprite[]> | Sprite[];
|
||||||
|
onAddSprite: (file: File) => void;
|
||||||
|
onAddSpriteWithResize: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileDrop(options: FileDropOptions) {
|
||||||
|
const { onAddSprite, onAddSpriteWithResize } = options;
|
||||||
|
|
||||||
|
// Helper to get sprites array
|
||||||
|
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||||
|
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
isDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
isDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLCanvasElement | null) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (canvasRef) {
|
||||||
|
const rect = canvasRef.getBoundingClientRect();
|
||||||
|
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
|
||||||
|
isDragOver.value = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isDragOver.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processDroppedImage = (file: File): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = e => {
|
||||||
|
if (e.target?.result) {
|
||||||
|
img.src = e.target.result as string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const sprites = getSprites();
|
||||||
|
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
|
||||||
|
|
||||||
|
// Check if the dropped image is larger than current cells
|
||||||
|
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
|
||||||
|
onAddSpriteWithResize(file);
|
||||||
|
} else {
|
||||||
|
onAddSprite(file);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('Failed to load image:', file.name);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
isDragOver.value = false;
|
||||||
|
|
||||||
|
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
alert('Please drop image files only.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each dropped file
|
||||||
|
for (const file of files) {
|
||||||
|
await processDroppedImage(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDragOver,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
src/composables/useZoom.ts
Normal file
83
src/composables/useZoom.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
export interface ZoomOptionsStep {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
initial?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoomOptionsAllowed {
|
||||||
|
allowedValues: number[];
|
||||||
|
initial?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZoomOptions = ZoomOptionsStep | ZoomOptionsAllowed;
|
||||||
|
|
||||||
|
function isStepOptions(options: ZoomOptions): options is ZoomOptionsStep {
|
||||||
|
return 'step' in options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZoom(options: ZoomOptions) {
|
||||||
|
const initial = options.initial ?? (isStepOptions(options) ? 1 : (options.allowedValues[1] ?? options.allowedValues[0]));
|
||||||
|
const zoom = ref(initial);
|
||||||
|
|
||||||
|
const zoomPercent = computed(() => Math.round(zoom.value * 100));
|
||||||
|
|
||||||
|
const increase = () => {
|
||||||
|
if (isStepOptions(options)) {
|
||||||
|
zoom.value = Math.min(options.max, zoom.value + options.step);
|
||||||
|
} else {
|
||||||
|
const currentIndex = options.allowedValues.indexOf(zoom.value);
|
||||||
|
if (currentIndex < options.allowedValues.length - 1) {
|
||||||
|
zoom.value = options.allowedValues[currentIndex + 1];
|
||||||
|
} else if (currentIndex === -1) {
|
||||||
|
// Find the nearest higher value
|
||||||
|
const higher = options.allowedValues.find(v => v > zoom.value);
|
||||||
|
if (higher !== undefined) {
|
||||||
|
zoom.value = higher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrease = () => {
|
||||||
|
if (isStepOptions(options)) {
|
||||||
|
zoom.value = Math.max(options.min, zoom.value - options.step);
|
||||||
|
} else {
|
||||||
|
const currentIndex = options.allowedValues.indexOf(zoom.value);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
zoom.value = options.allowedValues[currentIndex - 1];
|
||||||
|
} else if (currentIndex === -1) {
|
||||||
|
// Find the nearest lower value
|
||||||
|
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
|
||||||
|
if (lower !== undefined) {
|
||||||
|
zoom.value = lower;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
zoom.value = initial;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setZoom = (value: number) => {
|
||||||
|
if (isStepOptions(options)) {
|
||||||
|
zoom.value = Math.max(options.min, Math.min(options.max, value));
|
||||||
|
} else {
|
||||||
|
// Snap to nearest allowed value
|
||||||
|
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
|
||||||
|
zoom.value = nearest;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
zoom,
|
||||||
|
zoomPercent,
|
||||||
|
increase,
|
||||||
|
decrease,
|
||||||
|
reset,
|
||||||
|
setZoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user