Files
spritesheet-generator/src/composables/useDragSprite.ts
2025-11-23 01:48:05 +01:00

324 lines
11 KiB
TypeScript

import { ref, computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite, Layer } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
import { calculateNegativeSpacing } from './useNegativeSpacing';
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[];
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
columns: Ref<number> | number;
zoom?: Ref<number>;
allowCellSwap?: Ref<boolean>;
negativeSpacingEnabled?: Ref<boolean>;
manualCellSizeEnabled?: Ref<boolean>;
manualCellWidth?: Ref<number>;
manualCellHeight?: Ref<number>;
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 getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
const getZoom = () => options.zoom?.value ?? 1;
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
// 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 negativeSpacingEnabled = getNegativeSpacingEnabled();
const manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
const maxWidth = getManualCellWidth();
const maxHeight = getManualCellHeight();
// When manual cell size is used, negative spacing is not applied
const negativeSpacing = 0;
// Don't update lastMaxWidth/lastMaxHeight when in manual mode
return { maxWidth, maxHeight, negativeSpacing };
}
// Get all sprites to calculate dimensions from
// If layers are provided, use all visible layers; otherwise use current sprites
const layers = getLayers();
const spritesToMeasure = layers ? layers.filter(l => l.visible).flatMap(l => l.sprites) : getSprites();
// Otherwise, calculate based on sprite dimensions across all visible layers
const base = getMaxDimensions(spritesToMeasure);
// When switching back from manual mode, reset to actual sprite dimensions
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
lastMaxWidth.value = baseMaxWidth;
lastMaxHeight.value = baseMaxHeight;
// Calculate negative spacing using shared composable
const negativeSpacing = calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled);
// Add negative spacing to expand each cell
const maxWidth = baseMaxWidth + negativeSpacing;
const maxHeight = baseMaxHeight + negativeSpacing;
return { maxWidth, maxHeight, negativeSpacing };
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
return sprites.map((sprite, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
// With negative spacing, sprites are positioned at bottom-right of cell
// (spacing added to top and left)
return {
id: sprite.id,
canvasX: col * maxWidth + negativeSpacing + sprite.x,
canvasY: row * maxHeight + negativeSpacing + 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, negativeSpacing } = 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;
// Calculate new position relative to cell origin (without the negative spacing offset)
// The sprite's x,y is stored relative to where it would be drawn after the negativeSpacing offset
const newX = mouseX - cellX - negativeSpacing - dragOffsetX.value;
const newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
// The sprite can move within the full expanded cell area
// Allow negative values up to -negativeSpacing so sprite can fill the expanded area
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - 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,
};
}