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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz",
|
||||||
"integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==",
|
"integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"enhanced-resolve": "^5.18.1",
|
"enhanced-resolve": "^5.18.1",
|
||||||
"jiti": "^2.4.2",
|
"jiti": "^2.4.2",
|
||||||
"lightningcss": "1.29.2",
|
"lightningcss": "1.29.2",
|
||||||
"tailwindcss": "4.1.2"
|
"tailwindcss": "4.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide": {
|
"node_modules/@tailwindcss/oxide": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz",
|
||||||
"integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==",
|
"integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-android-arm64": "4.1.2",
|
"@tailwindcss/oxide-android-arm64": "4.1.3",
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.1.2",
|
"@tailwindcss/oxide-darwin-arm64": "4.1.3",
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.1.2",
|
"@tailwindcss/oxide-darwin-x64": "4.1.3",
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.1.2",
|
"@tailwindcss/oxide-freebsd-x64": "4.1.3",
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.2",
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3",
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.2",
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.3",
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.2",
|
"@tailwindcss/oxide-linux-arm64-musl": "4.1.3",
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.2",
|
"@tailwindcss/oxide-linux-x64-gnu": "4.1.3",
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.2",
|
"@tailwindcss/oxide-linux-x64-musl": "4.1.3",
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.2",
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.3",
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.2"
|
"@tailwindcss/oxide-win32-x64-msvc": "4.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.3.tgz",
|
||||||
"integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==",
|
"integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1363,9 +1363,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.3.tgz",
|
||||||
"integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==",
|
"integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1379,9 +1379,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.3.tgz",
|
||||||
"integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==",
|
"integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1395,9 +1395,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.3.tgz",
|
||||||
"integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==",
|
"integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1411,9 +1411,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.3.tgz",
|
||||||
"integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==",
|
"integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1427,9 +1427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.3.tgz",
|
||||||
"integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==",
|
"integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1443,9 +1443,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.3.tgz",
|
||||||
"integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==",
|
"integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1459,9 +1459,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.3.tgz",
|
||||||
"integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==",
|
"integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1475,9 +1475,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.3.tgz",
|
||||||
"integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==",
|
"integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1491,9 +1491,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.3.tgz",
|
||||||
"integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==",
|
"integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1507,9 +1507,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.3.tgz",
|
||||||
"integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==",
|
"integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1523,14 +1523,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/vite": {
|
"node_modules/@tailwindcss/vite": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.3.tgz",
|
||||||
"integrity": "sha512-3r/ZdMW0gxY8uOx1To0lpYa4coq4CzINcCX4laM1rS340Kcn0ac4A/MMFfHN8qba51aorZMYwMcOxYk4wJ9FYg==",
|
"integrity": "sha512-lUI/QaDxLtlV52Lho6pu07CG9pSnRYLOPmKGIQjyHdTBagemc6HmgZxyjGAQ/5HMPrNeWBfTVIpQl0/jLXvWHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/node": "4.1.2",
|
"@tailwindcss/node": "4.1.3",
|
||||||
"@tailwindcss/oxide": "4.1.2",
|
"@tailwindcss/oxide": "4.1.3",
|
||||||
"tailwindcss": "4.1.2"
|
"tailwindcss": "4.1.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^5.2.0 || ^6"
|
"vite": "^5.2.0 || ^6"
|
||||||
@ -2014,9 +2014,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001709",
|
"version": "1.0.30001711",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001711.tgz",
|
||||||
"integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==",
|
"integrity": "sha512-OpFA8GsKtoV3lCcsI3U5XBAV+oVrMu96OS8XafKqnhOaEAW2mveD1Mx81Sx/02chERwhDakuXs28zbyEc4QMKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@ -2178,9 +2178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.131",
|
"version": "1.5.132",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.131.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.132.tgz",
|
||||||
"integrity": "sha512-fJFRYXVEJgDCiqFOgRGJm8XR97hZ13tw7FXI9k2yC5hgY+nyzC2tMO8baq1cQR7Ur58iCkASx2zrkZPZUnfzPg==",
|
"integrity": "sha512-QgX9EBvWGmvSRa74zqfnG7+Eno0Ak0vftBll0Pt2/z5b3bEGYL6OUXLgKPtvx73dn3dvwrlyVkjPKRRlhLYTEg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@ -3393,9 +3393,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
||||||
"integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==",
|
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
@ -3418,9 +3418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"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;
|
const cellBottom = cellTop + store.cellSize.height - store.draggedSprite.value.img.height;
|
||||||
|
|
||||||
// Constrain position to stay within the cell
|
// 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.x = Math.max(cellLeft, Math.min(newX, cellRight));
|
||||||
store.draggedSprite.value.y = Math.max(cellTop, Math.min(newY, cellBottom));
|
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 {
|
} else {
|
||||||
// Calculate new position based on grid cells (snap to grid)
|
// Calculate new position based on grid cells (snap to grid)
|
||||||
const newCellX = Math.floor((x - store.dragOffset.x) / store.cellSize.width);
|
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 boundedCellX = Math.max(0, Math.min(newCellX, maxCellX));
|
||||||
const boundedCellY = Math.max(0, Math.min(newCellY, maxCellY));
|
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.x = boundedCellX * store.cellSize.width;
|
||||||
store.draggedSprite.value.y = boundedCellY * store.cellSize.height;
|
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>
|
<template #header-title>Animation Preview</template>
|
||||||
|
|
||||||
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
|
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
|
||||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
<!-- Animation controls -->
|
||||||
<div class="flex gap-2">
|
<AnimationControls :sprites="sprites" :animation="animation" :currentFrame="currentFrame" :currentFrameDisplay="currentFrameDisplay" @start="startAnimation" @stop="stopAnimation" @frameChange="handleFrameChange" @frameRateChange="handleFrameRateChange" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 flex-grow">
|
<!-- View controls -->
|
||||||
<div class="flex justify-between text-sm text-gray-400">
|
<ViewControls :previewZoom="previewZoom" :spriteOffset="spriteOffset" :hasSpriteOffset="hasSpriteOffset" :showAllSprites="showAllSprites" @zoomIn="zoomIn" @zoomOut="zoomOut" @resetZoom="resetZoom" @resetPosition="resetSpritePosition" @toggleShowAllSprites="showAllSprites = !showAllSprites" />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<!-- Canvas container -->
|
||||||
<div class="flex flex-col justify-center items-center bg-gray-700 p-6 rounded mb-6 relative overflow-auto flex-grow">
|
<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">
|
<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>
|
<span>Position: {{ Math.round(spriteOffset?.x ?? 0) }}px, {{ Math.round(spriteOffset?.y ?? 0) }}px (drag to move within cell)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas viewport -->
|
||||||
<div
|
<div
|
||||||
class="canvas-container relative transition-transform duration-100 flex items-center justify-center"
|
class="canvas-container relative transition-transform duration-100 flex items-center justify-center"
|
||||||
:style="{
|
:style="{
|
||||||
@ -90,7 +27,7 @@
|
|||||||
minHeight: `${store.cellSize.height * previewZoom}px`,
|
minHeight: `${store.cellSize.height * previewZoom}px`,
|
||||||
cursor: previewZoom > 1 ? (isViewportDragging ? 'grabbing' : 'grab') : 'default',
|
cursor: previewZoom > 1 ? (isViewportDragging ? 'grabbing' : 'grab') : 'default',
|
||||||
}"
|
}"
|
||||||
@mousedown="startViewportDrag"
|
@mousedown="e => startViewportDrag(e, isCanvasDragging)"
|
||||||
@wheel="handleCanvasWheel"
|
@wheel="handleCanvasWheel"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -110,7 +47,7 @@
|
|||||||
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
|
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
|
||||||
backgroundColor: '#2d3748',
|
backgroundColor: '#2d3748',
|
||||||
}"
|
}"
|
||||||
@mousedown.stop="startCanvasDrag"
|
@mousedown.stop="e => startCanvasDrag(e, isViewportDragging, previewZoom)"
|
||||||
title="Drag to move sprite within cell"
|
title="Drag to move sprite within cell"
|
||||||
>
|
>
|
||||||
<canvas ref="animCanvas" class="block pixel-art absolute top-0 left-0"></canvas>
|
<canvas ref="animCanvas" class="block pixel-art absolute top-0 left-0"></canvas>
|
||||||
@ -123,15 +60,22 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
|
|
||||||
import BaseModal from './BaseModal.vue';
|
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 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({
|
const isModalOpen = computed({
|
||||||
get: () => store.isModalOpen.value,
|
get: () => store.isModalOpen.value,
|
||||||
set: value => {
|
set: value => {
|
||||||
@ -142,107 +86,28 @@
|
|||||||
const animation = computed(() => store.animation);
|
const animation = computed(() => store.animation);
|
||||||
const previewBorder = computed(() => store.previewBorder);
|
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
|
// Modal size state - used by BaseModal
|
||||||
const modalSize = ref({ width: 800, height: 600 });
|
const modalSize = ref({ width: 800, height: 600 });
|
||||||
|
|
||||||
const currentFrameDisplay = computed(() => {
|
// Initialize canvas management
|
||||||
if (sprites.value.length === 0) return '0 / 0';
|
const { animCanvas, initializeCanvas, updateCanvasSize } = useCanvasInitialization(animation, store.cellSize);
|
||||||
return `${currentFrame.value + 1} / ${sprites.value.length}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed property to check if sprite has been moved from original position
|
// Handle frame rendering
|
||||||
const hasSpriteOffset = computed(() => {
|
const renderFrame = (frameIndex: number, offset: { x: number; y: number }) => {
|
||||||
return spriteOffset.value.x !== 0 || spriteOffset.value.y !== 0;
|
store.renderAnimationFrame(frameIndex, showAllSprites.value, offset);
|
||||||
});
|
store.renderSpritesheetPreview();
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 () => {
|
const openModal = async () => {
|
||||||
if (sprites.value.length === 0) {
|
if (sprites.value.length === 0) {
|
||||||
store.showNotification('Please add sprites first', 'error');
|
store.showNotification('Please add sprites first', 'error');
|
||||||
@ -255,7 +120,6 @@
|
|||||||
|
|
||||||
// Reset modal size to default
|
// Reset modal size to default
|
||||||
modalSize.value = { width: 800, height: 600 };
|
modalSize.value = { width: 800, height: 600 };
|
||||||
// Note: Modal positioning is now handled by BaseModal
|
|
||||||
|
|
||||||
// Reset to first frame
|
// Reset to first frame
|
||||||
currentFrame.value = 0;
|
currentFrame.value = 0;
|
||||||
@ -276,7 +140,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for next frame to ensure DOM is updated
|
// Wait for next frame to ensure DOM is updated
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
await nextTick();
|
||||||
|
|
||||||
// Set proper canvas size before rendering
|
// Set proper canvas size before rendering
|
||||||
updateCanvasSize();
|
updateCanvasSize();
|
||||||
@ -304,23 +168,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCanvasSize = () => {
|
// Close modal function
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
store.isModalOpen.value = false;
|
store.isModalOpen.value = false;
|
||||||
|
|
||||||
@ -330,302 +178,8 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startAnimation = () => {
|
// Keyboard shortcuts
|
||||||
if (sprites.value.length === 0) return;
|
useKeyboardShortcuts(isModalOpen, sprites, animation, closeModal, startAnimation, stopAnimation, nextFrame, prevFrame, zoomIn, zoomOut, resetZoom, resetSpritePosition, panViewport, store.showNotification);
|
||||||
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;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Watch for changes in sprites to update the canvas when new sprites are added
|
// Watch for changes in sprites to update the canvas when new sprites are added
|
||||||
watch(
|
watch(
|
||||||
@ -674,24 +228,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
input[type='range']::-webkit-slider-thumb {
|
/* Cursor styles */
|
||||||
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-move {
|
.cursor-move {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@ -706,11 +243,6 @@
|
|||||||
display: none; /* Chrome, Safari and Opera */
|
display: none; /* Chrome, Safari and Opera */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cursor styles */
|
|
||||||
.cursor-se-resize {
|
|
||||||
cursor: se-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas container styles */
|
/* Canvas container styles */
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
margin: auto;
|
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 {
|
import { getSpriteOffset, showNotification } from '@/application/utilities';
|
||||||
img: HTMLImageElement;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
uploadOrder: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CellSize {
|
import { addSprites, updateCellSize, autoArrangeSprites, highlightSprite, clearAllSprites, applyOffsetsToMainView } from '@/application/spriteOperations';
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnimationState {
|
import { updateCanvasSize, renderSpritesheetPreview, drawGrid, downloadSpritesheet, zoomIn, zoomOut, resetZoom } from '@/application/canvasOperations';
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
ctx: CanvasRenderingContext2D | null;
|
|
||||||
currentFrame: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
frameRate: number;
|
|
||||||
lastFrameTime: number;
|
|
||||||
animationId: number | null;
|
|
||||||
slider: HTMLInputElement | null;
|
|
||||||
manualUpdate: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sprites = ref<Sprite[]>([]);
|
import { startAnimation, stopAnimation, renderAnimationFrame, animationLoop } from '@/application/animationController';
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main store function that provides access to all spritesheet functionality
|
||||||
|
*/
|
||||||
export function useSpritesheetStore() {
|
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 {
|
return {
|
||||||
|
// State
|
||||||
sprites,
|
sprites,
|
||||||
canvas,
|
canvas,
|
||||||
ctx,
|
ctx,
|
||||||
@ -693,23 +32,34 @@ export function useSpritesheetStore() {
|
|||||||
previewBorder,
|
previewBorder,
|
||||||
currentSpriteOffset,
|
currentSpriteOffset,
|
||||||
spriteOffsets,
|
spriteOffsets,
|
||||||
|
|
||||||
|
// Utils
|
||||||
getSpriteOffset,
|
getSpriteOffset,
|
||||||
|
showNotification,
|
||||||
|
|
||||||
|
// Sprite operations
|
||||||
addSprites,
|
addSprites,
|
||||||
updateCellSize,
|
updateCellSize,
|
||||||
updateCanvasSize,
|
|
||||||
autoArrangeSprites,
|
autoArrangeSprites,
|
||||||
|
highlightSprite,
|
||||||
|
clearAllSprites: () => clearAllSprites(animation),
|
||||||
|
applyOffsetsToMainView: () => applyOffsetsToMainView(currentSpriteOffset),
|
||||||
|
|
||||||
|
// Canvas operations
|
||||||
|
updateCanvasSize,
|
||||||
renderSpritesheetPreview,
|
renderSpritesheetPreview,
|
||||||
drawGrid,
|
drawGrid,
|
||||||
highlightSprite,
|
|
||||||
clearAllSprites,
|
|
||||||
downloadSpritesheet,
|
downloadSpritesheet,
|
||||||
startAnimation,
|
|
||||||
stopAnimation,
|
|
||||||
renderAnimationFrame,
|
|
||||||
showNotification,
|
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
resetZoom,
|
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