Many clean

This commit is contained in:
Dennis Postma 2025-04-05 13:09:29 +02:00
parent 8eec236105
commit a527a0e83b
17 changed files with 1722 additions and 1257 deletions

138
package-lock.json generated
View File

@ -1313,43 +1313,43 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.2.tgz",
"integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz",
"integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==",
"license": "MIT",
"dependencies": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"lightningcss": "1.29.2",
"tailwindcss": "4.1.2"
"tailwindcss": "4.1.3"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.2.tgz",
"integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz",
"integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.2",
"@tailwindcss/oxide-darwin-arm64": "4.1.2",
"@tailwindcss/oxide-darwin-x64": "4.1.2",
"@tailwindcss/oxide-freebsd-x64": "4.1.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.2",
"@tailwindcss/oxide-linux-x64-musl": "4.1.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.2"
"@tailwindcss/oxide-android-arm64": "4.1.3",
"@tailwindcss/oxide-darwin-arm64": "4.1.3",
"@tailwindcss/oxide-darwin-x64": "4.1.3",
"@tailwindcss/oxide-freebsd-x64": "4.1.3",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.3",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.3",
"@tailwindcss/oxide-linux-x64-musl": "4.1.3",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.3",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.2.tgz",
"integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.3.tgz",
"integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==",
"cpu": [
"arm64"
],
@ -1363,9 +1363,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.2.tgz",
"integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.3.tgz",
"integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==",
"cpu": [
"arm64"
],
@ -1379,9 +1379,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.2.tgz",
"integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.3.tgz",
"integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==",
"cpu": [
"x64"
],
@ -1395,9 +1395,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.2.tgz",
"integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.3.tgz",
"integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==",
"cpu": [
"x64"
],
@ -1411,9 +1411,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.2.tgz",
"integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.3.tgz",
"integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==",
"cpu": [
"arm"
],
@ -1427,9 +1427,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.2.tgz",
"integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.3.tgz",
"integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==",
"cpu": [
"arm64"
],
@ -1443,9 +1443,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.2.tgz",
"integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.3.tgz",
"integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==",
"cpu": [
"arm64"
],
@ -1459,9 +1459,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.2.tgz",
"integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.3.tgz",
"integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==",
"cpu": [
"x64"
],
@ -1475,9 +1475,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.2.tgz",
"integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.3.tgz",
"integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==",
"cpu": [
"x64"
],
@ -1491,9 +1491,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.2.tgz",
"integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.3.tgz",
"integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==",
"cpu": [
"arm64"
],
@ -1507,9 +1507,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.2.tgz",
"integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.3.tgz",
"integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==",
"cpu": [
"x64"
],
@ -1523,14 +1523,14 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.2.tgz",
"integrity": "sha512-3r/ZdMW0gxY8uOx1To0lpYa4coq4CzINcCX4laM1rS340Kcn0ac4A/MMFfHN8qba51aorZMYwMcOxYk4wJ9FYg==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.3.tgz",
"integrity": "sha512-lUI/QaDxLtlV52Lho6pu07CG9pSnRYLOPmKGIQjyHdTBagemc6HmgZxyjGAQ/5HMPrNeWBfTVIpQl0/jLXvWHQ==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.1.2",
"@tailwindcss/oxide": "4.1.2",
"tailwindcss": "4.1.2"
"@tailwindcss/node": "4.1.3",
"@tailwindcss/oxide": "4.1.3",
"tailwindcss": "4.1.3"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6"
@ -2014,9 +2014,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001709",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz",
"integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==",
"version": "1.0.30001711",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001711.tgz",
"integrity": "sha512-OpFA8GsKtoV3lCcsI3U5XBAV+oVrMu96OS8XafKqnhOaEAW2mveD1Mx81Sx/02chERwhDakuXs28zbyEc4QMKg==",
"dev": true,
"funding": [
{
@ -2178,9 +2178,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.131",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.131.tgz",
"integrity": "sha512-fJFRYXVEJgDCiqFOgRGJm8XR97hZ13tw7FXI9k2yC5hgY+nyzC2tMO8baq1cQR7Ur58iCkASx2zrkZPZUnfzPg==",
"version": "1.5.132",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.132.tgz",
"integrity": "sha512-QgX9EBvWGmvSRa74zqfnG7+Eno0Ak0vftBll0Pt2/z5b3bEGYL6OUXLgKPtvx73dn3dvwrlyVkjPKRRlhLYTEg==",
"dev": true,
"license": "ISC"
},
@ -3393,9 +3393,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.2.tgz",
"integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
"license": "MIT"
},
"node_modules/tapable": {
@ -3418,9 +3418,9 @@
}
},
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {

View File

@ -0,0 +1,160 @@
import { sprites, animation, cellSize, previewBorder } from '@/application/state';
import { getSpriteOffset } from '@/application/utilities';
/**
* Start the animation
*/
export function startAnimation() {
if (sprites.value.length === 0 || !animation.canvas) return;
animation.isPlaying = true;
animation.lastFrameTime = performance.now();
animation.manualUpdate = false;
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
// Start the animation loop without resetting sprite offset
animationLoop();
}
/**
* Stop the animation
*/
export function stopAnimation() {
animation.isPlaying = false;
if (animation.animationId) {
cancelAnimationFrame(animation.animationId);
animation.animationId = null;
}
}
/**
* Render a specific frame of the animation
*/
export function renderAnimationFrame(frameIndex: number, showAllSprites = false, spriteOffset = { x: 0, y: 0 }) {
if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return;
// Resize the animation canvas to match the cell size if needed
if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) {
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
}
// Clear the canvas
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
// Draw background (transparent by default)
animation.ctx.fillStyle = 'transparent';
animation.ctx.fillRect(0, 0, animation.canvas.width, animation.canvas.height);
// Keep pixel art sharp
animation.ctx.imageSmoothingEnabled = false;
// Draw background sprites with reduced opacity if requested
if (showAllSprites && sprites.value.length > 1) {
renderBackgroundSprites(frameIndex);
}
// Get the current sprite and render it
renderCurrentSprite(frameIndex, spriteOffset);
// Draw border around the cell if enabled
renderPreviewBorder();
}
/**
* Draw all sprites except the current one with reduced opacity
*/
function renderBackgroundSprites(frameIndex: number) {
if (!animation.ctx) return;
// Save the current context state
animation.ctx.save();
// Set global alpha for background sprites
animation.ctx.globalAlpha = 0.3;
// Draw all sprites except the current one
sprites.value.forEach((sprite, index) => {
if (index !== frameIndex) {
const spriteCellX = Math.floor(sprite.x / cellSize.width);
const spriteCellY = Math.floor(sprite.y / cellSize.height);
// Calculate precise offset for pixel-perfect rendering
const spriteOffsetX = Math.round(sprite.x - spriteCellX * cellSize.width);
const spriteOffsetY = Math.round(sprite.y - spriteCellY * cellSize.height);
// Draw the sprite with transparency
animation.ctx.drawImage(sprite.img, spriteOffsetX, spriteOffsetY);
}
});
// Restore the context to full opacity
animation.ctx.restore();
}
/**
* Render the current sprite at full opacity
*/
function renderCurrentSprite(frameIndex: number, spriteOffset: { x: number; y: number }) {
if (!animation.ctx) return;
// Get the current sprite
const currentSprite = sprites.value[frameIndex % sprites.value.length];
// Draw the current sprite at full opacity at the specified position
animation.ctx.drawImage(currentSprite.img, spriteOffset.x, spriteOffset.y);
}
/**
* Render a border around the animation preview if enabled
*/
function renderPreviewBorder() {
if (!animation.ctx || !animation.canvas || !previewBorder.enabled) return;
animation.ctx.strokeStyle = previewBorder.color;
animation.ctx.lineWidth = previewBorder.width;
// Use pixel-perfect coordinates for the border (0.5 offset for crisp lines)
const x = 0.5;
const y = 0.5;
const width = animation.canvas.width - 1;
const height = animation.canvas.height - 1;
animation.ctx.strokeRect(x, y, width, height);
}
/**
* Animation loop for continuous playback
*/
export function animationLoop(timestamp?: number) {
if (!animation.isPlaying) return;
const currentTime = timestamp || performance.now();
const elapsed = currentTime - animation.lastFrameTime;
const frameInterval = 1000 / animation.frameRate;
if (elapsed >= frameInterval) {
animation.lastFrameTime = currentTime;
if (sprites.value.length > 0) {
// Get the stored offset for the current frame
const frameOffset = getSpriteOffset(animation.currentFrame);
// Render the current frame with its offset
renderAnimationFrame(animation.currentFrame, false, frameOffset);
// Move to the next frame
animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length;
// Update the slider position if available
if (animation.slider) {
animation.slider.value = animation.currentFrame.toString();
}
}
}
animation.animationId = requestAnimationFrame(animationLoop);
}

View File

