Compare commits
12 Commits
f97879b642
...
feature/la
| Author | SHA1 | Date | |
|---|---|---|---|
| aee07f23f2 | |||
| 474ddd3e27 | |||
| 097df1f5de | |||
| 5cc4eb8731 | |||
| 57d62db219 | |||
| f7a01e6c92 | |||
| 590d76205f | |||
| 5c33e77595 | |||
| 404ca9ce88 | |||
| d571cb51cb | |||
| c1620d6bbb | |||
| 6afbd42794 |
213
package-lock.json
generated
213
package-lock.json
generated
@@ -75,7 +75,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1386,9 +1385,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
|
||||
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1399,9 +1398,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1412,9 +1411,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1425,9 +1424,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz",
|
||||
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1438,9 +1437,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1451,9 +1450,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz",
|
||||
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1464,9 +1463,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz",
|
||||
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1477,9 +1476,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz",
|
||||
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1490,9 +1489,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1503,9 +1502,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1516,9 +1515,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1529,9 +1528,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1542,9 +1541,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1555,9 +1554,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1568,9 +1567,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1581,9 +1580,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1594,9 +1593,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1607,9 +1606,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1620,9 +1619,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1633,9 +1632,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1646,9 +1645,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1659,9 +1658,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1984,7 +1983,6 @@
|
||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -2113,7 +2111,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
|
||||
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
@@ -2377,9 +2374,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz",
|
||||
"integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==",
|
||||
"version": "2.8.30",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
|
||||
"integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2438,7 +2435,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -2477,9 +2473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001755",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz",
|
||||
"integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==",
|
||||
"version": "1.0.30001756",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
|
||||
"integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2670,9 +2666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.255",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.255.tgz",
|
||||
"integrity": "sha512-Z9oIp4HrFF/cZkDPMpz2XSuVpc1THDpT4dlmATFlJUIBVCy9Vap5/rIXsASP1CscBacBqhabwh8vLctqBwEerQ==",
|
||||
"version": "1.5.259",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
|
||||
"integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -3809,7 +3805,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3832,7 +3827,6 @@
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3915,11 +3909,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
|
||||
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -3931,28 +3924,28 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.2",
|
||||
"@rollup/rollup-android-arm64": "4.53.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.2",
|
||||
"@rollup/rollup-darwin-x64": "4.53.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||
"@rollup/rollup-android-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -4012,7 +4005,6 @@
|
||||
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.5.0",
|
||||
"buffer-builder": "^0.2.0",
|
||||
@@ -4569,7 +4561,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4613,7 +4604,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4701,7 +4691,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -4881,7 +4870,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4901,7 +4889,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
|
||||
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-sfc": "3.5.24",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.7.0] - 2025-11-22
|
||||
- Add layer support
|
||||
- Add background color picker
|
||||
- Improved UI
|
||||
|
||||
## [1.6.0] - 2025-11-18
|
||||
- Improved animation preview modal
|
||||
- Add toggle for negative spacing in cells
|
||||
- Show cell size
|
||||
|
||||
## [1.5.0] - 2025-11-17
|
||||
- Show offset values in sprite cells and in preview modal
|
||||
@@ -32,30 +39,30 @@ All notable changes to this project will be documented in this file.
|
||||
## [1.7.0] - 2025-05-02
|
||||
|
||||
### Removed
|
||||
- 🪟 Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
|
||||
- Checkerboard pattern inside sprite cells as it could conflict with the sprite. (Thanks Rivers)
|
||||
|
||||
## [1.6.0] - 2025-04-30
|
||||
|
||||
### Added
|
||||
- 🎨 Dark mode support
|
||||
- ⭐ Preview other sprites inside cells from overview
|
||||
- Dark mode support
|
||||
- Preview other sprites inside cells from overview
|
||||
|
||||
## [1.5.0] - 2025-04-30
|
||||
|
||||
### Added
|
||||
- 📏 Offset indicators for better sprite alignment
|
||||
- Offset indicators for better sprite alignment
|
||||
- Set base offset for offset indicators
|
||||
|
||||
## [1.4.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- 🎥 Download as GIF functionality
|
||||
- 🗂 Download as ZIP functionality
|
||||
- Download as GIF functionality
|
||||
- Download as ZIP functionality
|
||||
|
||||
## [1.3.0] - 2025-04-06
|
||||
|
||||
### Fixed
|
||||
- 📄 When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
|
||||
- When importing a spritesheet, the tool will now remove transparent from the edges of each sprite so you can move them inside their cells.
|
||||
|
||||
## [1.2.0] - 2025-04-06
|
||||
|
||||
@@ -65,22 +72,22 @@ All notable changes to this project will be documented in this file.
|
||||
## [1.1.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- 📝 Help modal with instructions and tips
|
||||
- 🎨 Pixel perfect mode for better sprite alignment
|
||||
- Help modal with instructions and tips
|
||||
- Pixel perfect mode for better sprite alignment
|
||||
|
||||
## [1.0.0] - 2025-04-06
|
||||
|
||||
### Added
|
||||
- 🎉 Initial release
|
||||
- ✨ Basic spritesheet generation functionality
|
||||
- Drag and drop image upload
|
||||
- Grid-based sprite arrangement
|
||||
- Custom grid size configuration
|
||||
- 🎮 Animation preview functionality
|
||||
- Real-time animation preview
|
||||
- Adjustable animation speed
|
||||
- Frame-by-frame navigation
|
||||
- 💾 JSON import/export support
|
||||
- Save sprite arrangements
|
||||
- Load previous projects
|
||||
- Export configuration files
|
||||
- Basic spritesheet generation functionality
|
||||
- Drag and drop image upload
|
||||
- Grid-based sprite arrangement
|
||||
- Custom grid size configuration
|
||||
- Animation preview functionality
|
||||
- Real-time animation preview
|
||||
- Adjustable animation speed
|
||||
- Frame-by-frame navigation
|
||||
- JSON import/export support
|
||||
- Save sprite arrangements
|
||||
- Load previous projects
|
||||
- Export configuration files
|
||||
745
src/App.vue
745
src/App.vue
@@ -11,7 +11,6 @@
|
||||
<a href="#" @click.prevent="openHelpModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="help-link"> <i class="fas fa-question-circle"></i> Help </a>
|
||||
<a href="#" @click.prevent="openFeedbackModal" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" data-rybbit-event="feedback-link"> <i class="fas fa-comment-dots"></i> Feedback </a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-soft dark:shadow-gray-900/30 p-4 sm:p-8 transition-colors duration-300">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-6 gap-3">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Upload sprites</h2>
|
||||
@@ -26,7 +25,7 @@
|
||||
</div>
|
||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
|
||||
<div v-if="!sprites.length" class="mt-8">
|
||||
<div v-if="!visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="mt-2 leading-relaxed space-y-2">
|
||||
<p>Create spritesheets for your game development and animation projects with our completely free, open-source spritesheet generator.</p>
|
||||
<p>This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
|
||||
@@ -37,7 +36,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sprites.length > 0" class="mt-8">
|
||||
<div v-if="visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Layers</span>
|
||||
<button @click="addLayer()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 rounded">Add</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="layer in layers" :key="layer.id" class="flex items-center gap-2 px-2 py-1 rounded border border-gray-200 dark:border-gray-600" :class="{ 'ring-2 ring-blue-500': layer.id === activeLayerId }">
|
||||
<button @click="activeLayerId = layer.id" class="px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">{{ layer.name }}</button>
|
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300"> <input type="checkbox" v-model="layer.visible" /> Visible </label>
|
||||
<button @click="moveLayer(layer.id, 'up')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↑</button>
|
||||
<button @click="moveLayer(layer.id, 'down')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↓</button>
|
||||
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-200 rounded">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-3 sm:gap-6 mb-6 sm:mb-8">
|
||||
<div class="flex items-center space-x-1">
|
||||
<label for="columns" class="text-gray-700 dark:text-gray-200 font-medium">Columns:</label>
|
||||
@@ -51,6 +65,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Cell size:</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ cellSize.width }} × {{ cellSize.height }}px</span>
|
||||
</div>
|
||||
|
||||
<!-- Add mass position buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<button @click="alignSprites('left')" class="p-3 sm:p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-100 rounded-lg transition-colors" title="Align Left" data-rybbit-event="align-left">
|
||||
@@ -102,14 +121,23 @@
|
||||
<span>Preview animation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<sprite-canvas :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" @add-sprite-with-resize="addSpriteWithResize" />
|
||||
<sprite-canvas
|
||||
:layers="layers"
|
||||
:active-layer-id="activeLayerId"
|
||||
:columns="columns"
|
||||
@update-sprite="updateSpritePosition"
|
||||
@update-sprite-cell="updateSpriteCell"
|
||||
@remove-sprite="removeSprite"
|
||||
@replace-sprite="replaceSprite"
|
||||
@add-sprite="addSprite"
|
||||
@add-sprite-with-resize="addSpriteWithResize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||||
<sprite-preview :sprites="sprites" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
</Modal>
|
||||
|
||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||
@@ -135,7 +163,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, toRef, computed } from 'vue';
|
||||
import FileUploader from './components/FileUploader.vue';
|
||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||
import Modal from './components/utilities/Modal.vue';
|
||||
@@ -145,36 +173,24 @@
|
||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from './components/GifFpsModal.vue';
|
||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||
import GIF from 'gif.js';
|
||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
||||
import JSZip from 'jszip';
|
||||
import { useExportLayers } from './composables/useExportLayers';
|
||||
import { useLayers } from './composables/useLayers';
|
||||
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
|
||||
import { useSettingsStore } from './stores/useSettingsStore';
|
||||
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
|
||||
import type { SpriteFile } from './types/sprites';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
file: File;
|
||||
img: HTMLImageElement;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
|
||||
|
||||
interface SpriteFile {
|
||||
file: File;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), activeLayerId, toRef(settingsStore, 'backgroundColor'));
|
||||
|
||||
const sprites = ref<Sprite[]>([]);
|
||||
const columns = ref(4);
|
||||
// Clamp and coerce columns to a safe range [1..10]
|
||||
watch(columns, val => {
|
||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
||||
if (safe !== columns.value) columns.value = safe;
|
||||
const cellSize = computed(() => {
|
||||
if (!layers.value.length) return { width: 0, height: 0 };
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
||||
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
|
||||
});
|
||||
const isPreviewModalOpen = ref(false);
|
||||
const isHelpModalOpen = ref(false);
|
||||
@@ -186,128 +202,43 @@
|
||||
const spritesheetImageFile = ref<File | null>(null);
|
||||
const showFeedbackPopup = ref(false);
|
||||
|
||||
const handleSpritesUpload = (files: File[]) => {
|
||||
// Check if any of the files is a JSON file
|
||||
const handleSpritesUpload = async (files: File[]) => {
|
||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||
|
||||
if (jsonFile) {
|
||||
// If it's a JSON file, try to import it
|
||||
importSpritesheetJSON(jsonFile);
|
||||
try {
|
||||
await importSpritesheetJSON(jsonFile);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a single image file that might be a spritesheet
|
||||
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
||||
const file = files[0];
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
// Load the image to check its dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Ask the user if they want to split the spritesheet
|
||||
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
|
||||
// Open the spritesheet splitter
|
||||
spritesheetImageUrl.value = url;
|
||||
spritesheetImageFile.value = file;
|
||||
isSpritesheetSplitterOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user doesn't want to split or it's not large enough, process as a single sprite
|
||||
processImageFiles([file]);
|
||||
};
|
||||
img.src = url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Process multiple image files normally
|
||||
processImageFiles(files);
|
||||
};
|
||||
|
||||
// Extract the image processing logic to a separate function for reuse
|
||||
const processImageFiles = (files: File[]) => {
|
||||
Promise.all(
|
||||
files.map(file => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
})
|
||||
).then(newSprites => {
|
||||
sprites.value = [...sprites.value, ...newSprites];
|
||||
});
|
||||
};
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
if (spriteIndex !== -1) {
|
||||
// Ensure integer positions for pixel-perfect rendering
|
||||
sprites.value[spriteIndex].x = Math.floor(x);
|
||||
sprites.value[spriteIndex].y = Math.floor(y);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
||||
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
||||
});
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||
canvas.width = maxWidth * columns.value;
|
||||
canvas.height = maxHeight * rows;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw sprites with integer positions
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
ctx.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||
});
|
||||
|
||||
// Create download link with PNG format
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = canvas.toDataURL('image/png', 1.0); // Use maximum quality
|
||||
link.click();
|
||||
};
|
||||
|
||||
// Preview modal control
|
||||
const openPreviewModal = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites to preview an animation.');
|
||||
return;
|
||||
}
|
||||
@@ -318,7 +249,6 @@
|
||||
isPreviewModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Help modal control
|
||||
const openHelpModal = () => {
|
||||
isHelpModalOpen.value = true;
|
||||
};
|
||||
@@ -327,7 +257,6 @@
|
||||
isHelpModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Feedback modal control
|
||||
const openFeedbackModal = () => {
|
||||
isFeedbackModalOpen.value = true;
|
||||
};
|
||||
@@ -336,10 +265,8 @@
|
||||
isFeedbackModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Spritesheet splitter modal control
|
||||
const closeSpritesheetSplitter = () => {
|
||||
isSpritesheetSplitterOpen.value = false;
|
||||
// Clean up the URL object to prevent memory leaks
|
||||
if (spritesheetImageUrl.value) {
|
||||
URL.revokeObjectURL(spritesheetImageUrl.value);
|
||||
spritesheetImageUrl.value = '';
|
||||
@@ -347,9 +274,8 @@
|
||||
spritesheetImageFile.value = null;
|
||||
};
|
||||
|
||||
// GIF FPS modal control
|
||||
const openGifFpsModal = () => {
|
||||
if (sprites.value.length === 0) {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
@@ -360,488 +286,37 @@
|
||||
isGifFpsModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Handle the split spritesheet result
|
||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||
// Process sprite files with their positions
|
||||
Promise.all(
|
||||
spriteFiles.map(spriteFile => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
const url = URL.createObjectURL(spriteFile.file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file: spriteFile.file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0, // Start at top-left of cell; ignore splitter bounding-box offset for display
|
||||
y: 0, // Start at top-left of cell; ignore splitter bounding-box offset for display
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
})
|
||||
).then(newSprites => {
|
||||
sprites.value = [...sprites.value, ...newSprites];
|
||||
});
|
||||
processImageFiles(spriteFiles.map(s => s.file));
|
||||
};
|
||||
|
||||
// Export spritesheet as JSON with base64 images
|
||||
const exportSpritesheetJSON = async () => {
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Nothing to export. Please add sprites first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an array to store sprite data with base64 images
|
||||
const spritesData = await Promise.all(
|
||||
sprites.value.map(async (sprite, index) => {
|
||||
// Create a canvas for each sprite to get its base64 data
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
// Set canvas size to match the sprite
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
|
||||
// Draw the sprite
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
|
||||
// Get base64 data
|
||||
const base64Data = canvas.toDataURL('image/png');
|
||||
|
||||
return {
|
||||
id: sprite.id,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
base64: base64Data,
|
||||
name: sprite.file.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Create JSON object with all necessary data
|
||||
const jsonData = {
|
||||
columns: columns.value,
|
||||
sprites: spritesData.filter(Boolean), // Remove any null values
|
||||
};
|
||||
|
||||
// Convert to JSON string
|
||||
const jsonString = JSON.stringify(jsonData, null, 2);
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.json';
|
||||
link.href = url;
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Open file dialog for JSON import
|
||||
const openJSONImportDialog = () => {
|
||||
jsonFileInput.value?.click();
|
||||
};
|
||||
|
||||
// Handle JSON file selection
|
||||
const handleJSONFileChange = (event: Event) => {
|
||||
const handleJSONFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const jsonFile = input.files[0];
|
||||
importSpritesheetJSON(jsonFile);
|
||||
// Reset input value so uploading the same file again will trigger the event
|
||||
try {
|
||||
await importSpritesheetJSON(jsonFile);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
if (jsonFileInput.value) jsonFileInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Import spritesheet from JSON
|
||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||
try {
|
||||
const jsonText = await jsonFile.text();
|
||||
const jsonData = JSON.parse(jsonText);
|
||||
|
||||
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) {
|
||||
throw new Error('Invalid JSON format: missing sprites array');
|
||||
}
|
||||
|
||||
// Set columns if available
|
||||
if (jsonData.columns && typeof jsonData.columns === 'number') {
|
||||
columns.value = jsonData.columns;
|
||||
}
|
||||
|
||||
// Process each sprite
|
||||
// Replace current sprites with imported ones
|
||||
// Revoke existing blob: URLs to avoid memory leaks
|
||||
if (sprites.value.length) {
|
||||
sprites.value.forEach(s => {
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sprites.value = await Promise.all(
|
||||
jsonData.sprites.map(async (spriteData: any) => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
// Create image from base64
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Create a file from the base64 data
|
||||
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||
const file = new File([blob], fileName, { type: mimeType });
|
||||
|
||||
resolve({
|
||||
id: spriteData.id || crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url: spriteData.base64,
|
||||
width: spriteData.width,
|
||||
height: spriteData.height,
|
||||
x: spriteData.x || 0,
|
||||
y: spriteData.y || 0,
|
||||
});
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
};
|
||||
|
||||
// Add new alignment function
|
||||
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||
if (sprites.value.length === 0) return;
|
||||
|
||||
// Find max dimensions for the current column layout
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
sprites.value.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
sprites.value = sprites.value.map((sprite, index) => {
|
||||
let x = sprite.x;
|
||||
let y = sprite.y;
|
||||
|
||||
switch (position) {
|
||||
case 'left':
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
|
||||
// Ensure integer positions for pixel-perfect rendering
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
});
|
||||
|
||||
// Force redraw of the preview canvas
|
||||
setTimeout(() => {
|
||||
const event = new Event('forceRedraw');
|
||||
window.dispatchEvent(event);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const updateSpriteCell = (id: string, newIndex: number) => {
|
||||
// Find the current index of the sprite
|
||||
const currentIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// If we're trying to move to the same position, do nothing
|
||||
if (currentIndex === newIndex) return;
|
||||
|
||||
// Create a copy of the sprites array
|
||||
const newSprites = [...sprites.value];
|
||||
|
||||
// Perform a swap between the two positions
|
||||
if (newIndex < sprites.value.length) {
|
||||
// Get references to both sprites
|
||||
const movingSprite = { ...newSprites[currentIndex] };
|
||||
const targetSprite = { ...newSprites[newIndex] };
|
||||
|
||||
// Swap them
|
||||
newSprites[currentIndex] = targetSprite;
|
||||
newSprites[newIndex] = movingSprite;
|
||||
} else {
|
||||
// If dragging to an empty cell (beyond the array length)
|
||||
// Use the original reordering logic
|
||||
const [movedSprite] = newSprites.splice(currentIndex, 1);
|
||||
newSprites.splice(newIndex, 0, movedSprite);
|
||||
}
|
||||
|
||||
// Update the sprites array
|
||||
sprites.value = newSprites;
|
||||
};
|
||||
|
||||
const removeSprite = (id: string) => {
|
||||
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
if (spriteIndex !== -1) {
|
||||
const sprite = sprites.value[spriteIndex];
|
||||
// Revoke the blob URL to prevent memory leaks
|
||||
if (sprite.url && sprite.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(sprite.url);
|
||||
} catch {}
|
||||
}
|
||||
// Remove the sprite from the array
|
||||
sprites.value.splice(spriteIndex, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const replaceSprite = (id: string, file: File) => {
|
||||
const spriteIndex = sprites.value.findIndex(sprite => sprite.id === id);
|
||||
|
||||
if (spriteIndex !== -1) {
|
||||
const oldSprite = sprites.value[spriteIndex];
|
||||
|
||||
// Revoke the old blob URL to prevent memory leaks
|
||||
if (oldSprite.url && oldSprite.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(oldSprite.url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Create new sprite from the replacement file
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const newSprite: Sprite = {
|
||||
id: oldSprite.id, // Keep the same ID
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: oldSprite.x, // Keep the same position
|
||||
y: oldSprite.y,
|
||||
};
|
||||
// Create a new array to trigger Vue's reactivity
|
||||
const newSprites = [...sprites.value];
|
||||
newSprites[spriteIndex] = newSprite;
|
||||
sprites.value = newSprites;
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load replacement image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const newSprite: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
sprites.value = [...sprites.value, newSprite];
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Find current max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
sprites.value.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
|
||||
// Create new sprite
|
||||
const newSprite: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
// Calculate new max dimensions after adding the new sprite
|
||||
const newMaxWidth = Math.max(maxWidth, img.width);
|
||||
const newMaxHeight = Math.max(maxHeight, img.height);
|
||||
|
||||
// Resize existing sprites if the new image is larger
|
||||
if (img.width > maxWidth || img.height > maxHeight) {
|
||||
// Update all existing sprites to center them in the new larger cells
|
||||
sprites.value = sprites.value.map(sprite => {
|
||||
let newX = sprite.x;
|
||||
let newY = sprite.y;
|
||||
|
||||
// Adjust x position if width increased
|
||||
if (img.width > maxWidth) {
|
||||
const widthDiff = newMaxWidth - maxWidth;
|
||||
// Try to keep the sprite in the same relative position
|
||||
const relativeX = maxWidth > 0 ? sprite.x / maxWidth : 0;
|
||||
newX = Math.floor(relativeX * newMaxWidth);
|
||||
// Make sure it doesn't go out of bounds
|
||||
newX = Math.max(0, Math.min(newX, newMaxWidth - sprite.width));
|
||||
}
|
||||
|
||||
// Adjust y position if height increased
|
||||
if (img.height > maxHeight) {
|
||||
const heightDiff = newMaxHeight - maxHeight;
|
||||
const relativeY = maxHeight > 0 ? sprite.y / maxHeight : 0;
|
||||
newY = Math.floor(relativeY * newMaxHeight);
|
||||
newY = Math.max(0, Math.min(newY, newMaxHeight - sprite.height));
|
||||
}
|
||||
|
||||
return { ...sprite, x: newX, y: newY };
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new sprite
|
||||
sprites.value = [...sprites.value, newSprite];
|
||||
|
||||
// Force redraw of the canvas
|
||||
setTimeout(() => {
|
||||
const event = new Event('forceRedraw');
|
||||
window.dispatchEvent(event);
|
||||
}, 0);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
// Download as GIF with specified FPS
|
||||
const downloadAsGif = (fps: number) => {
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
||||
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
||||
});
|
||||
|
||||
// Create a canvas for rendering frames
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to just fit one sprite cell
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = maxHeight;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Create GIF encoder
|
||||
const gif = new GIF({
|
||||
workers: 2,
|
||||
quality: 10,
|
||||
width: maxWidth,
|
||||
height: maxHeight,
|
||||
workerScript: gifWorkerUrl,
|
||||
});
|
||||
|
||||
// Add each sprite as a frame
|
||||
sprites.value.forEach(sprite => {
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid background (cell)
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, maxWidth, maxHeight);
|
||||
|
||||
// Draw sprite
|
||||
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
||||
|
||||
// Add frame to GIF
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
|
||||
// Generate GIF
|
||||
gif.on('finished', (blob: Blob) => {
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'animation.gif';
|
||||
link.href = url;
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
gif.render();
|
||||
};
|
||||
|
||||
// Check for one-time feedback popup on mount
|
||||
onMounted(() => {
|
||||
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
|
||||
if (!hasShownFeedbackPopup) {
|
||||
// Show popup after a short delay to let the page load
|
||||
setTimeout(() => {
|
||||
showFeedbackPopup.value = true;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle feedback popup response
|
||||
const handleFeedbackPopupResponse = (showModal: boolean) => {
|
||||
showFeedbackPopup.value = false;
|
||||
localStorage.setItem('hasShownFeedbackPopup', 'true');
|
||||
@@ -850,90 +325,4 @@
|
||||
openFeedbackModal();
|
||||
}
|
||||
};
|
||||
|
||||
// Revoke blob URLs on unmount to avoid memory leaks
|
||||
onUnmounted(() => {
|
||||
sprites.value.forEach(s => {
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Download as ZIP with each cell individually
|
||||
const downloadAsZip = async () => {
|
||||
if (sprites.value.length === 0) {
|
||||
alert('Please upload or import sprites before downloading a ZIP.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new ZIP file
|
||||
const zip = new JSZip();
|
||||
|
||||
// Find max dimensions
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
if (sprite.width > maxWidth) maxWidth = sprite.width;
|
||||
if (sprite.height > maxHeight) maxHeight = sprite.height;
|
||||
});
|
||||
|
||||
// Create a canvas for rendering individual sprites
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size to just fit one sprite cell
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = maxHeight;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Add each sprite as an individual file
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid background (cell)
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.fillRect(0, 0, maxWidth, maxHeight);
|
||||
|
||||
// Draw sprite
|
||||
ctx.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
||||
|
||||
// Convert to PNG data URL
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
|
||||
// Convert data URL to binary data
|
||||
const binaryData = atob(dataURL.split(',')[1]);
|
||||
const arrayBuffer = new ArrayBuffer(binaryData.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
uint8Array[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Add to ZIP file with clearer naming
|
||||
const baseName = sprite.file?.name ? sprite.file.name.replace(/\s+/g, '_') : `sprite_${index + 1}.png`;
|
||||
const name = `${index + 1}_${baseName}`;
|
||||
zip.file(name, uint8Array);
|
||||
});
|
||||
|
||||
// Generate ZIP file
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(content);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'sprites.zip';
|
||||
link.href = url;
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2 w-4 h-4" @change="drawCanvas" />
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2 w-4 h-4" @change="requestDraw" />
|
||||
<label for="pixel-perfect" class="dark:text-gray-200 text-sm sm:text-base">Pixel perfect rendering</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -18,6 +18,23 @@
|
||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
||||
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
||||
</div>
|
||||
<!-- Negative spacing control -->
|
||||
<div class="flex items-center">
|
||||
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
|
||||
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
|
||||
</div>
|
||||
<!-- Background color picker -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="bg-color" class="dark:text-gray-200 text-sm sm:text-base">Background:</label>
|
||||
<select id="bg-color" v-model="settingsStore.backgroundColor" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 dark:text-gray-200 text-sm">
|
||||
<option value="transparent">Transparent</option>
|
||||
<option value="#ffffff">White</option>
|
||||
<option value="#000000">Black</option>
|
||||
<option value="#f9fafb">Light Gray</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<input v-if="settingsStore.backgroundColor === 'custom'" type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="w-8 h-8 border border-gray-300 dark:border-gray-600 rounded cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +65,7 @@
|
||||
@contextmenu.prevent
|
||||
@dragover="handleDragOver"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="handleDrop"
|
||||
class="w-full transition-all"
|
||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||
@@ -95,26 +112,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, watch, onUnmounted, toRef, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useDragSprite } from '@/composables/useDragSprite';
|
||||
import { useFileDrop } from '@/composables/useFileDrop';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface CellPosition {
|
||||
col: number;
|
||||
row: number;
|
||||
index: number;
|
||||
}
|
||||
import type { Layer } from '@/types/sprites';
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
layers: Layer[];
|
||||
activeLayerId: string;
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
@@ -131,22 +141,67 @@
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// State for tracking drag operations
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const activeSpriteCellIndex = ref<number | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragOffsetX = ref(0);
|
||||
const dragOffsetY = ref(0);
|
||||
// rAF-based draw scheduler to coalesce multiple draw requests into a single frame
|
||||
// Define before usage to avoid TDZ issues when passing into composables during setup
|
||||
let rafId: number | null = null;
|
||||
function requestDraw() {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
drawCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize composables
|
||||
const canvas2D = useCanvas2D(canvasRef);
|
||||
|
||||
const {
|
||||
zoom,
|
||||
increase: zoomIn,
|
||||
decrease: zoomOut,
|
||||
reset: resetZoom,
|
||||
} = useZoom({
|
||||
min: 0.5,
|
||||
max: 3,
|
||||
step: 0.25,
|
||||
initial: 1,
|
||||
});
|
||||
|
||||
const allowCellSwap = ref(false);
|
||||
const currentHoverCell = ref<CellPosition | null>(null);
|
||||
|
||||
// Visual feedback refs
|
||||
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
||||
const highlightCell = ref<CellPosition | null>(null);
|
||||
const {
|
||||
isDragging,
|
||||
activeSpriteId,
|
||||
ghostSprite,
|
||||
highlightCell,
|
||||
spritePositions,
|
||||
startDrag: dragStart,
|
||||
drag: dragMove,
|
||||
stopDrag,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
findSpriteAtPosition,
|
||||
calculateMaxDimensions,
|
||||
} = useDragSprite({
|
||||
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
||||
columns: toRef(props, 'columns'),
|
||||
zoom,
|
||||
allowCellSwap,
|
||||
negativeSpacingEnabled: toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
|
||||
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
|
||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||
onDraw: requestDraw,
|
||||
});
|
||||
|
||||
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
|
||||
|
||||
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
|
||||
sprites: activeSprites,
|
||||
onAddSprite: file => emit('addSprite', file),
|
||||
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
|
||||
});
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
@@ -155,59 +210,7 @@
|
||||
const contextMenuSpriteId = ref<string | null>(null);
|
||||
const replacingSpriteId = ref<string | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
const spritePositions = computed(() => {
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
return props.sprites.map((sprite, index) => {
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
return {
|
||||
id: sprite.id,
|
||||
canvasX: col * maxWidth + sprite.x,
|
||||
canvasY: row * maxHeight + sprite.y,
|
||||
cellX: col * maxWidth,
|
||||
cellY: row * maxHeight,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
col,
|
||||
row,
|
||||
index,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Cache last known max dimensions to avoid collapsing cells while images are loading
|
||||
const lastMaxWidth = ref(1);
|
||||
const lastMaxHeight = ref(1);
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
const img = sprite.img as HTMLImageElement | undefined;
|
||||
const w = Math.max(0, sprite.width || (img ? img.naturalWidth || img.width || 0 : 0));
|
||||
const h = Math.max(0, sprite.height || (img ? img.naturalHeight || img.height || 0 : 0));
|
||||
maxWidth = Math.max(maxWidth, w);
|
||||
maxHeight = Math.max(maxHeight, h);
|
||||
});
|
||||
|
||||
// Keep dimensions at least as large as last known to prevent temporary collapse during loading
|
||||
maxWidth = Math.max(1, maxWidth, lastMaxWidth.value);
|
||||
maxHeight = Math.max(1, maxHeight, lastMaxHeight.value);
|
||||
|
||||
lastMaxWidth.value = maxWidth;
|
||||
lastMaxHeight.value = maxHeight;
|
||||
|
||||
return { maxWidth, maxHeight };
|
||||
};
|
||||
const customColor = ref('#ffffff');
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!canvasRef.value) return;
|
||||
@@ -218,14 +221,10 @@
|
||||
// Handle right-click for context menu
|
||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||
event.preventDefault();
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
const pos = canvas2D.getMousePosition(event, zoom.value);
|
||||
if (!pos) return;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||
contextMenuX.value = event.clientX;
|
||||
contextMenuY.value = event.clientY;
|
||||
@@ -236,167 +235,13 @@
|
||||
// Ignore non-left mouse buttons (but allow touch-generated events without a button prop)
|
||||
if ('button' in event && (event as MouseEvent).button !== 0) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(mouseX, mouseY);
|
||||
if (clickedSprite) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = clickedSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
|
||||
// Find the sprite's position to calculate offset from mouse to sprite origin
|
||||
const spritePosition = spritePositions.value.find(pos => pos.id === clickedSprite.id);
|
||||
if (spritePosition) {
|
||||
dragOffsetX.value = mouseX - spritePosition.canvasX;
|
||||
dragOffsetY.value = mouseY - spritePosition.canvasY;
|
||||
activeSpriteCellIndex.value = spritePosition.index;
|
||||
|
||||
// Store the starting cell position
|
||||
const startCell = findCellAtPosition(mouseX, mouseY);
|
||||
if (startCell) {
|
||||
currentHoverCell.value = startCell;
|
||||
highlightCell.value = null; // No highlight at the start
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
const col = Math.floor(x / maxWidth);
|
||||
const row = Math.floor(y / maxHeight);
|
||||
|
||||
// Check if the cell position is valid
|
||||
const totalRows = Math.ceil(props.sprites.length / props.columns);
|
||||
if (col >= 0 && col < props.columns && row >= 0 && row < totalRows) {
|
||||
const index = row * props.columns + col;
|
||||
if (index < props.sprites.length) {
|
||||
return { col, row, index };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
// Delegate to composable for actual drag handling
|
||||
dragStart(event);
|
||||
};
|
||||
|
||||
// Wrapper for drag move
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || !canvasRef.value || activeSpriteCellIndex.value === null) return;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / rect.width;
|
||||
const scaleY = canvasRef.value.height / rect.height;
|
||||
|
||||
const mouseX = (event.clientX - rect.left) * scaleX;
|
||||
const mouseY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const spriteIndex = props.sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||
if (spriteIndex === -1) return;
|
||||
|
||||
// Find the cell the mouse is currently over
|
||||
const hoverCell = findCellAtPosition(mouseX, mouseY);
|
||||
currentHoverCell.value = hoverCell;
|
||||
|
||||
if (allowCellSwap.value && hoverCell) {
|
||||
// If we're hovering over a different cell than the sprite's current cell
|
||||
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
||||
// Show a highlight for the target cell
|
||||
highlightCell.value = hoverCell;
|
||||
|
||||
// Create a ghost sprite that follows the mouse
|
||||
ghostSprite.value = {
|
||||
id: activeSpriteId.value,
|
||||
x: mouseX - dragOffsetX.value,
|
||||
y: mouseY - dragOffsetY.value,
|
||||
};
|
||||
|
||||
drawCanvas();
|
||||
} else {
|
||||
// Same cell as the sprite's origin, just do regular movement
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
||||
}
|
||||
} else {
|
||||
// Regular in-cell movement
|
||||
handleInCellMovement(mouseX, mouseY, spriteIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
|
||||
if (!activeSpriteId.value) return;
|
||||
|
||||
const position = spritePositions.value.find(pos => pos.id === activeSpriteId.value);
|
||||
if (!position) return;
|
||||
|
||||
// Calculate new position based on mouse position and initial click offset
|
||||
const newX = mouseX - position.cellX - dragOffsetX.value;
|
||||
const newY = mouseY - position.cellY - dragOffsetY.value;
|
||||
|
||||
// Constrain within cell boundaries and ensure integer positions
|
||||
const constrainedX = Math.floor(Math.max(0, Math.min(position.maxWidth - props.sprites[spriteIndex].width, newX)));
|
||||
const constrainedY = Math.floor(Math.max(0, Math.min(position.maxHeight - props.sprites[spriteIndex].height, newY)));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, constrainedX, constrainedY);
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
if (isDragging.value && allowCellSwap.value && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
||||
// We've dragged from one cell to another
|
||||
// Tell parent component to update the sprite's cell index
|
||||
emit('updateSpriteCell', activeSpriteId.value, currentHoverCell.value.index);
|
||||
|
||||
// Also reset the sprite's position within the cell to 0,0
|
||||
emit('updateSprite', activeSpriteId.value, 0, 0);
|
||||
}
|
||||
|
||||
// Reset all drag state
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
activeSpriteCellIndex.value = null;
|
||||
currentHoverCell.value = null;
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
|
||||
// Redraw without highlights
|
||||
drawCanvas();
|
||||
};
|
||||
|
||||
// Add zoom functionality for mobile
|
||||
const zoom = ref(1);
|
||||
const minZoom = 0.5;
|
||||
const maxZoom = 3;
|
||||
const zoomStep = 0.25;
|
||||
|
||||
const zoomIn = () => {
|
||||
zoom.value = Math.min(maxZoom, zoom.value + zoomStep);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
zoom.value = Math.max(minZoom, zoom.value - zoomStep);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
zoom.value = 1;
|
||||
};
|
||||
|
||||
// Improved touch handling
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
// Don't prevent default to allow scrolling
|
||||
if (event.touches.length === 1) {
|
||||
if (!canvasRef.value) return;
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
startDrag(mouseEvent);
|
||||
}
|
||||
dragMove(event);
|
||||
};
|
||||
|
||||
const removeSprite = () => {
|
||||
@@ -453,132 +298,26 @@
|
||||
contextMenuSpriteId.value = null;
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
isDragOver.value = true;
|
||||
// Wrapper for drag leave to pass canvasRef
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
handleDragLeave(event, canvasRef.value);
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
isDragOver.value = true;
|
||||
};
|
||||
function drawCanvas() {
|
||||
if (!canvasRef.value || !canvas2D.ctx.value) return;
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Only set to false if we're leaving the canvas entirely
|
||||
const rect = canvasRef.value?.getBoundingClientRect();
|
||||
if (rect && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
isDragOver.value = false;
|
||||
|
||||
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
||||
|
||||
if (files.length === 0) {
|
||||
alert('Please drop image files only.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each dropped file
|
||||
for (const file of files) {
|
||||
await processDroppedImage(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processDroppedImage = (file: File): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = e => {
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result as string;
|
||||
}
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Check if the dropped image is larger than current cells
|
||||
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
|
||||
// Emit event with resize flag
|
||||
emit('addSpriteWithResize', file);
|
||||
} else {
|
||||
// Normal add
|
||||
emit('addSprite', file);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load image:', file.name);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
// Only prevent default when we're actually dragging
|
||||
if (isDragging.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
drag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const findSpriteAtPosition = (x: number, y: number) => {
|
||||
// Search in reverse order to get the topmost sprite first
|
||||
for (let i = spritePositions.value.length - 1; i >= 0; i--) {
|
||||
const pos = spritePositions.value[i];
|
||||
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
|
||||
return props.sprites.find(s => s.id === pos.id) || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const drawCanvas = () => {
|
||||
if (!canvasRef.value || !ctx.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
|
||||
canvasRef.value.width = maxWidth * props.columns;
|
||||
canvasRef.value.height = maxHeight * rows;
|
||||
const maxLen = Math.max(1, ...props.layers.map(l => (l.visible ? l.sprites.length : 1)));
|
||||
const rows = Math.max(1, Math.ceil(maxLen / props.columns));
|
||||
canvas2D.setCanvasSize(maxWidth * props.columns, maxHeight * rows);
|
||||
|
||||
// Clear canvas
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
canvas2D.clear();
|
||||
|
||||
// Disable image smoothing based on pixel perfect setting
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
// Apply pixel art optimization
|
||||
canvas2D.applySmoothing();
|
||||
|
||||
// Draw background for each cell
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
@@ -587,102 +326,79 @@
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw cell background
|
||||
ctx.value.fillStyle = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
||||
canvas2D.fillCellBackground(cellX, cellY, maxWidth, maxHeight);
|
||||
|
||||
// Highlight the target cell if specified
|
||||
if (highlightCell.value && highlightCell.value.col === col && highlightCell.value.row === row) {
|
||||
ctx.value.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Light blue highlight
|
||||
ctx.value.fillRect(cellX, cellY, maxWidth, maxHeight);
|
||||
canvas2D.fillRect(cellX, cellY, maxWidth, maxHeight, 'rgba(59, 130, 246, 0.2)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If showing all sprites, draw all sprites with transparency in each cell
|
||||
if (showAllSprites.value) {
|
||||
for (let cellIndex = 0; cellIndex < props.sprites.length; cellIndex++) {
|
||||
const total = Math.max(...props.layers.map(l => (l.visible ? l.sprites.length : 0)));
|
||||
for (let cellIndex = 0; cellIndex < total; cellIndex++) {
|
||||
const cellCol = cellIndex % props.columns;
|
||||
const cellRow = Math.floor(cellIndex / props.columns);
|
||||
const cellX = Math.floor(cellCol * maxWidth);
|
||||
const cellY = Math.floor(cellRow * maxHeight);
|
||||
|
||||
// Draw all sprites with transparency in this cell
|
||||
ctx.value.globalAlpha = 0.3;
|
||||
props.sprites.forEach((sprite, spriteIndex) => {
|
||||
if (spriteIndex !== cellIndex) {
|
||||
// Don't draw the cell's own sprite with transparency
|
||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||
}
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (sprite) canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.35);
|
||||
});
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw sprites normally
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
// Skip the active sprite if we're showing a ghost instead
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) {
|
||||
return;
|
||||
}
|
||||
// Draw layers in order; active layer will be interactable
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
layer.sprites.forEach((sprite, index) => {
|
||||
// Skip the active sprite if we're showing a ghost instead
|
||||
if (activeSpriteId.value === sprite.id && ghostSprite.value) return;
|
||||
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
const col = index % props.columns;
|
||||
const row = Math.floor(index / props.columns);
|
||||
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw sprite using integer positions for pixel-perfect rendering
|
||||
ctx.value?.drawImage(sprite.img, Math.floor(cellX + sprite.x), Math.floor(cellY + sprite.y));
|
||||
const alpha = layer.id === props.activeLayerId ? 1 : 0.85;
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, alpha);
|
||||
});
|
||||
});
|
||||
|
||||
// Draw ghost sprite if we're dragging between cells
|
||||
if (ghostSprite.value && activeSpriteId.value) {
|
||||
const sprite = props.sprites.find(s => s.id === activeSpriteId.value);
|
||||
const sprite = activeSprites.value.find(s => s.id === activeSpriteId.value);
|
||||
if (sprite) {
|
||||
// Semi-transparent ghost
|
||||
ctx.value.globalAlpha = 0.6;
|
||||
ctx.value.drawImage(sprite.img, Math.floor(ghostSprite.value.x), Math.floor(ghostSprite.value.y));
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid lines on top of everything
|
||||
ctx.value.strokeStyle = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||
ctx.value.lineWidth = 1;
|
||||
|
||||
for (let col = 0; col < props.columns; col++) {
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const cellX = Math.floor(col * maxWidth);
|
||||
const cellY = Math.floor(row * maxHeight);
|
||||
|
||||
// Draw grid lines
|
||||
ctx.value.strokeRect(cellX, cellY, maxWidth, maxHeight);
|
||||
canvas2D.strokeGridCell(cellX, cellY, maxWidth, maxHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Track which images already have listeners
|
||||
const imagesWithListeners = new WeakSet<HTMLImageElement>();
|
||||
|
||||
const attachImageListeners = () => {
|
||||
props.sprites.forEach(sprite => {
|
||||
const img = sprite.img as HTMLImageElement | undefined;
|
||||
if (img && !imagesWithListeners.has(img)) {
|
||||
imagesWithListeners.add(img);
|
||||
if (!img.complete) {
|
||||
// Redraw when the image loads or errors (to reflect updated dimensions)
|
||||
img.addEventListener('load', handleForceRedraw, { once: true } as AddEventListenerOptions);
|
||||
img.addEventListener('error', handleForceRedraw, { once: true } as AddEventListenerOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
const sprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.attachImageListeners(sprites, handleForceRedraw, imagesWithListeners);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
drawCanvas();
|
||||
}
|
||||
canvas2D.initContext();
|
||||
requestDraw();
|
||||
|
||||
// Attach listeners for current sprites
|
||||
attachImageListeners();
|
||||
@@ -701,31 +417,27 @@
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure we're using integer positions for pixel-perfect rendering
|
||||
props.sprites.forEach(sprite => {
|
||||
sprite.x = Math.floor(sprite.x);
|
||||
sprite.y = Math.floor(sprite.y);
|
||||
});
|
||||
|
||||
// Force a redraw with the correct image smoothing settings
|
||||
if (ctx.value) {
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
drawCanvas();
|
||||
}
|
||||
// Ensure integer positioning for crisp rendering on the active layer
|
||||
canvas2D.ensureIntegerPositions(activeSprites.value);
|
||||
canvas2D.applySmoothing();
|
||||
requestDraw();
|
||||
};
|
||||
|
||||
// Re-attach listeners and redraw whenever layers/sprites change
|
||||
watch(
|
||||
() => props.sprites,
|
||||
() => props.layers,
|
||||
() => {
|
||||
attachImageListeners();
|
||||
drawCanvas();
|
||||
requestDraw();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(() => props.columns, drawCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawCanvas);
|
||||
watch(() => settingsStore.darkMode, drawCanvas);
|
||||
watch(showAllSprites, drawCanvas);
|
||||
watch(() => props.columns, requestDraw);
|
||||
watch(() => settingsStore.pixelPerfect, requestDraw);
|
||||
watch(() => settingsStore.darkMode, requestDraw);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
|
||||
watch(() => settingsStore.backgroundColor, requestDraw);
|
||||
watch(showAllSprites, requestDraw);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -120,10 +120,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Current frame offset display -->
|
||||
<div v-if="props.sprites[currentFrameIndex]" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<div v-if="currentFrameSprite" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Offset</span>
|
||||
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ props.sprites[currentFrameIndex].x }}, y: {{ props.sprites[currentFrameIndex].y }}</span>
|
||||
<span class="text-xs font-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ currentFrameSprite.x }}, y: {{ currentFrameSprite.y }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
<div class="rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800">
|
||||
<div class="max-h-[180px] overflow-y-auto">
|
||||
<div class="space-y-0.5 p-1">
|
||||
<div v-for="(sprite, index) in props.sprites" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
|
||||
<div v-for="(sprite, index) in compositeFrames" :key="sprite.id" class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer rounded" @click="toggleHiddenFrame(index)">
|
||||
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-3.5 h-3.5 text-blue-500 focus:ring-blue-400 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700" @click.stop @change="toggleHiddenFrame(index)" />
|
||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
@@ -159,18 +159,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
|
||||
interface Sprite {
|
||||
id: string;
|
||||
img: HTMLImageElement;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||
|
||||
const props = defineProps<{
|
||||
sprites: Sprite[];
|
||||
layers: Layer[];
|
||||
activeLayerId: string;
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
@@ -179,17 +177,58 @@
|
||||
}>();
|
||||
|
||||
const previewCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Initialize composables
|
||||
const canvas2D = useCanvas2D(previewCanvasRef);
|
||||
|
||||
const {
|
||||
zoom,
|
||||
increase: increaseZoom,
|
||||
decrease: decreaseZoom,
|
||||
} = useZoom({
|
||||
allowedValues: [0.5, 1, 2, 3, 4, 5],
|
||||
initial: 1,
|
||||
});
|
||||
|
||||
const getVisibleLayers = () => props.layers.filter(l => l.visible);
|
||||
const maxFrames = () => Math.max(0, ...getVisibleLayers().map(l => l.sprites.length));
|
||||
|
||||
const { currentFrameIndex, isPlaying, fps, hiddenFrames, visibleFrames, visibleFramesCount, visibleFrameIndex, visibleFrameNumber, togglePlayback, nextFrame, previousFrame, handleSliderInput, toggleHiddenFrame, showAllFrames, hideAllFrames, stopAnimation } = useAnimationFrames({
|
||||
sprites: () => {
|
||||
const len = maxFrames();
|
||||
const frames: Sprite[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = getVisibleLayers().find(l => l.sprites[i])?.sprites[i];
|
||||
if (s) frames.push(s);
|
||||
}
|
||||
return frames;
|
||||
},
|
||||
onDraw: drawPreviewCanvas,
|
||||
});
|
||||
|
||||
// Preview state
|
||||
const currentFrameIndex = ref(0);
|
||||
const isPlaying = ref(false);
|
||||
const fps = ref(12);
|
||||
const zoom = ref(1);
|
||||
const isDraggable = ref(false);
|
||||
const showAllSprites = ref(false);
|
||||
const animationFrameId = ref<number | null>(null);
|
||||
const lastFrameTime = ref(0);
|
||||
|
||||
const compositeFrames = computed<Sprite[]>(() => {
|
||||
const v = getVisibleLayers();
|
||||
const len = maxFrames();
|
||||
const arr: Sprite[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = v.find(l => l.sprites[i])?.sprites[i];
|
||||
if (s) arr.push(s);
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
const currentFrameSprite = computed<Sprite | null>(() => {
|
||||
const layer = props.layers.find(l => l.id === props.activeLayerId);
|
||||
if (!layer) return null;
|
||||
return layer.sprites[currentFrameIndex.value] || null;
|
||||
});
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
@@ -198,139 +237,54 @@
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
|
||||
// Add this after other refs
|
||||
const hiddenFrames = ref<number[]>([]);
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Add these computed properties
|
||||
const visibleFrames = computed(() => props.sprites.filter((_, index) => !hiddenFrames.value.includes(index)));
|
||||
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
||||
const visibleFrameIndex = computed(() => {
|
||||
return visibleFrames.value.findIndex((_, idx) => idx === visibleFrames.value.findIndex(s => s === props.sprites[currentFrameIndex.value]));
|
||||
});
|
||||
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
||||
|
||||
// Canvas drawing
|
||||
const calculateMaxDimensions = () => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
|
||||
props.sprites.forEach(sprite => {
|
||||
maxWidth = Math.max(maxWidth, sprite.width);
|
||||
maxHeight = Math.max(maxHeight, sprite.height);
|
||||
});
|
||||
function drawPreviewCanvas() {
|
||||
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
|
||||
|
||||
return { maxWidth, maxHeight };
|
||||
};
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const allSprites = visibleLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
const drawPreviewCanvas = () => {
|
||||
if (!previewCanvasRef.value || !ctx.value || props.sprites.length === 0) return;
|
||||
// Apply pixel art optimization
|
||||
canvas2D.applySmoothing();
|
||||
|
||||
const currentSprite = props.sprites[currentFrameIndex.value];
|
||||
if (!currentSprite) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
|
||||
// Apply pixel art optimization consistently from store
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
|
||||
// Set canvas size to just fit one sprite cell
|
||||
previewCanvasRef.value.width = maxWidth;
|
||||
previewCanvasRef.value.height = maxHeight;
|
||||
// Set canvas size to fit one sprite cell (expanded with negative spacing)
|
||||
canvas2D.setCanvasSize(cellWidth, cellHeight);
|
||||
|
||||
// Clear canvas
|
||||
ctx.value.clearRect(0, 0, previewCanvasRef.value.width, previewCanvasRef.value.height);
|
||||
canvas2D.clear();
|
||||
|
||||
// Draw grid background (cell)
|
||||
ctx.value.fillStyle = '#f9fafb';
|
||||
ctx.value.fillRect(0, 0, maxWidth, maxHeight);
|
||||
canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
|
||||
|
||||
// Keep pixel art optimization consistent throughout all drawing operations
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
const frameIndex = currentFrameIndex.value;
|
||||
|
||||
// Draw all sprites with transparency if enabled
|
||||
if (showAllSprites.value && props.sprites.length > 1) {
|
||||
ctx.value.globalAlpha = 0.3;
|
||||
props.sprites.forEach((sprite, index) => {
|
||||
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
|
||||
// Use Math.floor for pixel-perfect positioning
|
||||
ctx.value?.drawImage(sprite.img, Math.floor(sprite.x), Math.floor(sprite.y));
|
||||
}
|
||||
});
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
if (showAllSprites.value) {
|
||||
const len = maxFrames();
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[i];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Draw current sprite with integer positions for pixel-perfect rendering
|
||||
ctx.value.drawImage(currentSprite.img, Math.floor(currentSprite.x), Math.floor(currentSprite.y));
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[frameIndex];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y);
|
||||
});
|
||||
|
||||
// Draw cell border
|
||||
ctx.value.strokeStyle = '#e5e7eb';
|
||||
ctx.value.lineWidth = 1;
|
||||
ctx.value.strokeRect(0, 0, maxWidth, maxHeight);
|
||||
};
|
||||
|
||||
// Animation control
|
||||
const togglePlayback = () => {
|
||||
isPlaying.value = !isPlaying.value;
|
||||
|
||||
if (isPlaying.value) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
lastFrameTime.value = performance.now();
|
||||
animateFrame();
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (animationFrameId.value !== null) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const animateFrame = () => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - lastFrameTime.value;
|
||||
const frameTime = 1000 / fps.value;
|
||||
|
||||
if (elapsed >= frameTime) {
|
||||
lastFrameTime.value = now - (elapsed % frameTime);
|
||||
nextFrame();
|
||||
}
|
||||
|
||||
animationFrameId.value = requestAnimationFrame(animateFrame);
|
||||
};
|
||||
|
||||
const nextFrame = () => {
|
||||
if (visibleFrames.value.length === 0) return;
|
||||
|
||||
const currentVisibleIndex = visibleFrameIndex.value;
|
||||
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
|
||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const previousFrame = () => {
|
||||
if (visibleFrames.value.length === 0) return;
|
||||
|
||||
const currentVisibleIndex = visibleFrameIndex.value;
|
||||
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
|
||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
// Add this method to handle slider input
|
||||
const handleSliderInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const index = parseInt(target.value);
|
||||
currentFrameIndex.value = props.sprites.indexOf(visibleFrames.value[index]);
|
||||
};
|
||||
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
|
||||
}
|
||||
|
||||
// Drag functionality
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
@@ -343,15 +297,22 @@
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
|
||||
const sprite = props.sprites[currentFrameIndex.value];
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
const vLayers = getVisibleLayers();
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
|
||||
// Check if click is on sprite
|
||||
if (sprite && mouseX >= sprite.x && mouseX <= sprite.x + sprite.width && mouseY >= sprite.y && mouseY <= sprite.y + sprite.height) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = sprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||
// Check if click is on sprite (accounting for negative spacing offset)
|
||||
if (activeSprite) {
|
||||
const spriteCanvasX = negativeSpacing + activeSprite.x;
|
||||
const spriteCanvasY = negativeSpacing + activeSprite.y;
|
||||
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + activeSprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + activeSprite.height) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = activeSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -368,18 +329,23 @@
|
||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||
|
||||
const sprite = props.sprites[currentFrameIndex.value];
|
||||
if (!sprite || sprite.id !== activeSpriteId.value) return;
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
const vLayers = getVisibleLayers();
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(vLayers);
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
// Calculate new position with constraints and round to integers
|
||||
let newX = Math.round(spritePosBeforeDrag.value.x + deltaX);
|
||||
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
|
||||
|
||||
// Constrain movement within cell
|
||||
newX = Math.max(0, Math.min(maxWidth - sprite.width, newX));
|
||||
newY = Math.max(0, Math.min(maxHeight - sprite.height, newY));
|
||||
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
|
||||
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - activeSprite.width, newX));
|
||||
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - activeSprite.height, newY));
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
drawPreviewCanvas();
|
||||
@@ -390,23 +356,6 @@
|
||||
activeSpriteId.value = null;
|
||||
};
|
||||
|
||||
// Add helper methods for mobile zoom controls
|
||||
const increaseZoom = () => {
|
||||
const zoomValues = [0.5, 1, 2, 3, 4];
|
||||
const currentIndex = zoomValues.indexOf(Number(zoom.value));
|
||||
if (currentIndex < zoomValues.length - 1) {
|
||||
zoom.value = zoomValues[currentIndex + 1];
|
||||
}
|
||||
};
|
||||
|
||||
const decreaseZoom = () => {
|
||||
const zoomValues = [0.5, 1, 2, 3, 4];
|
||||
const currentIndex = zoomValues.indexOf(Number(zoom.value));
|
||||
if (currentIndex > 0) {
|
||||
zoom.value = zoomValues[currentIndex - 1];
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (!isDraggable.value) return;
|
||||
|
||||
@@ -439,10 +388,8 @@
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
if (previewCanvasRef.value) {
|
||||
ctx.value = previewCanvasRef.value.getContext('2d');
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
canvas2D.initContext();
|
||||
drawPreviewCanvas();
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
@@ -455,68 +402,26 @@
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure we're using integer positions for pixel-perfect rendering
|
||||
props.sprites.forEach(sprite => {
|
||||
sprite.x = Math.floor(sprite.x);
|
||||
sprite.y = Math.floor(sprite.y);
|
||||
});
|
||||
|
||||
// Force a redraw with the correct image smoothing settings
|
||||
if (ctx.value) {
|
||||
ctx.value.imageSmoothingEnabled = !settingsStore.pixelPerfect;
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
const allSprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.ensureIntegerPositions(allSprites);
|
||||
canvas2D.applySmoothing();
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.sprites, drawPreviewCanvas, { deep: true });
|
||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
||||
watch(currentFrameIndex, drawPreviewCanvas);
|
||||
watch(zoom, drawPreviewCanvas);
|
||||
watch(isDraggable, drawPreviewCanvas);
|
||||
watch(showAllSprites, drawPreviewCanvas);
|
||||
watch(hiddenFrames, drawPreviewCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
||||
|
||||
// Initial draw
|
||||
if (props.sprites.length > 0) {
|
||||
if (props.layers.some(l => l.sprites.length > 0)) {
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
|
||||
const toggleHiddenFrame = (index: number) => {
|
||||
const currentIndex = hiddenFrames.value.indexOf(index);
|
||||
if (currentIndex === -1) {
|
||||
// Adding to hidden frames
|
||||
hiddenFrames.value.push(index);
|
||||
|
||||
// If we're hiding the current frame, switch to the next visible frame
|
||||
if (index === currentFrameIndex.value) {
|
||||
const nextVisibleSprite = props.sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
||||
if (nextVisibleSprite !== -1) {
|
||||
currentFrameIndex.value = nextVisibleSprite;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Removing from hidden frames
|
||||
hiddenFrames.value.splice(currentIndex, 1);
|
||||
}
|
||||
|
||||
// Force a redraw
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const showAllFrames = () => {
|
||||
hiddenFrames.value = [];
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
const hideAllFrames = () => {
|
||||
hiddenFrames.value = props.sprites.map((_, index) => index);
|
||||
// Keep at least one frame visible
|
||||
if (hiddenFrames.value.length > 0) {
|
||||
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
||||
}
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { SpriteFile } from '@/types/sprites';
|
||||
|
||||
interface SpritePreview {
|
||||
url: string;
|
||||
@@ -117,14 +118,6 @@
|
||||
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
|
||||
}>();
|
||||
|
||||
interface SpriteFile {
|
||||
file: File;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<!-- Resize handle -->
|
||||
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize">
|
||||
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22Z"/>
|
||||
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
178
src/composables/useAnimationFrames.ts
Normal file
178
src/composables/useAnimationFrames.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ref, computed, type Ref, onUnmounted, toRef, isRef } from 'vue';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
|
||||
export interface AnimationFramesOptions {
|
||||
sprites: Ref<Sprite[]> | Sprite[] | (() => Sprite[]);
|
||||
onDraw: () => void;
|
||||
}
|
||||
|
||||
export function useAnimationFrames(options: AnimationFramesOptions) {
|
||||
const { onDraw } = options;
|
||||
|
||||
// Convert sprites to a computed ref for reactivity
|
||||
const spritesRef = computed(() => {
|
||||
if (typeof options.sprites === 'function') {
|
||||
return options.sprites();
|
||||
}
|
||||
if (isRef(options.sprites)) {
|
||||
return options.sprites.value;
|
||||
}
|
||||
return options.sprites;
|
||||
});
|
||||
|
||||
// Helper to get sprites array
|
||||
const getSprites = () => spritesRef.value;
|
||||
|
||||
// State
|
||||
const currentFrameIndex = ref(0);
|
||||
const isPlaying = ref(false);
|
||||
const fps = ref(12);
|
||||
const hiddenFrames = ref<number[]>([]);
|
||||
|
||||
// Animation internals
|
||||
const animationFrameId = ref<number | null>(null);
|
||||
const lastFrameTime = ref(0);
|
||||
|
||||
// Computed properties for visible frames
|
||||
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
|
||||
|
||||
const visibleFramesCount = computed(() => visibleFrames.value.length);
|
||||
|
||||
const visibleFrameIndex = computed(() => {
|
||||
const sprites = getSprites();
|
||||
const currentSprite = sprites[currentFrameIndex.value];
|
||||
return visibleFrames.value.findIndex(s => s === currentSprite);
|
||||
});
|
||||
|
||||
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
|
||||
|
||||
// Animation control
|
||||
const animateFrame = () => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - lastFrameTime.value;
|
||||
const frameTime = 1000 / fps.value;
|
||||
|
||||
if (elapsed >= frameTime) {
|
||||
lastFrameTime.value = now - (elapsed % frameTime);
|
||||
nextFrame();
|
||||
}
|
||||
|
||||
animationFrameId.value = requestAnimationFrame(animateFrame);
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
lastFrameTime.value = performance.now();
|
||||
animateFrame();
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (animationFrameId.value !== null) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayback = () => {
|
||||
isPlaying.value = !isPlaying.value;
|
||||
|
||||
if (isPlaying.value) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
const nextFrame = () => {
|
||||
if (visibleFrames.value.length === 0) return;
|
||||
const sprites = getSprites();
|
||||
|
||||
const currentVisibleIndex = visibleFrameIndex.value;
|
||||
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
|
||||
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
|
||||
onDraw();
|
||||
};
|
||||
|
||||
const previousFrame = () => {
|
||||
if (visibleFrames.value.length === 0) return;
|
||||
const sprites = getSprites();
|
||||
|
||||
const currentVisibleIndex = visibleFrameIndex.value;
|
||||
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
|
||||
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
|
||||
onDraw();
|
||||
};
|
||||
|
||||
const handleSliderInput = (event: Event) => {
|
||||
const sprites = getSprites();
|
||||
const target = event.target as HTMLInputElement;
|
||||
const index = parseInt(target.value);
|
||||
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
|
||||
};
|
||||
|
||||
// Frame visibility management
|
||||
const toggleHiddenFrame = (index: number) => {
|
||||
const sprites = getSprites();
|
||||
const currentIndex = hiddenFrames.value.indexOf(index);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
hiddenFrames.value.push(index);
|
||||
|
||||
// If hiding current frame, switch to next visible
|
||||
if (index === currentFrameIndex.value) {
|
||||
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
|
||||
if (nextVisible !== -1) {
|
||||
currentFrameIndex.value = nextVisible;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hiddenFrames.value.splice(currentIndex, 1);
|
||||
}
|
||||
|
||||
onDraw();
|
||||
};
|
||||
|
||||
const showAllFrames = () => {
|
||||
hiddenFrames.value = [];
|
||||
onDraw();
|
||||
};
|
||||
|
||||
const hideAllFrames = () => {
|
||||
const sprites = getSprites();
|
||||
hiddenFrames.value = sprites.map((_, index) => index);
|
||||
|
||||
// Keep at least one frame visible
|
||||
if (hiddenFrames.value.length > 0) {
|
||||
hiddenFrames.value.splice(currentFrameIndex.value, 1);
|
||||
}
|
||||
onDraw();
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
stopAnimation();
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
currentFrameIndex,
|
||||
isPlaying,
|
||||
fps,
|
||||
hiddenFrames,
|
||||
|
||||
// Computed
|
||||
visibleFrames,
|
||||
visibleFramesCount,
|
||||
visibleFrameIndex,
|
||||
visibleFrameNumber,
|
||||
|
||||
// Methods
|
||||
togglePlayback,
|
||||
nextFrame,
|
||||
previousFrame,
|
||||
handleSliderInput,
|
||||
toggleHiddenFrame,
|
||||
showAllFrames,
|
||||
hideAllFrames,
|
||||
stopAnimation,
|
||||
};
|
||||
}
|
||||
151
src/composables/useCanvas2D.ts
Normal file
151
src/composables/useCanvas2D.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
|
||||
export interface Canvas2DOptions {
|
||||
pixelPerfect?: Ref<boolean> | boolean;
|
||||
}
|
||||
|
||||
export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?: Canvas2DOptions) {
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const initContext = () => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
applySmoothing();
|
||||
}
|
||||
return ctx.value;
|
||||
};
|
||||
|
||||
const applySmoothing = () => {
|
||||
if (ctx.value) {
|
||||
const pixelPerfect = options?.pixelPerfect;
|
||||
const isPixelPerfect = typeof pixelPerfect === 'boolean' ? pixelPerfect : (pixelPerfect?.value ?? settingsStore.pixelPerfect);
|
||||
ctx.value.imageSmoothingEnabled = !isPixelPerfect;
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (!canvasRef.value || !ctx.value) return;
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
};
|
||||
|
||||
const setCanvasSize = (width: number, height: number) => {
|
||||
if (canvasRef.value) {
|
||||
canvasRef.value.width = width;
|
||||
canvasRef.value.height = height;
|
||||
}
|
||||
};
|
||||
|
||||
const fillRect = (x: number, y: number, width: number, height: number, color: string) => {
|
||||
if (!ctx.value) return;
|
||||
ctx.value.fillStyle = color;
|
||||
ctx.value.fillRect(Math.floor(x), Math.floor(y), width, height);
|
||||
};
|
||||
|
||||
const strokeRect = (x: number, y: number, width: number, height: number, color: string, lineWidth = 1) => {
|
||||
if (!ctx.value) return;
|
||||
ctx.value.strokeStyle = color;
|
||||
ctx.value.lineWidth = lineWidth;
|
||||
ctx.value.strokeRect(Math.floor(x), Math.floor(y), width, height);
|
||||
};
|
||||
|
||||
const drawImage = (img: HTMLImageElement | HTMLCanvasElement, x: number, y: number, alpha = 1) => {
|
||||
if (!ctx.value) return;
|
||||
const prevAlpha = ctx.value.globalAlpha;
|
||||
ctx.value.globalAlpha = alpha;
|
||||
ctx.value.drawImage(img, Math.floor(x), Math.floor(y));
|
||||
ctx.value.globalAlpha = prevAlpha;
|
||||
};
|
||||
|
||||
const setGlobalAlpha = (alpha: number) => {
|
||||
if (ctx.value) {
|
||||
ctx.value.globalAlpha = alpha;
|
||||
}
|
||||
};
|
||||
|
||||
const resetGlobalAlpha = () => {
|
||||
if (ctx.value) {
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to ensure integer positions for pixel-perfect rendering
|
||||
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
|
||||
items.forEach(item => {
|
||||
item.x = Math.floor(item.x);
|
||||
item.y = Math.floor(item.y);
|
||||
});
|
||||
};
|
||||
|
||||
// Centralized force redraw handler
|
||||
const createForceRedrawHandler = <T extends { x: number; y: number }>(items: T[], drawCallback: () => void) => {
|
||||
return () => {
|
||||
ensureIntegerPositions(items);
|
||||
applySmoothing();
|
||||
drawCallback();
|
||||
};
|
||||
};
|
||||
|
||||
// Get mouse position relative to canvas, accounting for zoom
|
||||
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
|
||||
if (!canvasRef.value) return null;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / (rect.width / zoom);
|
||||
const scaleY = canvasRef.value.height / (rect.height / zoom);
|
||||
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / zoom) * scaleX,
|
||||
y: ((event.clientY - rect.top) / zoom) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to attach load/error listeners to images that aren't yet loaded
|
||||
const attachImageListeners = (sprites: Sprite[], onLoad: () => void, tracked: WeakSet<HTMLImageElement>) => {
|
||||
sprites.forEach(sprite => {
|
||||
const img = sprite.img as HTMLImageElement | undefined;
|
||||
if (img && !tracked.has(img)) {
|
||||
tracked.add(img);
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', onLoad, { once: true });
|
||||
img.addEventListener('error', onLoad, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fill cell background with selected color or transparent
|
||||
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
||||
if (settingsStore.backgroundColor === 'transparent') return;
|
||||
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
|
||||
fillRect(x, y, width, height, color);
|
||||
};
|
||||
|
||||
// Stroke grid with theme-aware color
|
||||
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
|
||||
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||
strokeRect(x, y, width, height, color, 1);
|
||||
};
|
||||
|
||||
return {
|
||||
ctx,
|
||||
canvasRef,
|
||||
initContext,
|
||||
applySmoothing,
|
||||
clear,
|
||||
setCanvasSize,
|
||||
fillRect,
|
||||
strokeRect,
|
||||
drawImage,
|
||||
setGlobalAlpha,
|
||||
resetGlobalAlpha,
|
||||
ensureIntegerPositions,
|
||||
createForceRedrawHandler,
|
||||
getMousePosition,
|
||||
attachImageListeners,
|
||||
fillCellBackground,
|
||||
strokeGridCell,
|
||||
};
|
||||
}
|
||||
297
src/composables/useDragSprite.ts
Normal file
297
src/composables/useDragSprite.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export interface CellPosition {
|
||||
col: number;
|
||||
row: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SpritePosition {
|
||||
id: string;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
cellX: number;
|
||||
cellY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
col: number;
|
||||
row: number;
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DragSpriteOptions {
|
||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||
columns: Ref<number> | number;
|
||||
zoom?: Ref<number>;
|
||||
allowCellSwap?: Ref<boolean>;
|
||||
negativeSpacingEnabled?: Ref<boolean>;
|
||||
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||
onDraw: () => void;
|
||||
}
|
||||
|
||||
export function useDragSprite(options: DragSpriteOptions) {
|
||||
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
|
||||
|
||||
// Helper to get reactive values
|
||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
|
||||
const getZoom = () => options.zoom?.value ?? 1;
|
||||
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
|
||||
const getNegativeSpacingEnabled = () => options.negativeSpacingEnabled?.value ?? false;
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const activeSpriteCellIndex = ref<number | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const dragOffsetX = ref(0);
|
||||
const dragOffsetY = ref(0);
|
||||
const currentHoverCell = ref<CellPosition | null>(null);
|
||||
|
||||
// Visual feedback
|
||||
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
|
||||
const highlightCell = ref<CellPosition | null>(null);
|
||||
|
||||
// Cache for max dimensions
|
||||
const lastMaxWidth = ref(1);
|
||||
const lastMaxHeight = ref(1);
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
const sprites = getSprites();
|
||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||
const base = getMaxDimensions(sprites);
|
||||
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
||||
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
||||
lastMaxWidth.value = baseMaxWidth;
|
||||
lastMaxHeight.value = baseMaxHeight;
|
||||
|
||||
// Calculate negative spacing using shared composable
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites, negativeSpacingEnabled);
|
||||
|
||||
// Add negative spacing to expand each cell
|
||||
const maxWidth = baseMaxWidth + negativeSpacing;
|
||||
const maxHeight = baseMaxHeight + negativeSpacing;
|
||||
return { maxWidth, maxHeight, negativeSpacing };
|
||||
};
|
||||
|
||||
// Computed sprite positions
|
||||
const spritePositions = computed<SpritePosition[]>(() => {
|
||||
const sprites = getSprites();
|
||||
const columns = getColumns();
|
||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||
|
||||
return sprites.map((sprite, index) => {
|
||||
const col = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
|
||||
// With negative spacing, sprites are positioned at bottom-right of cell
|
||||
// (spacing added to top and left)
|
||||
return {
|
||||
id: sprite.id,
|
||||
canvasX: col * maxWidth + negativeSpacing + sprite.x,
|
||||
canvasY: row * maxHeight + negativeSpacing + sprite.y,
|
||||
cellX: col * maxWidth,
|
||||
cellY: row * maxHeight,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
col,
|
||||
row,
|
||||
index,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
|
||||
const sprites = getSprites();
|
||||
const columns = getColumns();
|
||||
const { maxWidth, maxHeight } = calculateMaxDimensions();
|
||||
const col = Math.floor(x / maxWidth);
|
||||
const row = Math.floor(y / maxHeight);
|
||||
|
||||
const totalRows = Math.ceil(sprites.length / columns);
|
||||
if (col >= 0 && col < columns && row >= 0 && row < totalRows) {
|
||||
const index = row * columns + col;
|
||||
if (index < sprites.length) {
|
||||
return { col, row, index };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findSpriteAtPosition = (x: number, y: number): Sprite | null => {
|
||||
const sprites = getSprites();
|
||||
const positions = spritePositions.value;
|
||||
|
||||
for (let i = positions.length - 1; i >= 0; i--) {
|
||||
const pos = positions[i];
|
||||
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
|
||||
return sprites.find(s => s.id === pos.id) || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
const pos = getMousePosition(event, getZoom());
|
||||
if (!pos) return;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||
if (clickedSprite) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = clickedSprite.id;
|
||||
dragStartX.value = pos.x;
|
||||
dragStartY.value = pos.y;
|
||||
|
||||
const spritePosition = spritePositions.value.find(p => p.id === clickedSprite.id);
|
||||
if (spritePosition) {
|
||||
dragOffsetX.value = pos.x - spritePosition.canvasX;
|
||||
dragOffsetY.value = pos.y - spritePosition.canvasY;
|
||||
activeSpriteCellIndex.value = spritePosition.index;
|
||||
|
||||
const startCell = findCellAtPosition(pos.x, pos.y);
|
||||
if (startCell) {
|
||||
currentHoverCell.value = startCell;
|
||||
highlightCell.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
|
||||
if (!activeSpriteId.value) return;
|
||||
const sprites = getSprites();
|
||||
const columns = getColumns();
|
||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||
|
||||
// Use the sprite's current index in the array to calculate cell position
|
||||
const cellCol = spriteIndex % columns;
|
||||
const cellRow = Math.floor(spriteIndex / columns);
|
||||
const cellX = cellCol * maxWidth;
|
||||
const cellY = cellRow * maxHeight;
|
||||
|
||||
// Calculate new position relative to cell origin (without the negative spacing offset)
|
||||
// The sprite's x,y is stored relative to where it would be drawn after the negativeSpacing offset
|
||||
const newX = mouseX - cellX - negativeSpacing - dragOffsetX.value;
|
||||
const newY = mouseY - cellY - negativeSpacing - dragOffsetY.value;
|
||||
|
||||
// The sprite can move within the full expanded cell area
|
||||
// Allow negative values up to -negativeSpacing so sprite can fill the expanded area
|
||||
const constrainedX = Math.floor(Math.max(-negativeSpacing, Math.min(maxWidth - negativeSpacing - sprites[spriteIndex].width, newX)));
|
||||
const constrainedY = Math.floor(Math.max(-negativeSpacing, Math.min(maxHeight - negativeSpacing - sprites[spriteIndex].height, newY)));
|
||||
|
||||
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
|
||||
onDraw();
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !activeSpriteId.value || activeSpriteCellIndex.value === null) return;
|
||||
|
||||
const pos = getMousePosition(event, getZoom());
|
||||
if (!pos) return;
|
||||
|
||||
const sprites = getSprites();
|
||||
const spriteIndex = sprites.findIndex(s => s.id === activeSpriteId.value);
|
||||
if (spriteIndex === -1) return;
|
||||
|
||||
const hoverCell = findCellAtPosition(pos.x, pos.y);
|
||||
currentHoverCell.value = hoverCell;
|
||||
|
||||
if (getAllowCellSwap() && hoverCell) {
|
||||
if (hoverCell.index !== activeSpriteCellIndex.value) {
|
||||
highlightCell.value = hoverCell;
|
||||
ghostSprite.value = {
|
||||
id: activeSpriteId.value,
|
||||
x: pos.x - dragOffsetX.value,
|
||||
y: pos.y - dragOffsetY.value,
|
||||
};
|
||||
onDraw();
|
||||
} else {
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
handleInCellMovement(pos.x, pos.y, spriteIndex);
|
||||
}
|
||||
} else {
|
||||
handleInCellMovement(pos.x, pos.y, spriteIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
if (isDragging.value && getAllowCellSwap() && activeSpriteId.value && activeSpriteCellIndex.value !== null && currentHoverCell.value && activeSpriteCellIndex.value !== currentHoverCell.value.index) {
|
||||
if (onUpdateSpriteCell) {
|
||||
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
|
||||
}
|
||||
onUpdateSprite(activeSpriteId.value, 0, 0);
|
||||
}
|
||||
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
activeSpriteCellIndex.value = null;
|
||||
currentHoverCell.value = null;
|
||||
highlightCell.value = null;
|
||||
ghostSprite.value = null;
|
||||
|
||||
onDraw();
|
||||
};
|
||||
|
||||
// Touch event handlers
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
startDrag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (isDragging.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
} as unknown as MouseEvent;
|
||||
drag(mouseEvent);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
isDragging,
|
||||
activeSpriteId,
|
||||
ghostSprite,
|
||||
highlightCell,
|
||||
spritePositions,
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
drag,
|
||||
stopDrag,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
findSpriteAtPosition,
|
||||
findCellAtPosition,
|
||||
calculateMaxDimensions,
|
||||
};
|
||||
}
|
||||
216
src/composables/useExport.ts
Normal file
216
src/composables/useExport.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { Ref } from 'vue';
|
||||
import GIF from 'gif.js';
|
||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
||||
import JSZip from 'jszip';
|
||||
import type { Sprite } from '../types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>) => {
|
||||
const downloadSpritesheet = () => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = cellWidth * columns.value;
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
const cellX = Math.floor(col * cellWidth);
|
||||
const cellY = Math.floor(row * cellHeight);
|
||||
ctx.drawImage(sprite.img, Math.floor(cellX + negativeSpacing + sprite.x), Math.floor(cellY + negativeSpacing + sprite.y));
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
link.click();
|
||||
};
|
||||
|
||||
const exportSpritesheetJSON = async () => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Nothing to export. Please add sprites first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const spritesData = await Promise.all(
|
||||
sprites.value.map(async sprite => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
const base64Data = canvas.toDataURL('image/png');
|
||||
return {
|
||||
id: sprite.id,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
base64: base64Data,
|
||||
name: sprite.file.name,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const jsonData = {
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
sprites: spritesData.filter(Boolean),
|
||||
};
|
||||
const jsonString = JSON.stringify(jsonData, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.json';
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||
const jsonText = await jsonFile.text();
|
||||
const jsonData = JSON.parse(jsonText);
|
||||
|
||||
if (!jsonData.sprites || !Array.isArray(jsonData.sprites)) throw new Error('Invalid JSON format: missing sprites array');
|
||||
|
||||
if (jsonData.columns && typeof jsonData.columns === 'number') columns.value = jsonData.columns;
|
||||
if (typeof jsonData.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = jsonData.negativeSpacingEnabled;
|
||||
|
||||
// revoke existing blob urls
|
||||
if (sprites.value.length) {
|
||||
sprites.value.forEach(s => {
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const imported = await Promise.all(
|
||||
jsonData.sprites.map((spriteData: any) => {
|
||||
return new Promise<Sprite>(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||
const file = new File([blob], fileName, { type: mimeType });
|
||||
resolve({
|
||||
id: spriteData.id || crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url: spriteData.base64,
|
||||
width: spriteData.width,
|
||||
height: spriteData.height,
|
||||
x: spriteData.x || 0,
|
||||
y: spriteData.y || 0,
|
||||
});
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
sprites.value = imported;
|
||||
};
|
||||
|
||||
const downloadAsGif = (fps: number) => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const gif = new GIF({ workers: 2, quality: 10, width: cellWidth, height: cellHeight, workerScript: gifWorkerUrl });
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
|
||||
gif.on('finished', (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'animation.gif';
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
gif.render();
|
||||
};
|
||||
|
||||
const downloadAsZip = async () => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Please upload or import sprites before downloading a ZIP.');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
const buf = new ArrayBuffer(binary.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < binary.length; i++) view[i] = binary.charCodeAt(i);
|
||||
const baseName = sprite.file?.name ? sprite.file.name.replace(/\s+/g, '_') : `sprite_${index + 1}.png`;
|
||||
const name = `${index + 1}_${baseName}`;
|
||||
zip.file(name, view);
|
||||
});
|
||||
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(content);
|
||||
const link = document.createElement('a');
|
||||
link.download = 'sprites.zip';
|
||||
link.href = url;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
|
||||
};
|
||||
263
src/composables/useExportLayers.ts
Normal file
263
src/composables/useExportLayers.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { Ref } from 'vue';
|
||||
import GIF from 'gif.js';
|
||||
import gifWorkerUrl from 'gif.js/dist/gif.worker.js?url';
|
||||
import JSZip from 'jszip';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from './useLayers';
|
||||
import { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, activeLayerId?: Ref<string>, backgroundColor?: Ref<string>) => {
|
||||
const getVisibleLayers = () => layersRef.value.filter(l => l.visible);
|
||||
const getAllVisibleSprites = () => getVisibleLayers().flatMap(l => l.sprites);
|
||||
|
||||
const drawCompositeCell = (ctx: CanvasRenderingContext2D, cellIndex: number, cellWidth: number, cellHeight: number, negativeSpacing: number) => {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
// Apply background color if not transparent
|
||||
if (backgroundColor?.value && backgroundColor.value !== 'transparent') {
|
||||
ctx.fillStyle = backgroundColor.value;
|
||||
ctx.fillRect(0, 0, cellWidth, cellHeight);
|
||||
}
|
||||
const vLayers = getVisibleLayers();
|
||||
vLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (!sprite) return;
|
||||
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
|
||||
});
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
const rows = Math.ceil(maxLen / columns.value);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth * columns.value;
|
||||
canvas.height = cellHeight * rows;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
for (let index = 0; index < maxLen; index++) {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
const cellX = Math.floor(col * cellWidth);
|
||||
const cellY = Math.floor(row * cellHeight);
|
||||
|
||||
const cellCanvas = document.createElement('canvas');
|
||||
const cellCtx = cellCanvas.getContext('2d');
|
||||
if (!cellCtx) return;
|
||||
cellCanvas.width = cellWidth;
|
||||
cellCanvas.height = cellHeight;
|
||||
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
|
||||
ctx.drawImage(cellCanvas, cellX, cellY);
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = 'spritesheet.png';
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
link.click();
|
||||
};
|
||||
|
||||
const exportSpritesheetJSON = async () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Nothing to export. Please add sprites first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const layersData = await Promise.all(
|
||||
layersRef.value.map(async layer => {
|
||||
const sprites = await Promise.all(
|
||||
layer.sprites.map(async sprite => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
ctx.drawImage(sprite.img, 0, 0);
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
return { id: sprite.id, width: sprite.width, height: sprite.height, x: sprite.x, y: sprite.y, base64, name: sprite.file.name };
|
||||
})
|
||||
);
|
||||
return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, sprites: sprites.filter(Boolean) };
|
||||
})
|
||||
);
|
||||
|
||||
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData };
|
||||
const jsonString = JSON.stringify(json, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'spritesheet.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||
const text = await jsonFile.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
const loadSprite = (spriteData: any) =>
|
||||
new Promise<Sprite>(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
|
||||
const blob = new Blob([ab], { type: mimeType });
|
||||
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||
const file = new File([blob], fileName, { type: mimeType });
|
||||
resolve({ id: spriteData.id || crypto.randomUUID(), file, img, url: spriteData.base64, width: spriteData.width, height: spriteData.height, x: spriteData.x || 0, y: spriteData.y || 0 });
|
||||
};
|
||||
img.src = spriteData.base64;
|
||||
});
|
||||
|
||||
if (typeof data.columns === 'number') columns.value = data.columns;
|
||||
if (typeof data.negativeSpacingEnabled === 'boolean') negativeSpacingEnabled.value = data.negativeSpacingEnabled;
|
||||
|
||||
if (Array.isArray(data.layers)) {
|
||||
const newLayers: Layer[] = [];
|
||||
for (const layerData of data.layers) {
|
||||
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
|
||||
newLayers.push({ id: layerData.id || crypto.randomUUID(), name: layerData.name || 'Layer', visible: layerData.visible !== false, locked: !!layerData.locked, sprites });
|
||||
}
|
||||
// Ensure at least one layer with sprites is visible
|
||||
if (newLayers.length > 0 && !newLayers.some(l => l.visible && l.sprites.length > 0)) {
|
||||
const firstLayerWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||
if (firstLayerWithSprites) {
|
||||
firstLayerWithSprites.visible = true;
|
||||
}
|
||||
}
|
||||
layersRef.value = newLayers;
|
||||
// Set active layer to the first layer with sprites
|
||||
if (activeLayerId && newLayers.length > 0) {
|
||||
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.sprites)) {
|
||||
const sprites: Sprite[] = await Promise.all(data.sprites.map((s: any) => loadSprite(s)));
|
||||
const baseLayerId = crypto.randomUUID();
|
||||
layersRef.value = [
|
||||
{ id: baseLayerId, name: 'Base', visible: true, locked: false, sprites },
|
||||
{ id: crypto.randomUUID(), name: 'Other', visible: true, locked: false, sprites: [] },
|
||||
];
|
||||
if (activeLayerId) {
|
||||
activeLayerId.value = baseLayerId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Invalid JSON format');
|
||||
};
|
||||
|
||||
const downloadAsGif = (fps: number) => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const gif = new GIF({ workers: 2, quality: 10, width: cellWidth, height: cellHeight, workerScript: gifWorkerUrl });
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing);
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
}
|
||||
|
||||
gif.on('finished', (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'animation.gif';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
gif.render();
|
||||
};
|
||||
|
||||
const downloadAsZip = async () => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||
alert('Please upload or import sprites before downloading a ZIP.');
|
||||
return;
|
||||
}
|
||||
const zip = new JSZip();
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
drawCompositeCell(ctx, i, cellWidth, cellHeight, negativeSpacing);
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
const buf = new ArrayBuffer(binary.length);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let j = 0; j < binary.length; j++) view[j] = binary.charCodeAt(j);
|
||||
zip.file(`frames/frame_${String(i + 1).padStart(3, '0')}.png`, view);
|
||||
}
|
||||
|
||||
const jsonFolder = zip.folder('export')!;
|
||||
const jsonBlobPromise = (async () => {
|
||||
const layersPayload = await Promise.all(
|
||||
layersRef.value.map(async layer => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
locked: layer.locked,
|
||||
sprites: await Promise.all(layer.sprites.map(async s => ({ id: s.id, width: s.width, height: s.height, x: s.x, y: s.y, name: s.file.name }))),
|
||||
}))
|
||||
);
|
||||
const meta = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersPayload };
|
||||
const metaStr = JSON.stringify(meta, null, 2);
|
||||
jsonFolder.file('spritesheet.meta.json', metaStr);
|
||||
})();
|
||||
await jsonBlobPromise;
|
||||
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(content);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sprites.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
|
||||
};
|
||||
110
src/composables/useFileDrop.ts
Normal file
110
src/composables/useFileDrop.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
|
||||
export interface FileDropOptions {
|
||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||
onAddSprite: (file: File) => void;
|
||||
onAddSpriteWithResize: (file: File) => void;
|
||||
}
|
||||
|
||||
export function useFileDrop(options: FileDropOptions) {
|
||||
const { onAddSprite, onAddSpriteWithResize } = options;
|
||||
|
||||
// Helper to get sprites array
|
||||
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
|
||||
|
||||
const isDragOver = ref(false);
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLCanvasElement | null) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (canvasRef) {
|
||||
const rect = canvasRef.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
} else {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const processDroppedImage = (file: File): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = e => {
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result as string;
|
||||
}
|
||||
};
|
||||
|
||||
img.onload = () => {
|
||||
const sprites = getSprites();
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
|
||||
|
||||
// Check if the dropped image is larger than current cells
|
||||
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
|
||||
onAddSpriteWithResize(file);
|
||||
} else {
|
||||
onAddSprite(file);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load image:', file.name);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
isDragOver.value = false;
|
||||
|
||||
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
||||
|
||||
if (files.length === 0) {
|
||||
alert('Please drop image files only.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each dropped file
|
||||
for (const file of files) {
|
||||
await processDroppedImage(file);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
handleDragOver,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
};
|
||||
}
|
||||
205
src/composables/useLayers.ts
Normal file
205
src/composables/useLayers.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
|
||||
|
||||
export const createEmptyLayer = (name: string): Layer => ({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
sprites: [],
|
||||
visible: true,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
export const useLayers = () => {
|
||||
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
||||
const activeLayerId = ref<string>(layers.value[0].id);
|
||||
const columns = ref(4);
|
||||
|
||||
watch(columns, val => {
|
||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
||||
if (safe !== columns.value) columns.value = safe;
|
||||
});
|
||||
|
||||
const activeLayer = computed(() => layers.value.find(l => l.id === activeLayerId.value) || layers.value[0]);
|
||||
|
||||
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i !== -1) {
|
||||
l.sprites[i].x = Math.floor(x);
|
||||
l.sprites[i].y = Math.floor(y);
|
||||
}
|
||||
};
|
||||
|
||||
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||
const l = activeLayer.value;
|
||||
if (!l || !l.sprites.length) return;
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
|
||||
l.sprites = l.sprites.map(sprite => {
|
||||
let x = sprite.x;
|
||||
let y = sprite.y;
|
||||
switch (position) {
|
||||
case 'left':
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
});
|
||||
};
|
||||
|
||||
const updateSpriteCell = (id: string, newIndex: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const currentIndex = l.sprites.findIndex(s => s.id === id);
|
||||
if (currentIndex === -1 || currentIndex === newIndex) return;
|
||||
const next = [...l.sprites];
|
||||
if (newIndex < next.length) {
|
||||
const moving = { ...next[currentIndex] };
|
||||
const target = { ...next[newIndex] };
|
||||
next[currentIndex] = target;
|
||||
next[newIndex] = moving;
|
||||
} else {
|
||||
const [moved] = next.splice(currentIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
}
|
||||
l.sprites = next;
|
||||
};
|
||||
|
||||
const removeSprite = (id: string) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i === -1) return;
|
||||
const s = l.sprites[i];
|
||||
if (s.url && s.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(s.url);
|
||||
} catch {}
|
||||
}
|
||||
l.sprites.splice(i, 1);
|
||||
};
|
||||
|
||||
const replaceSprite = (id: string, file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const i = l.sprites.findIndex(s => s.id === id);
|
||||
if (i === -1) return;
|
||||
const old = l.sprites[i];
|
||||
if (old.url && old.url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(old.url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y };
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => addSpriteWithResize(file);
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const next: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
l.sprites = [...l.sprites, next];
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const processImageFiles = async (files: File[]) => {
|
||||
for (const f of files) addSpriteWithResize(f);
|
||||
};
|
||||
|
||||
const addLayer = (name?: string) => {
|
||||
const l = createEmptyLayer(name || `Layer ${layers.value.length + 1}`);
|
||||
layers.value.push(l);
|
||||
activeLayerId.value = l.id;
|
||||
};
|
||||
|
||||
const removeLayer = (id: string) => {
|
||||
if (layers.value.length === 1) return;
|
||||
const idx = layers.value.findIndex(l => l.id === id);
|
||||
if (idx === -1) return;
|
||||
layers.value.splice(idx, 1);
|
||||
if (activeLayerId.value === id) activeLayerId.value = layers.value[0].id;
|
||||
};
|
||||
|
||||
const moveLayer = (id: string, direction: 'up' | 'down') => {
|
||||
const idx = layers.value.findIndex(l => l.id === id);
|
||||
if (idx === -1) return;
|
||||
if (direction === 'up' && idx > 0) {
|
||||
const [l] = layers.value.splice(idx, 1);
|
||||
layers.value.splice(idx - 1, 0, l);
|
||||
}
|
||||
if (direction === 'down' && idx < layers.value.length - 1) {
|
||||
const [l] = layers.value.splice(idx, 1);
|
||||
layers.value.splice(idx + 1, 0, l);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
||||
|
||||
return {
|
||||
layers,
|
||||
visibleLayers,
|
||||
activeLayerId,
|
||||
activeLayer,
|
||||
columns,
|
||||
getMaxDimensions,
|
||||
updateSpritePosition,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
addSpriteWithResize,
|
||||
processImageFiles,
|
||||
alignSprites,
|
||||
addLayer,
|
||||
removeLayer,
|
||||
moveLayer,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMaxDimensionsAcrossLayers = (layers: Layer[]) => {
|
||||
const sprites = layers.flatMap(l => (l.visible ? l.sprites : []));
|
||||
return getMaxDimensionsSingle(sprites);
|
||||
};
|
||||
21
src/composables/useNegativeSpacing.ts
Normal file
21
src/composables/useNegativeSpacing.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensions } from './useSprites';
|
||||
|
||||
/**
|
||||
* Calculate negative spacing to add to top-left of cells.
|
||||
* Uses half the available space so spacing is equal on all sides.
|
||||
*/
|
||||
export function calculateNegativeSpacing(sprites: Sprite[], enabled: boolean): number {
|
||||
if (!enabled || sprites.length === 0) return 0;
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
|
||||
const minWidth = Math.min(...sprites.map(s => s.width));
|
||||
const minHeight = Math.min(...sprites.map(s => s.height));
|
||||
|
||||
// Available space is the gap between cell size and smallest sprite
|
||||
const availableWidth = maxWidth - minWidth;
|
||||
const availableHeight = maxHeight - minHeight;
|
||||
|
||||
// Use half to balance spacing equally on all sides
|
||||
return Math.floor(Math.min(availableWidth, availableHeight) / 2);
|
||||
}
|
||||
255
src/composables/useSprites.ts
Normal file
255
src/composables/useSprites.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
import type { Sprite } from '../types/sprites';
|
||||
|
||||
export const useSprites = () => {
|
||||
const sprites = ref<Sprite[]>([]);
|
||||
const columns = ref(4);
|
||||
|
||||
// Clamp and coerce columns to a safe range [1..10]
|
||||
watch(columns, val => {
|
||||
const num = typeof val === 'number' ? val : parseInt(String(val));
|
||||
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
||||
if (safe !== columns.value) columns.value = safe;
|
||||
});
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const i = sprites.value.findIndex(s => s.id === id);
|
||||
if (i !== -1) {
|
||||
sprites.value[i].x = Math.floor(x);
|
||||
sprites.value[i].y = Math.floor(y);
|
||||
}
|
||||
};
|
||||
|
||||
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
||||
if (!sprites.value.length) return;
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
|
||||
sprites.value = sprites.value.map(sprite => {
|
||||
let x = sprite.x;
|
||||
let y = sprite.y;
|
||||
|
||||
switch (position) {
|
||||
case 'left':
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
});
|
||||
|
||||
triggerForceRedraw();
|
||||
};
|
||||
|
||||
const updateSpriteCell = (id: string, newIndex: number) => {
|
||||
const currentIndex = sprites.value.findIndex(s => s.id === id);
|
||||
if (currentIndex === -1 || currentIndex === newIndex) return;
|
||||
|
||||
const next = [...sprites.value];
|
||||
if (newIndex < sprites.value.length) {
|
||||
const moving = { ...next[currentIndex] };
|
||||
const target = { ...next[newIndex] };
|
||||
next[currentIndex] = target;
|
||||
next[newIndex] = moving;
|
||||
} else {
|
||||
const [moved] = next.splice(currentIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
}
|
||||
sprites.value = next;
|
||||
};
|
||||
|
||||
const removeSprite = (id: string) => {
|
||||
const i = sprites.value.findIndex(s => s.id === id);
|
||||
if (i === -1) return;
|
||||
const s = sprites.value[i];
|
||||
revokeIfBlob(s.url);
|
||||
sprites.value.splice(i, 1);
|
||||
};
|
||||
|
||||
const replaceSprite = (id: string, file: File) => {
|
||||
const i = sprites.value.findIndex(s => s.id === id);
|
||||
if (i === -1) return;
|
||||
const old = sprites.value[i];
|
||||
revokeIfBlob(old.url);
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const next: Sprite = {
|
||||
id: old.id,
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: old.x,
|
||||
y: old.y,
|
||||
};
|
||||
const arr = [...sprites.value];
|
||||
arr[i] = next;
|
||||
sprites.value = arr;
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load replacement image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const s: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
sprites.value = [...sprites.value, s];
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
|
||||
const newSprite: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
const newMaxWidth = Math.max(maxWidth, img.width);
|
||||
const newMaxHeight = Math.max(maxHeight, img.height);
|
||||
|
||||
if (img.width > maxWidth || img.height > maxHeight) {
|
||||
sprites.value = sprites.value.map(sprite => {
|
||||
let newX = sprite.x;
|
||||
let newY = sprite.y;
|
||||
|
||||
if (img.width > maxWidth) {
|
||||
const relativeX = maxWidth > 0 ? sprite.x / maxWidth : 0;
|
||||
newX = Math.floor(relativeX * newMaxWidth);
|
||||
newX = Math.max(0, Math.min(newX, newMaxWidth - sprite.width));
|
||||
}
|
||||
|
||||
if (img.height > maxHeight) {
|
||||
const relativeY = maxHeight > 0 ? sprite.y / maxHeight : 0;
|
||||
newY = Math.floor(relativeY * newMaxHeight);
|
||||
newY = Math.max(0, Math.min(newY, newMaxHeight - sprite.height));
|
||||
}
|
||||
|
||||
return { ...sprite, x: newX, y: newY };
|
||||
});
|
||||
}
|
||||
|
||||
sprites.value = [...sprites.value, newSprite];
|
||||
triggerForceRedraw();
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const processImageFiles = (files: File[]) => {
|
||||
Promise.all(
|
||||
files.map(
|
||||
file =>
|
||||
new Promise<Sprite>(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
})
|
||||
)
|
||||
).then(newSprites => {
|
||||
sprites.value = [...sprites.value, ...newSprites];
|
||||
});
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
sprites.value.forEach(s => revokeIfBlob(s.url));
|
||||
});
|
||||
|
||||
return {
|
||||
sprites,
|
||||
columns,
|
||||
updateSpritePosition,
|
||||
alignSprites,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
addSpriteWithResize,
|
||||
processImageFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMaxDimensions = (arr: Sprite[] | Readonly<Sprite[]>): { maxWidth: number; maxHeight: number } => {
|
||||
let maxWidth = 0;
|
||||
let maxHeight = 0;
|
||||
arr.forEach(s => {
|
||||
if (s.width > maxWidth) maxWidth = s.width;
|
||||
if (s.height > maxHeight) maxHeight = s.height;
|
||||
});
|
||||
return { maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
export const revokeIfBlob = (url?: string) => {
|
||||
if (url && url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
export const triggerForceRedraw = () => {
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('forceRedraw'));
|
||||
}, 0);
|
||||
};
|
||||
83
src/composables/useZoom.ts
Normal file
83
src/composables/useZoom.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export interface ZoomOptionsStep {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
initial?: number;
|
||||
}
|
||||
|
||||
export interface ZoomOptionsAllowed {
|
||||
allowedValues: number[];
|
||||
initial?: number;
|
||||
}
|
||||
|
||||
export type ZoomOptions = ZoomOptionsStep | ZoomOptionsAllowed;
|
||||
|
||||
function isStepOptions(options: ZoomOptions): options is ZoomOptionsStep {
|
||||
return 'step' in options;
|
||||
}
|
||||
|
||||
export function useZoom(options: ZoomOptions) {
|
||||
const initial = options.initial ?? (isStepOptions(options) ? 1 : (options.allowedValues[1] ?? options.allowedValues[0]));
|
||||
const zoom = ref(initial);
|
||||
|
||||
const zoomPercent = computed(() => Math.round(zoom.value * 100));
|
||||
|
||||
const increase = () => {
|
||||
if (isStepOptions(options)) {
|
||||
zoom.value = Math.min(options.max, zoom.value + options.step);
|
||||
} else {
|
||||
const currentIndex = options.allowedValues.indexOf(zoom.value);
|
||||
if (currentIndex < options.allowedValues.length - 1) {
|
||||
zoom.value = options.allowedValues[currentIndex + 1];
|
||||
} else if (currentIndex === -1) {
|
||||
// Find the nearest higher value
|
||||
const higher = options.allowedValues.find(v => v > zoom.value);
|
||||
if (higher !== undefined) {
|
||||
zoom.value = higher;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const decrease = () => {
|
||||
if (isStepOptions(options)) {
|
||||
zoom.value = Math.max(options.min, zoom.value - options.step);
|
||||
} else {
|
||||
const currentIndex = options.allowedValues.indexOf(zoom.value);
|
||||
if (currentIndex > 0) {
|
||||
zoom.value = options.allowedValues[currentIndex - 1];
|
||||
} else if (currentIndex === -1) {
|
||||
// Find the nearest lower value
|
||||
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
|
||||
if (lower !== undefined) {
|
||||
zoom.value = lower;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
zoom.value = initial;
|
||||
};
|
||||
|
||||
const setZoom = (value: number) => {
|
||||
if (isStepOptions(options)) {
|
||||
zoom.value = Math.max(options.min, Math.min(options.max, value));
|
||||
} else {
|
||||
// Snap to nearest allowed value
|
||||
const nearest = options.allowedValues.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
|
||||
zoom.value = nearest;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
zoom,
|
||||
zoomPercent,
|
||||
increase,
|
||||
decrease,
|
||||
reset,
|
||||
setZoom,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { ref, watch } from 'vue';
|
||||
|
||||
const pixelPerfect = ref(true);
|
||||
const darkMode = ref(false);
|
||||
const negativeSpacingEnabled = ref(false);
|
||||
const backgroundColor = ref('transparent');
|
||||
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -51,12 +53,24 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
darkMode.value = value;
|
||||
}
|
||||
|
||||
function toggleNegativeSpacing() {
|
||||
negativeSpacingEnabled.value = !negativeSpacingEnabled.value;
|
||||
}
|
||||
|
||||
function setBackgroundColor(color: string) {
|
||||
backgroundColor.value = color;
|
||||
}
|
||||
|
||||
return {
|
||||
pixelPerfect,
|
||||
darkMode,
|
||||
negativeSpacingEnabled,
|
||||
backgroundColor,
|
||||
togglePixelPerfect,
|
||||
setPixelPerfect,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
toggleNegativeSpacing,
|
||||
setBackgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
26
src/types/sprites.ts
Normal file
26
src/types/sprites.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface Sprite {
|
||||
id: string;
|
||||
file: File;
|
||||
img: HTMLImageElement;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SpriteFile {
|
||||
file: File;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
id: string;
|
||||
name: string;
|
||||
sprites: Sprite[];
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user