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 | ComputedRef | Sprite[]; layers?: Ref | ComputedRef | Layer[]; columns: Ref | number; zoom?: Ref; allowCellSwap?: Ref; negativeSpacingEnabled?: Ref; manualCellSizeEnabled?: Ref; manualCellWidth?: Ref; manualCellHeight?: Ref; 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(null); const activeSpriteCellIndex = ref(null); const dragStartX = ref(0); const dragStartY = ref(0); const dragOffsetX = ref(0); const dragOffsetY = ref(0); const currentHoverCell = ref(null); // Visual feedback const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null); const highlightCell = ref(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(() => { 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, }; }