@ -0,0 +1,295 @@
// canvasOperations.ts
import { sprites, canvas, ctx, cellSize, columns, zoomLevel, previewBorder, animation } from '@/application/state';
import { logger, getSpriteOffset, isImageReady, getPixelPerfectCoordinate, showNotification } from '@/application/utilities';
/**
* Update the canvas size based on sprites and cell size
*/
export function updateCanvasSize() {
if (!canvas.value) {
logger.warn('Canvas not available for size update');
return;
}
if (sprites.value.length === 0) {
return;
}
try {
const totalSprites = sprites.value.length;
const cols = columns.value;
const rows = Math.ceil(totalSprites / cols);
if (cellSize.width <= 0 || cellSize.height <= 0) {
logger.error('Invalid cell size for canvas update', cellSize);
return;
}
const newWidth = cols * cellSize.width;
const newHeight = rows * cellSize.height;
// Ensure the canvas is large enough to display all sprites
if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) {
canvas.value.width = newWidth;
canvas.value.height = newHeight;
// Emit an event to update the wrapper dimensions
window.dispatchEvent(
new CustomEvent('canvas-size-updated', {
detail: { width: newWidth, height: newHeight },
})
);
}
} catch (error) {
logger.error('Error updating canvas size:', error);
}
}
/**
* Draw the grid on the canvas
*/
export function drawGrid() {
if (!ctx.value || !canvas.value) return;
ctx.value.strokeStyle = '#333';
ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level
// Calculate the visible area based on zoom level
const visibleWidth = canvas.value.width / zoomLevel.value;
const visibleHeight = canvas.value.height / zoomLevel.value;
// Draw vertical lines - ensure pixel-perfect grid lines
for (let x = 0; x <= visibleWidth; x += cellSize.width) {
const pixelX = getPixelPerfectCoordinate(x);
ctx.value.beginPath();
ctx.value.moveTo(pixelX, 0);
ctx.value.lineTo(pixelX, visibleHeight);
ctx.value.stroke();
}
// Draw horizontal lines - ensure pixel-perfect grid lines
for (let y = 0; y <= visibleHeight; y += cellSize.height) {
const pixelY = getPixelPerfectCoordinate(y);
ctx.value.beginPath();
ctx.value.moveTo(0, pixelY);
ctx.value.lineTo(visibleWidth, pixelY);
ctx.value.stroke();
}
}
/**
* Render the spritesheet preview on the canvas
*/
export function renderSpritesheetPreview(showGrid = true) {
if (!ctx.value || !canvas.value) {
logger.error('Canvas or context not available for rendering, will retry when ready');
setTimeout(() => {
if (ctx.value && canvas.value) {
renderSpritesheetPreview(showGrid);
}
}, 100);
return;
}
if (sprites.value.length === 0) return;
try {
// Make sure the canvas size is correct before rendering
updateCanvasSize();
// Clear the canvas
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
if (showGrid) {
drawGrid();
}
// First, collect all occupied cells
const occupiedCells = new Set<string>();
sprites.value.forEach(sprite => {
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
occupiedCells.add(`${cellX},${cellY}`);
});
// Draw each sprite - remove the zoom scaling from context
sprites.value.forEach((sprite, index) => {
try {
if (!sprite.img) {
logger.warn(`Sprite at index ${index} has no image, skipping render`);
return;
}
// Check if sprite is within canvas bounds
if (sprite.x >= 0 && sprite.y >= 0 && sprite.x + sprite.width <= canvas.value!.width && sprite.y + sprite.height <= canvas.value!.height) {
renderSpriteOnCanvas(sprite, index);
} else {
logger.warn(`Sprite at index ${index} is outside canvas bounds: sprite(${sprite.x},${sprite.y}) canvas(${canvas.value!.width},${canvas.value!.height})`);
}
} catch (spriteError) {
logger.error(`Error rendering sprite at index ${index}:`, spriteError);
}
});
// Draw borders around occupied cells if enabled (preview only)
drawPreviewBorders(occupiedCells);
} catch (error) {
logger.error('Error in renderSpritesheetPreview:', error);
}
}
/**
* Draw a single sprite on the canvas
*/
function renderSpriteOnCanvas(sprite: any, index: number) {
if (!ctx.value || !canvas.value) return;
if (isImageReady(sprite.img)) {
// For pixel art, ensure we're drawing at exact pixel boundaries
const x = Math.round(sprite.x);
const y = Math.round(sprite.y);
// Get the frame-specific offset for this sprite
const frameOffset = getSpriteOffset(index);
// Calculate the maximum allowed offset based on sprite and cell size
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
// Constrain the offset to prevent out-of-bounds positioning
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
// Update the frame offset with the constrained values
frameOffset.x = constrainedOffsetX;
frameOffset.y = constrainedOffsetY;
// Apply the constrained offset to the sprite position
const finalX = x + constrainedOffsetX;
const finalY = y + constrainedOffsetY;
// Draw the image at its final position with pixel-perfect rendering
ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp
ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
} else {
logger.warn(`Sprite image ${index} not fully loaded, setting onload handler`);
sprite.img.onload = () => {
if (ctx.value && canvas.value) {
renderSpriteOnCanvas(sprite, index);
}
};
}
}
/**
* Draw preview borders around occupied cells if enabled
*/
function drawPreviewBorders(occupiedCells: Set<string>) {
if (!ctx.value || !canvas.value || !previewBorder.enabled || occupiedCells.size === 0) return;
ctx.value.strokeStyle = previewBorder.color;
ctx.value.lineWidth = previewBorder.width / zoomLevel.value; // Adjust for zoom
// Draw borders around each occupied cell
occupiedCells.forEach(cellKey => {
const [cellX, cellY] = cellKey.split(',').map(Number);
// Calculate pixel-perfect coordinates for the cell
// Add 0.5 to align with pixel boundaries for crisp lines
const x = getPixelPerfectCoordinate(cellX * cellSize.width);
const y = getPixelPerfectCoordinate(cellY * cellSize.height);
// Adjust width and height to ensure the border is inside the cell
const width = cellSize.width - 1;
const height = cellSize.height - 1;
ctx.value!.strokeRect(x, y, width, height);
});
}
/**
* Download the spritesheet as a PNG
*/
export function downloadSpritesheet() {
if (sprites.value.length === 0 || !canvas.value) {
showNotification('No sprites to download', 'error');
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.value.width;
tempCanvas.height = canvas.value.height;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
showNotification('Failed to create download context', 'error');
return;
}
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
// Ensure pixel art remains sharp in the downloaded file
tempCtx.imageSmoothingEnabled = false;
sprites.value.forEach((sprite, index) => {
// Get the frame-specific offset for this sprite
const frameOffset = getSpriteOffset(index);
// Calculate the cell coordinates for this sprite
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
// Calculate the base position within the cell
const baseX = cellX * cellSize.width;
const baseY = cellY * cellSize.height;
// Calculate the maximum allowed offset based on sprite and cell size
// This prevents sprites from going out of bounds
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
// Constrain the offset to prevent out-of-bounds positioning
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
// Apply the constrained offset to the base position
const finalX = baseX + constrainedOffsetX;
const finalY = baseY + constrainedOffsetY;
// Draw the sprite at the calculated position
tempCtx.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
});
const link = document.createElement('a');
link.download = 'spritesheet.png';
link.href = tempCanvas.toDataURL('image/png');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showNotification('Spritesheet downloaded successfully');
}
/**
* Zoom-related operations
*/
export function zoomIn() {
// Increase zoom level by 0.1, max 3.0 (300%)
zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1);
renderSpritesheetPreview();
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
}
export function zoomOut() {
// Decrease zoom level by 0.1, min 0.5 (50%)
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
renderSpritesheetPreview();
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
}
export function resetZoom() {
// Reset to default zoom level (100%)
zoomLevel.value = 1;
renderSpritesheetPreview();
showNotification('Zoom reset to 100%');
}

View File

@ -0,0 +1,181 @@
// spriteOperations.ts
import { type Sprite } from '@/application/types';
import { sprites, cellSize, canvas, ctx, columns } from '@/application/state';
import { logger, getSpriteOffset } from '@/application/utilities';
import { updateCanvasSize, renderSpritesheetPreview } from '@/application/canvasOperations';
/**
* Add new sprites to the spritesheet
*/
export function addSprites(newSprites: Sprite[]) {
if (newSprites.length === 0) {
logger.warn('Attempted to add empty sprites array');
return;
}
try {
// Validate sprites before adding them
const validSprites = newSprites.filter(sprite => {
if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) {
logger.error('Invalid sprite detected', sprite);
return false;
}
return true;
});
if (validSprites.length === 0) {
logger.error('No valid sprites to add');
return;
}
sprites.value.push(...validSprites);
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
updateCellSize();
autoArrangeSprites();
} catch (error) {
logger.error('Error adding sprites:', error);
}
}
/**
* Update the cell size based on the largest sprite dimensions
*/
export function updateCellSize() {
if (sprites.value.length === 0) {
return;
}
try {
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
if (sprite.width <= 0 || sprite.height <= 0) {
logger.warn('Sprite with invalid dimensions detected', sprite);
return;
}
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
});
if (maxWidth === 0 || maxHeight === 0) {
logger.error('Failed to calculate valid cell size');
return;
}
cellSize.width = maxWidth;
cellSize.height = maxHeight;
updateCanvasSize();
} catch (error) {
logger.error('Error updating cell size:', error);
}
}
/**
* Automatically arrange sprites in a grid
*/
export function autoArrangeSprites() {
if (sprites.value.length === 0) {
return;
}
try {
if (cellSize.width <= 0 || cellSize.height <= 0) {
logger.error('Invalid cell size for auto-arranging', cellSize);
return;
}
// First update the canvas size to ensure it's large enough
updateCanvasSize();
// Then position each sprite in its grid cell
sprites.value.forEach((sprite, index) => {
const column = index % columns.value;
const row = Math.floor(index / columns.value);
sprite.x = column * cellSize.width;
sprite.y = row * cellSize.height;
});
// Check if the canvas is ready before attempting to render
if (!ctx.value || !canvas.value) {
logger.warn('Canvas or context not available for rendering after auto-arrange');
return;
}
renderSpritesheetPreview();
} catch (error) {
logger.error('Error auto-arranging sprites:', error);
}
}
/**
* Highlight a specific sprite by its ID
*/
export function highlightSprite(spriteId: string) {
if (!ctx.value || !canvas.value) return;
const sprite = sprites.value.find(s => s.id === spriteId);
if (!sprite) return;
// Calculate the cell coordinates
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
// Briefly flash the cell
ctx.value.save();
ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)';
ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height);
ctx.value.restore();
// Reset after a short delay
setTimeout(() => {
renderSpritesheetPreview();
}, 500);
}
/**
* Clear all sprites and reset the canvas
*/
export function clearAllSprites(animation: any) {
if (!canvas.value || !ctx.value) return;
sprites.value = [];
canvas.value.width = 400;
canvas.value.height = 300;
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
if (animation.canvas && animation.ctx) {
animation.canvas.width = 200;
animation.canvas.height = 200;
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
}
animation.currentFrame = 0;
}
/**
* Apply frame-specific offsets to the main sprite positions
*/
export function applyOffsetsToMainView(currentSpriteOffset: any) {
sprites.value.forEach((sprite, index) => {
const frameOffset = getSpriteOffset(index);
if (frameOffset.x !== 0 || frameOffset.y !== 0) {
// Update the sprite's position to include the offset
sprite.x += frameOffset.x;
sprite.y += frameOffset.y;
// Reset the offset
frameOffset.x = 0;
frameOffset.y = 0;
}
});
// Reset current offset
currentSpriteOffset.x = 0;
currentSpriteOffset.y = 0;
// Re-render the main view
renderSpritesheetPreview();
}

54
src/application/state.ts Normal file
View File

@ -0,0 +1,54 @@
// state.ts
import { ref, reactive } from 'vue';
import { type Sprite, type CellSize, type AnimationState, type NotificationState, type PreviewBorderSettings } from '@/application/types';
// Core state
export const sprites = ref<Sprite[]>([]);
export const canvas = ref<HTMLCanvasElement | null>(null);
export const ctx = ref<CanvasRenderingContext2D | null>(null);
export const cellSize = reactive<CellSize>({ width: 0, height: 0 });
export const columns = ref(4); // Default number of columns
// UI state
export const draggedSprite = ref<Sprite | null>(null);
export const dragOffset = reactive({ x: 0, y: 0 });
export const isShiftPressed = ref(false);
export const isModalOpen = ref(false);
export const isSettingsModalOpen = ref(false);
export const isSpritesModalOpen = ref(false);
export const isHelpModalOpen = ref(false);
export const zoomLevel = ref(1); // Default zoom level (1 = 100%)
// Preview border settings
export const previewBorder = reactive<PreviewBorderSettings>({
enabled: false,
color: '#ff0000', // Default red color
width: 2, // Default width in pixels
});
// Animation state
export const animation = reactive<AnimationState>({
canvas: null,
ctx: null,
currentFrame: 0,
isPlaying: false,
frameRate: 10,
lastFrameTime: 0,
animationId: null,
slider: null,
manualUpdate: false,
});
// Notification state
export const notification = reactive<NotificationState>({
isVisible: false,
message: '',
type: 'success',
});
// Store the current sprite offset for animation playback
// We'll use a Map to store offsets for each frame, so they're preserved when switching frames
export const spriteOffsets = reactive(new Map<number, { x: number; y: number }>());
// Current sprite offset is a reactive object that will be used for the current frame
export const currentSpriteOffset = reactive({ x: 0, y: 0 });

