Compare commits

3 Commits

Author SHA1 Message Date
f8b4e98f9c [FEAT] Allow to adjust cell size 2025-11-22 21:33:01 +01:00
69fc4c4a7e [FEAT] BG color enhancements 2025-11-22 18:31:06 +01:00
d35ae69265 [FEAT] Fix incorrect offset val. label placement 2025-11-22 18:09:32 +01:00
8 changed files with 332 additions and 58 deletions

View File

@@ -66,8 +66,30 @@
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span class="text-gray-700 dark:text-gray-200 font-medium">Cell size:</span> <label class="text-gray-700 dark:text-gray-200 font-medium flex items-center gap-1">
<span class="text-gray-600 dark:text-gray-300">{{ cellSize.width }} × {{ cellSize.height }}px</span> <input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="w-4 h-4" />
Cell size:
</label>
<input
v-if="settingsStore.manualCellSizeEnabled"
type="number"
v-model.number="settingsStore.manualCellWidth"
min="1"
max="2048"
class="w-20 px-2 py-1 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all text-sm"
placeholder="Width"
/>
<span v-if="settingsStore.manualCellSizeEnabled" class="text-gray-600 dark:text-gray-300">×</span>
<input
v-if="settingsStore.manualCellSizeEnabled"
type="number"
v-model.number="settingsStore.manualCellHeight"
min="1"
max="2048"
class="w-20 px-2 py-1 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all text-sm"
placeholder="Height"
/>
<span v-if="!settingsStore.manualCellSizeEnabled" class="text-gray-600 dark:text-gray-300">{{ cellSize.width }} × {{ cellSize.height }}px</span>
</div> </div>
<!-- Add mass position buttons --> <!-- Add mass position buttons -->
@@ -183,10 +205,26 @@
const settingsStore = useSettingsStore(); 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'), activeLayerId, toRef(settingsStore, 'backgroundColor')); const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
const cellSize = computed(() => { const cellSize = computed(() => {
if (!layers.value.length) return { width: 0, height: 0 }; if (!layers.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 { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites); const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled); const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);

View File

@@ -26,14 +26,14 @@
<!-- Background color picker --> <!-- Background color picker -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label for="bg-color" class="dark:text-gray-200 text-sm sm:text-base">Background:</label> <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"> <select id="bg-color" v-model="bgSelectValue" 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="transparent">Transparent</option>
<option value="#ffffff">White</option> <option value="#ffffff">White</option>
<option value="#000000">Black</option> <option value="#000000">Black</option>
<option value="#f9fafb">Light Gray</option> <option value="#f9fafb">Light Gray</option>
<option value="custom">Custom...</option> <option value="custom">Custom...</option>
</select> </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" /> <input v-if="bgSelectValue === '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> </div>
</div> </div>
@@ -79,8 +79,9 @@
:key="position.id" :key="position.id"
class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm" class="absolute text-[23px] leading-none font-mono text-cyan-600 dark:text-cyan-400 bg-white/90 dark:bg-gray-900/90 px-1 py-0.5 rounded-sm"
:style="{ :style="{
left: `calc(${(position.cellX / canvasRef.width) * 100}% + ${(position.maxWidth / canvasRef.width) * 100}% - 2px)`, // Use the global cell size so labels line up with the actual grid cells
top: `calc(${(position.cellY / canvasRef.height) * 100}% + ${(position.maxHeight / canvasRef.height) * 100}% - 2px)`, left: `calc(${(position.cellX / canvasWidth) * 100}% + ${(gridMetrics.maxWidth / canvasWidth) * 100}% - 2px)`,
top: `calc(${(position.cellY / canvasHeight) * 100}% + ${(gridMetrics.maxHeight / canvasHeight) * 100}% - 2px)`,
transform: 'translate(-100%, -100%)', transform: 'translate(-100%, -100%)',
}" }"
> >
@@ -112,7 +113,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, toRef, computed } from 'vue'; import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore'; import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites'; import type { Sprite } from '@/types/sprites';
import { useCanvas2D } from '@/composables/useCanvas2D'; import { useCanvas2D } from '@/composables/useCanvas2D';
@@ -189,6 +190,9 @@
zoom, zoom,
allowCellSwap, allowCellSwap,
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'), negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z), getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y), onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex), onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
@@ -212,6 +216,76 @@
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const customColor = ref('#ffffff'); const customColor = ref('#ffffff');
// Background select handling: keep the select stable on "custom"
const presetBgColors = ['transparent', '#ffffff', '#000000', '#f9fafb'] as const;
const isHexColor = (val: string) => /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(val);
// Ensure customColor mirrors the current stored custom color (only when needed)
if (isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any)) {
customColor.value = settingsStore.backgroundColor;
}
// Track if user is in custom mode to keep picker visible
// Initialize to true if current color is custom
const isCustomMode = ref(isHexColor(settingsStore.backgroundColor) && !presetBgColors.includes(settingsStore.backgroundColor as any));
const bgSelectValue = computed<string>({
get() {
// If user explicitly entered custom mode, stay in it regardless of color value
if (isCustomMode.value) {
// Keep color picker synced
const val = settingsStore.backgroundColor;
if (isHexColor(val)) {
customColor.value = val;
}
return 'custom';
}
const val = settingsStore.backgroundColor;
if (presetBgColors.includes(val as any)) return val;
if (isHexColor(val)) {
// Keep the color picker open and sync its swatch
customColor.value = val;
isCustomMode.value = true; // Auto-enable custom mode for non-preset hex colors
return 'custom';
}
// Fallback
return 'transparent';
},
set(v: string) {
if (v === 'custom') {
isCustomMode.value = true;
// Switch UI to custom mode but keep the stored value as a color
const fallback = '#ffffff';
const current = settingsStore.backgroundColor;
const fromStore = isHexColor(current) ? current : null;
const fromLocal = isHexColor(customColor.value) ? customColor.value : null;
const color = fromStore || fromLocal || fallback;
customColor.value = color;
settingsStore.setBackgroundColor(color);
} else {
isCustomMode.value = false;
settingsStore.setBackgroundColor(v);
}
},
});
// Ensure canvas redraw and UI flush after background changes
watch(
() => settingsStore.backgroundColor,
async () => {
await nextTick();
requestDraw();
}
);
// Grid metrics used to position offset labels relative to cell size
const gridMetrics = computed(() => calculateMaxDimensions());
// Reactive canvas dimensions to ensure label positions update when canvas size changes
const canvasWidth = ref(0);
const canvasHeight = ref(0);
const startDrag = (event: MouseEvent) => { const startDrag = (event: MouseEvent) => {
if (!canvasRef.value) return; if (!canvasRef.value) return;
@@ -311,7 +385,12 @@
// Set canvas size // Set canvas size
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1))); const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
const rows = Math.max(1, Math.ceil(maxLen / props.columns)); const rows = Math.max(1, Math.ceil(maxLen / props.columns));
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows); const newCanvasWidth = maxWidth * props.columns;
const newCanvasHeight = maxHeight * rows;
canvas2D.setCanvasSize(newCanvasWidth, newCanvasHeight);
// Update reactive dimensions for template-driven elements (like labels)
if (canvasWidth.value !== newCanvasWidth) canvasWidth.value = newCanvasWidth;
if (canvasHeight.value !== newCanvasHeight) canvasHeight.value = newCanvasHeight;
// Clear canvas // Clear canvas
canvas2D.clear(); canvas2D.clear();
@@ -437,6 +516,9 @@
watch(() => settingsStore.darkMode, requestDraw); watch(() => settingsStore.darkMode, requestDraw);
watch(() => settingsStore.negativeSpacingEnabled, requestDraw); watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
watch(() => settingsStore.backgroundColor, requestDraw); watch(() => settingsStore.backgroundColor, requestDraw);
watch(() => settingsStore.manualCellSizeEnabled, requestDraw);
watch(() => settingsStore.manualCellWidth, requestDraw);
watch(() => settingsStore.manualCellHeight, requestDraw);
watch(showAllSprites, requestDraw); watch(showAllSprites, requestDraw);
</script> </script>

