[FEAT] UX enhancements

This commit is contained in:
2025-11-23 03:40:26 +01:00
parent 2c25157621
commit 9a2bf6b2db
5 changed files with 135 additions and 68 deletions

View File

@@ -1,5 +1,9 @@
All notable changes to this project will be documented in this file.
## [1.8.0] - 2025-11-23
- Fix context menu location
- You can now reposition all sprites in current frame
## [1.7.0] - 2025-11-22
- Add layer support
- Add background color picker

View File

@@ -57,7 +57,6 @@
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
</div>
<file-uploader @upload-sprites="handleSpritesUpload" />
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
<div class="mt-10">
<div class="prose prose-lg dark:prose-invert max-w-none">
@@ -250,24 +249,14 @@
<!-- Right panel - Canvas Preview -->
<div class="p-6 overflow-y-auto overflow-x-auto max-h-[calc(100vh-200px)]">
<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"
@add-sprite-with-resize="addSpriteWithResize"
/>
<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" />
</div>
</div>
</main>
</div>
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation Preview">
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
</Modal>
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
@@ -311,7 +300,7 @@
import type { SpriteFile } from './types/sprites';
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
layers,
@@ -324,27 +313,27 @@
toRef(settingsStore, 'manualCellHeight')
);
const cellSize = computed(() => {
if (!layers.value.length) return { width: 0, height: 0 };
const getCellSize = () => {
if (!visibleLayers.value.length) return { width: 0, height: 0 };
// If manual cell size is enabled, use the manual values
if (settingsStore.manualCellSizeEnabled) {
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
}
// Otherwise, calculate based on max dimensions
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
});
};
const cellSize = computed(getCellSize);
const isPreviewModalOpen = ref(false);
const isHelpModalOpen = ref(false);
const isFeedbackModalOpen = ref(false);
const isSpritesheetSplitterOpen = ref(false);
const isGifFpsModalOpen = ref(false);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const uploadInput = ref<HTMLInputElement | null>(null);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const spritesheetImageUrl = ref('');
const spritesheetImageFile = ref<File | null>(null);
const showFeedbackPopup = ref(false);
@@ -356,12 +345,7 @@
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
await handleJSONImport(jsonFile);
return;
}
@@ -387,6 +371,15 @@
processImageFiles(files);
};
const handleJSONImport = async (jsonFile: File) => {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
const openPreviewModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites to preview an animation.');
@@ -447,14 +440,8 @@
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
const jsonFile = input.files[0];
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
if (jsonFileInput.value) jsonFileInput.value.value = '';
await handleJSONImport(input.files[0]);
input.value = '';
}
};
@@ -483,9 +470,8 @@
const handleUploadChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
const files = Array.from(input.files);
await handleSpritesUpload(files);
if (uploadInput.value) uploadInput.value.value = '';
await handleSpritesUpload(Array.from(input.files));
input.value = '';
}
};

View File

@@ -1,5 +1,5 @@
<template>
<div class="space-y-6">
<div class="space-y-6 relative">
<div class="bg-cyan-500 dark:bg-cyan-600 rounded-xl p-4 shadow-lg border border-cyan-400/50 dark:border-cyan-500/50">
<div class="flex items-start gap-3">
<i class="fas fa-lightbulb text-yellow-300 text-xl mt-0.5"></i>
@@ -104,7 +104,7 @@
</div>
</div>
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
<div v-if="showContextMenu" class="absolute bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
<button @click="addSprite" class="w-full px-5 py-3 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
<span>Add Sprite</span>
@@ -310,8 +310,17 @@
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
contextMenuSpriteId.value = clickedSprite?.id || null;
contextMenuX.value = event.clientX;
contextMenuY.value = event.clientY;
// Get the root component element to calculate offset
const rootElement = canvasRef.value.closest('.space-y-6') as HTMLElement;
if (!rootElement) return;
const rootRect = rootElement.getBoundingClientRect();
// Position relative to the component root
contextMenuX.value = event.clientX - rootRect.left;
contextMenuY.value = event.clientY - rootRect.top;
showContextMenu.value = true;
return;
}

View File