45
src/application/types.ts Normal file
View File

@ -0,0 +1,45 @@
// types.ts
export interface Sprite {
img: HTMLImageElement;
width: number;
height: number;
x: number;
y: number;
name: string;
id: string;
uploadOrder: number;
}
export interface CellSize {
width: number;
height: number;
}
export interface AnimationState {
canvas: HTMLCanvasElement | null;
ctx: CanvasRenderingContext2D | null;
currentFrame: number;
isPlaying: boolean;
frameRate: number;
lastFrameTime: number;
animationId: number | null;
slider: HTMLInputElement | null;
manualUpdate: boolean;
}
export interface SpriteOffset {
x: number;
y: number;
}
export interface NotificationState {
isVisible: boolean;
message: string;
type: 'success' | 'error';
}
export interface PreviewBorderSettings {
enabled: boolean;
color: string;
width: number;
}

View File

@ -0,0 +1,62 @@
import { spriteOffsets, notification } from '@/application/state';
/**
* Logger utility with consistent error format
*/
export const logger = {
warn: (message: string, details?: any) => {
console.warn(`Spritesheet: ${message}`, details || '');
},
error: (message: string, error?: any) => {
console.error(`Spritesheet: ${message}`, error || '');
},
};
/**
* Safely execute a function with error handling
*/
export function safeExecute<T>(fn: () => T, errorMessage: string): T | undefined {
try {
return fn();
} catch (error) {
logger.error(errorMessage, error);
return undefined;
}
}
/**
* Show a notification
*/
export function showNotification(message: string, type: 'success' | 'error' = 'success') {
notification.message = message;
notification.type = type;
notification.isVisible = true;
setTimeout(() => {
notification.isVisible = false;
}, 3000);
}
/**
* Get the offset for a specific frame
*/
export function getSpriteOffset(frameIndex: number) {
if (!spriteOffsets.has(frameIndex)) {
spriteOffsets.set(frameIndex, { x: 0, y: 0 });
}
return spriteOffsets.get(frameIndex)!;
}
/**
* Check if image is fully loaded and ready to use
*/
export function isImageReady(img: HTMLImageElement): boolean {
return img.complete && img.naturalWidth !== 0;
}
/**
* Get pixel-perfect coordinates aligned to pixel boundaries
*/
export function getPixelPerfectCoordinate(value: number): number {
return Math.floor(value) + 0.5;
}

View File

@ -0,0 +1,96 @@
<template>
<div class="flex flex-wrap items-center gap-4 mb-6">
<!-- Play/Pause controls -->
<div class="flex gap-2">
<button
@click="$emit('start')"
:disabled="sprites.length === 0"
:class="{
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': !animation.isPlaying,
}"
class="flex items-center gap-2 border rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<i class="fas fa-play"></i> Play
</button>
<button
@click="$emit('stop')"
:disabled="sprites.length === 0"
:class="{
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': animation.isPlaying,
}"
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
>
<i class="fas fa-pause"></i> Pause
</button>
</div>
<!-- Frame slider -->
<div class="flex flex-col gap-2 flex-grow">
<div class="flex justify-between text-sm text-gray-400">
<label for="frame-slider">Frame:</label>
<span>{{ currentFrameDisplay }}</span>
</div>
<input type="range" id="frame-slider" v-model="frameValue" :min="0" :max="Math.max(0, sprites.length - 1)" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" />
</div>
<!-- Frame rate slider -->
<div class="flex flex-col gap-2 flex-grow">
<div class="flex justify-between text-sm text-gray-400">
<label for="framerate">Frame Rate:</label>
<span>{{ animation.frameRate }} FPS</span>
</div>
<input type="range" id="framerate" v-model.number="frameRateValue" min="1" max="30" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { type Sprite, type AnimationState } from '@/application/types';
// Props
const props = defineProps<{
sprites: Sprite[];
animation: AnimationState;
currentFrame: number;
currentFrameDisplay: string;
}>();
// Emits
const emit = defineEmits<{
(e: 'start'): void;
(e: 'stop'): void;
(e: 'frameChange'): void;
(e: 'frameRateChange'): void;
}>();
// Computed values with two-way binding
const frameValue = computed({
get: () => props.currentFrame,
set: () => emit('frameChange'),
});
const frameRateValue = computed({
get: () => props.animation.frameRate,
set: () => emit('frameRateChange'),
});
</script>
<style scoped>
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@ -202,8 +202,28 @@
const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.img.height;
// Constrain position to stay within the cell
const oldX = store.draggedSprite.value.x;
const oldY = store.draggedSprite.value.y;
// Update the sprite position
store.draggedSprite.value.x = Math.max(cellLeft, Math.min(newX, cellRight));
store.draggedSprite.value.y = Math.max(cellTop, Math.min(newY, cellBottom));
// Calculate the offset within the cell
const offsetX = store.draggedSprite.value.x - cellLeft;
const offsetY = store.draggedSprite.value.y - cellTop;
// Update the sprite offset in the store for the current sprite
const spriteIndex = store.sprites.value.findIndex(s => s.id === store.draggedSprite.value.id);
if (spriteIndex !== -1) {
const frameOffset = store.getSpriteOffset(spriteIndex);
frameOffset.x = offsetX;
frameOffset.y = offsetY;
// Also update current offset for UI consistency
store.currentSpriteOffset.x = offsetX;
store.currentSpriteOffset.y = offsetY;
}
} else {
// Calculate new position based on grid cells (snap to grid)
const newCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width);
@ -217,8 +237,24 @@
const boundedCellX = Math.max(0, Math.min(newCellX, maxCellX));
const boundedCellY = Math.max(0, Math.min(newCellY, maxCellY));
const oldX = store.draggedSprite.value.x;
const oldY = store.draggedSprite.value.y;
// Update the sprite position
store.draggedSprite.value.x = boundedCellX * store.cellSize.width;
store.draggedSprite.value.y = boundedCellY * store.cellSize.height;
// When snapping to grid, reset any offsets for this sprite
const spriteIndex = store.sprites.value.findIndex(s => s.id === store.draggedSprite.value.id);
if (spriteIndex !== -1) {
const frameOffset = store.getSpriteOffset(spriteIndex);
frameOffset.x = 0;
frameOffset.y = 0;
// Also update current offset for UI consistency
store.currentSpriteOffset.x = 0;
store.currentSpriteOffset.y = 0;
}
}
}

View File

