## [1.7.0] - 2025-11-22
- Add layer support - Add background color picker - Improved UI
This commit is contained in:
@@ -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
|
||||
- Drag and drop image upload
|
||||
- Grid-based sprite arrangement
|
||||
- Custom grid size configuration
|
||||
- 🎮 Animation preview functionality
|
||||
- Real-time animation preview
|
||||
- Adjustable animation speed
|
||||
- Frame-by-frame navigation
|
||||
- 💾 JSON import/export support
|
||||
- Save sprite arrangements
|
||||
- Load previous projects
|
||||
- Export configuration files
|
||||
- Basic spritesheet generation functionality
|
||||
- Drag and drop image upload
|
||||
- Grid-based sprite arrangement
|
||||
- Custom grid size configuration
|
||||
- Animation preview functionality
|
||||
- Real-time animation preview
|
||||
- Adjustable animation speed
|
||||
- Frame-by-frame navigation
|
||||
- JSON import/export support
|
||||
- Save sprite arrangements
|
||||
- Load previous projects
|
||||
- Export configuration files
|
||||
37
src/App.vue
37
src/App.vue
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user