[FEAT] Clean code
This commit is contained in:
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%)`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('/');
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -8,14 +8,9 @@
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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' });
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user