[FEAT] Clean code

This commit is contained in:
2026-01-02 22:16:23 +01:00
parent 647083d5b9
commit 224d0d62fe
48 changed files with 83 additions and 384 deletions

View File

@@ -13,7 +13,6 @@
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const items: BreadcrumbItem[] = [{ name: 'Home', path: '/' }];
// Map route names to breadcrumb labels
const routeLabels: Record<string, string> = {
'blog-overview': 'Blog',
'blog-detail': 'Blog',
@@ -27,10 +26,8 @@
const routeName = route.name.toString();
if (routeName === 'blog-detail') {
// For blog detail pages, add Blog first, then the post title
items.push({ name: 'Blog', path: '/blog' });
// Get the post title from route meta or params if available
const postTitle = (route.meta.title as string) || 'Article';
items.push({ name: postTitle, path: route.path });
} else if (routeLabels[routeName]) {

View File

@@ -86,12 +86,10 @@
}
success.value = 'Thank you! Your feedback was sent.';
// Reset fields
name.value = '';
contact.value = '';
content.value = '';
// Optionally close after short delay
setTimeout(() => close(), 600);
} catch (e: any) {
console.error('Failed to send feedback:', e);

View File

@@ -63,7 +63,6 @@
if (input.files && input.files.length > 0) {
const files = Array.from(input.files);
emit('uploadSprites', files);
// Reset input value so uploading the same file again will trigger the event
if (fileInput.value) fileInput.value.value = '';
}
};

View File

