UI and application enhancements
This commit is contained in:
24
package-lock.json
generated
24
package-lock.json
generated
@@ -965,9 +965,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ianvs/prettier-plugin-sort-imports": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@ianvs/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.6.0.tgz",
|
||||
"integrity": "sha512-ZGhdOuMv6Y0dqv5eaKMBfHR3cwTY11fFEUhiTDpx7v2m1FrfgqZsBOrLd/zA0lTEKAo9ltNlHd6jx5h3eh4Htg==",
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@ianvs/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.6.1.tgz",
|
||||
"integrity": "sha512-Q0gujs/g6tw1uOGd1bTVLGDzAwb/UwrCehhPCQevTCYDkho8+eQSOhA0zcy9AM0XnXM1ChBWsZeE4P7Nym0pyw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -1971,9 +1971,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz",
|
||||
"integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==",
|
||||
"version": "22.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz",
|
||||
"integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2455,9 +2455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001731",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz",
|
||||
"integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==",
|
||||
"version": "1.0.30001733",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001733.tgz",
|
||||
"integrity": "sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2657,9 +2657,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.198",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.198.tgz",
|
||||
"integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==",
|
||||
"version": "1.5.199",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.199.tgz",
|
||||
"integrity": "sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.2.0] - 2025-09-08
|
||||
- Fixed splitter logic
|
||||
- Fixed minor bugs
|
||||
|
||||
## [1.9.0] - 2025-05-20
|
||||
- Fixed splitter UI in dark mode
|
||||
|
||||
|
||||
76
src/App.vue
76
src/App.vue
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-3 sm:p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 transition-colors duration-300">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-8 gap-2">
|
||||
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight text-center sm:text-left">Spritesheet generator</h1>
|
||||
<dark-mode-toggle />
|
||||
@@ -33,7 +33,7 @@
|
||||
<input
|
||||
id="columns"
|
||||
type="number"
|
||||
v-model="columns"
|
||||
v-model.number="columns"
|
||||
min="1"
|
||||
max="10"
|
||||
class="w-20 px-3 py-2 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent outline-none transition-all text-base"
|
||||
@@ -108,7 +108,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
import FileUploader from './components/FileUploader.vue';
|
||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||
import Modal from './components/utilities/Modal.vue';
|
||||
@@ -117,8 +117,8 @@
|
||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from './components/GifFpsModal.vue';
|
||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||
import { useSettingsStore } from './stores/useSettingsStore';
|
||||
import GIF from 'gif.js';
|
||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
interface Sprite {
|
||||
@@ -142,6 +142,12 @@
|
||||
|
||||
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;
|
||||
if (safe !== columns.value) columns.value = safe;
|
||||
});
|
||||
const isPreviewModalOpen = ref(false);
|
||||
const isHelpModalOpen = ref(false);
|
||||
const isSpritesheetSplitterOpen = ref(false);
|
||||
@@ -225,7 +231,10 @@
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -268,9 +277,8 @@
|
||||
|
||||
// Preview modal control
|
||||
const openPreviewModal = () => {
|
||||
console.log('Opening preview modal');
|
||||
if (sprites.value.length === 0) {
|
||||
console.log('No sprites to preview');
|
||||
alert('Please upload or import sprites to preview an animation.');
|
||||
return;
|
||||
}
|
||||
isPreviewModalOpen.value = true;
|
||||
@@ -302,7 +310,10 @@
|
||||
|
||||
// GIF FPS modal control
|
||||
const openGifFpsModal = () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
isGifFpsModalOpen.value = true;
|
||||
};
|
||||
|
||||
@@ -326,8 +337,8 @@
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: spriteFile.x, // Use the position from the splitter
|
||||
y: spriteFile.y, // Use the position from the splitter
|
||||
x: 0, // Start at top-left of cell; ignore splitter bounding-box offset for display
|
||||
y: 0, // Start at top-left of cell; ignore splitter bounding-box offset for display
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
@@ -340,7 +351,10 @@
|
||||
|
||||
// Export spritesheet as JSON with base64 images
|
||||
const exportSpritesheetJSON = async () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Nothing to export. Please add sprites first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an array to store sprite data with base64 images
|
||||
const spritesData = await Promise.all(
|
||||
@@ -426,6 +440,17 @@
|
||||
|
||||
// Process each sprite
|
||||
// Replace current sprites with imported ones
|
||||
// Revoke existing blob: URLs to avoid memory leaks
|
||||
if (sprites.value.length) {
|
||||
sprites.value.forEach(s => {
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sprites.value = await Promise.all(
|
||||
jsonData.sprites.map(async (spriteData: any) => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
@@ -548,7 +573,10 @@
|
||||
|
||||
// Download as GIF with specified FPS
|
||||
const downloadAsGif = (fps: number) => {
|
||||
if (sprites.value.length === 0) return;
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
@@ -577,7 +605,7 @@
|
||||
quality: 10,
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
workerScript: '/gif.worker.js',
|
||||
workerScript: gifWorkerUrl,
|
||||
});
|
||||
|
||||
// Add each sprite as a frame
|
||||
@@ -612,9 +640,23 @@
|
||||
gif.render();
|
||||
};
|
||||
|
||||
// Revoke blob URLs on unmount to avoid memory leaks
|
||||
onUnmounted(() => {
|
||||
sprites.value.forEach(s => {
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Download as ZIP with each cell individually
|
||||
const downloadAsZip = async () => {
|
||||
if (sprites.value.length === 0) return;
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before downloading a ZIP.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new ZIP file
|
||||
const zip = new JSZip();
|
||||
@@ -664,8 +706,10 @@
|
||||
uint8Array[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Add to ZIP file
|
||||
zip.file(`sprite_${index + 1}.png`, uint8Array);
|
||||
// Add to ZIP file with clearer naming
|
||||
const baseName = sprite.file?.name ? sprite.file.name.replace(/\s+/g, '_') : `sprite_${index + 1}.png`;
|
||||
const name = `${index + 1}_${baseName}`;
|
||||
zip.file(name, uint8Array);
|
||||
});
|
||||
|
||||
// Generate ZIP file
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="mb-6">
|
||||
<label for="fps" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frames Per Second (FPS)</label>
|
||||
<div class="flex items-center">
|
||||
<input id="fps" v-model="fpsValue" type="number" min="1" max="60" class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm text-base" data-rybbit-event="gif-fps-input" />
|
||||
<input id="fps" v-model.number="fpsValue" type="number" min="1" max="60" class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm text-base" data-rybbit-event="gif-fps-input" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Higher FPS will result in smoother animation but larger file size.</p>
|
||||
</div>
|
||||
|
||||
@@ -92,8 +92,6 @@
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
const spritePositions = computed(() => {
|
||||
if (!canvasRef.value) return [];
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
return props.sprites.map((sprite, index) => {
|
||||
@@ -119,22 +117,38 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Cache last known max dimensions to avoid collapsing cells while images are loading
|
||||
const lastMaxWidth = ref(1);
|
||||
const lastMaxHeight = ref(1);
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
const img = sprite.img as HTMLImageElement | undefined;
|
||||
const w = Math.max(0, sprite.width || (img ? img.naturalWidth || img.width || 0 : 0));
|
||||
const h = Math.max(0, sprite.height || (img ? img.naturalHeight || img.height || 0 : 0));
|
||||
maxWidth = Math.max(maxWidth, w);
|
||||
maxHeight = Math.max(maxHeight, h);
|
||||
});
|
||||
|
||||
// Add some padding to ensure sprites have room to move
|
||||
return { maxWidth: maxWidth, maxHeight: maxHeight };
|
||||
// Keep dimensions at least as large as last known to prevent temporary collapse during loading
|
||||
maxWidth = Math.max(1, maxWidth, lastMaxWidth.value);
|
||||
maxHeight = Math.max(1, maxHeight, lastMaxHeight.value);
|
||||
|
||||
lastMaxWidth.value = maxWidth;
|
||||
lastMaxHeight.value = maxHeight;
|
||||
|
||||
return { maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
|
||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
@@ -287,11 +301,8 @@
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
// Don't prevent default to allow scrolling
|
||||
if (event.touches.length === 1) {
|
||||
if (!canvasRef.value) return;
|
||||
const touch = event.touches[0];
|
||||
const rect = canvasRef.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
// Adjust for zoom
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
@@ -322,12 +333,9 @@
|
||||
// Search in reverse order to get the topmost sprite first
|
||||
for (let i = spritePositions.value.length - 1; i >= 0; i--) {
|
||||
const pos = spritePositions.value[i];
|
||||
const sprite = props.sprites.find(s => s.id === pos.id);
|
||||
|
||||
if (!sprite) continue;
|
||||
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + sprite.width && y >= pos.canvasY && y <= pos.canvasY + sprite.height) {
|
||||
return sprite;
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
|
||||
return props.sprites.find(s => s.id === pos.id) || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -339,7 +347,7 @@
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.ceil(props.sprites.length / props.columns);
|
||||
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
|
||||
canvasRef.value.width = maxWidth * props.columns;
|
||||
canvasRef.value.height = maxHeight * rows;
|
||||
|
||||
@@ -380,7 +388,7 @@
|
||||
props.sprites.forEach((sprite, spriteIndex) => {
|
||||
if (spriteIndex !== cellIndex) {
|
||||
// Don't draw the cell's own sprite with transparency
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||
}
|
||||
});
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
@@ -401,7 +409,7 @@
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw sprite using integer positions for pixel-perfect rendering
|
||||
ctx.value?.drawImage(sprite.img, cellX + sprite.x, cellY + sprite.y);
|
||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||
});
|
||||
|
||||
// Draw ghost sprite if we're dragging between cells
|
||||
@@ -430,12 +438,32 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Track which images already have listeners
|
||||
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
||||
|
||||
const attachImageListeners = () => {
|
||||
props.sprites.forEach(sprite => {
|
||||
const img = sprite.img as HTMLImageElement | undefined;
|
||||
if (img && !imagesWithListeners.has(img)) {
|
||||
imagesWithListeners.add(img);
|
||||
if (!img.complete) {
|
||||
// Redraw when the image loads or errors (to reflect updated dimensions)
|
||||
img.addEventListener('load', handleForceRedraw, { once: true } as AddEventListenerOptions);
|
||||
img.addEventListener('error', handleForceRedraw, { once: true } as AddEventListenerOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
// Attach listeners for current sprites
|
||||
attachImageListeners();
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
@@ -459,17 +487,18 @@
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.sprites, drawCanvas, { deep: true });
|
||||
watch(
|
||||
() => props.sprites,
|
||||
() => {
|
||||
attachImageListeners();
|
||||
drawCanvas();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(() => props.columns, drawCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawCanvas);
|
||||
watch(() => settingsStore.darkMode, drawCanvas);
|
||||
watch(showAllSprites, drawCanvas);
|
||||
|
||||
// Add scale computed property
|
||||
const scale = computed(() => {
|
||||
if (!canvasRef.value) return 1;
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
return rect.width / canvasRef.value.width;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split Spritesheet">
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split spritesheet">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
@@ -85,7 +85,7 @@
|
||||
:disabled="previewSprites.length === 0 || isProcessing"
|
||||
data-rybbit-event="spritesheet-split"
|
||||
>
|
||||
Split Spritesheet
|
||||
Split spritesheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,19 +224,26 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return bounding box with a small padding
|
||||
// Return bounding box with a small padding, ensuring it stays within bounds
|
||||
const bx = Math.max(0, minX - 1);
|
||||
const by = Math.max(0, minY - 1);
|
||||
const bw = Math.min(width - bx, maxX - minX + 3); // +1 for inclusive bounds, +2 for padding
|
||||
const bh = Math.min(height - by, maxY - minY + 3);
|
||||
|
||||
return {
|
||||
x: Math.max(0, minX - 1),
|
||||
y: Math.max(0, minY - 1),
|
||||
width: Math.min(width, maxX - minX + 3), // +1 for inclusive bounds, +2 for padding
|
||||
height: Math.min(height, maxY - minY + 3),
|
||||
x: bx,
|
||||
y: by,
|
||||
width: Math.max(1, bw),
|
||||
height: Math.max(1, bh),
|
||||
};
|
||||
}
|
||||
|
||||
// Split spritesheet manually based on rows and columns
|
||||
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
|
||||
const spriteWidth = Math.floor(img.width / columns);
|
||||
const spriteHeight = Math.floor(img.height / rows);
|
||||
const safeColumns = Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 1;
|
||||
const safeRows = Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1;
|
||||
const spriteWidth = Math.max(1, Math.floor(img.width / safeColumns));
|
||||
const spriteHeight = Math.max(1, Math.floor(img.height / safeRows));
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
@@ -254,8 +261,8 @@
|
||||
canvas.height = spriteHeight;
|
||||
|
||||
// Split the image into individual sprites
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < columns; col++) {
|
||||
for (let row = 0; row < safeRows; row++) {
|
||||
for (let col = 0; col < safeColumns; col++) {
|
||||
// Clear the canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
@@ -339,35 +346,233 @@
|
||||
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
|
||||
|
||||
if (detectedWidth > 0 && detectedHeight > 0) {
|
||||
const detectedRows = Math.floor(img.height / detectedHeight);
|
||||
const detectedColumns = Math.floor(img.width / detectedWidth);
|
||||
// Sanity thresholds to avoid absurdly tiny tiles/huge counts
|
||||
const MIN_TILE = 8;
|
||||
const MAX_SPRITES = 1024;
|
||||
if (detectedWidth < MIN_TILE || detectedHeight < MIN_TILE) {
|
||||
// Fallback if tile is too small
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
const detectedRows = Math.max(1, Math.floor(img.height / detectedHeight));
|
||||
const detectedColumns = Math.max(1, Math.floor(img.width / detectedWidth));
|
||||
|
||||
// If the detected combination is unreasonably high, fallback to estimate
|
||||
if (detectedRows * detectedColumns > MAX_SPRITES) {
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the detected size to split the spritesheet
|
||||
await splitSpritesheet(img, detectedRows, detectedColumns);
|
||||
} else {
|
||||
// Fallback to manual splitting with a reasonable guess
|
||||
const estimatedSize = Math.max(16, Math.floor(Math.min(img.width, img.height) / 8));
|
||||
const estimatedRows = Math.floor(img.height / estimatedSize);
|
||||
const estimatedColumns = Math.floor(img.width / estimatedSize);
|
||||
// Fallback to manual splitting with a reasonable guess based on sensitivity
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(8, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to detect sprite size based on transparency patterns
|
||||
// Helper function to detect sprite size based on transparency/color gutters and edge periodicity
|
||||
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
|
||||
// This is a simplified implementation
|
||||
// A real implementation would be more sophisticated
|
||||
// Map sensitivity (1-100) to thresholds:
|
||||
// Higher sensitivity -> allows stricter background matching and lower gutter proportion thresholds
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
|
||||
// The sensitivity affects how aggressive we are in detecting patterns
|
||||
const threshold = 100 - sensitivity.value; // Lower threshold = more sensitive
|
||||
// Background/color thresholds
|
||||
const alphaBgThresh = Math.round(255 * (0.15 + (100 - s) * 0.001)); // 15%-25% depending on sensitivity
|
||||
const colorTol = Math.round(10 + (100 - s) * 0.8); // 10..90 Euclidean approx
|
||||
const gutterPropThresh = 0.92 - s * 0.004; // 0.92 down to ~0.52
|
||||
|
||||
// For now, return a simple estimate based on image size
|
||||
// In a real implementation, we would analyze the image data to find patterns
|
||||
return {
|
||||
detectedWidth: 0, // Return 0 to fall back to the manual method
|
||||
detectedHeight: 0,
|
||||
};
|
||||
function getPixel(x: number, y: number) {
|
||||
const idx = (y * width + x) * 4;
|
||||
return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]] as [number, number, number, number];
|
||||
}
|
||||
|
||||
// Estimate background color from corners (median of corners)
|
||||
const corners: [number, number, number, number][] = [getPixel(0, 0), getPixel(width - 1, 0), getPixel(0, height - 1), getPixel(width - 1, height - 1)];
|
||||
function median(arr: number[]) {
|
||||
const a = arr.slice().sort((a, b) => a - b);
|
||||
const mid = (a.length - 1) / 2;
|
||||
return (a[Math.floor(mid)] + a[Math.ceil(mid)]) / 2;
|
||||
}
|
||||
const bg = [median(corners.map(c => c[0])), median(corners.map(c => c[1])), median(corners.map(c => c[2])), median(corners.map(c => c[3]))] as [number, number, number, number];
|
||||
|
||||
function isBg(r: number, g: number, b: number, a: number) {
|
||||
if (a <= alphaBgThresh) return true;
|
||||
const dr = r - bg[0];
|
||||
const dg = g - bg[1];
|
||||
const db = b - bg[2];
|
||||
// Use Manhattan distance approximation for speed
|
||||
const manhattan = Math.abs(dr) + Math.abs(dg) + Math.abs(db);
|
||||
// Normalize approx to 0..~765 and compare to scaled tolerance
|
||||
return manhattan <= colorTol * 3;
|
||||
}
|
||||
|
||||
// Sample stride to speed up scanning large sheets
|
||||
const rowSample = Math.max(1, Math.floor(height / 64));
|
||||
const colSample = Math.max(1, Math.floor(width / 64));
|
||||
|
||||
// Compute background proportion per column and row
|
||||
const colBgProp: number[] = new Array(width).fill(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let y = 0; y < height; y += rowSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
colBgProp[x] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
const rowBgProp: number[] = new Array(height).fill(0);
|
||||
for (let y = 0; y < height; y++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let x = 0; x < width; x += colSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
rowBgProp[y] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
function extractRuns(bgProp: number[]): { emptyRuns: [number, number][]; segSizes: number[] } {
|
||||
const emptyRuns: [number, number][] = [];
|
||||
const segSizes: number[] = [];
|
||||
let inEmpty = false;
|
||||
let runStart = 0;
|
||||
let lastSeparatorEnd = -1;
|
||||
for (let i = 0; i < bgProp.length; i++) {
|
||||
const isEmpty = bgProp[i] >= gutterPropThresh;
|
||||
if (isEmpty && !inEmpty) {
|
||||
inEmpty = true;
|
||||
runStart = i;
|
||||
if (lastSeparatorEnd >= 0) {
|
||||
const seg = runStart - lastSeparatorEnd - 1;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
} else if (!isEmpty && inEmpty) {
|
||||
inEmpty = false;
|
||||
emptyRuns.push([runStart, i - 1]);
|
||||
lastSeparatorEnd = i - 1;
|
||||
}
|
||||
}
|
||||
if (inEmpty) {
|
||||
emptyRuns.push([runStart, bgProp.length - 1]);
|
||||
lastSeparatorEnd = bgProp.length - 1;
|
||||
}
|
||||
// Trailing segment after last empty run
|
||||
if (lastSeparatorEnd >= 0 && lastSeparatorEnd < bgProp.length - 1) {
|
||||
const seg = bgProp.length - 1 - lastSeparatorEnd;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
return { emptyRuns, segSizes };
|
||||
}
|
||||
|
||||
function modeWithTolerance(values: number[], tol = 2): number {
|
||||
if (values.length === 0) return 0;
|
||||
values.sort((a, b) => a - b);
|
||||
let bestCount = 0;
|
||||
let bestVal = values[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const base = values[i];
|
||||
let count = 1;
|
||||
for (let j = i + 1; j < values.length; j++) {
|
||||
if (Math.abs(values[j] - base) <= tol) count++;
|
||||
else break;
|
||||
}
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestVal = base;
|
||||
}
|
||||
}
|
||||
return bestVal;
|
||||
}
|
||||
|
||||
const colRuns = extractRuns(colBgProp);
|
||||
const rowRuns = extractRuns(rowBgProp);
|
||||
|
||||
let detectedWidth = modeWithTolerance(colRuns.segSizes, 2);
|
||||
let detectedHeight = modeWithTolerance(rowRuns.segSizes, 2);
|
||||
|
||||
// Fallback: use edge periodicity via autocorrelation if gutters not found
|
||||
function edgeAutocorrLength(axis: 'x' | 'y'): number {
|
||||
const maxLen = axis === 'x' ? width : height;
|
||||
const otherLen = axis === 'x' ? height : width;
|
||||
const sampleStepMajor = Math.max(1, Math.floor(maxLen / 512));
|
||||
const sampleStepMinor = Math.max(1, Math.floor(otherLen / 64));
|
||||
const energy: number[] = new Array(maxLen).fill(0);
|
||||
if (axis === 'x') {
|
||||
for (let x = 0; x < maxLen - 1; x += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let y = 0; y < otherLen; y += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x + 1, y);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[x] = e;
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < maxLen - 1; y += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let x = 0; x < otherLen; x += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x, y + 1);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[y] = e;
|
||||
}
|
||||
}
|
||||
const minTile = Math.max(3, Math.floor(Math.min(maxLen / 32, 128)));
|
||||
const maxTile = Math.max(minTile + 1, Math.floor(Math.min(maxLen / 2, 512)));
|
||||
let bestLag = 0;
|
||||
let bestVal = -Infinity;
|
||||
for (let lag = minTile; lag <= maxTile; lag++) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i + lag < energy.length; i++) {
|
||||
const e1 = energy[i] || 0;
|
||||
const e2 = energy[i + lag] || 0;
|
||||
sum += e1 * e2;
|
||||
}
|
||||
if (sum > bestVal) {
|
||||
bestVal = sum;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
return bestLag;
|
||||
}
|
||||
|
||||
if (detectedWidth <= 0 || detectedWidth > width) {
|
||||
const lagX = edgeAutocorrLength('x');
|
||||
if (lagX > 0 && lagX <= width) detectedWidth = lagX;
|
||||
}
|
||||
if (detectedHeight <= 0 || detectedHeight > height) {
|
||||
const lagY = edgeAutocorrLength('y');
|
||||
if (lagY > 0 && lagY <= height) detectedHeight = lagY;
|
||||
}
|
||||
|
||||
// Sanity checks
|
||||
if (!Number.isFinite(detectedWidth) || detectedWidth <= 0 || detectedWidth > width) detectedWidth = 0;
|
||||
if (!Number.isFinite(detectedHeight) || detectedHeight <= 0 || detectedHeight > height) detectedHeight = 0;
|
||||
|
||||
return { detectedWidth, detectedHeight };
|
||||
}
|
||||
|
||||
// Check if a canvas is empty (all transparent or same color)
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
height: isFullScreen ? '100%' : `${size.height}px`,
|
||||
}"
|
||||
class="bg-white dark:bg-gray-800 rounded-2xl border-2 border-gray-300 dark:border-gray-700 shadow-xl flex flex-col fixed z-50 transition-colors duration-300"
|
||||
:class="{ 'rounded-none border-0': isFullScreen }"
|
||||
:class="{ 'rounded-none border-0': isFullScreen, 'select-none': isDragging }"
|
||||
>
|
||||
<!-- Header with drag handle -->
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700" :class="{ 'cursor-move': !isFullScreen && !isMobile }">
|
||||
<h3 class="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-gray-100 truncate pr-2" @mousedown="startDrag" @touchstart="handleTouchStart">{{ title }}</h3>
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700" :class="{ 'cursor-move': !isFullScreen && !isMobile }" @mousedown="startDrag" @touchstart="handleTouchStart">
|
||||
<h3 class="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-gray-100 truncate pr-2">{{ title }}</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="toggleFullScreen" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-rybbit-event="modal-fullscreen">
|
||||
<button @click="toggleFullScreen" @mousedown.stop @touchstart.stop class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-rybbit-event="modal-fullscreen">
|
||||
<img src="@/assets/images/fullscreen-icon.svg" class="w-4 h-4 dark:invert" alt="Fullscreen" :class="{ 'rotate-180': isFullScreen }" />
|
||||
</button>
|
||||
<button @click="close" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-rybbit-event="modal-close">
|
||||
<button @click="close" @mousedown.stop @touchstart.stop class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-rybbit-event="modal-close">
|
||||
<img src="@/assets/images/close-icon.svg" class="w-5 h-5 dark:invert" alt="Close" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@
|
||||
isDragging.value = false;
|
||||
isResizing.value = false;
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
document.removeEventListener('touchmove', handleTouchMove); // Fix: was handleMove
|
||||
document.removeEventListener('touchmove', handleTouchMove, false); // Fix: ensure matching capture option
|
||||
document.removeEventListener('mouseup', stopAction);
|
||||
document.removeEventListener('touchend', stopAction);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user