@ -6,83 +6,20 @@
<template #header-title>Animation Preview</template>
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex gap-2">
<button
@click="startAnimation"
:disabled="sprites.length === 0"
:class="{
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': !animation.isPlaying,
}"
class="flex items-center gap-2 border rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<i class="fas fa-play"></i> Play
</button>
<button
@click="stopAnimation"
:disabled="sprites.length === 0"
:class="{
'bg-blue-500 border-blue-500 hover:bg-blue-600 hover:border-blue-600': animation.isPlaying,
}"
class="flex items-center gap-2 bg-gray-700 text-gray-200 border border-gray-600 rounded px-4 py-2 text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500"
>
<i class="fas fa-pause"></i> Pause
</button>
</div>
<!-- Animation controls -->
<AnimationControls :sprites="sprites" :animation="animation" :currentFrame="currentFrame" :currentFrameDisplay="currentFrameDisplay" @start="startAnimation" @stop="stopAnimation" @frameChange="handleFrameChange" @frameRateChange="handleFrameRateChange" />
<div class="flex flex-col gap-2 flex-grow">
<div class="flex justify-between text-sm text-gray-400">
<label for="frame-slider">Frame:</label>
<span>{{ currentFrameDisplay }}</span>
</div>
<input type="range" id="frame-slider" v-model="currentFrame" :min="0" :max="Math.max(0, sprites.length - 1)" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" @input="handleFrameChange" />
</div>
<div class="flex flex-col gap-2 flex-grow">
<div class="flex justify-between text-sm text-gray-400">
<label for="framerate">Frame Rate:</label>
<span>{{ animation.frameRate }} FPS</span>
</div>
<input type="range" id="framerate" v-model.number="animation.frameRate" min="1" max="30" class="w-full h-1 bg-gray-600 rounded appearance-none focus:outline-none cursor-pointer" @input="handleFrameRateChange" />
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<!-- Zoom controls -->
<div class="flex items-center gap-2">
<button @click="zoomOut" :disabled="previewZoom <= 1" class="flex items-center justify-center w-8 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">
<i class="fas fa-search-minus"></i>
</button>
<span class="text-sm text-gray-300">{{ Math.round(previewZoom * 100) }}%</span>
<button @click="zoomIn" :disabled="previewZoom >= 5" class="flex items-center justify-center w-8 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">
<i class="fas fa-search-plus"></i>
</button>
<!-- Apply Offset button removed -->
<button @click="resetZoom" :disabled="previewZoom === 1" class="flex items-center justify-center px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500">Reset Zoom</button>
</div>
<!-- Controls -->
<div class="flex items-center gap-4">
<!-- Show all sprites toggle -->
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" v-model="showAllSprites" class="form-checkbox h-4 w-4 text-blue-500 rounded border-gray-600 bg-gray-700 focus:ring-blue-500" />
Show all frames
</label>
<!-- Reset sprite position button -->
<button @click="resetSpritePosition" :disabled="!hasSpriteOffset" class="flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded text-xs transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500" title="Center sprite in cell">
<i class="fas fa-crosshairs"></i>
Center Sprite
</button>
</div>
</div>
<!-- View controls -->
<ViewControls :previewZoom="previewZoom" :spriteOffset="spriteOffset" :hasSpriteOffset="hasSpriteOffset" :showAllSprites="showAllSprites" @zoomIn="zoomIn" @zoomOut="zoomOut" @resetZoom="resetZoom" @resetPosition="resetSpritePosition" @toggleShowAllSprites="showAllSprites = !showAllSprites" />
<!-- Canvas container -->
<div class="flex flex-col justify-center items-center bg-gray-700 p-6 rounded mb-6 relative overflow-auto flex-grow">
<!-- Tooltip for dragging instructions -->
<!-- Position info -->
<div class="text-xs text-gray-400 mb-2" v-if="sprites.length > 0">
<span>Position: {{ Math.round(spriteOffset?.x ?? 0) }}px, {{ Math.round(spriteOffset?.y ?? 0) }}px (drag to move within cell)</span>
</div>
<!-- Canvas viewport -->
<div
class="canvas-container relative transition-transform duration-100 flex items-center justify-center"
:style="{
@ -90,7 +27,7 @@
minHeight: `${store.cellSize.height * previewZoom}px`,
cursor: previewZoom > 1 ? (isViewportDragging ? 'grabbing' : 'grab') : 'default',
}"
@mousedown="startViewportDrag"
@mousedown="e => startViewportDrag(e, isCanvasDragging)"
@wheel="handleCanvasWheel"
>
<div
@ -110,7 +47,7 @@
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
backgroundColor: '#2d3748',
}"
@mousedown.stop="startCanvasDrag"
@mousedown.stop="e => startCanvasDrag(e, isViewportDragging, previewZoom)"
title="Drag to move sprite within cell"
>
<canvas ref="animCanvas" class="block pixel-art absolute top-0 left-0"></canvas>
@ -123,15 +60,22 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import BaseModal from './BaseModal.vue';
import AnimationControls from './AnimationControls.vue';
import ViewControls from './ViewControls.vue';
import { useSpritesheetStore } from '@/composables/useSpritesheetStore';
// Import custom composables
import { useAnimation } from '@/composables/useAnimation';
import { useSpritePosition } from '@/composables/useSpritePosition';
import { useViewport } from '@/composables/useViewport';
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts';
import { useCanvasInitialization } from '@/composables/useCanvasInitialization';
// Main store
const store = useSpritesheetStore();
const animCanvas = ref<HTMLCanvasElement | null>(null);
// Add this constant for pan amount
const panAmount = 10; // pixels to pan per keypress
// Computed refs for sprites and state
const isModalOpen = computed({
get: () => store.isModalOpen.value,
set: value => {
@ -142,107 +86,28 @@
const animation = computed(() => store.animation);
const previewBorder = computed(() => store.previewBorder);
const currentFrame = ref(0);
// Position is now handled by BaseModal
// New state for added features
const previewZoom = ref(1);
const showAllSprites = ref(false);
// Use a computed property to access and modify the store's sprite offset for the current frame
const spriteOffset = computed({
get: () => {
// Get the offset for the current frame
const frameOffset = store.getSpriteOffset(currentFrame.value);
// Return the frame-specific offset directly
return frameOffset;
},
set: val => {
// Update the frame-specific offset directly
const frameOffset = store.getSpriteOffset(currentFrame.value);
frameOffset.x = val.x;
frameOffset.y = val.y;
// Also update the current offset for UI consistency
store.currentSpriteOffset.x = val.x;
store.currentSpriteOffset.y = val.y;
},
});
const isCanvasDragging = ref(false);
const canvasDragStart = ref({ x: 0, y: 0 });
// Canvas viewport navigation state
const viewportOffset = ref({ x: 0, y: 0 });
const isViewportDragging = ref(false);
const viewportDragStart = ref({ x: 0, y: 0 });
// Modal size state - used by BaseModal
const modalSize = ref({ width: 800, height: 600 });
const currentFrameDisplay = computed(() => {
if (sprites.value.length === 0) return '0 / 0';
return `${currentFrame.value + 1} / ${sprites.value.length}`;
});
// Initialize canvas management
const { animCanvas, initializeCanvas, updateCanvasSize } = useCanvasInitialization(animation, store.cellSize);
// Computed property to check if sprite has been moved from original position
const hasSpriteOffset = computed(() => {
return spriteOffset.value.x !== 0 || spriteOffset.value.y !== 0;
});
// applyOffsetsToMainView function removed
const handleKeyDown = (e: KeyboardEvent) => {
if (!isModalOpen.value) return;
if (e.key === 'Escape') {
closeModal();
} else if (e.key === ' ' || e.key === 'Spacebar') {
// Toggle play/pause
if (animation.value.isPlaying) {
stopAnimation();
} else if (sprites.value.length > 0) {
startAnimation();
}
e.preventDefault();
} else if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
// Next frame
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
updateFrame();
} else if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
// Previous frame
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
updateFrame();
} else if (e.key === '+' || e.key === '=') {
// Zoom in
zoomIn();
e.preventDefault();
} else if (e.key === '-' || e.key === '_') {
// Zoom out
zoomOut();
e.preventDefault();
} else if (e.key === '0') {
// Reset zoom
resetZoom();
e.preventDefault();
} else if (e.key === 'r') {
if (e.shiftKey) {
// Shift+R: Reset sprite position only
resetSpritePosition();
} else {
// R: Reset both sprite position and viewport
resetSpritePosition();
updateFrame();
store.showNotification('View and position reset');
}
e.preventDefault();
} else if (e.key === 'ArrowLeft' && animation.value.isPlaying) {
viewportOffset.value.x += panAmount / previewZoom.value;
e.preventDefault();
} else if (e.key === 'ArrowRight' && animation.value.isPlaying) {
viewportOffset.value.x -= panAmount / previewZoom.value;
e.preventDefault();
}
// Handle frame rendering
const renderFrame = (frameIndex: number, offset: { x: number; y: number }) => {
store.renderAnimationFrame(frameIndex, showAllSprites.value, offset);
store.renderSpritesheetPreview();
};
// Animation controls
const { currentFrame, showAllSprites, currentFrameDisplay, startAnimation, stopAnimation, handleFrameChange, handleFrameRateChange, updateCurrentFrame: updateFrame, nextFrame, prevFrame } = useAnimation(sprites, animation, renderFrame, store.getSpriteOffset);
// Sprite positioning
const { isCanvasDragging, spriteOffset, hasSpriteOffset, resetSpritePosition, startCanvasDrag } = useSpritePosition(sprites, currentFrame, store.cellSize, store.getSpriteOffset, () => updateFrame(), store.showNotification);
// Viewport controls
const { previewZoom, viewportOffset, isViewportDragging, zoomIn, zoomOut, resetZoom, updateCanvasContainerSize, startViewportDrag, handleCanvasWheel, panViewport } = useViewport(animation, () => updateFrame(), store.showNotification);
// Open modal function
const openModal = async () => {
if (sprites.value.length === 0) {
store.showNotification('Please add sprites first', 'error');
@ -255,7 +120,6 @@
// Reset modal size to default
modalSize.value = { width: 800, height: 600 };
// Note: Modal positioning is now handled by BaseModal
// Reset to first frame
currentFrame.value = 0;
@ -276,7 +140,7 @@
}
// Wait for next frame to ensure DOM is updated
await new Promise(resolve => requestAnimationFrame(resolve));
await nextTick();
// Set proper canvas size before rendering
updateCanvasSize();
@ -304,23 +168,7 @@
}
};
const updateCanvasSize = () => {
if (!animCanvas.value) {
console.warn('PreviewModal: Cannot update canvas size - canvas not found');
return;
}
if (store.cellSize.width && store.cellSize.height) {
animCanvas.value.width = store.cellSize.width;
animCanvas.value.height = store.cellSize.height;
// Also update container size
updateCanvasContainerSize();
} else {
console.warn('PreviewModal: Cannot update canvas size - invalid cell dimensions');
}
};
// Close modal function
const closeModal = () => {
store.isModalOpen.value = false;
@ -330,302 +178,8 @@
}
};
const startAnimation = () => {
if (sprites.value.length === 0) return;
store.startAnimation();
};
const stopAnimation = () => {
store.stopAnimation();
};
const handleFrameChange = () => {
if (animation.value.isPlaying) {
stopAnimation();
}
// Ensure frame is within bounds
currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1));
updateFrame();
};
const updateFrame = () => {
// Ensure frame is within bounds
currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1));
animation.value.currentFrame = currentFrame.value;
animation.value.manualUpdate = true;
// Use the computed spriteOffset directly
store.renderAnimationFrame(currentFrame.value, showAllSprites.value, spriteOffset.value);
};
const handleFrameRateChange = () => {
// If animation is currently playing, restart it with the new frame rate
if (animation.value.isPlaying) {
stopAnimation();
startAnimation();
}
};
// Modal drag and resize functionality is now handled by BaseModal
const initializeCanvas = async () => {
// Wait for the next tick to ensure the canvas element is rendered
await nextTick();
if (!animCanvas.value) {
console.error('PreviewModal: Animation canvas not found');
return;
}
try {
const context = animCanvas.value.getContext('2d');
if (!context) {
console.error('PreviewModal: Failed to get 2D context from animation canvas');
return;
}
store.animation.canvas = animCanvas.value;
store.animation.ctx = context;
} catch (error) {
console.error('PreviewModal: Error initializing animation canvas:', error);
}
};
// Zoom functions
const zoomIn = () => {
if (previewZoom.value < 5) {
previewZoom.value = Math.min(5, previewZoom.value + 0.5);
// Adjust container size after zoom change
nextTick(() => {
updateCanvasContainerSize();
});
}
};
const zoomOut = () => {
if (previewZoom.value > 1) {
previewZoom.value = Math.max(1, previewZoom.value - 0.5);
// Adjust container size after zoom change
nextTick(() => {
updateCanvasContainerSize();
});
}
};
const resetZoom = () => {
previewZoom.value = 1;
viewportOffset.value = { x: 0, y: 0 };
// Adjust container size after zoom change
nextTick(() => {
updateCanvasContainerSize();
});
};
// Reset sprite position to center
const resetSpritePosition = () => {
// Get current sprite
const currentSprite = sprites.value[currentFrame.value];
if (!currentSprite) return;
// Calculate center position
const centerX = Math.max(0, Math.floor((store.cellSize.width - currentSprite.width) / 2));
const centerY = Math.max(0, Math.floor((store.cellSize.height - currentSprite.height) / 2));
// Update the sprite offset using the computed property setter
spriteOffset.value = { x: centerX, y: centerY };
// Update the frame to reflect the change
updateFrame();
// Show a notification
store.showNotification('Sprite position reset to center');
};
// Update canvas container size based on zoom level
const updateCanvasContainerSize = () => {
// This ensures the container grows with zoom while keeping the sprite visible
if (!store.cellSize.width || !store.cellSize.height) return;
// We'll update the container if needed through the reactive bindings
};
// Canvas drag functions for moving the sprite within its cell
const startCanvasDrag = (e: MouseEvent) => {
if (sprites.value.length === 0) return;
if (isViewportDragging.value) return;
isCanvasDragging.value = true;
// Store initial position
canvasDragStart.value = {
x: e.clientX,
y: e.clientY,
};
window.addEventListener('mousemove', handleCanvasDrag, { capture: true });
window.addEventListener('mouseup', stopCanvasDrag, { capture: true });
e.preventDefault();
e.stopPropagation();
};
const handleCanvasDrag = (e: MouseEvent) => {
if (!isCanvasDragging.value) return;
requestAnimationFrame(() => {
// Get current sprite
const currentSprite = sprites.value[currentFrame.value];
if (!currentSprite) return;
// Calculate delta from last position
const deltaX = e.clientX - canvasDragStart.value.x;
const deltaY = e.clientY - canvasDragStart.value.y;
// Only move when delta exceeds the threshold for one pixel movement at current zoom
const pixelThreshold = previewZoom.value; // One pixel at current zoom level
// Calculate the maximum allowed offset
const maxOffsetX = Math.max(0, store.cellSize.width - currentSprite.width);
const maxOffsetY = Math.max(0, store.cellSize.height - currentSprite.height);
// Move one pixel at a time when threshold is reached
if (Math.abs(deltaX) >= pixelThreshold) {
const pixelsToMove = Math.sign(deltaX);
const newX = spriteOffset.value.x + pixelsToMove;
spriteOffset.value.x = Math.max(0, Math.min(maxOffsetX, newX));
// Reset the start X position for next pixel move
canvasDragStart.value.x = e.clientX;
}
if (Math.abs(deltaY) >= pixelThreshold) {
const pixelsToMove = Math.sign(deltaY);
const newY = spriteOffset.value.y + pixelsToMove;
spriteOffset.value.y = Math.max(0, Math.min(maxOffsetY, newY));
// Reset the start Y position for next pixel move
canvasDragStart.value.y = e.clientY;
}
// Update the frame
updateFrame();
// Re-render the main view
store.renderSpritesheetPreview();
});
};
const stopCanvasDrag = (e: MouseEvent) => {
if (!isCanvasDragging.value) return;
isCanvasDragging.value = false;
window.removeEventListener('mousemove', handleCanvasDrag, { capture: true });
window.removeEventListener('mouseup', stopCanvasDrag, { capture: true });
e.preventDefault();
e.stopPropagation();
};
// Canvas viewport navigation functions
const startViewportDrag = (e: MouseEvent) => {
// Only enable viewport dragging when zoomed in
if (previewZoom.value <= 1 || isCanvasDragging.value) return;
isViewportDragging.value = true;
viewportDragStart.value = {
x: e.clientX,
y: e.clientY,
};
// Add temporary event listeners
window.addEventListener('mousemove', handleViewportDrag);
window.addEventListener('mouseup', stopViewportDrag);
// Prevent default to avoid text selection
e.preventDefault();
};
const handleViewportDrag = (e: MouseEvent) => {
if (!isViewportDragging.value) return;
const deltaX = e.clientX - viewportDragStart.value.x;
const deltaY = e.clientY - viewportDragStart.value.y;
// Update viewport offset with the delta, scaled by zoom level
viewportOffset.value = {
x: viewportOffset.value.x + deltaX / previewZoom.value,
y: viewportOffset.value.y + deltaY / previewZoom.value,
};
// Reset drag start position
viewportDragStart.value = {
x: e.clientX,
y: e.clientY,
};
};
const stopViewportDrag = () => {
isViewportDragging.value = false;
window.removeEventListener('mousemove', handleViewportDrag);
window.removeEventListener('mouseup', stopViewportDrag);
};
// Handle mouse wheel zooming and panning
const handleCanvasWheel = (e: WheelEvent) => {
// Prevent the default scroll behavior
e.preventDefault();
if (e.ctrlKey) {
// Ctrl + wheel = zoom in/out
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
} else {
// Just wheel = pan when zoomed in
if (previewZoom.value > 1) {
// Adjust pan amount by zoom level
const panFactor = 0.5 / previewZoom.value;
if (e.shiftKey) {
// Shift + wheel = horizontal pan
viewportOffset.value.x -= e.deltaY * panFactor;
} else {
// Regular wheel = vertical pan
viewportOffset.value.y -= e.deltaY * panFactor;
}
}
}
};
onMounted(async () => {
// Initialize canvas with a slight delay to ensure DOM is ready
await nextTick();
await initializeCanvas();
// Add event listeners
window.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousemove', handleCanvasDrag);
window.removeEventListener('mouseup', stopCanvasDrag);
window.removeEventListener('mousemove', handleViewportDrag);
window.removeEventListener('mouseup', stopViewportDrag);
});
// Keep currentFrame in sync with animation.currentFrame
watch(
() => animation.value.currentFrame,
newVal => {
currentFrame.value = newVal;
}
);
// Keyboard shortcuts
useKeyboardShortcuts(isModalOpen, sprites, animation, closeModal, startAnimation, stopAnimation, nextFrame, prevFrame, zoomIn, zoomOut, resetZoom, resetSpritePosition, panViewport, store.showNotification);
// Watch for changes in sprites to update the canvas when new sprites are added
watch(
@ -674,24 +228,7 @@
</script>
<style scoped>
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 15px;
height: 15px;
background: #0096ff;
border-radius: 50%;
cursor: pointer;
}
/* Prevent text selection while dragging */
/* Cursor styles */
.cursor-move {
user-select: none;
}
@ -706,11 +243,6 @@
display: none; /* Chrome, Safari and Opera */
}
/* Cursor styles */
.cursor-se-resize {
cursor: se-resize;
}
/* Canvas container styles */
.canvas-container {
margin: auto;

View File

@ -0,0 +1,62 @@
<template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<!-- Zoom controls -->
<div class="flex items-center gap-2">
<button @click="$emit('zoomOut')" :disabled="previewZoom <= 1" class="zoom-button" title="Zoom out">
<i class="fas fa-search-minus"></i>
</button>
<span class="text-sm text-gray-300">{{ Math.round(previewZoom * 100) }}%</span>
<button @click="$emit('zoomIn')" :disabled="previewZoom >= 5" class="zoom-button" title="Zoom in">
<i class="fas fa-search-plus"></i>
</button>
<button @click="$emit('resetZoom')" :disabled="previewZoom === 1" class="control-button text-xs" title="Reset zoom level">Reset Zoom</button>
</div>
<!-- View Controls -->
<div class="flex items-center gap-4">
<!-- Show all sprites toggle -->
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" :checked="showAllSprites" @change="$emit('toggleShowAllSprites')" class="form-checkbox h-4 w-4 text-blue-500 rounded border-gray-600 bg-gray-700 focus:ring-blue-500" />
Show all frames
</label>
<!-- Reset sprite position button -->
<button @click="$emit('resetPosition')" :disabled="!hasSpriteOffset" class="control-button text-xs" title="Center sprite in cell">
<i class="fas fa-crosshairs"></i>
Center Sprite
</button>
</div>
</div>
</template>
<script setup lang="ts">
// Props
defineProps<{
previewZoom: number;
spriteOffset: { x: number; y: number };
hasSpriteOffset: boolean;
showAllSprites: boolean;
}>();
// Emits
defineEmits<{
(e: 'zoomIn'): void;
(e: 'zoomOut'): void;
(e: 'resetZoom'): void;
(e: 'resetPosition'): void;
(e: 'toggleShowAllSprites'): void;
}>();
</script>
<style scoped lang="postcss">
.zoom-button {
@apply flex items-center justify-center w-8 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500;
}
.control-button {
@apply flex items-center gap-1 px-2 h-8 bg-gray-700 text-gray-200 border border-gray-600 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:border-blue-500;
}
</style>

View File

@ -0,0 +1,136 @@
import { ref, computed, watch, type Ref } from 'vue';
import { type Sprite, type AnimationState } from '@/application/types';
export function useAnimation(sprites: Ref<Sprite[]>, animation: Ref<AnimationState>, onUpdateFrame: (frameIndex: number, offset: { x: number; y: number }) => void, getSpriteOffset: (frameIndex: number) => { x: number; y: number }) {
// State
const currentFrame = ref(0);
const showAllSprites = ref(false);
// Computed
const currentFrameDisplay = computed(() => {
if (sprites.value.length === 0) return '0 / 0';
return `${currentFrame.value + 1} / ${sprites.value.length}`;
});
// Methods
const startAnimation = () => {
if (sprites.value.length === 0) return;
animation.value.isPlaying = true;
animation.value.lastFrameTime = performance.now();
animation.value.manualUpdate = false;
if (animation.value.canvas) {
animation.value.canvas.width = animation.value.canvas.width; // Reset canvas
}
// Start animation loop
if (!animation.value.animationId) {
animationLoop();
}
};
const stopAnimation = () => {
animation.value.isPlaying = false;
if (animation.value.animationId) {
cancelAnimationFrame(animation.value.animationId);
animation.value.animationId = null;
}
};
const animationLoop = (timestamp?: number) => {
if (!animation.value.isPlaying) return;
const currentTime = timestamp || performance.now();
const elapsed = currentTime - animation.value.lastFrameTime;
const frameInterval = 1000 / animation.value.frameRate;
if (elapsed >= frameInterval) {
animation.value.lastFrameTime = currentTime;
if (sprites.value.length > 0) {
// Get the stored offset for the current frame
const frameOffset = getSpriteOffset(animation.value.currentFrame);
// Update frame with offset
onUpdateFrame(animation.value.currentFrame, frameOffset);
// Move to the next frame
animation.value.currentFrame = (animation.value.currentFrame + 1) % sprites.value.length;
currentFrame.value = animation.value.currentFrame;
// Update slider if available
if (animation.value.slider) {
animation.value.slider.value = animation.value.currentFrame.toString();
}
}
}
animation.value.animationId = requestAnimationFrame(animationLoop);
};
const handleFrameChange = () => {
if (animation.value.isPlaying) {
stopAnimation();
}
// Ensure frame is within bounds
currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1));
animation.value.currentFrame = currentFrame.value;
updateCurrentFrame();
};
const handleFrameRateChange = () => {
// If animation is currently playing, restart it with the new frame rate
if (animation.value.isPlaying) {
stopAnimation();
startAnimation();
}
};
const updateCurrentFrame = () => {
// Ensure frame is within bounds
currentFrame.value = Math.max(0, Math.min(currentFrame.value, sprites.value.length - 1));
animation.value.currentFrame = currentFrame.value;
animation.value.manualUpdate = true;
// Get the offset for the current frame
const offset = getSpriteOffset(currentFrame.value);
// Update frame with the current offset
onUpdateFrame(currentFrame.value, offset);
};
const nextFrame = () => {
if (sprites.value.length === 0) return;
currentFrame.value = (currentFrame.value + 1) % sprites.value.length;
updateCurrentFrame();
};
const prevFrame = () => {
if (sprites.value.length === 0) return;
currentFrame.value = (currentFrame.value - 1 + sprites.value.length) % sprites.value.length;
updateCurrentFrame();
};
// Keep currentFrame in sync with animation.currentFrame
watch(
() => animation.value.currentFrame,
newVal => {
currentFrame.value = newVal;
}
);
return {
currentFrame,
showAllSprites,
currentFrameDisplay,
startAnimation,
stopAnimation,
handleFrameChange,
handleFrameRateChange,
updateCurrentFrame,
nextFrame,
prevFrame,
};
}