@@ -103,6 +103,11 @@
<span class="text-sm dark:text-gray-200">Reposition</span>
</label>
<label class="flex items-center gap-2 cursor-pointer ml-4" :class="{ 'opacity-50 cursor-not-allowed': !isDraggable }">
<input type="checkbox" v-model="repositionAllLayers" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" :disabled="!isDraggable" />
<span class="text-sm dark:text-gray-200">All layers</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="showAllSprites" class="w-4 h-4 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" />
<span class="text-sm dark:text-gray-200">Compare sprites</span>
@@ -170,6 +175,7 @@
const emit = defineEmits<{
(e: 'updateSprite', id: string, x: number, y: number): void;
(e: 'updateSpriteInLayer', layerId: string, spriteId: string, x: number, y: number): void;
}>();
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
@@ -207,6 +213,7 @@
// Preview state
const isDraggable = ref(false);
const repositionAllLayers = ref(false);
const showAllSprites = ref(false);
const compositeFrames = computed<Sprite[]>(() => {
@@ -238,6 +245,7 @@
const dragStartX = ref(0);
const dragStartY = ref(0);
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
// Canvas drawing
@@ -319,10 +327,37 @@
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
const { negativeSpacing } = getCellDimensions();
// Check if click is on sprite (accounting for negative spacing offset)
if (repositionAllLayers.value) {
// Check if click is on any sprite from any visible layer
const visibleLayers = getVisibleLayers();
for (const layer of visibleLayers) {
const sprite = layer.sprites[currentFrameIndex.value];
if (!sprite) continue;
const spriteCanvasX = negativeSpacing + sprite.x;
const spriteCanvasY = negativeSpacing + sprite.y;
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) {
isDragging.value = true;
activeSpriteId.value = 'ALL_LAYERS'; // Special marker for all layers
dragStartX.value = mouseX;
dragStartY.value = mouseY;
// Store initial positions for all sprites in this frame from all visible layers
allSpritesPosBeforeDrag.value.clear();
visibleLayers.forEach(layer => {
const s = layer.sprites[currentFrameIndex.value];
if (s) {
allSpritesPosBeforeDrag.value.set(s.id, { x: s.x, y: s.y });
}
});
return;
}
}
} else {
// Only check active layer sprite
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
if (activeSprite) {
const spriteCanvasX = negativeSpacing + activeSprite.x;
const spriteCanvasY = negativeSpacing + activeSprite.y;
@@ -334,6 +369,7 @@
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
}
}
}
};
const drag = (event: MouseEvent) => {
@@ -349,11 +385,34 @@
const deltaX = Math.round(mouseX - dragStartX.value);
const deltaY = Math.round(mouseY - dragStartY.value);
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
if (activeSpriteId.value === 'ALL_LAYERS') {
// Move all sprites in current frame from all visible layers
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
const sprite = layer.sprites[currentFrameIndex.value];
if (!sprite) return;
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
if (!originalPos) return;
// Calculate new position with constraints
let newX = Math.round(originalPos.x + deltaX);
let newY = Math.round(originalPos.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
});
drawPreviewCanvas();
} else {
// Move only the active layer sprite
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
// 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);
@@ -364,6 +423,7 @@
emit('updateSprite', activeSpriteId.value, newX, newY);
drawPreviewCanvas();
}
};
const stopDrag = () => {

View File

@@ -37,6 +37,16 @@ export const useLayers = () => {
}
};
const updateSpriteInLayer = (layerId: string, spriteId: string, x: number, y: number) => {
const l = layers.value.find(layer => layer.id === layerId);
if (!l) return;
const i = l.sprites.findIndex(s => s.id === spriteId);
if (i !== -1) {
l.sprites[i].x = Math.floor(x);
l.sprites[i].y = Math.floor(y);
}
};
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
const l = activeLayer.value;
if (!l || !l.sprites.length) return;
@@ -138,9 +148,7 @@ export const useLayers = () => {
img.src = url;
};
const addSprite = (file: File) => addSpriteWithResize(file);
const addSpriteWithResize = (file: File) => {
const addSprite = (file: File) => {
const l = activeLayer.value;
if (!l) return;
const url = URL.createObjectURL(file);
@@ -163,7 +171,7 @@ export const useLayers = () => {
};
const processImageFiles = async (files: File[]) => {
for (const f of files) addSpriteWithResize(f);
for (const f of files) addSprite(f);
};
const addLayer = (name?: string) => {
@@ -203,11 +211,11 @@ export const useLayers = () => {
columns,
getMaxDimensions,
updateSpritePosition,
updateSpriteInLayer,
updateSpriteCell,
removeSprite,
replaceSprite,
addSprite,
addSpriteWithResize,
processImageFiles,
alignSprites,
addLayer,