@@ -170,13 +170,11 @@
const response = await fetch('/CHANGELOG.md');
const text = await response.text();
// Configure marked options
marked.setOptions({
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert line breaks to <br>
});
// Convert markdown to HTML
changelogHtml.value = await marked(text);
} catch (error) {
console.error('Failed to load changelog:', error);

View File

@@ -87,7 +87,6 @@
copied.value = false;
}, 2000);
} catch {
// Fallback for older browsers
const input = document.createElement('input');
input.value = shareUrl.value;
document.body.appendChild(input);
@@ -101,14 +100,12 @@
}
};
// Start sharing when modal opens
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
performShare();
} else {
// Reset state when closing
loading.value = false;
shareUrl.value = '';
error.value = '';

View File

@@ -225,7 +225,6 @@
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
}>();
// Get settings from store
const settingsStore = useSettingsStore();
const gridContainerRef = ref<HTMLDivElement | null>(null);
@@ -242,6 +241,8 @@
};
};
const selectedSpriteIds = ref<Set<string>>(new Set());
const {
isDragging,
activeSpriteId,
@@ -266,6 +267,7 @@
manualCellSizeEnabled: toRef(settingsStore, 'manualCellSizeEnabled'),
manualCellWidth: toRef(settingsStore, 'manualCellWidth'),
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
selectedSpriteIds,
getMousePosition,
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
@@ -287,16 +289,13 @@
const contextMenuY = ref(0);
const contextMenuIndex = ref<number | null>(null);
const contextMenuSpriteId = ref<string | null>(null);
const selectedSpriteIds = ref<Set<string>>(new Set());
const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
// Copy to frame modal state
const showCopyToFrameModal = ref(false);
const copyTargetLayerId = ref(props.activeLayerId);
const copySpriteId = ref<string | null>(null);
// Clear selection when toggling multi-select mode
watch(
() => props.isMultiSelectMode,
() => {
@@ -304,7 +303,6 @@
}
);
// Use the new useGridMetrics composable for consistent calculations
const { gridMetrics: gridMetricsRef, getCellPosition: getCellPositionHelper } = useGridMetrics({
layers: toRef(props, 'layers'),
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
@@ -316,13 +314,11 @@
const gridMetrics = gridMetricsRef;
const totalCells = computed(() => {
// Use all layers regardless of visibility to keep canvas size stable
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
return Math.max(1, Math.ceil(maxLen / props.columns)) * props.columns;
});
const gridDimensions = computed(() => {
// Use all layers regardless of visibility to keep canvas size stable
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
return {
@@ -335,7 +331,6 @@
return getCellPositionHelper(index, props.columns, gridMetrics.value);
};
// Use the new useBackgroundStyles composable for consistent background styling
const {
backgroundColor: cellBackgroundColor,
backgroundImage: cellBackgroundImage,
@@ -353,17 +348,14 @@
const getCellBackgroundPosition = () => cellBackgroundPosition.value;
const startDrag = (event: MouseEvent) => {
// If the click originated from an interactive element (button, link, input), ignore drag handling
const target = event.target as HTMLElement;
if (target && target.closest('button, a, input, select, textarea')) {
return;
}
if (!gridContainerRef.value) return;
// Hide context menu if open
showContextMenu.value = false;
// Handle right-click for context menu
if ('button' in event && (event as MouseEvent).button === 2) {
event.preventDefault();
const pos = getMousePosition(event, props.zoom);
@@ -374,14 +366,11 @@
contextMenuSpriteId.value = clickedSprite?.id || null;
if (clickedSprite) {
// If the right-clicked sprite is not in the selection, clear selection and select just this one
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
selectedSpriteIds.value.clear();
selectedSpriteIds.value.add(clickedSprite.id);
}
// If it IS in the selection, keep the current selection (so we can apply action to all)
} else {
// Right click on empty space
selectedSpriteIds.value.clear();
}
@@ -392,37 +381,27 @@
return;
}
// Ignore non-left mouse buttons
if ('button' in event && (event as MouseEvent).button !== 0) return;
// Handle selection logic for left click
const pos = getMousePosition(event, props.zoom);
if (pos) {
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
if (clickedSprite) {
// Selection logic with multi-select mode check
if (event.ctrlKey || event.metaKey || props.isMultiSelectMode) {
// Toggle selection
if (selectedSpriteIds.value.has(clickedSprite.id)) {
selectedSpriteIds.value.delete(clickedSprite.id);
} else {
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
selectedSpriteIds.value.add(clickedSprite.id);
}
} else {
// Single select (but don't clear if dragging starts immediately?
// Usually standard behavior is to clear others unless shift/ctrl held)
if (!selectedSpriteIds.value.has(clickedSprite.id)) {
selectedSpriteIds.value.clear();
selectedSpriteIds.value.add(clickedSprite.id);
}
}
} else {
// Clicked on empty space
selectedSpriteIds.value.clear();
}
}
// Delegate to composable for actual drag handling
dragStart(event);
};
@@ -430,7 +409,6 @@
const latestEvent = ref<MouseEvent | null>(null);
const drag = (event: MouseEvent) => {
// Store the latest event and schedule a single animation frame update
latestEvent.value = event;
if (!pendingDrag.value) {
pendingDrag.value = true;
@@ -481,7 +459,6 @@
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
// Don't delete if editing text/input
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
if (selectedSpriteIds.value.size > 0) {
@@ -571,8 +548,6 @@
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('keydown', handleKeyDown);
});
// Watch for background color changes
</script>
<style scoped></style>

View File

@@ -303,7 +303,6 @@
const previewContainerRef = ref<HTMLDivElement | null>(null);
// Get settings from store
const settingsStore = useSettingsStore();
const {
@@ -331,14 +330,12 @@
onDraw: () => {}, // No longer needed for canvas drawing
});
// Preview state
const isDraggable = ref(false);
const repositionAllLayers = ref(false);
const arrowKeyMovement = ref(false);
const showAllSprites = ref(false);
const isDragOver = ref(false);
// Context menu state
const showContextMenu = ref(false);
const contextMenuX = ref(0);
const contextMenuY = ref(0);
@@ -347,11 +344,9 @@
const fileInput = ref<HTMLInputElement | null>(null);
const replacingSpriteId = ref<string | null>(null);
// Copy to frame modal state
const showCopyToFrameModal = ref(false);
const copyTargetLayerId = ref(props.activeLayerId);
// Drag and drop for new sprites
const onDragOver = () => {
isDragOver.value = true;
};
@@ -373,10 +368,8 @@
};
const compositeFrames = computed<Sprite[]>(() => {
// Show frames from the active layer for the thumbnail list
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
if (!activeLayer) {
// Fallback to first visible layer if no active layer
const v = getVisibleLayers();
const len = maxFrames();
const arr: Sprite[] = [];
@@ -395,7 +388,6 @@
return layer.sprites[currentFrameIndex.value] || null;
});
// Use the new useGridMetrics composable for consistent calculations
const { gridMetrics, getCellPosition: getCellPositionHelper } = useGridMetrics({
layers: toRef(props, 'layers'),
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
@@ -404,19 +396,16 @@
manualCellHeight: toRef(settingsStore, 'manualCellHeight'),
});
// Helper function to get cell position (same as SpriteCanvas)
const getCellPosition = (index: number) => {
return getCellPositionHelper(index, props.columns, gridMetrics.value);
};
// 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,
@@ -429,7 +418,6 @@
const getPreviewBackgroundImage = () => previewBackgroundImage.value;
// Dragging state
const isDragging = ref(false);
const activeSpriteId = ref<string | null>(null);
const activeLayerId = ref<string | null>(null);
@@ -438,7 +426,6 @@
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
// Drag functionality
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
if (!isDraggable.value || !previewContainerRef.value) return;
@@ -455,7 +442,6 @@
dragStartX.value = mouseX;
dragStartY.value = mouseY;
// Store initial positions for all sprites in this frame from all visible layers
allSpritesPosBeforeDrag.value.clear();
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
@@ -490,7 +476,6 @@
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
if (activeSpriteId.value === 'ALL_LAYERS') {
// Move all sprites in current frame from all visible layers
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
const sprite = layer.sprites[currentFrameIndex.value];
@@ -499,26 +484,21 @@
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
if (!originalPos) return;
// Calculate new position with constraints
let newX = Math.round(originalPos.x + deltaX);
let newY = Math.round(originalPos.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
});
} else {
// Move only the active layer sprite
const activeSprite = props.layers.find(l => l.id === activeLayerId.value)?.sprites[currentFrameIndex.value];
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
// Calculate new position with constraints and round to integers
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
@@ -562,7 +542,6 @@
}
};
// Arrow key movement handler
const handleKeyDown = (event: KeyboardEvent) => {
if (!isDraggable.value || !arrowKeyMovement.value) return;
@@ -593,7 +572,6 @@
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
if (repositionAllLayers.value) {
// Move all sprites in current frame from all visible layers
const visibleLayers = getVisibleLayers();
visibleLayers.forEach(layer => {
const sprite = layer.sprites[currentFrameIndex.value];
@@ -602,14 +580,12 @@
let newX = Math.round(sprite.x + deltaX);
let newY = Math.round(sprite.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
emit('updateSpriteInLayer', layer.id, sprite.id, newX, newY);
});
} else {
// Move only the active layer sprite
const activeLayer = props.layers.find(l => l.id === props.activeLayerId);
if (!activeLayer) return;
@@ -619,7 +595,6 @@
let newX = Math.round(sprite.x + deltaX);
let newY = Math.round(sprite.y + deltaY);
// Constrain movement within expanded cell
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
@@ -627,7 +602,6 @@
}
};
// Lifecycle hooks
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
@@ -637,8 +611,6 @@
window.removeEventListener('keydown', handleKeyDown);
});
// Watchers - most canvas-related watchers removed
// Keep layer watchers to ensure reactivity
watch(
() => props.layers,
() => {},
@@ -650,7 +622,6 @@
);
watch(currentFrameIndex, () => {});
// Context menu functions
const openContextMenu = (event: MouseEvent, sprite: Sprite, layerId: string) => {
event.preventDefault();
contextMenuSpriteId.value = sprite.id;

View File

@@ -115,7 +115,6 @@
const settingsStore = useSettingsStore();
const splitter = useSpritesheetSplitter();
// State
const detectionMode = ref<DetectionMode>('grid');
const cellWidth = ref(64);
const cellHeight = ref(64);
@@ -126,12 +125,10 @@
const isProcessing = ref(false);
const imageElement = ref<HTMLImageElement | null>(null);
// Computed
const gridCols = computed(() => (imageElement.value && cellWidth.value > 0 ? Math.floor(imageElement.value.width / cellWidth.value) : 0));
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
// Load image and set initial cell size
watch(
() => props.imageUrl,
url => {
@@ -141,7 +138,6 @@
img.onload = () => {
imageElement.value = img;
// Set suggested cell size
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
cellWidth.value = suggested.width;
cellHeight.value = suggested.height;
@@ -153,14 +149,12 @@
{ immediate: true }
);
// Regenerate preview when options change
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
if (imageElement.value) {
generatePreview();
}
});
// Generate preview
async function generatePreview() {
if (!imageElement.value) return;
@@ -190,7 +184,6 @@
}
}
// Actions
function cancel() {
emit('close');
}

View File

@@ -92,7 +92,6 @@
close();
} catch (e: any) {
error.value = e.message || 'An error occurred';
// Better PB error handling
if (e?.data?.message) error.value = e.data.message;
} finally {
loading.value = false;

View File

@@ -74,7 +74,6 @@
import { useProjectManager } from '@/composables/useProjectManager';
import { useToast } from '@/composables/useToast';
// Sub-components
import NavbarLogo from './navbar/NavbarLogo.vue';
import NavbarLinks from './navbar/NavbarLinks.vue';
import NavbarProjectActions from './navbar/NavbarProjectActions.vue';
@@ -144,7 +143,6 @@
} catch (error) {
addToast('Failed to save project', 'error');
console.error(error);
// Error handled in composable but kept here for toast
} finally {
isSaving.value = false;
}

View File

@@ -5,11 +5,7 @@
:key="link.path"
:to="link.path"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200"
:class="[
isActive(link.path)
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400'
]"
:class="[isActive(link.path) ? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400']"
>
{{ link.name }}
</router-link>
@@ -17,22 +13,22 @@
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useRoute } from 'vue-router';
const route = useRoute();
const route = useRoute();
const links = [
{ name: 'Home', path: '/' },
{ name: 'Blog', path: '/blog' },
{ name: 'About', path: '/about' },
{ name: 'FAQ', path: '/faq' },
{ name: 'Contact', path: '/contact' },
];
const links = [
{ name: 'Home', path: '/' },
{ name: 'Blog', path: '/blog' },
{ name: 'About', path: '/about' },
{ name: 'FAQ', path: '/faq' },
{ name: 'Contact', path: '/contact' },
];
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/';
}
return route.path.startsWith(path);
};
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/';
}
return route.path.startsWith(path);
};
</script>

View File

@@ -39,11 +39,7 @@
:key="link.path"
:to="link.path"
class="flex items-center gap-3 px-3 py-3 rounded-md text-base font-medium transition-all duration-200"
:class="[
isActive(link.path)
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400'
]"
:class="[isActive(link.path) ? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400']"
@click="$emit('close')"
>
<i :class="link.icon" class="w-5 text-center"></i> {{ link.name }}
@@ -163,6 +159,5 @@
const handleLogout = () => {
authStore.logout();
// emit('close'); // Optional: close menu on logout? Maybe better to keep open so they can login again if they want.
};
</script>

View File

@@ -15,9 +15,9 @@
<i class="fas fa-question-circle text-lg"></i>
</button>
</Tooltip>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 ml-1.5"></div>
<DarkModeToggle />
</div>
</template>

