## [1.7.0] - 2025-11-22

- Add layer support
- Add background color picker
- Improved UI
This commit is contained in:
2025-11-22 03:19:19 +01:00
parent 474ddd3e27
commit aee07f23f2
9 changed files with 98 additions and 67 deletions

View File

@@ -1,5 +1,10 @@
All notable changes to this project will be documented in this file.
## [1.7.0] - 2025-11-22
- Add layer support
- Add background color picker
- Improved UI
## [1.6.0] - 2025-11-18
- Improved animation preview modal
- Add toggle for negative spacing in cells
@@ -34,30 +39,30 @@ All notable changes to this project will be documented in this file.
## [1.7.0] - 2025-05-02
### Removed
- 🪟 Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
- Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
## [1.6.0] - 2025-04-30
### Added
- 🎨 Dark mode support
- Preview other sprites inside cells from overview
- Dark mode support
- Preview other sprites inside cells from overview
## [1.5.0] - 2025-04-30
### Added
- 📏 Offset indicators for better sprite alignment
- Offset indicators for better sprite alignment
- Set base offset for offset indicators
## [1.4.0] - 2025-04-06
### Added
- 🎥 Download as GIF functionality
- 🗂 Download as ZIP functionality
- Download as GIF functionality
- Download as ZIP functionality
## [1.3.0] - 2025-04-06
### Fixed
- 📄 When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
- When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
## [1.2.0] - 2025-04-06
@@ -67,22 +72,22 @@ All notable changes to this project will be documented in this file.
## [1.1.0] - 2025-04-06
### Added
- 📝 Help modal with instructions and tips
- 🎨 Pixel perfect mode for better sprite alignment
- Help modal with instructions and tips
- Pixel perfect mode for better sprite alignment
## [1.0.0] - 2025-04-06
### Added
- 🎉 Initial release
- Basic spritesheet generation functionality
- Basic spritesheet generation functionality
- Drag and drop image upload
- Grid-based sprite arrangement
- Custom grid size configuration
- 🎮 Animation preview functionality
- Animation preview functionality
- Real-time animation preview
- Adjustable animation speed
- Frame-by-frame navigation
- 💾 JSON import/export support
- JSON import/export support
- Save sprite arrangements
- Load previous projects
- Export configuration files

View File

@@ -45,9 +45,7 @@
<div class="flex flex-wrap gap-2">
<div v-for="layer in layers" :key="layer.id" class="flex items-center gap-2 px-2 py-1 rounded border border-gray-200 dark:border-gray-600" :class="{ 'ring-2 ring-blue-500': layer.id === activeLayerId }">
<button @click="activeLayerId = layer.id" class="px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">{{ layer.name }}</button>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<input type="checkbox" v-model="layer.visible" /> Visible
</label>
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300"> <input type="checkbox" v-model="layer.visible" /> Visible </label>
<button @click="moveLayer(layer.id, 'up')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded"></button>
<button @click="moveLayer(layer.id, 'down')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded"></button>
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-200 rounded">Remove</button>
@@ -123,7 +121,17 @@
<span>Preview animation</span>
</button>
</div>
<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"
@add-sprite-with-resize="addSpriteWithResize"
/>
</div>
</div>
</div>
@@ -173,26 +181,9 @@
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, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'));
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), activeLayerId, toRef(settingsStore, 'backgroundColor'));
const cellSize = computed(() => {
if (!layers.value.length) return { width: 0, height: 0 };

View File

@@ -23,6 +23,18 @@
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
</div>
<!-- Background color picker -->
<div class="flex items-center gap-2">
<label for="bg-color" class="dark:text-gray-200 text-sm sm:text-base">Background:</label>
<select id="bg-color" v-model="settingsStore.backgroundColor" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 dark:text-gray-200 text-sm">
<option value="transparent">Transparent</option>
<option value="#ffffff">White</option>
<option value="#000000">Black</option>
<option value="#f9fafb">Light Gray</option>
<option value="custom">Custom...</option>
</select>
<input v-if="settingsStore.backgroundColor === 'custom'" type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="w-8 h-8 border border-gray-300 dark:border-gray-600 rounded cursor-pointer" />
</div>
</div>
</div>
@@ -172,7 +184,7 @@
findSpriteAtPosition,
calculateMaxDimensions,
} = useDragSprite({
sprites: computed(() => (props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? [])),
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
columns: toRef(props, 'columns'),
zoom,
allowCellSwap,
@@ -198,6 +210,7 @@
const contextMenuSpriteId = ref<string | null>(null);
const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const customColor = ref('#ffffff');
const startDrag = (event: MouseEvent) => {
if (!canvasRef.value) return;
@@ -423,6 +436,7 @@
watch(() => settingsStore.pixelPerfect, requestDraw);
watch(() => settingsStore.darkMode, requestDraw);
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
watch(() => settingsStore.backgroundColor, requestDraw);
watch(showAllSprites, requestDraw);
</script>

View File

@@ -116,9 +116,10 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
});
};
// Fill cell background with theme-aware color
// Fill cell background with selected color or transparent
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
if (settingsStore.backgroundColor === 'transparent') return;
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
fillRect(x, y, width, height, color);
};