View File

@@ -239,16 +239,34 @@
// Canvas drawing // Canvas drawing
const getCellDimensions = () => {
const visibleLayers = getVisibleLayers();
// If manual cell size is enabled, use manual values
if (settingsStore.manualCellSizeEnabled) {
return {
cellWidth: settingsStore.manualCellWidth,
cellHeight: settingsStore.manualCellHeight,
negativeSpacing: 0,
};
}
// Otherwise, calculate from sprite dimensions
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
const allSprites = visibleLayers.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return {
cellWidth: maxWidth + negativeSpacing,
cellHeight: maxHeight + negativeSpacing,
negativeSpacing,
};
};
function drawPreviewCanvas() { function drawPreviewCanvas() {
if (!previewCanvasRef.value || !canvas2D.ctx.value) return; if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
const visibleLayers = getVisibleLayers(); const visibleLayers = getVisibleLayers();
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return; if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const allSprites = visibleLayers.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
// Apply pixel art optimization // Apply pixel art optimization
canvas2D.applySmoothing(); canvas2D.applySmoothing();
@@ -298,9 +316,7 @@
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY; const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value]; const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
const vLayers = getVisibleLayers(); const { negativeSpacing } = getCellDimensions();
const allSprites = vLayers.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
// Check if click is on sprite (accounting for negative spacing offset) // Check if click is on sprite (accounting for negative spacing offset)
if (activeSprite) { if (activeSprite) {
@@ -332,12 +348,7 @@
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value]; const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return; if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
const vLayers = getVisibleLayers(); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(vLayers);
const allSprites = vLayers.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
// Calculate new position with constraints and round to integers // Calculate new position with constraints and round to integers
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX); let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
@@ -417,6 +428,9 @@
watch(hiddenFrames, drawPreviewCanvas); watch(hiddenFrames, drawPreviewCanvas);
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas); watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas); watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
watch(() => settingsStore.manualCellSizeEnabled, drawPreviewCanvas);
watch(() => settingsStore.manualCellWidth, drawPreviewCanvas);
watch(() => settingsStore.manualCellHeight, drawPreviewCanvas);
// Initial draw // Initial draw
if (props.layers.some(l => l.sprites.length > 0)) { if (props.layers.some(l => l.sprites.length > 0)) {

View File

@@ -32,6 +32,9 @@ export interface DragSpriteOptions {
zoom?: Ref<number>; zoom?: Ref<number>;
allowCellSwap?: Ref<boolean>; allowCellSwap?: Ref<boolean>;
negativeSpacingEnabled?: Ref<boolean>; negativeSpacingEnabled?: Ref<boolean>;
manualCellSizeEnabled?: Ref<boolean>;
manualCellWidth?: Ref<number>;
manualCellHeight?: Ref<number>;
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null; getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
onUpdateSprite: (id: string, x: number, y: number) => void; onUpdateSprite: (id: string, x: number, y: number) => void;
onUpdateSpriteCell?: (id: string, newIndex: number) => void; onUpdateSpriteCell?: (id: string, newIndex: number) => void;
@@ -47,6 +50,9 @@ export function useDragSprite(options: DragSpriteOptions) {
const getZoom = () => options.zoom?.value ?? 1; const getZoom = () => options.zoom?.value ?? 1;
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false; const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false; const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
// Drag state // Drag state
const isDragging = ref(false); const isDragging = ref(false);
@@ -69,9 +75,23 @@ export function useDragSprite(options: DragSpriteOptions) {
const calculateMaxDimensions = () => { const calculateMaxDimensions = () => {
const sprites = getSprites(); const sprites = getSprites();
const negativeSpacingEnabled = getNegativeSpacingEnabled(); const negativeSpacingEnabled = getNegativeSpacingEnabled();
const manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
const maxWidth = getManualCellWidth();
const maxHeight = getManualCellHeight();
// When manual cell size is used, negative spacing is not applied
const negativeSpacing = 0;
// Don't update lastMaxWidth/lastMaxHeight when in manual mode
return { maxWidth, maxHeight, negativeSpacing };
}
// Otherwise, calculate based on sprite dimensions
const base = getMaxDimensions(sprites); const base = getMaxDimensions(sprites);
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value); // When switching back from manual mode, reset to actual sprite dimensions
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value); const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
lastMaxWidth.value = baseMaxWidth; lastMaxWidth.value = baseMaxWidth;
lastMaxHeight.value = baseMaxHeight; lastMaxHeight.value = baseMaxHeight;

View File

@@ -6,17 +6,34 @@ import type { Sprite } from '../types/sprites';
import { getMaxDimensions } from './useSprites'; import { getMaxDimensions } from './useSprites';
import { calculateNegativeSpacing } from './useNegativeSpacing'; import { calculateNegativeSpacing } from './useNegativeSpacing';
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => { export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
const getCellDimensions = () => {
// If manual cell size is enabled, use manual values
if (manualCellSizeEnabled?.value) {
return {
cellWidth: manualCellWidth?.value ?? 64,
cellHeight: manualCellHeight?.value ?? 64,
negativeSpacing: 0,
};
}
// Otherwise, calculate from sprite dimensions
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
return {
cellWidth: maxWidth + negativeSpacing,
cellHeight: maxHeight + negativeSpacing,
negativeSpacing,
};
};
const downloadSpritesheet = () => { const downloadSpritesheet = () => {
if (!sprites.value.length) { if (!sprites.value.length) {
alert('Please upload or import sprites before downloading the spritesheet.'); alert('Please upload or import sprites before downloading the spritesheet.');
return; return;
} }
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const rows = Math.ceil(sprites.value.length / columns.value); const rows = Math.ceil(sprites.value.length / columns.value);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -27,6 +44,12 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
canvas.height = cellHeight * rows; canvas.height = cellHeight * rows;
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
sprites.value.forEach((sprite, index) => { sprites.value.forEach((sprite, index) => {
const col = index % columns.value; const col = index % columns.value;
const row = Math.floor(index / columns.value); const row = Math.floor(index / columns.value);
@@ -71,6 +94,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
const jsonData = { const jsonData = {
columns: columns.value, columns: columns.value,
negativeSpacingEnabled: negativeSpacingEnabled.value, negativeSpacingEnabled: negativeSpacingEnabled.value,
backgroundColor: backgroundColor?.value || 'transparent',
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
manualCellWidth: manualCellWidth?.value || 64,
manualCellHeight: manualCellHeight?.value || 64,
sprites: spritesData.filter(Boolean), sprites: spritesData.filter(Boolean),
}; };
const jsonString = JSON.stringify(jsonData, null, 2); const jsonString = JSON.stringify(jsonData, null, 2);
@@ -91,6 +118,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns; if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled; if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled;
if (typeof jsonData.backgroundColor === 'string' && backgroundColor) backgroundColor.value = jsonData.backgroundColor;
if (typeof jsonData.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = jsonData.manualCellSizeEnabled;
if (typeof jsonData.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = jsonData.manualCellWidth;
if (typeof jsonData.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = jsonData.manualCellHeight;
// revoke existing blob urls // revoke existing blob urls
if (sprites.value.length) { if (sprites.value.length) {
@@ -141,10 +172,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
return; return;
} }
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
@@ -156,6 +184,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach(sprite => { sprites.value.forEach(sprite => {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
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)); ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
gif.addFrame(ctx, { copy: true, delay: 1000 / fps }); gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
}); });
@@ -179,10 +212,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
} }
const zip = new JSZip(); const zip = new JSZip();
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
@@ -192,6 +222,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach((sprite, index) => { sprites.value.forEach((sprite, index) => {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
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)); ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
const dataURL = canvas.toDataURL('image/png'); const dataURL = canvas.toDataURL('image/png');
const binary = atob(dataURL.split(',')[1]); const binary = atob(dataURL.split(',')[1]);

View File

@@ -6,10 +6,31 @@ import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensionsAcrossLayers } from './useLayers'; import { getMaxDimensionsAcrossLayers } from './useLayers';
import { calculateNegativeSpacing } from './useNegativeSpacing'; import { calculateNegativeSpacing } from './useNegativeSpacing';
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>) => { export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
const getVisibleLayers = () => layersRef.value.filter(l => l.visible); const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites); const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
const getCellDimensions = () => {
// If manual cell size is enabled, use manual values
if (manualCellSizeEnabled?.value) {
return {
cellWidth: manualCellWidth?.value ?? 64,
cellHeight: manualCellHeight?.value ?? 64,
negativeSpacing: 0,
};
}
// Otherwise, calculate from sprite dimensions
const visibleLayers = getVisibleLayers();
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
return {
cellWidth: maxWidth + negativeSpacing,
cellHeight: maxHeight + negativeSpacing,
negativeSpacing,
};
};
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => { const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
ctx.clearRect(0, 0, cellWidth, cellHeight); ctx.clearRect(0, 0, cellWidth, cellHeight);
// Apply background color if not transparent // Apply background color if not transparent
@@ -32,10 +53,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
return; return;
} }
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length)); const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
const rows = Math.ceil(maxLen / columns.value); const rows = Math.ceil(maxLen / columns.value);
@@ -46,6 +64,12 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
canvas.height = cellHeight * rows; canvas.height = cellHeight * rows;
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
// Apply background color to entire canvas if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
for (let index = 0; index < maxLen; index++) { for (let index = 0; index < maxLen; index++) {
const col = index % columns.value; const col = index % columns.value;
const row = Math.floor(index / columns.value); const row = Math.floor(index / columns.value);
@@ -57,6 +81,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
if (!cellCtx) return; if (!cellCtx) return;
cellCanvas.width = cellWidth; cellCanvas.width = cellWidth;
cellCanvas.height = cellHeight; cellCanvas.height = cellHeight;
cellCtx.imageSmoothingEnabled = false;
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing); drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
ctx.drawImage(cellCanvas, cellX, cellY); ctx.drawImage(cellCanvas, cellX, cellY);
} }
@@ -92,7 +117,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
}) })
); );
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData }; const json = {
version: 2,
columns: columns.value,
negativeSpacingEnabled: negativeSpacingEnabled.value,
backgroundColor: backgroundColor?.value || 'transparent',
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
manualCellWidth: manualCellWidth?.value || 64,
manualCellHeight: manualCellHeight?.value || 64,
layers: layersData,
};
const jsonString = JSON.stringify(json, null, 2); const jsonString = JSON.stringify(json, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' }); const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -126,6 +160,10 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
if (typeof data.columns === 'number') columns.value = data.columns; if (typeof data.columns === 'number') columns.value = data.columns;
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled; if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
if (typeof data.backgroundColor === 'string' && backgroundColor) backgroundColor.value = data.backgroundColor;
if (typeof data.manualCellSizeEnabled === 'boolean' && manualCellSizeEnabled) manualCellSizeEnabled.value = data.manualCellSizeEnabled;
if (typeof data.manualCellWidth === 'number' && manualCellWidth) manualCellWidth.value = data.manualCellWidth;
if (typeof data.manualCellHeight === 'number' && manualCellHeight) manualCellHeight.value = data.manualCellHeight;
if (Array.isArray(data.layers)) { if (Array.isArray(data.layers)) {
const newLayers: Layer[] = []; const newLayers: Layer[] = [];
@@ -172,10 +210,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
return; return;
} }
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -210,10 +245,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
} }
const zip = new JSZip(); const zip = new JSZip();
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers); const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -244,7 +276,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
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 }; const meta = {
version: 2,
columns: columns.value,
negativeSpacingEnabled: negativeSpacingEnabled.value,
backgroundColor: backgroundColor?.value || 'transparent',
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
manualCellWidth: manualCellWidth?.value || 64,
manualCellHeight: manualCellHeight?.value || 64,
layers: layersPayload,
};
const metaStr = JSON.stringify(meta, null, 2); const metaStr = JSON.stringify(meta, null, 2);
jsonFolder.file('spritesheet.meta.json', metaStr); jsonFolder.file('spritesheet.meta.json', metaStr);
})(); })();