View File

@@ -61,7 +61,6 @@
return width.value > 0 && height.value > 0 && columns.value > 0 && rows.value > 0;
});
// Reset to defaults when opened
watch(
() => props.isOpen,
val => {

View File

@@ -187,9 +187,7 @@
const sprites: any[] = [];
if (!project.data || !project.data.layers) return sprites;
// Iterate through layers to find sprites
for (const layer of project.data.layers as any[]) {
// Check if layer is visible (default to true if undefined)
if (layer.visible === false) continue;
if (layer.sprites && layer.sprites.length > 0) {

View File

@@ -76,59 +76,47 @@
const startPos = ref({ x: 0, y: 0 });
const startSize = ref({ width: 0, height: 0 });
// Add isFullScreen ref
const isFullScreen = ref(false);
const isMobile = ref(false);
// Add previous state storage for restoring from full screen
const previousState = ref({
position: { x: 0, y: 0 },
size: { width: 0, height: 0 },
});
// Check if device is mobile
const checkMobile = () => {
isMobile.value = window.innerWidth < 640; // sm breakpoint in Tailwind
// Auto fullscreen on mobile
if (isMobile.value && !isFullScreen.value) {
toggleFullScreen();
} else if (!isMobile.value && isFullScreen.value && autoFullScreened.value) {
// If we're no longer on mobile and were auto-fullscreened, exit fullscreen
toggleFullScreen();
autoFullScreened.value = false;
}
};
// Track if fullscreen was automatic (for mobile)
const autoFullScreened = ref(false);
// Add toggleFullScreen function
const toggleFullScreen = () => {
if (!isFullScreen.value) {
// Store current state before going full screen
previousState.value = {
position: { ...position.value },
size: { ...size.value },
};
// If toggling to fullscreen on mobile automatically, track it
if (isMobile.value) {
autoFullScreened.value = true;
}
} else {
// Restore previous state
position.value = { ...previousState.value.position };
size.value = { ...previousState.value.size };
}
isFullScreen.value = !isFullScreen.value;
};
// Unified start function for both drag and resize
const startAction = (event: MouseEvent | TouchEvent, action: 'drag' | 'resize') => {
if (isFullScreen.value) return;
// Extract the correct coordinates based on event type
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
@@ -211,7 +199,6 @@
position.value = { x: 0, y: 0 };
};
// Event handlers
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) close();
};
@@ -223,7 +210,6 @@
}
};
// Add these new touch handling functions
const handleTouchStart = (event: TouchEvent) => {
if (isFullScreen.value) return;
if (event.touches.length === 1) {
@@ -237,7 +223,6 @@
handleMove(event);
};
// Lifecycle
watch(
() => props.isOpen,
newValue => {
@@ -250,7 +235,6 @@
window.addEventListener('resize', handleResize);
window.addEventListener('resize', checkMobile);
// Initial check for mobile
checkMobile();
if (props.isOpen) centerModal();

View File

@@ -27,7 +27,6 @@
import type { Toast } from '@/composables/useToast';
import { useToast } from '@/composables/useToast';
// Simple functional components for icons using h (avoid runtime compiler)
const SuccessIcon = {
render: () =>
h(

View File

@@ -42,30 +42,24 @@
let x = mouseX.value + offsetX;
let y = mouseY.value + offsetY;
// Get tooltip dimensions (estimate if not mounted yet)
const tooltipWidth = tooltipRef.value?.offsetWidth || 200;
const tooltipHeight = tooltipRef.value?.offsetHeight || 30;
// Screen boundaries
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// Adjust horizontal position if too close to right edge
if (x + tooltipWidth + padding > screenWidth) {
x = mouseX.value - tooltipWidth - offsetX;
}
// Adjust horizontal position if too close to left edge
if (x < padding) {
x = padding;
}
// Adjust vertical position if too close to bottom edge
if (y + tooltipHeight + padding > screenHeight) {
y = mouseY.value - tooltipHeight - offsetY;
}
// Adjust vertical position if too close to top edge
if (y < padding) {
y = padding;
}

View File

@@ -9,7 +9,6 @@ export interface AnimationFramesOptions {
export function useAnimationFrames(options: AnimationFramesOptions) {
const { onDraw } = options;
// Convert sprites to a computed ref for reactivity
const spritesRef = computed(() => {
if (typeof options.sprites === 'function') {
return options.sprites();
@@ -20,20 +19,16 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
return options.sprites;
});
// Helper to get sprites array
const getSprites = () => spritesRef.value;
// State
const currentFrameIndex = ref(0);
const isPlaying = ref(false);
const fps = ref(12);
const hiddenFrames = ref<number[]>([]);
// Animation internals
const animationFrameId = ref<number | null>(null);
const lastFrameTime = ref(0);
// Computed properties for visible frames
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
const visibleFramesCount = computed(() => visibleFrames.value.length);
@@ -46,7 +41,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Animation control
const animateFrame = () => {
const now = performance.now();
const elapsed = now - lastFrameTime.value;
@@ -109,7 +103,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
};
// Frame visibility management
const toggleHiddenFrame = (index: number) => {
const sprites = getSprites();
const currentIndex = hiddenFrames.value.indexOf(index);
@@ -117,7 +110,6 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
if (currentIndex === -1) {
hiddenFrames.value.push(index);
// If hiding current frame, switch to next visible
if (index === currentFrameIndex.value) {
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
if (nextVisible !== -1) {
@@ -140,32 +132,27 @@ export function useAnimationFrames(options: AnimationFramesOptions) {
const sprites = getSprites();
hiddenFrames.value = sprites.map((_, index) => index);
// Keep at least one frame visible
if (hiddenFrames.value.length > 0) {
hiddenFrames.value.splice(currentFrameIndex.value, 1);
}
onDraw();
};
// Cleanup on unmount
onUnmounted(() => {
stopAnimation();
});
return {
// State
currentFrameIndex,
isPlaying,
fps,
hiddenFrames,
// Computed
visibleFrames,
visibleFramesCount,
visibleFrameIndex,
visibleFrameNumber,
// Methods
togglePlayback,
nextFrame,
previousFrame,

View File

@@ -18,7 +18,6 @@ export interface BackgroundStyles {
* 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));
@@ -40,7 +39,6 @@ export function useBackgroundStyles(options: BackgroundStylesOptions) {
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%)`;
}

View File

@@ -71,7 +71,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
}
};
// Helper to ensure integer positions for pixel-perfect rendering
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
items.forEach(item => {
item.x = Math.floor(item.x);
@@ -79,7 +78,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
});
};
// Centralized force redraw handler
const createForceRedrawHandler = <T extends { x: number; y: number }>(items: T[], drawCallback: () => void) => {
return () => {
ensureIntegerPositions(items);
@@ -88,7 +86,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
};
};
// Get mouse position relative to canvas, accounting for zoom
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
if (!canvasRef.value) return null;
@@ -102,7 +99,6 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
};
};
// Helper to attach load/error listeners to images that aren't yet loaded
const attachImageListeners = (sprites: Sprite[], onLoad: () => void, tracked: WeakSet<HTMLImageElement>) => {
sprites.forEach(sprite => {
const img = sprite.img as HTMLImageElement | undefined;
@@ -116,14 +112,12 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
});
};
// Fill cell background with selected color or transparent
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
if (settingsStore.backgroundColor === 'transparent') return;
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
fillRect(x, y, width, height, color);
};
// Stroke grid with theme-aware color
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
strokeRect(x, y, width, height, color, 1);

View File

@@ -35,6 +35,7 @@ export interface DragSpriteOptions {
manualCellSizeEnabled?: Ref<boolean>;
manualCellWidth?: Ref<number>;
manualCellHeight?: Ref<number>;
selectedSpriteIds?: Ref<Set<string>> | ComputedRef<Set<string>>;
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
onUpdateSprite: (id: string, x: number, y: number) => void;
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
@@ -44,7 +45,6 @@ export interface DragSpriteOptions {
export function useDragSprite(options: DragSpriteOptions) {
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
// Helper to get reactive values
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const getLayers = () => (options.layers ? (Array.isArray(options.layers) ? options.layers : options.layers.value) : null);
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
@@ -54,8 +54,8 @@ export function useDragSprite(options: DragSpriteOptions) {
const getManualCellSizeEnabled = () => options.manualCellSizeEnabled?.value ?? false;
const getManualCellWidth = () => options.manualCellWidth?.value ?? 64;
const getManualCellHeight = () => options.manualCellHeight?.value ?? 64;
const getSelectedSpriteIds = () => options.selectedSpriteIds?.value ?? new Set<string>();
// Drag state
const isDragging = ref(false);
const activeSpriteId = ref<string | null>(null);
const activeSpriteCellIndex = ref<number | null>(null);
@@ -65,11 +65,11 @@ export function useDragSprite(options: DragSpriteOptions) {
const dragOffsetY = ref(0);
const currentHoverCell = ref<CellPosition | null>(null);
// Visual feedback
const initialSpritePositions = ref<Map<string, { x: number; y: number; index: number }>>(new Map());
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
const highlightCell = ref<CellPosition | null>(null);
// Use the new useGridMetrics composable for consistent calculations
const gridMetricsComposable = useGridMetrics({
layers: options.layers,
sprites: options.sprites,
@@ -83,7 +83,6 @@ export function useDragSprite(options: DragSpriteOptions) {
return gridMetricsComposable.calculateCellDimensions();
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
@@ -118,7 +117,6 @@ export function useDragSprite(options: DragSpriteOptions) {
const col = Math.floor(x / maxWidth);
const row = Math.floor(y / maxHeight);
// Allow dropping anywhere in the columns, assuming infinite rows effectively
if (col >= 0 && col < columns && row >= 0) {
const index = row * columns + col;
return { col, row, index };
@@ -162,6 +160,19 @@ export function useDragSprite(options: DragSpriteOptions) {
highlightCell.value = null;
}
}
const selectedIds = getSelectedSpriteIds();
initialSpritePositions.value.clear();
if (selectedIds.has(clickedSprite.id) && selectedIds.size > 1) {
const sprites = getSprites();
selectedIds.forEach(id => {
const sprite = sprites.find(s => s.id === id);
const spriteIdx = sprites.findIndex(s => s.id === id);
if (sprite) {
initialSpritePositions.value.set(id, { x: sprite.x, y: sprite.y, index: spriteIdx });
}
});
}
}
};
@@ -171,23 +182,44 @@ export function useDragSprite(options: DragSpriteOptions) {
const columns = getColumns();
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
// Use the sprite's current index in the array to calculate cell position
const cellCol = spriteIndex % columns;
const cellRow = Math.floor(spriteIndex / columns);
const cellX = Math.round(cellCol * maxWidth);
const cellY = Math.round(cellRow * maxHeight);
// Calculate new position relative to cell origin (without the negative spacing offset)
// The sprite's x,y is stored relative to where it would be drawn after the negativeSpacing offset
const newX = mouseX - cellX - negativeSpacing - dragOffsetX.value;
const newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
// The sprite can move within the full expanded cell area
// Allow negative values up to -negativeSpacing so sprite can fill the expanded area
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
const selectedIds = getSelectedSpriteIds();
const isMultiDrag = selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
if (isMultiDrag && initialSpritePositions.value.size > 0) {
const activeInitial = initialSpritePositions.value.get(activeSpriteId.value);
if (activeInitial) {
const deltaX = newX - activeInitial.x;
const deltaY = newY - activeInitial.y;
initialSpritePositions.value.forEach((initPos, id) => {
const sprite = sprites.find(s => s.id === id);
if (sprite) {
const newSpriteX = initPos.x + deltaX;
const newSpriteY = initPos.y + deltaY;
const spriteCellCol = initPos.index % columns;
const spriteCellRow = Math.floor(initPos.index / columns);
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprite.width, newSpriteX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprite.height, newSpriteY)));
onUpdateSprite(id, constrainedX, constrainedY);
}
});
}
} else {
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
}
onDraw();
};
@@ -204,7 +236,10 @@ export function useDragSprite(options: DragSpriteOptions) {
const hoverCell = findCellAtPosition(pos.x, pos.y);
currentHoverCell.value = hoverCell;
if (getAllowCellSwap() && hoverCell) {
const selectedIds = getSelectedSpriteIds();
const isMultiDrag = selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
if (getAllowCellSwap() && hoverCell && !isMultiDrag) {
if (hoverCell.index !== activeSpriteCellIndex.value) {
highlightCell.value = hoverCell;
ghostSprite.value = {
@@ -224,7 +259,10 @@ export function useDragSprite(options: DragSpriteOptions) {
};
const stopDrag = () => {
if (isDragging.value && getAllowCellSwap() && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
const selectedIds = getSelectedSpriteIds();
const isMultiDrag = activeSpriteId.value && selectedIds.has(activeSpriteId.value) && selectedIds.size > 1;
if (isDragging.value && getAllowCellSwap() && !isMultiDrag && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
if (onUpdateSpriteCell) {
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
}
@@ -237,11 +275,11 @@ export function useDragSprite(options: DragSpriteOptions) {
currentHoverCell.value = null;
highlightCell.value = null;
ghostSprite.value = null;
initialSpritePositions.value.clear();
onDraw();
};
// Touch event handlers
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length === 1) {
const touch = event.touches[0];
@@ -271,14 +309,12 @@ export function useDragSprite(options: DragSpriteOptions) {
};
return {
// State
isDragging,
activeSpriteId,
ghostSprite,
highlightCell,
spritePositions,
// Methods
startDrag,
drag,
stopDrag,

View File

@@ -9,7 +9,6 @@ import { getMaxDimensionsAcrossLayers } from './useLayers';
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>, layers?: Ref<Layer[]>) => {
const getCellDimensions = () => {
// If manual cell size is enabled, use manual values
if (manualCellSizeEnabled?.value) {
return {
cellWidth: manualCellWidth?.value ?? 64,
@@ -18,11 +17,8 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
};
}
// Use dimensions from ALL layers to keep canvas size stable when hiding layers
// Fall back to current sprites if layers ref is not provided
const { maxWidth, maxHeight } = layers?.value ? getMaxDimensionsAcrossLayers(layers.value, false) : getMaxDimensions(sprites.value);
// Calculate negative spacing from all layers' sprites for consistency
const allSprites = layers?.value ? layers.value.flatMap(l => l.sprites) : sprites.value;
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
@@ -50,7 +46,6 @@ 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);
@@ -151,7 +146,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
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) {
sprites.value.forEach(s => {
if (s.url && s.url.startsWith('blob:')) {
@@ -215,7 +209,6 @@ 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);
@@ -263,7 +256,6 @@ 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);

View File

@@ -11,7 +11,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
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,
@@ -20,10 +19,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
};
}
// Otherwise, calculate from sprite dimensions across ALL layers (same as canvas)
// This ensures export dimensions match what's shown in the canvas
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(layersRef.value);
// Calculate negative spacing from ALL layers (not just visible) to keep canvas size stable
const allSprites = layersRef.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, negativeSpacingEnabled.value);
return {
@@ -35,7 +31,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
ctx.clearRect(0, 0, cellWidth, cellHeight);
// Apply background color if not transparent
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
ctx.fillStyle = backgroundColor.value;
ctx.fillRect(0, 0, cellWidth, cellHeight);
@@ -78,7 +73,6 @@ 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);
@@ -186,7 +180,6 @@ 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) {
@@ -194,7 +187,6 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
}
}
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;

View File

@@ -11,7 +11,6 @@ export interface FileDropOptions {
export function useFileDrop(options: FileDropOptions) {
const { onAddSprite, onAddSpriteWithResize } = options;
// Helper to get sprites array
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const isDragOver = ref(false);
@@ -60,7 +59,6 @@ export function useFileDrop(options: FileDropOptions) {
const sprites = getSprites();
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
// Check if the dropped image is larger than current cells
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
onAddSpriteWithResize(file);
} else {
@@ -94,7 +92,6 @@ export function useFileDrop(options: FileDropOptions) {
return;
}
// Process each dropped file
for (const file of files) {
await processDroppedImage(file);
}

View File

@@ -37,7 +37,6 @@ export interface GridMetricsOptions {
* 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));
@@ -52,7 +51,6 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
const calculateCellDimensions = (): GridMetrics => {
const manualCellSizeEnabled = getManualCellSizeEnabled();
// If manual cell size is enabled, use manual dimensions
if (manualCellSizeEnabled) {
return {
maxWidth: Math.round(getManualCellWidth()),
@@ -61,20 +59,16 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
};
}
// 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),
@@ -116,10 +110,8 @@ export function useGridMetrics(options: GridMetricsOptions = {}) {
const gridMetrics = computed(() => calculateCellDimensions());
return {
// Computed values
gridMetrics,
// Methods
calculateCellDimensions,
getCellPosition,
getSpriteCanvasPosition,
@@ -147,7 +139,6 @@ export function getGridMetrics(
};
}
// 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[]);

View File

@@ -70,16 +70,13 @@ export const useLayers = () => {
const l = activeLayer.value;
if (!l || !l.sprites.length) return;
// 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 ALL visible layers (not just active layer)
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
cellWidth = maxWidth;
cellHeight = maxHeight;
@@ -120,60 +117,26 @@ export const useLayers = () => {
const next = [...l.sprites];
// Remove the moving sprite first
const [moving] = next.splice(currentIndex, 1);
// Determine the actual index to insert at, considering we removed one item
// If the target index was greater than current index, it shifts down by 1 in the original array perspective?
// Actually simpler: we just want to put 'moving' at 'newIndex' in the final array.
// If newIndex is beyond the current bounds (after removal), fill with placeholders
while (next.length < newIndex) {
next.push(createEmptySprite());
}
// Now insert
// If newIndex is within bounds, we might be swapping if there was something there
// But the DragSprite logic implies we are "moving to this cell".
// If there is existing content at newIndex, we should swap or splice?
// The previous implementation did a swap if newIndex < length (before removal).
// Let's stick to the "swap" logic if there's a sprite there, or "move" if we are reordering.
// Wait, Drag and Drop usually implies "insert here" or "swap with this".
// useDragSprite says: "if allowCellSwap... updateSpriteCell".
// The original logic:
// if (newIndex < next.length) -> swap
// else -> splice (move)
// Re-evaluating original logic:
// next has NOT had the item removed yet in the original logic 'if' block.
// Let's implement robust swap/move logic.
// 1. If target is empty placeholder -> just move there (replace placeholder).
// 2. If target has sprite -> swap.
// 3. If target is out of bounds -> pad and move.
if (newIndex < l.sprites.length) {
// Perform Swap
const target = l.sprites[newIndex];
const moving = l.sprites[currentIndex];
// Clone array
const newSprites = [...l.sprites];
newSprites[currentIndex] = target;
newSprites[newIndex] = moving;
l.sprites = newSprites;
} else {
// Move to previously empty/non-existent cell
const newSprites = [...l.sprites];
// Remove from old pos
const [moved] = newSprites.splice(currentIndex, 1);
// Pad
while (newSprites.length < newIndex) {
newSprites.push(createEmptySprite());
}
// Insert (or push if equal length)
newSprites.splice(newIndex, 0, moved);
l.sprites = newSprites;
}
@@ -197,7 +160,6 @@ export const useLayers = () => {
const l = activeLayer.value;
if (!l) return;
// Sort indices in descending order to avoid shift issues when splicing
const indicesToRemove: number[] = [];
ids.forEach(id => {
const i = l.sprites.findIndex(s => s.id === id);
@@ -291,21 +253,11 @@ export const useLayers = () => {
const currentSprites = [...l.sprites];
if (typeof index === 'number') {
// If index is provided, insert there (padding if needed)
while (currentSprites.length < index) {
currentSprites.push(createEmptySprite());
}
// If valid index, replace if empty or splice?
// "Adds it not in the one I selected".
// If I select a cell, I expect it to go there.
// If the cell is empty (placeholder), replace it.
// If the cell has a sprite, maybe insert/shift?
// Usually "Add" implies append, but context menu "Add sprite" on a cell implies "Put it here".
// Let's Insert (Shift others) for safety, or check if empty.
// But simpler: just splice it in.
currentSprites.splice(index, 0, next);
} else {
// No index, append to end
currentSprites.push(next);
}
l.sprites = currentSprites;
@@ -353,7 +305,6 @@ export const useLayers = () => {
};
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
// Find the source sprite in any layer
let sourceSprite: Sprite | undefined;
for (const layer of layers.value) {
sourceSprite = layer.sprites.find(s => s.id === spriteId);
@@ -362,11 +313,9 @@ export const useLayers = () => {
if (!sourceSprite) return;
// Find target layer
const targetLayer = layers.value.find(l => l.id === targetLayerId);
if (!targetLayer) return;
// Create a deep copy of the sprite with a new ID
const copiedSprite: Sprite = {
id: crypto.randomUUID(),
file: sourceSprite.file,
@@ -381,14 +330,11 @@ export const useLayers = () => {
flipY: sourceSprite.flipY,
};
// Expand the sprites array if necessary with empty placeholder sprites
while (targetLayer.sprites.length < targetFrameIndex) {
targetLayer.sprites.push(createEmptySprite());
}
// Replace or insert the sprite at the target index
if (targetFrameIndex < targetLayer.sprites.length) {
// Replace existing sprite at this frame
const old = targetLayer.sprites[targetFrameIndex];
if (old.url && old.url.startsWith('blob:')) {
try {
@@ -397,7 +343,6 @@ export const useLayers = () => {
}
targetLayer.sprites[targetFrameIndex] = copiedSprite;
} else {
// Add at the end
targetLayer.sprites.push(copiedSprite);
}
};
@@ -429,8 +374,6 @@ export const useLayers = () => {
};
export const getMaxDimensionsAcrossLayers = (layers: Layer[], visibleOnly: boolean = false) => {
// When visibleOnly is false (default), consider ALL layers to keep canvas size stable
// When visibleOnly is true (export), only consider visible layers
const sprites = layers.flatMap(l => (visibleOnly ? (l.visible ? l.sprites : []) : l.sprites));
return getMaxDimensionsSingle(sprites);
};

View File

@@ -12,10 +12,8 @@ export function calculateNegativeSpacing(sprites: Sprite[], enabled: boolean): n
const minWidth = Math.min(...sprites.map(s => s.width));
const minHeight = Math.min(...sprites.map(s => s.height));
// Available space is the gap between cell size and smallest sprite
const availableWidth = maxWidth - minWidth;
const availableHeight = maxHeight - minHeight;
// Use half to balance spacing equally on all sides
return Math.floor(Math.min(availableWidth, availableHeight) / 2);
}

View File

@@ -23,22 +23,17 @@ export const useProjectManager = () => {
);
const createProject = (config: { width: number; height: number; columns: number; rows: number }) => {
// 1. Reset Settings
settingsStore.setManualCellSize(config.width, config.height);
settingsStore.manualCellSizeEnabled = true;
// 2. Reset Layers
const newLayer = createEmptyLayer('Base');
layers.value = [newLayer];
activeLayerId.value = newLayer.id;
// 3. Set Columns
columns.value = config.columns;
// 4. Reset Project Store
projectStore.currentProject = null;
// 5. Navigate to Editor
router.push('/editor');
};
@@ -60,12 +55,9 @@ export const useProjectManager = () => {
const data = await generateProjectJSON();
if (projectStore.currentProject) {
// Update existing project (even if name changed)
await projectStore.updateProject(projectStore.currentProject.id, name, data);
} else {
// Create new project if none exists
await projectStore.createProject(name, data);
// After creating, we should update route to include ID so subsequent saves update it
const newProject = projectStore.currentProject as Project | null;
if (newProject) {
router.replace({ name: 'editor', params: { id: newProject.id } });
@@ -81,9 +73,7 @@ export const useProjectManager = () => {
const saveAsProject = async (name: string) => {
try {
const data = await generateProjectJSON();
// Always create new
await projectStore.createProject(name, data);
// Navigate to new project
if (projectStore.currentProject) {
router.push({ name: 'editor', params: { id: projectStore.currentProject.id } });
}
@@ -95,18 +85,14 @@ export const useProjectManager = () => {
};
const closeProject = () => {
// Reset Layers
const newLayer = createEmptyLayer('Base');
layers.value = [newLayer];
activeLayerId.value = newLayer.id;
// Reset columns
columns.value = 4;
// Reset Project Store
projectStore.currentProject = null;
// Navigate Home
router.push('/');
};

View File

@@ -24,12 +24,10 @@ export function useSEO(metadata: SEOMetaData) {
const imageUrl = metadata.image ? `${SITE_URL}${metadata.image}` : `${SITE_URL}${DEFAULT_IMAGE}`;
const metaTags: any[] = [
// Primary Meta Tags
{ name: 'title', content: fullTitle },
{ name: 'description', content: metadata.description },
{ name: 'robots', content: 'index, follow' },
// Open Graph / Facebook
{ property: 'og:type', content: metadata.type || 'website' },
{ property: 'og:url', content: fullUrl },
{ property: 'og:title', content: fullTitle },
@@ -37,7 +35,6 @@ export function useSEO(metadata: SEOMetaData) {
{ property: 'og:image', content: imageUrl },
{ property: 'og:site_name', content: SITE_NAME },
// Twitter
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:url', content: fullUrl },
{ name: 'twitter:title', content: fullTitle },
@@ -45,7 +42,6 @@ export function useSEO(metadata: SEOMetaData) {
{ name: 'twitter:image', content: imageUrl },
];
// Add article-specific meta tags
if (metadata.type === 'article') {
if (metadata.author) {
metaTags.push({ property: 'article:author', content: metadata.author });
@@ -58,7 +54,6 @@ export function useSEO(metadata: SEOMetaData) {
}
}
// Add keywords if provided
if (metadata.keywords) {
metaTags.push({ name: 'keywords', content: metadata.keywords });
}

View File

@@ -59,7 +59,6 @@ export const buildShareUrl = (id: string): string => {
* Share a spritesheet by uploading to PocketBase
*/
export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>): Promise<ShareResult> => {
// Build layers data with base64 sprites (same format as exportSpritesheetJSON)
const layersData = await Promise.all(
layersRef.value.map(async layer => {
const sprites = await Promise.all(
@@ -80,7 +79,6 @@ export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<num
ctx.drawImage(sprite.img, 0, 0);
}
const base64 = canvas.toDataURL('image/png');
// Since we bake transformations into the image, set them to 0/false in metadata
return {
id: sprite.id,
width: sprite.width,

View File

@@ -5,7 +5,6 @@ export const useSprites = () => {
const sprites = ref<Sprite[]>([]);
const columns = ref(4);
// Clamp and coerce columns to a safe range [1..10]
watch(columns, val => {
const num = typeof val === 'number' ? val : parseInt(String(val));
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;

View File

@@ -49,10 +49,8 @@ export function useSpritesheetSplitter() {
let height = cellHeight;
if (preserveCellSize) {
// Keep full cell with transparent padding
url = canvas.toDataURL('image/png');
} else {
// Crop to sprite bounds
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
if (bounds) {
x = bounds.x;
@@ -94,7 +92,6 @@ export function useSpritesheetSplitter() {
const imageData = ctx.getImageData(0, 0, img.width, img.height);
// Initialize worker lazily
if (!worker.value) {
try {
worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
@@ -161,7 +158,6 @@ export function useSpritesheetSplitter() {
spriteCtx.clearRect(0, 0, width, height);
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
// Remove background color
removeBackground(spriteCtx, width, height, backgroundColor);
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
@@ -209,7 +205,6 @@ export function useSpritesheetSplitter() {
if (!hasContent) return null;
// Add small padding
const pad = 1;
return {
x: Math.max(0, minX - pad),

View File

@@ -24,7 +24,6 @@ export interface FAQItem {
}
export function useStructuredData() {
// Organization Schema
const addOrganizationSchema = () => {
const schema = {
'@context': 'https://schema.org',
@@ -46,7 +45,6 @@ export function useStructuredData() {
});
};
// WebSite Schema
const addWebSiteSchema = () => {
const schema = {
'@context': 'https://schema.org',
@@ -71,7 +69,6 @@ export function useStructuredData() {
});
};
// BlogPosting Schema
const addBlogPostSchema = (post: BlogPostSchema) => {
const schema = {
'@context': 'https://schema.org',
@@ -109,7 +106,6 @@ export function useStructuredData() {
});
};
// Breadcrumb Schema
const addBreadcrumbSchema = (items: BreadcrumbItem[]) => {
const schema = {
'@context': 'https://schema.org',
@@ -132,7 +128,6 @@ export function useStructuredData() {
});
};
// Blog List Schema
const addBlogListSchema = (posts: BlogPostSchema[]) => {
const schema = {
'@context': 'https://schema.org',
@@ -164,7 +159,6 @@ export function useStructuredData() {
});
};
// FAQ Schema
const addFAQSchema = (faqs: FAQItem[]) => {
const schema = {
'@context': 'https://schema.org',

View File

@@ -32,7 +32,6 @@ export function useZoom(options: ZoomOptions) {
if (currentIndex < options.allowedValues.length - 1) {
zoom.value = options.allowedValues[currentIndex + 1];
} else if (currentIndex === -1) {
// Find the nearest higher value
const higher = options.allowedValues.find(v => v > zoom.value);
if (higher !== undefined) {
zoom.value = higher;
@@ -49,7 +48,6 @@ export function useZoom(options: ZoomOptions) {
if (currentIndex > 0) {
zoom.value = options.allowedValues[currentIndex - 1];
} else if (currentIndex === -1) {
// Find the nearest lower value
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
if (lower !== undefined) {
zoom.value = lower;
@@ -66,7 +64,6 @@ export function useZoom(options: ZoomOptions) {
if (isStepOptions(options)) {
zoom.value = Math.max(options.min, Math.min(options.max, value));
} else {
// Snap to nearest allowed value
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
zoom.value = nearest;
}

View File

@@ -6,7 +6,6 @@ export const useAuthStore = defineStore('auth', () => {
const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
const user = ref(pb.authStore.model);
// Sync user state on change
pb.authStore.onChange(() => {
user.value = pb.authStore.model;
});
@@ -21,7 +20,6 @@ export const useAuthStore = defineStore('auth', () => {
password,
passwordConfirm,
});
// Auto login after register
await login(email, password);
}

View File

@@ -10,27 +10,21 @@ const manualCellWidth = ref(64);
const manualCellHeight = ref(64);
const checkerboardEnabled = ref(false);
// Initialize dark mode from localStorage or system preference
if (typeof window !== 'undefined') {
// Check localStorage first
const storedDarkMode = localStorage.getItem('darkMode');
if (storedDarkMode !== null) {
darkMode.value = storedDarkMode === 'true';
} else {
// If not in localStorage, check system preference
darkMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
}
export const useSettingsStore = defineStore('settings', () => {
// Watch for changes to update localStorage and apply class
watch(
darkMode,
newValue => {
// Save to localStorage
localStorage.setItem('darkMode', newValue.toString());
// Apply or remove dark class on document
if (newValue) {
document.documentElement.classList.add('dark');
} else {
@@ -40,7 +34,6 @@ export const useSettingsStore = defineStore('settings', () => {
{ immediate: true }
);
// Actions
function togglePixelPerfect() {
pixelPerfect.value = !pixelPerfect.value;
}

View File

@@ -4,7 +4,6 @@
const { addBreadcrumbSchema } = useStructuredData();
// Set SEO synchronously
useSEO({
title: 'About Us - Our Mission & Story',
description: 'Learn about Spritesheet Generator, a free tool designed to help game developers and artists streamline their workflow with optimized spritesheet creation.',

View File

@@ -13,7 +13,6 @@
const slug = computed(() => route.params.slug as string);
// Reactive SEO data that updates when post loads
const pageTitle = computed(() => (post.value ? `${post.value.title} - Spritesheet Generator` : 'Blog Post - Spritesheet Generator'));
const pageDescription = computed(() => post.value?.description || 'Read our latest article about spritesheet generation and game development.');
@@ -24,7 +23,6 @@
const keywords = computed(() => post.value?.keywords || 'sprite sheet, game development, blog');
// Dynamic meta tags using reactive computed values
useHead({
title: pageTitle,
meta: [
@@ -33,7 +31,6 @@
{ name: 'keywords', content: keywords },
{ name: 'robots', content: 'index, follow' },
// Open Graph
{ property: 'og:type', content: 'article' },
{ property: 'og:url', content: pageUrl },
{ property: 'og:title', content: pageTitle },
@@ -43,7 +40,6 @@
{ property: 'article:author', content: computed(() => post.value?.author || 'streetshadow') },
{ property: 'article:published_time', content: computed(() => post.value?.date || '') },
// Twitter
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:url', content: pageUrl },
{ name: 'twitter:title', content: pageTitle },
@@ -54,7 +50,6 @@
script: computed(() => {
const scripts = [];
// Breadcrumb schema
scripts.push({
type: 'application/ld+json',
children: JSON.stringify({
@@ -83,7 +78,6 @@
}),
});
// Blog post schema
if (post.value) {
scripts.push({
type: 'application/ld+json',

View File

@@ -9,7 +9,6 @@
const { addBreadcrumbSchema } = useStructuredData();
const posts = ref<BlogPost[]>([]);
// Set SEO meta tags synchronously
useSEO({
title: 'Blog - Latest Articles on Spritesheet Generation',
description: 'Explore our latest articles about sprite sheet generation, game development, pixel art, and sprite animation techniques.',
@@ -18,7 +17,6 @@
keywords: 'sprite sheet blog, game development articles, pixel art tutorials, sprite animation',
});
// Add breadcrumb synchronously
addBreadcrumbSchema([
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog' },

View File

@@ -4,7 +4,6 @@
const { addBreadcrumbSchema } = useStructuredData();
// Set SEO synchronously
useSEO({
title: 'Contact Us - Get in Touch',
description: "Contact the Spritesheet Generator team. Join our Discord community or report bugs and contribute on Gitea. We'd love to hear from you!",

View File

@@ -374,7 +374,6 @@
toRef(settingsStore, 'manualCellHeight')
);
// Zoom Control
const {
zoom,
increase: zoomIn,
@@ -387,7 +386,6 @@
initial: 1,
});
// View Options & Tools
const isMultiSelectMode = ref(false);
const showActiveBorder = ref(true);
const allowCellSwap = ref(false);
@@ -397,7 +395,6 @@
const customColor = ref('#ffffff');
const isCustomMode = ref(false);
// Background Color Logic
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);
@@ -460,7 +457,6 @@
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
// Upload Handlers
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
@@ -561,7 +557,6 @@
}
};
// Layer Editing
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
@@ -640,7 +635,6 @@
const id = route.params.id as string;
if (id) {
if (projectStore.currentProject?.id !== id) {
// Only load if active project is different
await projectStore.loadProject(id);
if (projectStore.currentProject?.data) {
await loadProjectData(projectStore.currentProject.data);
@@ -660,8 +654,6 @@
}
}
} else {
// If navigated to /editor without ID, maybe clear logic?
// We likely want to keep state if user created new project.
}
}
);

View File

@@ -4,7 +4,6 @@
const { addFAQSchema, addBreadcrumbSchema } = useStructuredData();
// Set SEO synchronously
useSEO({
title: 'FAQ - Frequently Asked Questions',
description: 'Find answers to common questions about the Spritesheet Generator. Learn about supported formats, export options, and how to use the tool effectively.',

View File

@@ -5,7 +5,6 @@ import { useHead } from '@vueuse/head';
export function useHomeViewSEO() {
const { addOrganizationSchema, addWebSiteSchema } = useStructuredData();
// Set page SEO synchronously
useSEO({
title: 'Spritesheet Generator - Create Game Spritesheets Online',
description: 'Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.',
@@ -14,13 +13,10 @@ export function useHomeViewSEO() {
keywords: 'spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools',
});
// Add organization schema
addOrganizationSchema();
// Add website schema
addWebSiteSchema();
// Add SoftwareApplication schema
useHead({
script: [
{

View File

@@ -6,18 +6,13 @@
<div class="text-6xl">👻</div>
</div>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4">Page not found</h1>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-md mb-8">
Oops! The page you're looking for doesn't exist or has been moved.
</p>
<router-link
to="/"
class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl transition-all shadow-lg hover:shadow-indigo-500/30 flex items-center gap-2"
>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-md mb-8">Oops! The page you're looking for doesn't exist or has been moved.</p>
<router-link to="/" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl transition-all shadow-lg hover:shadow-indigo-500/30 flex items-center gap-2">
<i class="fas fa-home"></i>
<span>Go home</span>
</router-link>
</div>
</template>
</template>

View File

@@ -4,7 +4,6 @@
const { addBreadcrumbSchema } = useStructuredData();
// Set SEO synchronously
useSEO({
title: 'Privacy Policy - Your Data Protection',
description: 'Read our privacy policy. Spritesheet Generator is a client-side tool that does not collect personal data or upload your images to our servers.',

View File

@@ -169,7 +169,6 @@
const data = spritesheetData.value;
// Apply config settings
columns.value = data.config.columns;
settingsStore.negativeSpacingEnabled = data.config.negativeSpacingEnabled;
settingsStore.backgroundColor = data.config.backgroundColor;
@@ -177,7 +176,6 @@
settingsStore.manualCellWidth = data.config.manualCellWidth;
settingsStore.manualCellHeight = data.config.manualCellHeight;
// Load sprites into layers
const loadSprite = (spriteData: any): Promise<Sprite> =>
new Promise(resolve => {
const img = new Image();
@@ -199,7 +197,6 @@
height: spriteData.height,
x: spriteData.x || 0,
y: spriteData.y || 0,
// Transformations are already baked into the base64 image
rotation: 0,
flipX: false,
flipY: false,
@@ -226,10 +223,8 @@
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
}
// Clear current project so it's treated as new/unsaved
projectStore.currentProject = null;
// Navigate to editor
router.push({ name: 'editor' });
};

View File

@@ -19,7 +19,6 @@ interface WorkerResponse {
backgroundColor: [number, number, number, number];
}
// Pre-allocate arrays for better performance
let maskBuffer: Uint8Array;
let visitedBuffer: Uint8Array;
let stackBuffer: Int32Array;
@@ -43,7 +42,6 @@ self.onmessage = function (e: MessageEvent<WorkerMessage>) {
function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSize: number): { sprites: SpriteRegion[]; backgroundColor: [number, number, number, number] } {
const { data, width, height } = imageData;
// Downsample for very large images
const shouldDownsample = width > maxSize || height > maxSize;
let processedData: Uint8ClampedArray;
let processedWidth: number;
@@ -61,23 +59,17 @@ function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSi
processedHeight = height;
}
// Fast background detection using histogram
const backgroundColor = fastBackgroundDetection(processedData, processedWidth, processedHeight);
// Create optimized mask
const mask = createOptimizedMask(processedData, processedWidth, processedHeight, backgroundColor, sensitivity);
// Clean up mask with morphological operations
const cleanedMask = cleanUpMask(mask, processedWidth, processedHeight);
// Find connected components with optimized flood fill
const sprites = findOptimizedConnectedComponents(cleanedMask, processedWidth, processedHeight);
// Filter noise
const minSpriteSize = Math.max(4, Math.floor(Math.min(processedWidth, processedHeight) / 100));
const filteredSprites = sprites.filter(sprite => sprite.pixelCount >= minSpriteSize);
// Scale results back up if downsampled
const finalSprites = shouldDownsample
? filteredSprites.map(sprite => ({
x: Math.floor(sprite.x / scale),
@@ -88,7 +80,6 @@ function detectIrregularSprites(imageData: ImageData, sensitivity: number, maxSi
}))
: filteredSprites;
// Convert background color back to original format
const finalBackgroundColor: [number, number, number, number] = [backgroundColor[0], backgroundColor[1], backgroundColor[2], backgroundColor[3]];
return {
@@ -120,10 +111,8 @@ function downsampleImageData(data: Uint8ClampedArray, width: number, height: num
}
function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height: number): Uint32Array {
// Enhanced background detection focusing on edges and corners
const colorCounts = new Map<string, number>();
// Sample from corners (most likely to be background)
const cornerSamples = [
[0, 0],
[width - 1, 0],
@@ -131,26 +120,21 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
[width - 1, height - 1],
];
// Sample from edges (also likely background)
const edgeSamples: [number, number][] = [];
const edgeStep = Math.max(1, Math.floor(Math.min(width, height) / 20));
// Top and bottom edges
for (let x = 0; x < width; x += edgeStep) {
edgeSamples.push([x, 0]);
edgeSamples.push([x, height - 1]);
}
// Left and right edges
for (let y = 0; y < height; y += edgeStep) {
edgeSamples.push([0, y]);
edgeSamples.push([width - 1, y]);
}
// Collect all samples
const allSamples = [...cornerSamples, ...edgeSamples];
// Count colors with tolerance grouping
const tolerance = 15;
for (const [x, y] of allSamples) {
@@ -160,7 +144,6 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
const b = data[idx + 2];
const a = data[idx + 3];
// Find existing similar color or create new entry
let matched = false;
for (const [colorKey, count] of colorCounts.entries()) {
const [existingR, existingG, existingB, existingA] = colorKey.split(',').map(Number);
@@ -178,7 +161,6 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
}
}
// Find most common color
let maxCount = 0;
let backgroundColor = [0, 0, 0, 0];
@@ -195,13 +177,10 @@ function fastBackgroundDetection(data: Uint8ClampedArray, width: number, height:
function createOptimizedMask(data: Uint8ClampedArray, width: number, height: number, backgroundColor: Uint32Array, sensitivity: number): Uint8Array {
const size = width * height;
// Reuse buffer if possible
if (!maskBuffer || maskBuffer.length < size) {
maskBuffer = new Uint8Array(size);
}
// Map sensitivity (1-100) to more aggressive thresholds
// Higher sensitivity = stricter background matching (lower tolerance)
const colorTolerance = Math.round(50 - sensitivity * 0.45); // 50 down to 5
const alphaTolerance = Math.round(40 - sensitivity * 0.35); // 40 down to 5
@@ -214,14 +193,12 @@ function createOptimizedMask(data: Uint8ClampedArray, width: number, height: num
const b = data[idx + 2];
const a = data[idx + 3];
// Handle fully transparent pixels (common background case)
if (a < 10) {
maskBuffer[i] = 0; // Treat as background
idx += 4;
continue;
}
// Calculate color difference using Euclidean distance for better accuracy
const rDiff = r - bgR;
const gDiff = g - bgG;
const bDiff = b - bgB;
@@ -230,7 +207,6 @@ function createOptimizedMask(data: Uint8ClampedArray, width: number, height: num
const colorDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
const alphaDistance = Math.abs(aDiff);
// Pixel is foreground if it's significantly different from background
const isBackground = colorDistance <= colorTolerance && alphaDistance <= alphaTolerance;
maskBuffer[i] = isBackground ? 0 : 1;
@@ -241,20 +217,12 @@ function createOptimizedMask(data: Uint8ClampedArray, width: number, height: num
}
function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Array {
// Simple morphological closing to fill small gaps in sprites
// and opening to remove small noise
const cleaned = new Uint8Array(mask.length);
// Erosion followed by dilation (opening) to remove small noise
// Then dilation followed by erosion (closing) to fill gaps
// Simple 3x3 kernel operations
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Count non-zero neighbors in 3x3 area
let neighbors = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
@@ -263,13 +231,10 @@ function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Arra
}
}
// Use majority rule for cleaning
// If more than half the neighbors are foreground, make this foreground
cleaned[idx] = neighbors >= 5 ? 1 : 0;
}
}
// Copy borders as-is
for (let x = 0; x < width; x++) {
cleaned[x] = mask[x]; // Top row
cleaned[(height - 1) * width + x] = mask[(height - 1) * width + x]; // Bottom row
@@ -285,7 +250,6 @@ function cleanUpMask(mask: Uint8Array, width: number, height: number): Uint8Arra
function findOptimizedConnectedComponents(mask: Uint8Array, width: number, height: number): SpriteRegion[] {
const size = width * height;
// Reuse buffers
if (!visitedBuffer || visitedBuffer.length < size) {
visitedBuffer = new Uint8Array(size);
}
@@ -293,7 +257,6 @@ function findOptimizedConnectedComponents(mask: Uint8Array, width: number, heigh
stackBuffer = new Int32Array(size * 2);
}
// Clear visited array
visitedBuffer.fill(0);
const sprites: SpriteRegion[] = [];
@@ -337,13 +300,11 @@ function optimizedFloodFill(mask: Uint8Array, visited: Uint8Array, startX: numbe
visited[idx] = 1;
pixelCount++;
// Update bounding box
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
// Add neighbors (check bounds to avoid stack overflow)
if (x + 1 < width && !visited[idx + 1] && mask[idx + 1]) {
stackBuffer[stackTop++] = x + 1;
stackBuffer[stackTop++] = y;
@@ -364,7 +325,6 @@ function optimizedFloodFill(mask: Uint8Array, visited: Uint8Array, startX: numbe
if (pixelCount === 0) return null;
// Add padding
const padding = 1;
return {
x: Math.max(0, minX - padding),