View File

@ -0,0 +1,52 @@
// composables/useCanvasInitialization.ts
import { ref, nextTick, type Ref } from 'vue';
import { type CellSize, type AnimationState } from '@/application/types';
export function useCanvasInitialization(animation: Ref<AnimationState>, cellSize: Ref<CellSize>) {
const animCanvas = ref<HTMLCanvasElement | null>(null);
const initializeCanvas = async (): Promise<boolean> => {
// Wait for the next tick to ensure the canvas element is rendered
await nextTick();
if (!animCanvas.value) {
console.error('PreviewModal: Animation canvas not found');
return false;
}
try {
const context = animCanvas.value.getContext('2d');
if (!context) {
console.error('PreviewModal: Failed to get 2D context from animation canvas');
return false;
}
animation.value.canvas = animCanvas.value;
animation.value.ctx = context;
return true;
} catch (error) {
console.error('PreviewModal: Error initializing animation canvas:', error);
return false;
}
};
const updateCanvasSize = () => {
if (!animCanvas.value) {
console.warn('PreviewModal: Cannot update canvas size - canvas not found');
return;
}
if (cellSize.value.width && cellSize.value.height) {
animCanvas.value.width = cellSize.value.width;
animCanvas.value.height = cellSize.value.height;
} else {
console.warn('PreviewModal: Cannot update canvas size - invalid cell dimensions');
}
};
return {
animCanvas,
initializeCanvas,
updateCanvasSize,
};
}