View File

@@ -1,6 +1,7 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { Layer, Sprite } from '@/types/sprites'; import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites'; import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
import { useSettingsStore } from '@/stores/useSettingsStore';
export const createEmptyLayer = (name: string): Layer => ({ export const createEmptyLayer = (name: string): Layer => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -14,6 +15,7 @@ export const useLayers = () => {
const layers = ref<Layer[]>([createEmptyLayer('Base')]); const layers = ref<Layer[]>([createEmptyLayer('Base')]);
const activeLayerId = ref<string>(layers.value[0].id); const activeLayerId = ref<string>(layers.value[0].id);
const columns = ref(4); const columns = ref(4);
const settingsStore = useSettingsStore();
watch(columns, val => { watch(columns, val => {
const num = typeof val === 'number' ? val : parseInt(String(val)); const num = typeof val === 'number' ? val : parseInt(String(val));
@@ -38,7 +40,22 @@ export const useLayers = () => {
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => { const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
const l = activeLayer.value; const l = activeLayer.value;
if (!l || !l.sprites.length) return; if (!l || !l.sprites.length) return;
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
// Determine the cell dimensions to align within
let cellWidth: number;
let cellHeight: number;
if (settingsStore.manualCellSizeEnabled) {
// Use manual cell size (without negative spacing)
cellWidth = settingsStore.manualCellWidth;
cellHeight = settingsStore.manualCellHeight;
} else {
// Use auto-calculated dimensions based on sprite sizes
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
cellWidth = maxWidth;
cellHeight = maxHeight;
}
l.sprites = l.sprites.map(sprite => { l.sprites = l.sprites.map(sprite => {
let x = sprite.x; let x = sprite.x;
let y = sprite.y; let y = sprite.y;
@@ -47,19 +64,19 @@ export const useLayers = () => {
x = 0; x = 0;
break; break;
case 'center': case 'center':
x = Math.floor((maxWidth - sprite.width) / 2); x = Math.floor((cellWidth - sprite.width) / 2);
break; break;
case 'right': case 'right':
x = Math.floor(maxWidth - sprite.width); x = Math.floor(cellWidth - sprite.width);
break; break;
case 'top': case 'top':
y = 0; y = 0;
break; break;
case 'middle': case 'middle':
y = Math.floor((maxHeight - sprite.height) / 2); y = Math.floor((cellHeight - sprite.height) / 2);
break; break;
case 'bottom': case 'bottom':
y = Math.floor(maxHeight - sprite.height); y = Math.floor(cellHeight - sprite.height);
break; break;
} }
return { ...sprite, x: Math.floor(x), y: Math.floor(y) }; return { ...sprite, x: Math.floor(x), y: Math.floor(y) };

View File

@@ -5,6 +5,9 @@ const pixelPerfect = ref(true);
const darkMode = ref(false); const darkMode = ref(false);
const negativeSpacingEnabled = ref(false); const negativeSpacingEnabled = ref(false);
const backgroundColor = ref('transparent'); const backgroundColor = ref('transparent');
const manualCellSizeEnabled = ref(false);
const manualCellWidth = ref(64);
const manualCellHeight = ref(64);
// Initialize dark mode from localStorage or system preference // Initialize dark mode from localStorage or system preference
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -61,16 +64,40 @@ export const useSettingsStore = defineStore('settings', () => {
backgroundColor.value = color; backgroundColor.value = color;
} }
function toggleManualCellSize() {
manualCellSizeEnabled.value = !manualCellSizeEnabled.value;
}
function setManualCellWidth(width: number) {
manualCellWidth.value = Math.max(1, Math.floor(width));
}
function setManualCellHeight(height: number) {
manualCellHeight.value = Math.max(1, Math.floor(height));
}
function setManualCellSize(width: number, height: number) {
setManualCellWidth(width);
setManualCellHeight(height);
}
return { return {
pixelPerfect, pixelPerfect,
darkMode, darkMode,
negativeSpacingEnabled, negativeSpacingEnabled,
backgroundColor, backgroundColor,
manualCellSizeEnabled,
manualCellWidth,
manualCellHeight,
togglePixelPerfect, togglePixelPerfect,
setPixelPerfect, setPixelPerfect,
toggleDarkMode, toggleDarkMode,
setDarkMode, setDarkMode,
toggleNegativeSpacing, toggleNegativeSpacing,
setBackgroundColor, setBackgroundColor,
toggleManualCellSize,
setManualCellWidth,
setManualCellHeight,
setManualCellSize,
}; };
}); });