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,
};
});