View File

@ -0,0 +1,117 @@
import { onMounted, onBeforeUnmount, type Ref } from 'vue';
import { type Sprite, type AnimationState } from '@/application/types';
export function useKeyboardShortcuts(
isModalOpen: Ref<boolean>,
sprites: Ref<Sprite[]>,
animation: Ref<AnimationState>,
closeModal: () => void,
startAnimation: () => void,
stopAnimation: () => void,
nextFrame: () => void,
prevFrame: () => void,
zoomIn: () => void,
zoomOut: () => void,
resetZoom: () => void,
resetSpritePosition: () => void,
panViewport: (direction: 'left' | 'right' | 'up' | 'down', amount?: number) => void,
showNotification: (message: string, type?: 'success' | 'error') => void
) {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isModalOpen.value) return;
// Modal control
if (e.key === 'Escape') {
closeModal();
return;
}
// Animation control
if (e.key === ' ' || e.key === 'Spacebar') {
// Toggle play/pause
if (animation.value.isPlaying) {
stopAnimation();
} else if (sprites.value.length > 0) {
startAnimation();
}
e.preventDefault();
return;
}
// Frame navigation
if (e.key === 'ArrowRight' && !animation.value.isPlaying && sprites.value.length > 0) {
nextFrame();
e.preventDefault();
return;
}
if (e.key === 'ArrowLeft' && !animation.value.isPlaying && sprites.value.length > 0) {
prevFrame();
e.preventDefault();
return;
}
// Zoom controls
if (e.key === '+' || e.key === '=') {
zoomIn();
e.preventDefault();
return;
}
if (e.key === '-' || e.key === '_') {
zoomOut();
e.preventDefault();
return;
}
if (e.key === '0') {
resetZoom();
e.preventDefault();
return;
}
// Reset sprite position
if (e.key === 'r') {
if (e.shiftKey) {
// Shift+R: Reset sprite position only
resetSpritePosition();
} else {
// R: Reset both sprite position and viewport
resetSpritePosition();
resetZoom();
showNotification('View and position reset');
}
e.preventDefault();
return;
}
// Viewport panning when animation is playing
if (animation.value.isPlaying) {
if (e.key === 'ArrowLeft') {
panViewport('left');
e.preventDefault();
} else if (e.key === 'ArrowRight') {
panViewport('right');
e.preventDefault();
} else if (e.key === 'ArrowUp') {
panViewport('up');
e.preventDefault();
} else if (e.key === 'ArrowDown') {
panViewport('down');
e.preventDefault();
}
}
};
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
});
return {
handleKeyDown,
};
}

View File

@ -0,0 +1,134 @@
import { ref, computed, type Ref } from 'vue';
import { type Sprite, type CellSize } from '@/application/types';
export function useSpritePosition(sprites: Ref<Sprite[]>, currentFrame: Ref<number>, cellSize: Ref<CellSize>, getSpriteOffset: (frameIndex: number) => { x: number; y: number }, onUpdateFrame: () => void, showNotification: (message: string, type?: 'success' | 'error') => void) {
// State
const isCanvasDragging = ref(false);
const canvasDragStart = ref({ x: 0, y: 0 });
// Computed
const spriteOffset = computed({
get: () => {
// Get the offset for the current frame
return getSpriteOffset(currentFrame.value);
},
set: val => {
// Update the frame-specific offset directly
const frameOffset = getSpriteOffset(currentFrame.value);
frameOffset.x = val.x;
frameOffset.y = val.y;
// Re-render the frame
onUpdateFrame();
},
});
const hasSpriteOffset = computed(() => {
return spriteOffset.value.x !== 0 || spriteOffset.value.y !== 0;
});
// Methods
const resetSpritePosition = () => {
// Get current sprite
const sprite = sprites.value[currentFrame.value];
if (!sprite) return;
// Calculate center position
const centerX = Math.max(0, Math.floor((cellSize.value.width - sprite.width) / 2));
const centerY = Math.max(0, Math.floor((cellSize.value.height - sprite.height) / 2));
// Update the sprite offset
spriteOffset.value = { x: centerX, y: centerY };
// Update the frame
onUpdateFrame();
// Show a notification
showNotification('Sprite position reset to center');
};
const startCanvasDrag = (e: MouseEvent, isViewportDragging: Ref<boolean>, previewZoom: Ref<number>) => {
if (sprites.value.length === 0) return;
if (isViewportDragging.value) return;
isCanvasDragging.value = true;
// Store initial position
canvasDragStart.value = {
x: e.clientX,
y: e.clientY,
};
// Add event listeners
window.addEventListener('mousemove', e => handleCanvasDrag(e, previewZoom), { capture: true });
window.addEventListener('mouseup', stopCanvasDrag, { capture: true });
e.preventDefault();
e.stopPropagation();
};
const handleCanvasDrag = (e: MouseEvent, previewZoom: Ref<number>) => {
if (!isCanvasDragging.value) return;
requestAnimationFrame(() => {
// Get current sprite
const sprite = sprites.value[currentFrame.value];
if (!sprite) return;
// Calculate delta from last position
const deltaX = e.clientX - canvasDragStart.value.x;
const deltaY = e.clientY - canvasDragStart.value.y;
// Only move when delta exceeds the threshold for one pixel movement at current zoom
const pixelThreshold = previewZoom.value; // One pixel at current zoom level
// Calculate the maximum allowed offset
const maxOffsetX = Math.max(0, cellSize.value.width - sprite.width);
const maxOffsetY = Math.max(0, cellSize.value.height - sprite.height);
// Move one pixel at a time when threshold is reached
if (Math.abs(deltaX) >= pixelThreshold) {
const pixelsToMove = Math.sign(deltaX);
const newX = spriteOffset.value.x + pixelsToMove;
spriteOffset.value.x = Math.max(0, Math.min(maxOffsetX, newX));
// Reset the start X position for next pixel move
canvasDragStart.value.x = e.clientX;
}
if (Math.abs(deltaY) >= pixelThreshold) {
const pixelsToMove = Math.sign(deltaY);
const newY = spriteOffset.value.y + pixelsToMove;
spriteOffset.value.y = Math.max(0, Math.min(maxOffsetY, newY));
// Reset the start Y position for next pixel move
canvasDragStart.value.y = e.clientY;
}
// Update the frame
onUpdateFrame();
});
};
const stopCanvasDrag = (e: MouseEvent) => {
if (!isCanvasDragging.value) return;
isCanvasDragging.value = false;
window.removeEventListener('mousemove', handleCanvasDrag as any, { capture: true });
window.removeEventListener('mouseup', stopCanvasDrag, { capture: true });
e.preventDefault();
e.stopPropagation();
};
return {
isCanvasDragging,
canvasDragStart,
spriteOffset,
hasSpriteOffset,
resetSpritePosition,
startCanvasDrag,
handleCanvasDrag,
stopCanvasDrag,
};
}

View File