View File

@@ -156,8 +156,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach(sprite => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, cellWidth, cellHeight);
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
});
@@ -194,8 +192,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach((sprite, index) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, cellWidth, cellHeight);
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]);

View File

@@ -6,14 +6,17 @@ import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensionsAcrossLayers } from './useLayers';
import { calculateNegativeSpacing } from './useNegativeSpacing';
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>) => {
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
ctx.clearRect(0, 0, cellWidth, cellHeight);
ctx.fillStyle = '#f9fafb';
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
ctx.fillRect(0, 0, cellWidth, cellHeight);
}
const vLayers = getVisibleLayers();
vLayers.forEach(layer => {
const sprite = layer.sprites[cellIndex];
@@ -130,16 +133,32 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites });
}
// Ensure at least one layer with sprites is visible
if (newLayers.length > 0 && !newLayers.some(l => l.visible && l.sprites.length > 0)) {
const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0);
if (firstLayerWithSprites) {
firstLayerWithSprites.visible = true;
}
}
layersRef.value = newLayers;
// Set active layer to the first layer with sprites
if (activeLayerId && newLayers.length > 0) {
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
}
return;
}
if (Array.isArray(data.sprites)) {
const sprites: Sprite[] = await Promise.all(data.sprites.map((s: any) => loadSprite(s)));
const baseLayerId = crypto.randomUUID();
layersRef.value = [
{ id: crypto.randomUUID(), name: 'Base', visible: true, locked: false, sprites },
{ id: crypto.randomUUID(), name: 'Clothes', visible: true, locked: false, sprites: [] },
{ id: baseLayerId, name: 'Base', visible: true, locked: false, sprites },
{ id: crypto.randomUUID(), name: 'Other', visible: true, locked: false, sprites: [] },
];
if (activeLayerId) {
activeLayerId.value = baseLayerId;
}
return;
}
@@ -222,9 +241,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, name: s.file.name }))),
}))
);
const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersPayload };

View File

@@ -200,6 +200,6 @@ export const useLayers = () => {
};
export const getMaxDimensionsAcrossLayers = (layers: Layer[]) => {
const sprites = layers.flatMap(l => l.visible ? l.sprites : []);
const sprites = layers.flatMap(l => (l.visible ? l.sprites : []));
return getMaxDimensionsSingle(sprites);
};

View File

@@ -4,6 +4,7 @@ import { ref, watch } from 'vue';
const pixelPerfect = ref(true);
const darkMode = ref(false);
const negativeSpacingEnabled = ref(false);
const backgroundColor = ref('transparent');
// Initialize dark mode from localStorage or system preference
if (typeof window !== 'undefined') {
@@ -56,14 +57,20 @@ export const useSettingsStore = defineStore('settings', () => {
negativeSpacingEnabled.value = !negativeSpacingEnabled.value;
}
function setBackgroundColor(color: string) {
backgroundColor.value = color;
}
return {
pixelPerfect,
darkMode,
negativeSpacingEnabled,
backgroundColor,
togglePixelPerfect,
setPixelPerfect,
toggleDarkMode,
setDarkMode,
toggleNegativeSpacing,
setBackgroundColor,
};
});