[FEAT] add grid metrics

This commit is contained in:
2026-01-01 17:14:21 +01:00
parent 281a37fa7e
commit 1f9fc4d5bb
5 changed files with 352 additions and 128 deletions

View File

@@ -204,6 +204,8 @@
import type { Sprite } from '@/types/sprites';
import { useDragSprite } from '@/composables/useDragSprite';
import { useFileDrop } from '@/composables/useFileDrop';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import type { Layer } from '@/types/sprites';
@@ -315,8 +317,16 @@
}
);
// Grid metrics
const gridMetrics = computed(() => calculateMaxDimensions());
// Use the new useGridMetrics composable for consistent calculations
const { gridMetrics: gridMetricsRef, getCellPosition: getCellPositionHelper } = useGridMetrics({
layers: toRef(props, 'layers'),
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
});
const gridMetrics = gridMetricsRef;
const totalCells = computed(() => {
// Use all layers regardless of visibility to keep canvas size stable
@@ -335,47 +345,20 @@
});
const getCellPosition = (index: number) => {
const col = index % props.columns;
const row = Math.floor(index / props.columns);
return {
x: Math.round(col * gridMetrics.value.maxWidth),
y: Math.round(row * gridMetrics.value.maxHeight),
};
return getCellPositionHelper(index, props.columns, gridMetrics.value);
};
const getCellBackground = () => {
const bg = settingsStore.backgroundColor;
if (bg === 'transparent') {
return 'transparent';
}
return bg;
};
// Use the new useBackgroundStyles composable for consistent background styling
const { backgroundColor: cellBackgroundColor, backgroundImage: cellBackgroundImage, backgroundSize: cellBackgroundSize, backgroundPosition: cellBackgroundPosition } = useBackgroundStyles({
backgroundColor: toRef(settingsStore, 'backgroundColor'),
checkerboardEnabled: toRef(settingsStore, 'checkerboardEnabled'),
darkMode: toRef(settingsStore, 'darkMode'),
});
const getCellBackgroundImage = () => {
const bg = settingsStore.backgroundColor;
if (bg === 'transparent' && settingsStore.checkerboardEnabled) {
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
}
return 'none';
};
const getCellBackgroundSize = () => {
const bg = settingsStore.backgroundColor;
if (bg === 'transparent') {
return '20px 20px';
}
return 'auto';
};
const getCellBackgroundPosition = () => {
const bg = settingsStore.backgroundColor;
if (bg === 'transparent') {
return '0 0, 0 10px, 10px -10px, -10px 0px';
}
return '0 0';
};
const getCellBackground = () => cellBackgroundColor.value;
const getCellBackgroundImage = () => cellBackgroundImage.value;
const getCellBackgroundSize = () => cellBackgroundSize.value;
const getCellBackgroundPosition = () => cellBackgroundPosition.value;
const startDrag = (event: MouseEvent) => {
// If the click originated from an interactive element (button, link, input), ignore drag handling

View File

@@ -73,9 +73,9 @@
width: `${cellDimensions.cellWidth}px`,
height: `${cellDimensions.cellHeight}px`,
backgroundColor: settingsStore.backgroundColor === 'transparent' ? '#f9fafb' : settingsStore.backgroundColor,
backgroundImage: getPreviewBackgroundImage(),
backgroundSize: settingsStore.backgroundColor === 'transparent' ? '20px 20px' : 'auto',
backgroundPosition: settingsStore.backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
backgroundImage: previewBackgroundImage,
backgroundSize: previewBackgroundSize,
backgroundPosition: previewBackgroundPosition,
border: `1px solid ${settingsStore.darkMode ? '#4b5563' : '#e5e7eb'}`,
}"
@dragover.prevent="onDragOver"
@@ -302,13 +302,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import { ref, onMounted, watch, onUnmounted, computed, toRef } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useZoom } from '@/composables/useZoom';
import { useAnimationFrames } from '@/composables/useAnimationFrames';
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
const props = defineProps<{
layers: Layer[];
@@ -426,38 +426,30 @@
return layer.sprites[currentFrameIndex.value] || null;
});
// Computed cell dimensions
const cellDimensions = computed(() => {
// Use ALL layers (regardless of visibility) to keep preview size stable
const allLayers = props.layers;
// If manual cell size is enabled, use manual values
if (settingsStore.manualCellSizeEnabled) {
return {
cellWidth: Math.round(settingsStore.manualCellWidth),
cellHeight: Math.round(settingsStore.manualCellHeight),
negativeSpacing: 0,
};
}
// Otherwise, calculate from sprite dimensions across ALL layers
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(allLayers);
const allSprites = allLayers.flatMap(l => l.sprites);
const negativeSpacing = Math.round(calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled));
return {
cellWidth: Math.round(maxWidth + negativeSpacing),
cellHeight: Math.round(maxHeight + negativeSpacing),
negativeSpacing,
};
// Use the new useGridMetrics composable for consistent calculations
const { gridMetrics } = useGridMetrics({
layers: toRef(props, 'layers'),
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
});
// Helper for background image (dark mode friendly)
const getPreviewBackgroundImage = () => {
if (settingsStore.backgroundColor === 'transparent' && settingsStore.checkerboardEnabled) {
const color = settingsStore.darkMode ? '#4b5563' : '#d1d5db';
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
}
return 'none';
};
// Computed cell dimensions (for backward compatibility with existing code)
const cellDimensions = computed(() => ({
cellWidth: gridMetrics.value.maxWidth,
cellHeight: gridMetrics.value.maxHeight,
negativeSpacing: gridMetrics.value.negativeSpacing,
}));
// Use the new useBackgroundStyles composable for consistent background styling
const { backgroundImage: previewBackgroundImage, backgroundSize: previewBackgroundSize, backgroundPosition: previewBackgroundPosition } = useBackgroundStyles({
backgroundColor: toRef(settingsStore, 'backgroundColor'),
checkerboardEnabled: toRef(settingsStore, 'checkerboardEnabled'),
darkMode: toRef(settingsStore, 'darkMode'),
});
const getPreviewBackgroundImage = () => previewBackgroundImage.value;
// Dragging state
const isDragging = ref(false);

View File

@@ -0,0 +1,113 @@
import { computed, type Ref, type ComputedRef } from 'vue';
export interface BackgroundStylesOptions {
backgroundColor: Ref<string> | ComputedRef<string> | string;
checkerboardEnabled?: Ref<boolean> | ComputedRef<boolean> | boolean;
darkMode?: Ref<boolean> | ComputedRef<boolean> | boolean;
}
export interface BackgroundStyles {
backgroundColor: string;
backgroundImage: string;
backgroundSize: string;
backgroundPosition: string;
}
/**
* Composable for generating consistent background styles across components.
* Handles transparent backgrounds with checkerboard patterns and dark mode.
*/
export function useBackgroundStyles(options: BackgroundStylesOptions) {
// Helper to get reactive values
const getBackgroundColor = () => (typeof options.backgroundColor === 'string' ? options.backgroundColor : options.backgroundColor.value);
const getCheckerboardEnabled = () => (typeof options.checkerboardEnabled === 'boolean' ? options.checkerboardEnabled : options.checkerboardEnabled?.value ?? true);
const getDarkMode = () => (typeof options.darkMode === 'boolean' ? options.darkMode : options.darkMode?.value ?? false);
/**
* Get the background color.
*/
const backgroundColor = computed(() => {
const bg = getBackgroundColor();
return bg === 'transparent' ? 'transparent' : bg;
});
/**
* Get the background image (checkerboard pattern for transparent backgrounds).
*/
const backgroundImage = computed(() => {
const bg = getBackgroundColor();
const checkerboardEnabled = getCheckerboardEnabled();
const darkMode = getDarkMode();
if (bg === 'transparent' && checkerboardEnabled) {
// Checkerboard pattern for transparent backgrounds (dark mode friendly)
const color = darkMode ? '#4b5563' : '#d1d5db';
return `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
}
return 'none';
});
/**
* Get the background size.
*/
const backgroundSize = computed(() => {
const bg = getBackgroundColor();
return bg === 'transparent' ? '20px 20px' : 'auto';
});
/**
* Get the background position.
*/
const backgroundPosition = computed(() => {
const bg = getBackgroundColor();
return bg === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0';
});
/**
* Get all background styles as a single object.
*/
const backgroundStyles = computed<BackgroundStyles>(() => ({
backgroundColor: backgroundColor.value,
backgroundImage: backgroundImage.value,
backgroundSize: backgroundSize.value,
backgroundPosition: backgroundPosition.value,
}));
return {
backgroundColor,
backgroundImage,
backgroundSize,
backgroundPosition,
backgroundStyles,
};
}
/**
* Standalone helper to get background styles.
* Useful when you don't need the full composable.
*/
export function getBackgroundStyles(
backgroundColor: string,
options: {
checkerboardEnabled?: boolean;
darkMode?: boolean;
} = {}
): BackgroundStyles {
const checkerboardEnabled = options.checkerboardEnabled ?? true;
const darkMode = options.darkMode ?? false;
const bg = backgroundColor === 'transparent' ? 'transparent' : backgroundColor;
let bgImage = 'none';
if (backgroundColor === 'transparent' && checkerboardEnabled) {
const color = darkMode ? '#4b5563' : '#d1d5db';
bgImage = `linear-gradient(45deg, ${color} 25%, transparent 25%), linear-gradient(-45deg, ${color} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${color} 75%), linear-gradient(-45deg, transparent 75%, ${color} 75%)`;
}
return {
backgroundColor: bg,
backgroundImage: bgImage,
backgroundSize: backgroundColor === 'transparent' ? '20px 20px' : 'auto',
backgroundPosition: backgroundColor === 'transparent' ? '0 0, 0 10px, 10px -10px, -10px 0px' : '0 0',
};
}

View File

@@ -1,7 +1,6 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite, Layer } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
import { calculateNegativeSpacing } from './useNegativeSpacing';
import { useGridMetrics, type GridMetrics } from './useGridMetrics';
export interface CellPosition {
col: number;
@@ -70,70 +69,42 @@ export function useDragSprite(options: DragSpriteOptions) {
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
const highlightCell = ref<CellPosition | null>(null);
// Cache for max dimensions
const lastMaxWidth = ref(1);
const lastMaxHeight = ref(1);
// Use the new useGridMetrics composable for consistent calculations
const gridMetricsComposable = useGridMetrics({
layers: options.layers,
sprites: options.sprites,
negativeSpacingEnabled: options.negativeSpacingEnabled,
manualCellSizeEnabled: options.manualCellSizeEnabled,
manualCellWidth: options.manualCellWidth,
manualCellHeight: options.manualCellHeight,
});
const calculateMaxDimensions = () => {
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 };
}
// Get all sprites to calculate dimensions from
// If layers are provided, use ALL layers (regardless of visibility) to keep canvas size stable
const layers = getLayers();
const spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
// Otherwise, calculate based on sprite dimensions across all visible layers
const base = getMaxDimensions(spritesToMeasure);
// 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;
// Calculate negative spacing using shared composable
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
// Add negative spacing to expand each cell
const maxWidth = Math.round(baseMaxWidth + negativeSpacing);
const maxHeight = Math.round(baseMaxHeight + negativeSpacing);
return { maxWidth, maxHeight, negativeSpacing };
const calculateMaxDimensions = (): GridMetrics => {
return gridMetricsComposable.calculateCellDimensions();
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
const metrics = calculateMaxDimensions();
return sprites.map((sprite, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
const cellPos = gridMetricsComposable.getCellPosition(index, columns, metrics);
const canvasPos = gridMetricsComposable.getSpriteCanvasPosition(sprite, index, columns, metrics);
// With negative spacing, sprites are positioned at bottom-right of cell
// (spacing added to top and left)
return {
id: sprite.id,
canvasX: Math.round(col * maxWidth + negativeSpacing + sprite.x),
canvasY: Math.round(row * maxHeight + negativeSpacing + sprite.y),
cellX: Math.round(col * maxWidth),
cellY: Math.round(row * maxHeight),
canvasX: canvasPos.canvasX,
canvasY: canvasPos.canvasY,
cellX: canvasPos.cellX,
cellY: canvasPos.cellY,
width: Math.round(sprite.width),
height: Math.round(sprite.height),
maxWidth: Math.round(maxWidth),
maxHeight: Math.round(maxHeight),
col,
row,
maxWidth: metrics.maxWidth,
maxHeight: metrics.maxHeight,
col: cellPos.col,
row: cellPos.row,
index,
x: sprite.x,
y: sprite.y,

View File

@@ -0,0 +1,165 @@
import { computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite, Layer } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
import { calculateNegativeSpacing } from './useNegativeSpacing';
export interface GridMetrics {
maxWidth: number;
maxHeight: number;
negativeSpacing: number;
}
export interface CellPosition {
x: number;
y: number;
col: number;
row: number;
}
export interface SpriteCanvasPosition {
canvasX: number;
canvasY: number;
cellX: number;
cellY: number;
}
export interface GridMetricsOptions {
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
sprites?: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
negativeSpacingEnabled?: Ref<boolean> | boolean;
manualCellSizeEnabled?: Ref<boolean> | boolean;
manualCellWidth?: Ref<number> | number;
manualCellHeight?: Ref<number> | number;
}
/**
* Composable for calculating grid metrics and sprite positions.
* Provides a single source of truth for cell dimensions and positioning calculations.
*/
export function useGridMetrics(options: GridMetricsOptions = {}) {
// Helper to get reactive values
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
const getSprites = () => (options.sprites ? (Array.isArray(options.sprites) ? options.sprites : options.sprites.value) : []);
const getNegativeSpacingEnabled = () => (typeof options.negativeSpacingEnabled === 'boolean' ? options.negativeSpacingEnabled : options.negativeSpacingEnabled?.value ?? false);
const getManualCellSizeEnabled = () => (typeof options.manualCellSizeEnabled === 'boolean' ? options.manualCellSizeEnabled : options.manualCellSizeEnabled?.value ?? false);
const getManualCellWidth = () => (typeof options.manualCellWidth === 'number' ? options.manualCellWidth : options.manualCellWidth?.value ?? 64);
const getManualCellHeight = () => (typeof options.manualCellHeight === 'number' ? options.manualCellHeight : options.manualCellHeight?.value ?? 64);
/**
* Calculate cell dimensions including negative spacing.
* This is the canonical calculation used across all components.
*/
const calculateCellDimensions = (): GridMetrics => {
const manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
return {
maxWidth: Math.round(getManualCellWidth()),
maxHeight: Math.round(getManualCellHeight()),
negativeSpacing: 0,
};
}
// Get sprites to measure from layers or direct sprites array
const layers = getLayers();
const spritesToMeasure = layers ? layers.flatMap(l => l.sprites) : getSprites();
// Calculate base dimensions from sprites
const base = getMaxDimensions(spritesToMeasure);
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
// Calculate negative spacing
const negativeSpacingEnabled = getNegativeSpacingEnabled();
const negativeSpacing = Math.round(calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled));
// Add negative spacing to expand each cell
return {
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
negativeSpacing,
};
};
/**
* Get the position of a cell in the grid.
*/
const getCellPosition = (index: number, columns: number, metrics: GridMetrics): CellPosition => {
const col = index % columns;
const row = Math.floor(index / columns);
return {
x: Math.round(col * metrics.maxWidth),
y: Math.round(row * metrics.maxHeight),
col,
row,
};
};
/**
* Get the canvas position of a sprite within a cell.
* This is the final position where the sprite should be rendered.
*/
const getSpriteCanvasPosition = (sprite: Sprite, cellIndex: number, columns: number, metrics: GridMetrics): SpriteCanvasPosition => {
const cellPos = getCellPosition(cellIndex, columns, metrics);
return {
canvasX: Math.round(cellPos.x + metrics.negativeSpacing + sprite.x),
canvasY: Math.round(cellPos.y + metrics.negativeSpacing + sprite.y),
cellX: cellPos.x,
cellY: cellPos.y,
};
};
/**
* Computed grid metrics that automatically updates when dependencies change.
*/
const gridMetrics = computed(() => calculateCellDimensions());
return {
// Computed values
gridMetrics,
// Methods
calculateCellDimensions,
getCellPosition,
getSpriteCanvasPosition,
};
}
/**
* Standalone helper to calculate cell dimensions.
* Useful when you don't need the full composable.
*/
export function getGridMetrics(
spritesOrLayers: Sprite[] | Layer[],
options: {
negativeSpacingEnabled?: boolean;
manualCellSizeEnabled?: boolean;
manualCellWidth?: number;
manualCellHeight?: number;
} = {}
): GridMetrics {
if (options.manualCellSizeEnabled) {
return {
maxWidth: Math.round(options.manualCellWidth ?? 64),
maxHeight: Math.round(options.manualCellHeight ?? 64),
negativeSpacing: 0,
};
}
// Check if we have layers or sprites
const isLayers = spritesOrLayers.length > 0 && 'sprites' in spritesOrLayers[0];
const sprites = isLayers ? (spritesOrLayers as Layer[]).flatMap(l => l.sprites) : (spritesOrLayers as Sprite[]);
const base = getMaxDimensions(sprites);
const baseMaxWidth = Math.max(1, base.maxWidth);
const baseMaxHeight = Math.max(1, base.maxHeight);
const negativeSpacing = Math.round(calculateNegativeSpacing(sprites, options.negativeSpacingEnabled ?? false));
return {
maxWidth: Math.round(baseMaxWidth + negativeSpacing),
maxHeight: Math.round(baseMaxHeight + negativeSpacing),
negativeSpacing,
};
}