@ -1,680 +1,19 @@
import { ref, reactive, computed } from 'vue';
import { sprites, canvas, ctx, cellSize, columns, draggedSprite, dragOffset, isShiftPressed, isModalOpen, isSettingsModalOpen, isSpritesModalOpen, isHelpModalOpen, zoomLevel, previewBorder, animation, notification, currentSpriteOffset, spriteOffsets } from '@/application/state';
export interface Sprite {
img: HTMLImageElement;
width: number;
height: number;
x: number;
y: number;
name: string;
id: string;
uploadOrder: number;
}
import { getSpriteOffset, showNotification } from '@/application/utilities';
export interface CellSize {
width: number;
height: number;
}
import { addSprites, updateCellSize, autoArrangeSprites, highlightSprite, clearAllSprites, applyOffsetsToMainView } from '@/application/spriteOperations';
export interface AnimationState {
canvas: HTMLCanvasElement | null;
ctx: CanvasRenderingContext2D | null;
currentFrame: number;
isPlaying: boolean;
frameRate: number;
lastFrameTime: number;
animationId: number | null;
slider: HTMLInputElement | null;
manualUpdate: boolean;
}
import { updateCanvasSize, renderSpritesheetPreview, drawGrid, downloadSpritesheet, zoomIn, zoomOut, resetZoom } from '@/application/canvasOperations';
const sprites = ref<Sprite[]>([]);
const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
const cellSize = reactive<CellSize>({ width: 0, height: 0 });
const columns = ref(4); // Default number of columns
const draggedSprite = ref<Sprite | null>(null);
const dragOffset = reactive({ x: 0, y: 0 });
const isShiftPressed = ref(false);
const isModalOpen = ref(false);
const isSettingsModalOpen = ref(false);
const isSpritesModalOpen = ref(false);
const isHelpModalOpen = ref(false);
const zoomLevel = ref(1); // Default zoom level (1 = 100%)
// Preview border settings
const previewBorder = reactive({
enabled: false,
color: '#ff0000', // Default red color
width: 2, // Default width in pixels
});
import { startAnimation, stopAnimation, renderAnimationFrame, animationLoop } from '@/application/animationController';
/**
* Main store function that provides access to all spritesheet functionality
*/
export function useSpritesheetStore() {
const animation = reactive<AnimationState>({
canvas: null,
ctx: null,
currentFrame: 0,
isPlaying: false,
frameRate: 10,
lastFrameTime: 0,
animationId: null,
slider: null,
manualUpdate: false,
});
const notification = reactive({
isVisible: false,
message: '',
type: 'success' as 'success' | 'error',
});
function addSprites(newSprites: Sprite[]) {
if (newSprites.length === 0) {
console.warn('Store: Attempted to add empty sprites array');
return;
}
try {
// Validate sprites before adding them
const validSprites = newSprites.filter(sprite => {
if (!sprite.img || sprite.width <= 0 || sprite.height <= 0) {
console.error('Store: Invalid sprite detected', sprite);
return false;
}
return true;
});
if (validSprites.length === 0) {
console.error('Store: No valid sprites to add');
return;
}
sprites.value.push(...validSprites);
sprites.value.sort((a, b) => a.uploadOrder - b.uploadOrder);
updateCellSize();
autoArrangeSprites();
} catch (error) {
console.error('Store: Error adding sprites:', error);
}
}
function updateCellSize() {
if (sprites.value.length === 0) {
return;
}
try {
let maxWidth = 0;
let maxHeight = 0;
sprites.value.forEach(sprite => {
if (sprite.width <= 0 || sprite.height <= 0) {
console.warn('Store: Sprite with invalid dimensions detected', sprite);
return;
}
maxWidth = Math.max(maxWidth, sprite.width);
maxHeight = Math.max(maxHeight, sprite.height);
});
if (maxWidth === 0 || maxHeight === 0) {
console.error('Store: Failed to calculate valid cell size');
return;
}
cellSize.width = maxWidth;
cellSize.height = maxHeight;
updateCanvasSize();
} catch (error) {
console.error('Store: Error updating cell size:', error);
}
}
function updateCanvasSize() {
if (!canvas.value) {
console.warn('Store: Canvas not available for size update');
return;
}
if (sprites.value.length === 0) {
return;
}
try {
const totalSprites = sprites.value.length;
const cols = columns.value;
const rows = Math.ceil(totalSprites / cols);
if (cellSize.width <= 0 || cellSize.height <= 0) {
console.error('Store: Invalid cell size for canvas update', cellSize);
return;
}
const newWidth = cols * cellSize.width;
const newHeight = rows * cellSize.height;
// Ensure the canvas is large enough to display all sprites
if (canvas.value.width !== newWidth || canvas.value.height !== newHeight) {
canvas.value.width = newWidth;
canvas.value.height = newHeight;
// Emit an event to update the wrapper dimensions
window.dispatchEvent(
new CustomEvent('canvas-size-updated', {
detail: { width: newWidth, height: newHeight },
})
);
}
} catch (error) {
console.error('Store: Error updating canvas size:', error);
}
}
function autoArrangeSprites() {
if (sprites.value.length === 0) {
return;
}
try {
if (cellSize.width <= 0 || cellSize.height <= 0) {
console.error('Store: Invalid cell size for auto-arranging', cellSize);
return;
}
// First update the canvas size to ensure it's large enough
updateCanvasSize();
// Then position each sprite in its grid cell
sprites.value.forEach((sprite, index) => {
const column = index % columns.value;
const row = Math.floor(index / columns.value);
sprite.x = column * cellSize.width;
sprite.y = row * cellSize.height;
});
// Check if the canvas is ready before attempting to render
if (!ctx.value || !canvas.value) {
console.warn('Store: Canvas or context not available for rendering after auto-arrange');
return;
}
renderSpritesheetPreview();
if (!animation.isPlaying && animation.manualUpdate && isModalOpen.value) {
renderAnimationFrame(animation.currentFrame);
}
} catch (error) {
console.error('Store: Error auto-arranging sprites:', error);
}
}
function renderSpritesheetPreview(showGrid = true) {
if (!ctx.value || !canvas.value) {
console.error('Store: Canvas or context not available for rendering, will retry when ready');
setTimeout(() => {
if (ctx.value && canvas.value) {
renderSpritesheetPreview(showGrid);
}
}, 100);
return;
}
if (sprites.value.length === 0) return;
try {
// Make sure the canvas size is correct before rendering
updateCanvasSize();
// Clear the canvas
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
if (showGrid) {
drawGrid();
}
// First, collect all occupied cells
const occupiedCells = new Set<string>();
sprites.value.forEach(sprite => {
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
occupiedCells.add(`${cellX},${cellY}`);
});
// Draw each sprite - remove the zoom scaling from context
sprites.value.forEach((sprite, index) => {
try {
if (!sprite.img) {
console.warn(`Store: Sprite at index ${index} has no image, skipping render`);
return;
}
// Check if sprite is within canvas bounds
if (sprite.x >= 0 && sprite.y >= 0 && sprite.x + sprite.width <= canvas.value!.width && sprite.y + sprite.height <= canvas.value!.height) {
if (sprite.img.complete && sprite.img.naturalWidth !== 0) {
// For pixel art, ensure we're drawing at exact pixel boundaries
const x = Math.round(sprite.x);
const y = Math.round(sprite.y);
// Get the frame-specific offset for this sprite
const frameOffset = getSpriteOffset(index);
// Calculate the maximum allowed offset based on sprite and cell size
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
// Constrain the offset to prevent out-of-bounds positioning
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
// Update the frame offset with the constrained values
frameOffset.x = constrainedOffsetX;
frameOffset.y = constrainedOffsetY;
// Apply the constrained offset to the sprite position
const finalX = x + constrainedOffsetX;
const finalY = y + constrainedOffsetY;
// Draw the image at its final position with pixel-perfect rendering
ctx.value!.imageSmoothingEnabled = false; // Keep pixel art sharp
ctx.value!.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
} else {
console.warn(`Store: Sprite image ${index} not fully loaded, setting onload handler`);
sprite.img.onload = () => {
if (ctx.value && canvas.value) {
// For pixel art, ensure we're drawing at exact pixel boundaries
const x = Math.round(sprite.x);
const y = Math.round(sprite.y);
// Get the frame-specific offset for this sprite
const frameOffset = getSpriteOffset(index);
// Calculate the maximum allowed offset based on sprite and cell size
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
// Constrain the offset to prevent out-of-bounds positioning
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
// Update the frame offset with the constrained values
frameOffset.x = constrainedOffsetX;
frameOffset.y = constrainedOffsetY;
// Apply the constrained offset to the sprite position
const finalX = x + constrainedOffsetX;
const finalY = y + constrainedOffsetY;
ctx.value.imageSmoothingEnabled = false; // Keep pixel art sharp
ctx.value.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
}
};
}
} else {
console.warn(`Store: Sprite at index ${index} is outside canvas bounds: sprite(${sprite.x},${sprite.y}) canvas(${canvas.value!.width},${canvas.value!.height})`);
}
} catch (spriteError) {
console.error(`Store: Error rendering sprite at index ${index}:`, spriteError);
}
});
// Draw borders around occupied cells if enabled (preview only)
if (previewBorder.enabled && occupiedCells.size > 0) {
ctx.value!.strokeStyle = previewBorder.color;
ctx.value!.lineWidth = previewBorder.width / zoomLevel.value; // Adjust for zoom
// Draw borders around each occupied cell
occupiedCells.forEach(cellKey => {
const [cellX, cellY] = cellKey.split(',').map(Number);
// Calculate pixel-perfect coordinates for the cell
// Add 0.5 to align with pixel boundaries for crisp lines
const x = Math.floor(cellX * cellSize.width) + 0.5;
const y = Math.floor(cellY * cellSize.height) + 0.5;
// Adjust width and height to ensure the border is inside the cell
const width = cellSize.width - 1;
const height = cellSize.height - 1;
ctx.value!.strokeRect(x, y, width, height);
});
}
} catch (error) {
console.error('Store: Error in renderSpritesheetPreview:', error);
}
}
function applyOffsetsToMainView() {
sprites.value.forEach((sprite, index) => {
const frameOffset = getSpriteOffset(index);
if (frameOffset.x !== 0 || frameOffset.y !== 0) {
// Update the sprite's position to include the offset
sprite.x += frameOffset.x;
sprite.y += frameOffset.y;
// Reset the offset
frameOffset.x = 0;
frameOffset.y = 0;
}
});
// Reset current offset
currentSpriteOffset.x = 0;
currentSpriteOffset.y = 0;
// Re-render the main view
renderSpritesheetPreview();
}
function drawGrid() {
if (!ctx.value || !canvas.value) return;
ctx.value.strokeStyle = '#333';
ctx.value.lineWidth = 1 / zoomLevel.value; // Adjust line width based on zoom level
// Calculate the visible area based on zoom level
const visibleWidth = canvas.value.width / zoomLevel.value;
const visibleHeight = canvas.value.height / zoomLevel.value;
// Draw vertical lines - ensure pixel-perfect grid lines
for (let x = 0; x <= visibleWidth; x += cellSize.width) {
const pixelX = Math.floor(x) + 0.5; // Align to pixel boundary for crisp lines
ctx.value.beginPath();
ctx.value.moveTo(pixelX, 0);
ctx.value.lineTo(pixelX, visibleHeight);
ctx.value.stroke();
}
// Draw horizontal lines - ensure pixel-perfect grid lines
for (let y = 0; y <= visibleHeight; y += cellSize.height) {
const pixelY = Math.floor(y) + 0.5; // Align to pixel boundary for crisp lines
ctx.value.beginPath();
ctx.value.moveTo(0, pixelY);
ctx.value.lineTo(visibleWidth, pixelY);
ctx.value.stroke();
}
}
function highlightSprite(spriteId: string) {
if (!ctx.value || !canvas.value) return;
const sprite = sprites.value.find(s => s.id === spriteId);
if (!sprite) return;
// Calculate the cell coordinates
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
// Briefly flash the cell
ctx.value.save();
ctx.value.fillStyle = 'rgba(0, 150, 255, 0.3)';
ctx.value.fillRect(cellX * cellSize.width, cellY * cellSize.height, cellSize.width, cellSize.height);
ctx.value.restore();
// Reset after a short delay
setTimeout(() => {
renderSpritesheetPreview();
}, 500);
}
function clearAllSprites() {
if (!canvas.value || !ctx.value) return;
sprites.value = [];
canvas.value.width = 400;
canvas.value.height = 300;
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
if (animation.canvas && animation.ctx) {
animation.canvas.width = 200;
animation.canvas.height = 200;
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
}
animation.currentFrame = 0;
isModalOpen.value = false;
}
function downloadSpritesheet() {
if (sprites.value.length === 0 || !canvas.value) {
showNotification('No sprites to download', 'error');
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvas.value.width;
tempCanvas.height = canvas.value.height;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
showNotification('Failed to create download context', 'error');
return;
}
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
// Ensure pixel art remains sharp in the downloaded file
tempCtx.imageSmoothingEnabled = false;
sprites.value.forEach((sprite, index) => {
// Get the frame-specific offset for this sprite
const frameOffset = getSpriteOffset(index);
// Calculate the cell coordinates for this sprite
const cellX = Math.floor(sprite.x / cellSize.width);
const cellY = Math.floor(sprite.y / cellSize.height);
// Calculate the base position within the cell
const baseX = cellX * cellSize.width;
const baseY = cellY * cellSize.height;
// Calculate the maximum allowed offset based on sprite and cell size
// This prevents sprites from going out of bounds
const maxOffsetX = Math.max(0, cellSize.width - sprite.width);
const maxOffsetY = Math.max(0, cellSize.height - sprite.height);
// Constrain the offset to prevent out-of-bounds positioning
const constrainedOffsetX = Math.max(0, Math.min(maxOffsetX, frameOffset.x));
const constrainedOffsetY = Math.max(0, Math.min(maxOffsetY, frameOffset.y));
// Apply the constrained offset to the base position
const finalX = baseX + constrainedOffsetX;
const finalY = baseY + constrainedOffsetY;
// Draw the sprite at the calculated position
tempCtx.drawImage(sprite.img, finalX, finalY, sprite.width, sprite.height);
});
const link = document.createElement('a');
link.download = 'spritesheet.png';
link.href = tempCanvas.toDataURL('image/png');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showNotification('Spritesheet downloaded successfully');
}
function startAnimation() {
if (sprites.value.length === 0 || !animation.canvas) return;
animation.isPlaying = true;
animation.lastFrameTime = performance.now();
animation.manualUpdate = false;
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
// Start the animation loop without resetting sprite offset
animationLoop();
}
function stopAnimation() {
animation.isPlaying = false;
if (animation.animationId) {
cancelAnimationFrame(animation.animationId);
animation.animationId = null;
}
}
function renderAnimationFrame(frameIndex: number, showAllSprites = false, spriteOffset = { x: 0, y: 0 }) {
if (sprites.value.length === 0 || !animation.canvas || !animation.ctx) return;
if (animation.canvas.width !== cellSize.width || animation.canvas.height !== cellSize.height) {
animation.canvas.width = cellSize.width;
animation.canvas.height = cellSize.height;
}
animation.ctx.clearRect(0, 0, animation.canvas.width, animation.canvas.height);
// Draw background (transparent by default)
animation.ctx.fillStyle = 'transparent';
animation.ctx.fillRect(0, 0, animation.canvas.width, animation.canvas.height);
// Keep pixel art sharp
animation.ctx.imageSmoothingEnabled = false;
// If showAllSprites is enabled, draw all sprites with transparency
if (showAllSprites && sprites.value.length > 1) {
// Save the current context state
animation.ctx.save();
// Set global alpha for background sprites
animation.ctx.globalAlpha = 0.3;
// Draw all sprites except the current one
sprites.value.forEach((sprite, index) => {
if (index !== frameIndex) {
const spriteCellX = Math.floor(sprite.x / cellSize.width);
const spriteCellY = Math.floor(sprite.y / cellSize.height);
// Calculate precise offset for pixel-perfect rendering
const spriteOffsetX = Math.round(sprite.x - spriteCellX * cellSize.width);
const spriteOffsetY = Math.round(sprite.y - spriteCellY * cellSize.height);
// Draw the sprite with transparency
animation.ctx.drawImage(sprite.img, spriteOffsetX, spriteOffsetY);
}
});
// Restore the context to full opacity
animation.ctx.restore();
}
// Get the current sprite
const currentSprite = sprites.value[frameIndex % sprites.value.length];
const cellX = Math.floor(currentSprite.x / cellSize.width);
const cellY = Math.floor(currentSprite.y / cellSize.height);
// Calculate the original position (without user offset)
const originalOffsetX = Math.round(currentSprite.x - cellX * cellSize.width);
const originalOffsetY = Math.round(currentSprite.y - cellY * cellSize.height);
// Calculate precise offset for pixel-perfect rendering, including the user's drag offset
// Use the spriteOffset directly as the position within the cell
const offsetX = spriteOffset.x;
const offsetY = spriteOffset.y;
// Draw the current sprite at full opacity at the new position
animation.ctx.drawImage(currentSprite.img, offsetX, offsetY);
// Draw border around the cell if enabled (only for preview, not included in download)
if (previewBorder.enabled) {
animation.ctx.strokeStyle = previewBorder.color;
animation.ctx.lineWidth = previewBorder.width;
// Use pixel-perfect coordinates for the border (0.5 offset for crisp lines)
const x = 0.5;
const y = 0.5;
const width = animation.canvas.width - 1;
const height = animation.canvas.height - 1;
animation.ctx.strokeRect(x, y, width, height);
}
}
// Store the current sprite offset for animation playback
// We'll use a Map to store offsets for each frame, so they're preserved when switching frames
const spriteOffsets = reactive(new Map<number, { x: number; y: number }>());
// Current sprite offset is a reactive object that will be used for the current frame
const currentSpriteOffset = reactive({ x: 0, y: 0 });
// Helper function to get the offset for a specific frame
function getSpriteOffset(frameIndex: number) {
if (!spriteOffsets.has(frameIndex)) {
spriteOffsets.set(frameIndex, { x: 0, y: 0 });
}
return spriteOffsets.get(frameIndex)!;
}
function animationLoop(timestamp?: number) {
if (!animation.isPlaying) return;
const currentTime = timestamp || performance.now();
const elapsed = currentTime - animation.lastFrameTime;
const frameInterval = 1000 / animation.frameRate;
if (elapsed >= frameInterval) {
animation.lastFrameTime = currentTime;
if (sprites.value.length > 0) {
// Get the stored offset for the current frame
const frameOffset = getSpriteOffset(animation.currentFrame);
// Update the current offset for rendering
currentSpriteOffset.x = frameOffset.x;
currentSpriteOffset.y = frameOffset.y;
// Render the current frame with its offset
renderAnimationFrame(animation.currentFrame, false, frameOffset);
// Move to the next frame
animation.currentFrame = (animation.currentFrame + 1) % sprites.value.length;
if (animation.slider) {
animation.slider.value = animation.currentFrame.toString();
}
}
}
animation.animationId = requestAnimationFrame(animationLoop);
}
function showNotification(message: string, type: 'success' | 'error' = 'success') {
notification.message = message;
notification.type = type;
notification.isVisible = true;
setTimeout(() => {
notification.isVisible = false;
}, 3000);
}
function zoomIn() {
// Increase zoom level by 0.1, max 3.0 (300%)
zoomLevel.value = Math.min(3.0, zoomLevel.value + 0.1);
renderSpritesheetPreview();
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
}
function zoomOut() {
// Decrease zoom level by 0.1, min 0.5 (50%)
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
renderSpritesheetPreview();
showNotification(`Zoom: ${Math.round(zoomLevel.value * 100)}%`);
}
function resetZoom() {
// Reset to default zoom level (100%)
zoomLevel.value = 1;
renderSpritesheetPreview();
showNotification('Zoom reset to 100%');
}
return {
// State
sprites,
canvas,
ctx,
@ -693,23 +32,34 @@ export function useSpritesheetStore() {
previewBorder,
currentSpriteOffset,
spriteOffsets,
// Utils
getSpriteOffset,
showNotification,
// Sprite operations
addSprites,
updateCellSize,
updateCanvasSize,
autoArrangeSprites,
highlightSprite,
clearAllSprites: () => clearAllSprites(animation),
applyOffsetsToMainView: () => applyOffsetsToMainView(currentSpriteOffset),
// Canvas operations
updateCanvasSize,
renderSpritesheetPreview,
drawGrid,
highlightSprite,
clearAllSprites,
downloadSpritesheet,
startAnimation,
stopAnimation,
renderAnimationFrame,
showNotification,
zoomIn,
zoomOut,
resetZoom,
applyOffsetsToMainView,
// Animation
startAnimation,
stopAnimation,
renderAnimationFrame,
};
}
// Re-export types
export { type Sprite, type CellSize, type AnimationState } from '@/application/types';

View File

@ -0,0 +1,153 @@
import { ref, nextTick, type Ref } from 'vue';
import { type AnimationState } from '@/application/types';
export function useViewport(animation: Ref<AnimationState>, onUpdateFrame: () => void, showNotification: (message: string, type?: 'success' | 'error') => void) {
// State
const previewZoom = ref(1);
const viewportOffset = ref({ x: 0, y: 0 });
const isViewportDragging = ref(false);
const viewportDragStart = ref({ x: 0, y: 0 });
// Methods
const zoomIn = () => {
if (previewZoom.value < 5) {
previewZoom.value = Math.min(5, previewZoom.value + 0.5);
// Adjust container size after zoom change
nextTick(() => {
updateCanvasContainerSize();
});
}
};
const zoomOut = () => {
if (previewZoom.value > 1) {
previewZoom.value = Math.max(1, previewZoom.value - 0.5);
// Adjust container size after zoom change
nextTick(() => {
updateCanvasContainerSize();
});
}
};
const resetZoom = () => {
previewZoom.value = 1;
viewportOffset.value = { x: 0, y: 0 };
// Adjust container size after zoom change
nextTick(() => {
updateCanvasContainerSize();
});
};
const updateCanvasContainerSize = () => {
// This is a no-op in the composable, but can be implemented if needed
// The actual container size is managed through reactive bindings in the template
};
const startViewportDrag = (e: MouseEvent, isCanvasDragging: Ref<boolean>) => {
// Only enable viewport dragging when zoomed in
if (previewZoom.value <= 1 || isCanvasDragging.value) return;
isViewportDragging.value = true;
viewportDragStart.value = {
x: e.clientX,
y: e.clientY,
};
// Add temporary event listeners
window.addEventListener('mousemove', handleViewportDrag);
window.addEventListener('mouseup', stopViewportDrag);
// Prevent default to avoid text selection
e.preventDefault();
};
const handleViewportDrag = (e: MouseEvent) => {
if (!isViewportDragging.value) return;
const deltaX = e.clientX - viewportDragStart.value.x;
const deltaY = e.clientY - viewportDragStart.value.y;
// Update viewport offset with the delta, scaled by zoom level
viewportOffset.value = {
x: viewportOffset.value.x + deltaX / previewZoom.value,
y: viewportOffset.value.y + deltaY / previewZoom.value,
};
// Reset drag start position
viewportDragStart.value = {
x: e.clientX,
y: e.clientY,
};
};
const stopViewportDrag = () => {
isViewportDragging.value = false;
window.removeEventListener('mousemove', handleViewportDrag);
window.removeEventListener('mouseup', stopViewportDrag);
};
const handleCanvasWheel = (e: WheelEvent) => {
// Prevent the default scroll behavior
e.preventDefault();
if (e.ctrlKey) {
// Ctrl + wheel = zoom in/out
if (e.deltaY < 0) {
zoomIn();
} else {
zoomOut();
}
} else {
// Just wheel = pan when zoomed in
if (previewZoom.value > 1) {
// Adjust pan amount by zoom level
const panFactor = 0.5 / previewZoom.value;
if (e.shiftKey) {
// Shift + wheel = horizontal pan
viewportOffset.value.x -= e.deltaY * panFactor;
} else {
// Regular wheel = vertical pan
viewportOffset.value.y -= e.deltaY * panFactor;
}
}
}
};
const panViewport = (direction: 'left' | 'right' | 'up' | 'down', amount: number = 10) => {
const panAmount = amount / previewZoom.value;
switch (direction) {
case 'left':
viewportOffset.value.x += panAmount;
break;
case 'right':
viewportOffset.value.x -= panAmount;
break;
case 'up':
viewportOffset.value.y += panAmount;
break;
case 'down':
viewportOffset.value.y -= panAmount;
break;
}
};
return {
previewZoom,
viewportOffset,
isViewportDragging,
zoomIn,
zoomOut,
resetZoom,
updateCanvasContainerSize,
startViewportDrag,
handleViewportDrag,
stopViewportDrag,
handleCanvasWheel,
panViewport,
};
}