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 class="flex items-center space-x-1">
<span class="text-gray-700 dark:text-gray-200 font-medium">Cell size:</span>
<span class="text-gray-600 dark:text-gray-300">{{ cellSize.width }} × {{ cellSize.height }}px</span>
<label class="text-gray-700 dark:text-gray-200 font-medium flex items-center gap-1">
<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>
<!-- Add mass position buttons -->
@@ -183,10 +205,26 @@
const settingsStore = useSettingsStore();
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(() => {
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 allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);

View File

@@ -26,14 +26,14 @@
<!-- 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">
<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="#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" />
<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>
@@ -79,8 +79,9 @@
: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"
:style="{
left: `calc(${(position.cellX / canvasRef.width) * 100}% + ${(position.maxWidth / canvasRef.width) * 100}% - 2px)`,
top: `calc(${(position.cellY / canvasRef.height) * 100}% + ${(position.maxHeight / canvasRef.height) * 100}% - 2px)`,
// Use the global cell size so labels line up with the actual grid cells
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%)',
}"
>
@@ -112,7 +113,7 @@
</template>
<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 type { Sprite } from '@/types/sprites';
import { useCanvas2D } from '@/composables/useCanvas2D';
@@ -189,6 +190,9 @@
zoom,
allowCellSwap,
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
@@ -212,6 +216,76 @@
const fileInput = ref<HTMLInputElement | null>(null);
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) => {
if (!canvasRef.value) return;
@@ -311,7 +385,12 @@
// Set canvas size
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));
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
canvas2D.clear();
@@ -437,6 +516,9 @@
watch(() => settingsStore.darkMode, requestDraw);
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
watch(() => settingsStore.backgroundColor, requestDraw);
watch(() => settingsStore.manualCellSizeEnabled, requestDraw);
watch(() => settingsStore.manualCellWidth, requestDraw);
watch(() => settingsStore.manualCellHeight, requestDraw);
watch(showAllSprites, requestDraw);
</script>

View File

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

View File

@@ -32,6 +32,9 @@ export interface DragSpriteOptions {
zoom?: Ref<number>;
allowCellSwap?: Ref<boolean>;
negativeSpacingEnabled?: Ref<boolean>;
manualCellSizeEnabled?: Ref<boolean>;
manualCellWidth?: Ref<number>;
manualCellHeight?: Ref<number>;
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
onUpdateSprite: (id: string, x: number, y: number) => void;
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
@@ -47,6 +50,9 @@ export function useDragSprite(options: DragSpriteOptions) {
const getZoom = () => options.zoom?.value ?? 1;
const getAllowCellSwap = () => options.allowCellSwap?.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
const isDragging = ref(false);
@@ -69,9 +75,23 @@ export function useDragSprite(options: DragSpriteOptions) {
const calculateMaxDimensions = () => {
const sprites = getSprites();
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 baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
// When switching back from manual mode, reset to actual sprite dimensions
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
lastMaxWidth.value = baseMaxWidth;
lastMaxHeight.value = baseMaxHeight;

View File

@@ -6,17 +6,34 @@ import type { Sprite } from '../types/sprites';
import { getMaxDimensions } from './useSprites';
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 = () => {
if (!sprites.value.length) {
alert('Please upload or import sprites before downloading the spritesheet.');
return;
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const rows = Math.ceil(sprites.value.length / columns.value);
const canvas = document.createElement('canvas');
@@ -27,6 +44,12 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
canvas.height = cellHeight * rows;
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) => {
const col = 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 = {
columns: columns.value,
negativeSpacingEnabled: negativeSpacingEnabled.value,
backgroundColor: backgroundColor?.value || 'transparent',
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
manualCellWidth: manualCellWidth?.value || 64,
manualCellHeight: manualCellHeight?.value || 64,
sprites: spritesData.filter(Boolean),
};
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 (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
if (sprites.value.length) {
@@ -141,10 +172,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
return;
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
@@ -156,6 +184,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach(sprite => {
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));
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 { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
@@ -192,6 +222,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach((sprite, index) => {
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));
const dataURL = canvas.toDataURL('image/png');
const binary = atob(dataURL.split(',')[1]);

View File

@@ -6,10 +6,31 @@ 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>, 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 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) => {
ctx.clearRect(0, 0, cellWidth, cellHeight);
// Apply background color if not transparent
@@ -32,10 +53,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
return;
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
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;
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++) {
const col = 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;
cellCanvas.width = cellWidth;
cellCanvas.height = cellHeight;
cellCtx.imageSmoothingEnabled = false;
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
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 blob = new Blob([jsonString], { type: 'application/json' });
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.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)) {
const newLayers: Layer[] = [];
@@ -172,10 +210,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
return;
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@@ -210,10 +245,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
}
const zip = new JSZip();
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
const canvas = document.createElement('canvas');
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 }))),
}))
);
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);
jsonFolder.file('spritesheet.meta.json', metaStr);
})();

View File

@@ -1,6 +1,7 @@
import { computed, ref, watch } from 'vue';
import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
import { useSettingsStore } from '@/stores/useSettingsStore';
export const createEmptyLayer = (name: string): Layer => ({
id: crypto.randomUUID(),
@@ -14,6 +15,7 @@ export const useLayers = () => {
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
const activeLayerId = ref<string>(layers.value[0].id);
const columns = ref(4);
const settingsStore = useSettingsStore();
watch(columns, 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 l = activeLayer.value;
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 => {
let x = sprite.x;
let y = sprite.y;
@@ -47,19 +64,19 @@ export const useLayers = () => {
x = 0;
break;
case 'center':
x = Math.floor((maxWidth - sprite.width) / 2);
x = Math.floor((cellWidth - sprite.width) / 2);
break;
case 'right':
x = Math.floor(maxWidth - sprite.width);
x = Math.floor(cellWidth - sprite.width);
break;
case 'top':
y = 0;
break;
case 'middle':
y = Math.floor((maxHeight - sprite.height) / 2);
y = Math.floor((cellHeight - sprite.height) / 2);
break;
case 'bottom':
y = Math.floor(maxHeight - sprite.height);
y = Math.floor(cellHeight - sprite.height);
break;
}
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 negativeSpacingEnabled = ref(false);
const backgroundColor = ref('transparent');
const manualCellSizeEnabled = ref(false);
const manualCellWidth = ref(64);
const manualCellHeight = ref(64);
// Initialize dark mode from localStorage or system preference
if (typeof window !== 'undefined') {
@@ -61,16 +64,40 @@ export const useSettingsStore = defineStore('settings', () => {
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 {
pixelPerfect,
darkMode,
negativeSpacingEnabled,
backgroundColor,
manualCellSizeEnabled,
manualCellWidth,
manualCellHeight,
togglePixelPerfect,
setPixelPerfect,
toggleDarkMode,
setDarkMode,
toggleNegativeSpacing,
setBackgroundColor,
toggleManualCellSize,
setManualCellWidth,
setManualCellHeight,
setManualCellSize,
};
});