Many clean
This commit is contained in:
parent
8eec236105
commit
a527a0e83b
138
package-lock.json
generated
138
package-lock.json
generated
@ -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": {
|
||||
|
160
src/application/animationController.ts
Normal file
160
src/application/animationController.ts
Normal 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);
|
||||
}
|
295
src/application/canvasOperations.ts
Normal file
295
src/application/canvasOperations.ts
Normal 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%');
|
||||
}
|
181
src/application/spriteOperations.ts
Normal file
181
src/application/spriteOperations.ts
Normal 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
54
src/application/state.ts
Normal 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
45
src/application/types.ts
Normal 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;
|
||||
}
|
62
src/application/utilities.ts
Normal file
62
src/application/utilities.ts
Normal 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;
|
||||
}
|
96
src/components/AnimationControls.vue
Normal file
96
src/components/AnimationControls.vue
Normal 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>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
62
src/components/ViewControls.vue
Normal file
62
src/components/ViewControls.vue
Normal 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>
|
136
src/composables/useAnimation.ts
Normal file
136
src/composables/useAnimation.ts
Normal 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,
|
||||
};
|
||||
}
|
52
src/composables/useCanvasInitialization.ts
Normal file
52
src/composables/useCanvasInitialization.ts
Normal 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,
|
||||
};
|
||||
}
|
117
src/composables/useKeyboardShortcuts.ts
Normal file
117
src/composables/useKeyboardShortcuts.ts
Normal 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,
|
||||
};
|
||||
}
|
134
src/composables/useSpritePosition.ts
Normal file
134
src/composables/useSpritePosition.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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';
|
||||
|
153
src/composables/useViewport.ts
Normal file
153
src/composables/useViewport.ts
Normal 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,
|
||||
};
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user