[FEAT] Multi select, flip, rotate, multi remove
This commit is contained in:
@@ -5,6 +5,18 @@
|
||||
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
|
||||
<span>Add Sprite</span>
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="rotateSpriteInMenu(90)" class="w-full px-5 py-3 text-left hover:bg-green-50 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-redo text-green-600 dark:text-green-400"></i>
|
||||
<span>Rotate +90°</span>
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('horizontal')" class="w-full px-5 py-3 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-arrows-alt-h text-orange-600 dark:text-orange-400"></i>
|
||||
<span>Flip Horizontal</span>
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="flipSpriteInMenu('vertical')" class="w-full px-5 py-3 text-left hover:bg-orange-50 dark:hover:bg-orange-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-arrows-alt-v text-orange-600 dark:text-orange-400"></i>
|
||||
<span>Flip Vertical</span>
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="replaceSprite" class="w-full px-5 py-3 text-left hover:bg-purple-50 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
|
||||
<span>Replace Sprite</span>
|
||||
@@ -12,7 +24,7 @@
|
||||
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-5 py-3 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Remove Sprite</span>
|
||||
<span>{{ selectedSpriteIds.size > 1 ? `Remove ${selectedSpriteIds.size} Sprites` : 'Remove Sprite' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -27,6 +39,15 @@
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<!-- Toggles Group -->
|
||||
<div class="flex items-center gap-1 p-1">
|
||||
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Toggle Multi-Select Mode">
|
||||
<input id="multi-select-mode" type="checkbox" v-model="isMultiSelectMode" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Multi-Select</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Show Selection Borders">
|
||||
<input id="show-active-border" type="checkbox" v-model="showActiveBorder" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Borders</span>
|
||||
</label>
|
||||
<div class="w-px h-4 bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||
<label class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors" title="Pixel Perfect">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="w-4 h-4 rounded text-blue-600 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Pixel Perfect</span>
|
||||
@@ -181,7 +202,8 @@
|
||||
<img
|
||||
v-if="!(activeSpriteId === sprite.id && ghostSprite)"
|
||||
:src="sprite.url"
|
||||
class="absolute cursor-move"
|
||||
class="absolute cursor-move transition-transform duration-200"
|
||||
:class="{ 'ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-900': showActiveBorder && selectedSpriteIds.has(sprite.id) }"
|
||||
:style="{
|
||||
left: `${getCellPosition(index).x + gridMetrics.negativeSpacing + sprite.x}px`,
|
||||
top: `${getCellPosition(index).y + gridMetrics.negativeSpacing + sprite.y}px`,
|
||||
@@ -189,6 +211,7 @@
|
||||
height: `${sprite.height}px`,
|
||||
opacity: layer.id === activeLayerId ? '1' : '0.85',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
transform: `rotate(${sprite.rotation}deg) scale(${sprite.flipX ? -1 : 1}, ${sprite.flipY ? -1 : 1})`,
|
||||
}"
|
||||
:data-sprite-id="sprite.id"
|
||||
:data-layer-id="layer.id"
|
||||
@@ -210,6 +233,7 @@
|
||||
height: `${activeSpriteSprite?.height}px`,
|
||||
opacity: '0.6',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
transform: `rotate(${activeSpriteSprite?.rotation || 0}deg) scale(${activeSpriteSprite?.flipX ? -1 : 1}, ${activeSpriteSprite?.flipY ? -1 : 1})`,
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -256,9 +280,12 @@
|
||||
(e: 'updateSprite', id: string, x: number, y: number): void;
|
||||
(e: 'updateSpriteCell', id: string, newIndex: number): void;
|
||||
(e: 'removeSprite', id: string): void;
|
||||
(e: 'removeSprites', ids: string[]): void;
|
||||
(e: 'replaceSprite', id: string, file: File): void;
|
||||
(e: 'addSprite', file: File): void;
|
||||
(e: 'addSpriteWithResize', file: File): void;
|
||||
(e: 'rotateSprite', id: string, angle: number): void;
|
||||
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||
}>();
|
||||
|
||||
// Get settings from store
|
||||
@@ -337,9 +364,17 @@
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
const contextMenuSpriteId = ref<string | null>(null);
|
||||
const selectedSpriteIds = ref<Set<string>>(new Set());
|
||||
const replacingSpriteId = ref<string | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const customColor = ref('#ffffff');
|
||||
const isMultiSelectMode = ref(false);
|
||||
const showActiveBorder = ref(true);
|
||||
|
||||
// Clear selection when toggling multi-select mode
|
||||
watch(isMultiSelectMode, () => {
|
||||
selectedSpriteIds.value.clear();
|
||||
});
|
||||
|
||||
// Grid metrics
|
||||
const gridMetrics = computed(() => calculateMaxDimensions());
|
||||
@@ -467,6 +502,18 @@
|
||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||
|
||||
if (clickedSprite) {
|
||||
// If the right-clicked sprite is not in the selection, clear selection and select just this one
|
||||
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
||||
selectedSpriteIds.value.clear();
|
||||
selectedSpriteIds.value.add(clickedSprite.id);
|
||||
}
|
||||
// If it IS in the selection, keep the current selection (so we can apply action to all)
|
||||
} else {
|
||||
// Right click on empty space
|
||||
selectedSpriteIds.value.clear();
|
||||
}
|
||||
|
||||
contextMenuX.value = event.clientX;
|
||||
contextMenuY.value = event.clientY;
|
||||
|
||||
@@ -477,6 +524,34 @@
|
||||
// Ignore non-left mouse buttons
|
||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||
|
||||
// Handle selection logic for left click
|
||||
const pos = getMousePosition(event, zoom.value);
|
||||
if (pos) {
|
||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||
if (clickedSprite) {
|
||||
// Selection logic with multi-select mode check
|
||||
if (event.ctrlKey || event.metaKey || isMultiSelectMode.value) {
|
||||
// Toggle selection
|
||||
if (selectedSpriteIds.value.has(clickedSprite.id)) {
|
||||
selectedSpriteIds.value.delete(clickedSprite.id);
|
||||
} else {
|
||||
selectedSpriteIds.value.add(clickedSprite.id);
|
||||
}
|
||||
} else {
|
||||
// Single select (but don't clear if dragging starts immediately?
|
||||
// Usually standard behavior is to clear others unless shift/ctrl held)
|
||||
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
|
||||
selectedSpriteIds.value.clear();
|
||||
selectedSpriteIds.value.add(clickedSprite.id);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Clicked on empty space
|
||||
selectedSpriteIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to composable for actual drag handling
|
||||
dragStart(event);
|
||||
};
|
||||
@@ -500,13 +575,52 @@
|
||||
};
|
||||
|
||||
const removeSprite = () => {
|
||||
if (contextMenuSpriteId.value) {
|
||||
if (selectedSpriteIds.value.size > 0) {
|
||||
emit('removeSprites', Array.from(selectedSpriteIds.value));
|
||||
selectedSpriteIds.value.clear();
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
} else if (contextMenuSpriteId.value) {
|
||||
emit('removeSprite', contextMenuSpriteId.value);
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const rotateSpriteInMenu = (angle: number) => {
|
||||
if (selectedSpriteIds.value.size > 0) {
|
||||
selectedSpriteIds.value.forEach(id => {
|
||||
emit('rotateSprite', id, angle);
|
||||
});
|
||||
} else if (contextMenuSpriteId.value) {
|
||||
emit('rotateSprite', contextMenuSpriteId.value, angle);
|
||||
}
|
||||
showContextMenu.value = false;
|
||||
};
|
||||
|
||||
const flipSpriteInMenu = (direction: 'horizontal' | 'vertical') => {
|
||||
if (selectedSpriteIds.value.size > 0) {
|
||||
selectedSpriteIds.value.forEach(id => {
|
||||
emit('flipSprite', id, direction);
|
||||
});
|
||||
} else if (contextMenuSpriteId.value) {
|
||||
emit('flipSprite', contextMenuSpriteId.value, direction);
|
||||
}
|
||||
showContextMenu.value = false;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
// Don't delete if editing text/input
|
||||
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (selectedSpriteIds.value.size > 0) {
|
||||
emit('removeSprites', Array.from(selectedSpriteIds.value));
|
||||
selectedSpriteIds.value.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const replaceSprite = () => {
|
||||
if (contextMenuSpriteId.value && fileInput.value) {
|
||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||
@@ -554,10 +668,12 @@
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
// Watch for background color changes
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
height: `${layer.sprites[i - 1].height}px`,
|
||||
opacity: '0.3',
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
transform: `rotate(${layer.sprites[i - 1].rotation || 0}deg) scale(${layer.sprites[i - 1].flipX ? -1 : 1}, ${layer.sprites[i - 1].flipY ? -1 : 1})`,
|
||||
}"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -62,6 +63,7 @@
|
||||
width: `${layer.sprites[currentFrameIndex].width}px`,
|
||||
height: `${layer.sprites[currentFrameIndex].height}px`,
|
||||
imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto',
|
||||
transform: `rotate(${layer.sprites[currentFrameIndex].rotation || 0}deg) scale(${layer.sprites[currentFrameIndex].flipX ? -1 : 1}, ${layer.sprites[currentFrameIndex].flipY ? -1 : 1})`,
|
||||
}"
|
||||
@mousedown="startDrag($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||
@touchstart="handleTouchStart($event, layer.sprites[currentFrameIndex], layer.id)"
|
||||
@@ -213,7 +215,7 @@
|
||||
@change="toggleHiddenFrame(index)"
|
||||
/>
|
||||
<div class="w-8 h-8 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded flex items-center justify-center overflow-hidden flex-shrink-0 shadow-sm">
|
||||
<img :src="sprite.url" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
<img :src="sprite.url" class="max-w-full max-h-full object-contain" :style="{ ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}), transform: `rotate(${sprite.rotation || 0}deg) scale(${sprite.flipX ? -1 : 1}, ${sprite.flipY ? -1 : 1})` }" />
|
||||
</div>
|
||||
<span class="text-xs font-mono text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Frame {{ index + 1 }}</span>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,18 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
const row = Math.floor(index / columns.value);
|
||||
const cellX = Math.floor(col * cellWidth);
|
||||
const cellY = Math.floor(row * cellHeight);
|
||||
ctx.drawImage(sprite.img, Math.floor(cellX + negativeSpacing + sprite.x), Math.floor(cellY + negativeSpacing + sprite.y));
|
||||
if (sprite.rotation || sprite.flipX || sprite.flipY) {
|
||||
ctx.save();
|
||||
const centerX = Math.floor(cellX + negativeSpacing + sprite.x + sprite.width / 2);
|
||||
const centerY = Math.floor(cellY + negativeSpacing + sprite.y + sprite.height / 2);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
||||
ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
|
||||
ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(sprite.img, Math.floor(cellX + negativeSpacing + sprite.x), Math.floor(cellY + negativeSpacing + sprite.y));
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
@@ -77,7 +88,15 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
if (!ctx) return null;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
if (sprite.rotation) {
|
||||
ctx.save();
|
||||
ctx.translate(sprite.width / 2, sprite.height / 2);
|
||||
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
||||
ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
}
|
||||
const base64Data = canvas.toDataURL('image/png');
|
||||
return {
|
||||
id: sprite.id,
|
||||
@@ -85,6 +104,9 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
height: sprite.height,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
rotation: sprite.rotation || 0,
|
||||
flipX: sprite.flipX || false,
|
||||
flipY: sprite.flipY || false,
|
||||
base64: base64Data,
|
||||
name: sprite.file.name,
|
||||
};
|
||||
@@ -156,6 +178,9 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
height: spriteData.height,
|
||||
x: spriteData.x || 0,
|
||||
y: spriteData.y || 0,
|
||||
rotation: spriteData.rotation || 0,
|
||||
flipX: spriteData.flipX || false,
|
||||
flipY: spriteData.flipY || false,
|
||||
});
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
@@ -189,7 +214,17 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
if (sprite.rotation) {
|
||||
ctx.save();
|
||||
const centerX = Math.floor(negativeSpacing + sprite.x + sprite.width / 2);
|
||||
const centerY = Math.floor(negativeSpacing + sprite.y + sprite.height / 2);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
||||
ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
}
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
|
||||
@@ -227,7 +262,17 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
if (sprite.rotation) {
|
||||
ctx.save();
|
||||
const centerX = Math.floor(negativeSpacing + sprite.x + sprite.width / 2);
|
||||
const centerY = Math.floor(negativeSpacing + sprite.y + sprite.height / 2);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
||||
ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
}
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
const buf = new ArrayBuffer(binary.length);
|
||||
|
||||
@@ -42,7 +42,19 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
vLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (!sprite) return;
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
|
||||
if (sprite.rotation || sprite.flipX || sprite.flipY) {
|
||||
ctx.save();
|
||||
const centerX = Math.floor(negativeSpacing + sprite.x + sprite.width / 2);
|
||||
const centerY = Math.floor(negativeSpacing + sprite.y + sprite.height / 2);
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
||||
ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
|
||||
ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -110,7 +122,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
canvas.height = sprite.height;
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
return { id: sprite.id, width: sprite.width, height: sprite.height, x: sprite.x, y: sprite.y, base64, name: sprite.file.name };
|
||||
return { id: sprite.id, width: sprite.width, height: sprite.height, x: sprite.x, y: sprite.y, rotation: sprite.rotation, flipX: sprite.flipX, flipY: sprite.flipY, base64, name: sprite.file.name };
|
||||
})
|
||||
);
|
||||
return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: sprites.filter(Boolean) };
|
||||
@@ -153,7 +165,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||
const file = new File([blob], fileName, { type: mimeType });
|
||||
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0 });
|
||||
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0, rotation: spriteData.rotation || 0, flipX: spriteData.flipX || false, flipY: spriteData.flipY || false });
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
@@ -273,7 +285,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: layer.locked,
|
||||
sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, name: s.file.name }))),
|
||||
sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, rotation: s.rotation, flipX: s.flipX, flipY: s.flipY, name: s.file.name }))),
|
||||
}))
|
||||
);
|
||||
const meta = {
|
||||
|
||||
@@ -130,6 +130,49 @@ export const useLayers = () => {
|
||||
l.sprites.splice(i, 1);
|
||||
};
|
||||
|
||||
const removeSprites = (ids: string[]) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
|
||||
// Sort indices in descending order to avoid shift issues when splicing
|
||||
const indicesToRemove: number[] = [];
|
||||
ids.forEach(id => {
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i !== -1) indicesToRemove.push(i);
|
||||
});
|
||||
|
||||
indicesToRemove.sort((a, b) => b - a);
|
||||
|
||||
indicesToRemove.forEach(i => {
|
||||
const s = l.sprites[i];
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
l.sprites.splice(i, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const rotateSprite = (id: string, angle: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const s = l.sprites.find(s => s.id === id);
|
||||
if (s) {
|
||||
s.rotation = (s.rotation + angle) % 360;
|
||||
}
|
||||
};
|
||||
|
||||
const flipSprite = (id: string, direction: 'horizontal' | 'vertical') => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const s = l.sprites.find(s => s.id === id);
|
||||
if (s) {
|
||||
if (direction === 'horizontal') s.flipX = !s.flipX;
|
||||
if (direction === 'vertical') s.flipY = !s.flipY;
|
||||
}
|
||||
};
|
||||
|
||||
const replaceSprite = (id: string, file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
@@ -147,7 +190,7 @@ export const useLayers = () => {
|
||||
const url = e.target?.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y };
|
||||
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y, rotation: old.rotation, flipX: old.flipX || false, flipY: old.flipY || false };
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load replacement image:', file.name);
|
||||
@@ -177,6 +220,9 @@ export const useLayers = () => {
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
};
|
||||
l.sprites = [...l.sprites, next];
|
||||
};
|
||||
@@ -233,6 +279,9 @@ export const useLayers = () => {
|
||||
updateSpriteInLayer,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
removeSprites,
|
||||
rotateSprite,
|
||||
flipSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
processImageFiles,
|
||||
|
||||
@@ -48,7 +48,16 @@ export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<num
|
||||
if (!ctx) return null;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
if (sprite.rotation || sprite.flipX || sprite.flipY) {
|
||||
ctx.save();
|
||||
ctx.translate(sprite.width / 2, sprite.height / 2);
|
||||
ctx.rotate((sprite.rotation * Math.PI) / 180);
|
||||
ctx.scale(sprite.flipX ? -1 : 1, sprite.flipY ? -1 : 1);
|
||||
ctx.drawImage(sprite.img, -sprite.width / 2, -sprite.height / 2);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
}
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
return {
|
||||
id: sprite.id,
|
||||
@@ -56,6 +65,9 @@ export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<num
|
||||
height: sprite.height,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
rotation: sprite.rotation || 0,
|
||||
flipX: sprite.flipX || false,
|
||||
flipY: sprite.flipY || false,
|
||||
base64,
|
||||
name: sprite.file.name,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface Sprite {
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
}
|
||||
|
||||
export interface SpriteFile {
|
||||
|
||||
@@ -252,7 +252,19 @@
|
||||
<!-- Tab Content -->
|
||||
<div class="p-6 lg:flex-1 lg:overflow-auto lg:min-h-0">
|
||||
<div v-if="activeTab === 'canvas'" class="h-full">
|
||||
<sprite-canvas :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" />
|
||||
<sprite-canvas
|
||||
:layers="layers"
|
||||
:active-layer-id="activeLayerId"
|
||||
:columns="columns"
|
||||
@update-sprite="updateSpritePosition"
|
||||
@update-sprite-cell="updateSpriteCell"
|
||||
@remove-sprite="removeSprite"
|
||||
@remove-sprites="removeSprites"
|
||||
@replace-sprite="replaceSprite"
|
||||
@add-sprite="addSprite"
|
||||
@rotate-sprite="rotateSprite"
|
||||
@flip-sprite="flipSprite"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="activeTab === 'preview'" class="h-full">
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
|
||||
@@ -288,7 +300,7 @@
|
||||
useHomeViewSEO();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, removeSprites, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer, rotateSprite, flipSprite } = useLayers();
|
||||
|
||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
|
||||
layers,
|
||||
|
||||
Reference in New Issue
Block a user