diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 0ff6c6b..f919d66 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -2,6 +2,7 @@ All notable changes to this project will be documented in this file. ## [1.6.0] - 2025-11-18 - Improved animation preview modal +- Add toggle for negative spacing in cells ## [1.5.0] - 2025-11-17 - Show offset values in sprite cells and in preview modal diff --git a/src/components/SpriteCanvas.vue b/src/components/SpriteCanvas.vue index 1c65f3e..e991832 100644 --- a/src/components/SpriteCanvas.vue +++ b/src/components/SpriteCanvas.vue @@ -18,6 +18,11 @@ + +
+ + +
@@ -157,6 +162,7 @@ columns: toRef(props, 'columns'), zoom, allowCellSwap, + negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'), getMousePosition: (event, z) => canvas2D.getMousePosition(event, z), onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y), onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex), @@ -271,7 +277,7 @@ function drawCanvas() { if (!canvasRef.value || !canvas2D.ctx.value) return; - const { maxWidth, maxHeight } = calculateMaxDimensions(); + const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions(); // Set canvas size const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns)); @@ -308,9 +314,10 @@ const cellY = Math.floor(cellRow * maxHeight); // Draw all sprites with transparency in this cell + // Position at bottom-right with negative spacing offset props.sprites.forEach((sprite, spriteIndex) => { if (spriteIndex !== cellIndex) { - canvas2D.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y, 0.3); + canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.3); } }); } @@ -329,8 +336,8 @@ const cellX = Math.floor(col * maxWidth); const cellY = Math.floor(row * maxHeight); - // Draw sprite - canvas2D.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y); + // Draw sprite with negative spacing offset (bottom-right positioning) + canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y); }); // Draw ghost sprite if we're dragging between cells @@ -395,6 +402,7 @@ watch(() => props.columns, drawCanvas); watch(() => settingsStore.pixelPerfect, drawCanvas); watch(() => settingsStore.darkMode, drawCanvas); + watch(() => settingsStore.negativeSpacingEnabled, drawCanvas); watch(showAllSprites, drawCanvas); diff --git a/src/components/SpritePreview.vue b/src/components/SpritePreview.vue index 34003f1..6125e1f 100644 --- a/src/components/SpritePreview.vue +++ b/src/components/SpritePreview.vue @@ -209,6 +209,18 @@ // Canvas drawing + // Calculate negative spacing based on sprite dimensions + function calculateNegativeSpacing(): number { + if (!settingsStore.negativeSpacingEnabled || props.sprites.length === 0) return 0; + + const { maxWidth, maxHeight } = getMaxDimensions(props.sprites); + const minWidth = Math.min(...props.sprites.map(s => s.width)); + const minHeight = Math.min(...props.sprites.map(s => s.height)); + const widthDiff = maxWidth - minWidth; + const heightDiff = maxHeight - minHeight; + return Math.max(widthDiff, heightDiff); + } + function drawPreviewCanvas() { if (!previewCanvasRef.value || !canvas2D.ctx.value || props.sprites.length === 0) return; @@ -216,33 +228,36 @@ if (!currentSprite) return; const { maxWidth, maxHeight } = getMaxDimensions(props.sprites); + const negativeSpacing = calculateNegativeSpacing(); + const cellWidth = maxWidth + negativeSpacing; + const cellHeight = maxHeight + negativeSpacing; // Apply pixel art optimization canvas2D.applySmoothing(); - // Set canvas size to just fit one sprite cell - canvas2D.setCanvasSize(maxWidth, maxHeight); + // Set canvas size to fit one sprite cell (expanded with negative spacing) + canvas2D.setCanvasSize(cellWidth, cellHeight); // Clear canvas canvas2D.clear(); // Draw grid background (cell) - canvas2D.fillRect(0, 0, maxWidth, maxHeight, '#f9fafb'); + canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb'); // Draw all sprites with transparency if enabled if (showAllSprites.value && props.sprites.length > 1) { props.sprites.forEach((sprite, index) => { if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) { - canvas2D.drawImage(sprite.img, sprite.x, sprite.y, 0.3); + canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3); } }); } - // Draw current sprite - canvas2D.drawImage(currentSprite.img, currentSprite.x, currentSprite.y); + // Draw current sprite with negative spacing offset + canvas2D.drawImage(currentSprite.img, negativeSpacing + currentSprite.x, negativeSpacing + currentSprite.y); // Draw cell border - canvas2D.strokeRect(0, 0, maxWidth, maxHeight, '#e5e7eb', 1); + canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1); } // Drag functionality @@ -257,9 +272,12 @@ const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY; const sprite = props.sprites[currentFrameIndex.value]; + const negativeSpacing = calculateNegativeSpacing(); - // Check if click is on sprite - if (sprite && mouseX >= sprite.x && mouseX <= sprite.x + sprite.width && mouseY >= sprite.y && mouseY <= sprite.y + sprite.height) { + // Check if click is on sprite (accounting for negative spacing offset) + const spriteCanvasX = negativeSpacing + sprite.x; + const spriteCanvasY = negativeSpacing + sprite.y; + if (sprite && mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) { isDragging.value = true; activeSpriteId.value = sprite.id; dragStartX.value = mouseX; @@ -285,14 +303,17 @@ if (!sprite || sprite.id !== activeSpriteId.value) return; const { maxWidth, maxHeight } = getMaxDimensions(props.sprites); + const negativeSpacing = calculateNegativeSpacing(); + const cellWidth = maxWidth + negativeSpacing; + const cellHeight = maxHeight + negativeSpacing; // Calculate new position with constraints and round to integers let newX = Math.round(spritePosBeforeDrag.value.x + deltaX); let newY = Math.round(spritePosBeforeDrag.value.y + deltaY); - // Constrain movement within cell - newX = Math.max(0, Math.min(maxWidth - sprite.width, newX)); - newY = Math.max(0, Math.min(maxHeight - sprite.height, newY)); + // Constrain movement within expanded cell (allow negative values up to -negativeSpacing) + newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX)); + newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY)); emit('updateSprite', activeSpriteId.value, newX, newY); drawPreviewCanvas(); @@ -362,6 +383,7 @@ watch(showAllSprites, drawPreviewCanvas); watch(hiddenFrames, drawPreviewCanvas); watch(() => settingsStore.pixelPerfect, drawPreviewCanvas); + watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas); // Initial draw if (props.sprites.length > 0) { diff --git a/src/composables/useDragSprite.ts b/src/composables/useDragSprite.ts index 854d01f..9ec5923 100644 --- a/src/composables/useDragSprite.ts +++ b/src/composables/useDragSprite.ts @@ -30,6 +30,7 @@ export interface DragSpriteOptions { columns: Ref | number; zoom?: Ref; allowCellSwap?: Ref; + negativeSpacingEnabled?: 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; @@ -44,6 +45,7 @@ export function useDragSprite(options: DragSpriteOptions) { 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; // Drag state const isDragging = ref(false); @@ -65,28 +67,47 @@ export function useDragSprite(options: DragSpriteOptions) { const calculateMaxDimensions = () => { const sprites = getSprites(); + const negativeSpacingEnabled = getNegativeSpacingEnabled(); 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 }; + const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value); + const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value); + lastMaxWidth.value = baseMaxWidth; + lastMaxHeight.value = baseMaxHeight; + + // Calculate negative spacing based on sprite size differences + let negativeSpacing = 0; + if (negativeSpacingEnabled && sprites.length > 0) { + // Find the smallest sprite dimensions + const minWidth = Math.min(...sprites.map(s => s.width)); + const minHeight = Math.min(...sprites.map(s => s.height)); + // Negative spacing is the difference between max and min dimensions + const widthDiff = baseMaxWidth - minWidth; + const heightDiff = baseMaxHeight - minHeight; + negativeSpacing = Math.max(widthDiff, heightDiff); + } + + // 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 } = calculateMaxDimensions(); + 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 + sprite.x, - canvasY: row * maxHeight + sprite.y, + canvasX: col * maxWidth + negativeSpacing + sprite.x, + canvasY: row * maxHeight + negativeSpacing + sprite.y, cellX: col * maxWidth, cellY: row * maxHeight, width: sprite.width, @@ -162,7 +183,7 @@ export function useDragSprite(options: DragSpriteOptions) { if (!activeSpriteId.value) return; const sprites = getSprites(); const columns = getColumns(); - const { maxWidth, maxHeight } = calculateMaxDimensions(); + const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions(); // Use the sprite's current index in the array to calculate cell position const cellCol = spriteIndex % columns; @@ -170,11 +191,15 @@ export function useDragSprite(options: DragSpriteOptions) { const cellX = cellCol * maxWidth; const cellY = cellRow * maxHeight; - const newX = mouseX - cellX - dragOffsetX.value; - const newY = mouseY - cellY - dragOffsetY.value; + // 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; - 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))); + // 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(); diff --git a/src/stores/useSettingsStore.ts b/src/stores/useSettingsStore.ts index a5c544c..4029b36 100644 --- a/src/stores/useSettingsStore.ts +++ b/src/stores/useSettingsStore.ts @@ -3,6 +3,7 @@ import { ref, watch } from 'vue'; const pixelPerfect = ref(true); const darkMode = ref(false); +const negativeSpacingEnabled = ref(false); // Initialize dark mode from localStorage or system preference if (typeof window !== 'undefined') { @@ -51,12 +52,18 @@ export const useSettingsStore = defineStore('settings', () => { darkMode.value = value; } + function toggleNegativeSpacing() { + negativeSpacingEnabled.value = !negativeSpacingEnabled.value; + } + return { pixelPerfect, darkMode, + negativeSpacingEnabled, togglePixelPerfect, setPixelPerfect, toggleDarkMode, setDarkMode, + toggleNegativeSpacing, }; });