Compare commits

5 Commits

Author SHA1 Message Date
aee07f23f2 ## [1.7.0] - 2025-11-22
- Add layer support
- Add background color picker
- Improved UI
2025-11-22 03:19:19 +01:00
474ddd3e27 Perf. fix 2025-11-22 02:58:54 +01:00
097df1f5de POC 2025-11-22 02:52:36 +01:00
5cc4eb8731 [FEAT] negative spacing in JSON export logic, show cell size 2025-11-18 22:18:38 +01:00
57d62db219 Fix for negative spacing 2025-11-18 21:11:26 +01:00
14 changed files with 858 additions and 272 deletions

213
package-lock.json generated
View File

@@ -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",

View File

@@ -1,8 +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
@@ -33,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
@@ -66,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

View File

@@ -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, onMounted, toRef } 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,15 +173,25 @@
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
import GifFpsModal from './components/GifFpsModal.vue';
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
import { useSprites } from './composables/useSprites';
import { useExport } from './composables/useExport';
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';
const settingsStore = useSettingsStore();
const { sprites, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites } = useSprites();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExport(sprites, columns, toRef(settingsStore, 'negativeSpacingEnabled'));
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), activeLayerId, toRef(settingsStore, 'backgroundColor'));
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);
const isFeedbackModalOpen = ref(false);
@@ -200,7 +238,7 @@
};
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;
}
@@ -237,7 +275,7 @@
};
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;
}

View File

@@ -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">
@@ -23,6 +23,18 @@
<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>
@@ -100,7 +112,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, toRef } 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';
@@ -108,8 +120,11 @@
import { useDragSprite } from '@/composables/useDragSprite';
import { useFileDrop } from '@/composables/useFileDrop';
import type { Layer } from '@/types/sprites';
const props = defineProps<{
sprites: Sprite[];
layers: Layer[];
activeLayerId: string;
columns: number;
}>();
@@ -127,6 +142,17 @@
const canvasRef = ref<HTMLCanvasElement | null>(null);
// 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);
@@ -158,7 +184,7 @@
findSpriteAtPosition,
calculateMaxDimensions,
} = useDragSprite({
sprites: toRef(props, 'sprites'),
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
columns: toRef(props, 'columns'),
zoom,
allowCellSwap,
@@ -166,11 +192,13 @@
getMousePosition: (event, z) => canvas2D.getMousePosition(event, z),
onUpdateSprite: (id, x, y) => emit('updateSprite', id, x, y),
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
onDraw: drawCanvas,
onDraw: requestDraw,
});
const activeSprites = computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []);
const { isDragOver, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileDrop({
sprites: props.sprites,
sprites: activeSprites,
onAddSprite: file => emit('addSprite', file),
onAddSpriteWithResize: file => emit('addSpriteWithResize', file),
});
@@ -182,6 +210,7 @@
const contextMenuSpriteId = ref<string | null>(null);
const replacingSpriteId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const customColor = ref('#ffffff');
const startDrag = (event: MouseEvent) => {
if (!canvasRef.value) return;
@@ -280,7 +309,8 @@
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
// Set canvas size
const rows = Math.max(1, Math.ceil(props.sprites.length / props.columns));
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
@@ -307,42 +337,42 @@
// 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
// Position at bottom-right with negative spacing offset
props.sprites.forEach((sprite, spriteIndex) => {
if (spriteIndex !== cellIndex) {
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.3);
}
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);
});
}
}
// 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 with negative spacing offset (bottom-right positioning)
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + 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) {
canvas2D.drawImage(sprite.img, ghostSprite.value.x, ghostSprite.value.y, 0.6);
}
@@ -362,12 +392,13 @@
const imagesWithListeners = new WeakSet<HTMLImageElement>();
const attachImageListeners = () => {
canvas2D.attachImageListeners(props.sprites, handleForceRedraw, imagesWithListeners);
const sprites = props.layers.flatMap(l => l.sprites);
canvas2D.attachImageListeners(sprites, handleForceRedraw, imagesWithListeners);
};
onMounted(() => {
canvas2D.initContext();
drawCanvas();
requestDraw();
// Attach listeners for current sprites
attachImageListeners();
@@ -386,24 +417,27 @@
// Handler for force redraw event
const handleForceRedraw = () => {
canvas2D.ensureIntegerPositions(props.sprites);
// Ensure integer positioning for crisp rendering on the active layer
canvas2D.ensureIntegerPositions(activeSprites.value);
canvas2D.applySmoothing();
drawCanvas();
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(() => settingsStore.negativeSpacingEnabled, 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>

View File

@@ -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' } : {}" />
@@ -157,16 +157,18 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } from 'vue';
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from '@/composables/useSprites';
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;
}>();
@@ -191,8 +193,19 @@
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: () => props.sprites,
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,
});
@@ -200,6 +213,23 @@
const isDraggable = ref(false);
const showAllSprites = ref(false);
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);
const activeSpriteId = ref<string | null>(null);
@@ -209,26 +239,14 @@
// Canvas drawing
// Calculate negative spacing based on sprite dimensions
function calculateNegativeSpacing(): number {
if (!settingsStore.negativeSpacingEnabled || props.sprites.length === 0) return 0;
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
const minWidth = Math.min(...props.sprites.map(s => s.width));
const minHeight = Math.min(...props.sprites.map(s => s.height));
const widthDiff = maxWidth - minWidth;
const heightDiff = maxHeight - minHeight;
return Math.max(widthDiff, heightDiff);
}
function drawPreviewCanvas() {
if (!previewCanvasRef.value || !canvas2D.ctx.value || props.sprites.length === 0) return;
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
const visibleLayers = getVisibleLayers();
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
const currentSprite = props.sprites[currentFrameIndex.value];
if (!currentSprite) return;
const { maxWidth, maxHeight } = getMaxDimensions(props.sprites);
const negativeSpacing = calculateNegativeSpacing();
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;
@@ -244,17 +262,25 @@
// Draw grid background (cell)
canvas2D.fillRect(0, 0, cellWidth, cellHeight, '#f9fafb');
// Draw all sprites with transparency if enabled
if (showAllSprites.value && props.sprites.length > 1) {
props.sprites.forEach((sprite, index) => {
if (index !== currentFrameIndex.value && !hiddenFrames.value.includes(index)) {
const frameIndex = currentFrameIndex.value;
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 negative spacing offset
canvas2D.drawImage(currentSprite.img, negativeSpacing + currentSprite.x, negativeSpacing + 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
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
@@ -271,18 +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 negativeSpacing = calculateNegativeSpacing();
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 (accounting for negative spacing offset)
const spriteCanvasX = negativeSpacing + sprite.x;
const spriteCanvasY = negativeSpacing + sprite.y;
if (sprite && mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + sprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + sprite.height) {
isDragging.value = true;
activeSpriteId.value = sprite.id;
dragStartX.value = mouseX;
dragStartY.value = mouseY;
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
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 };
}
}
};
@@ -299,11 +329,13 @@
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 } = getMaxDimensions(props.sprites);
const negativeSpacing = calculateNegativeSpacing();
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;
@@ -312,8 +344,8 @@
let newY = Math.round(spritePosBeforeDrag.value.y + deltaY);
// Constrain movement within expanded cell (allow negative values up to -negativeSpacing)
newX = Math.max(-negativeSpacing, Math.min(cellWidth - negativeSpacing - sprite.width, newX));
newY = Math.max(-negativeSpacing, Math.min(cellHeight - negativeSpacing - sprite.height, newY));
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();
@@ -370,13 +402,14 @@
// Handler for force redraw event
const handleForceRedraw = () => {
canvas2D.ensureIntegerPositions(props.sprites);
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);
@@ -386,7 +419,7 @@
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
// Initial draw
if (props.sprites.length > 0) {
if (props.layers.some(l => l.sprites.length > 0)) {
drawPreviewCanvas();
}
</script>

View File

@@ -116,9 +116,10 @@ export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?:
});
};
// Fill cell background with theme-aware color
// Fill cell background with selected color or transparent
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
if (settingsStore.backgroundColor === 'transparent') return;
const color = settingsStore.backgroundColor === 'custom' ? settingsStore.backgroundColor : settingsStore.backgroundColor;
fillRect(x, y, width, height, color);
};

View File

@@ -1,6 +1,7 @@
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;
@@ -74,17 +75,8 @@ export function useDragSprite(options: DragSpriteOptions) {
lastMaxWidth.value = baseMaxWidth;
lastMaxHeight.value = baseMaxHeight;
// Calculate negative spacing based on sprite size differences
let negativeSpacing = 0;
if (negativeSpacingEnabled && sprites.length > 0) {
// Find the smallest sprite dimensions
const minWidth = Math.min(...sprites.map(s => s.width));
const minHeight = Math.min(...sprites.map(s => s.height));
// Negative spacing is the difference between max and min dimensions
const widthDiff = baseMaxWidth - minWidth;
const heightDiff = baseMaxHeight - minHeight;
negativeSpacing = Math.max(widthDiff, heightDiff);
}
// Calculate negative spacing using shared composable
const negativeSpacing = calculateNegativeSpacing(sprites, negativeSpacingEnabled);
// Add negative spacing to expand each cell
const maxWidth = baseMaxWidth + negativeSpacing;

View File

@@ -4,19 +4,9 @@ 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>) => {
// Calculate negative spacing based on sprite dimensions
const calculateNegativeSpacing = (): number => {
if (!negativeSpacingEnabled.value || sprites.value.length === 0) return 0;
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const minWidth = Math.min(...sprites.value.map(s => s.width));
const minHeight = Math.min(...sprites.value.map(s => s.height));
const widthDiff = maxWidth - minWidth;
const heightDiff = maxHeight - minHeight;
return Math.max(widthDiff, heightDiff);
};
const downloadSpritesheet = () => {
if (!sprites.value.length) {
alert('Please upload or import sprites before downloading the spritesheet.');
@@ -24,7 +14,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing();
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const rows = Math.ceil(sprites.value.length / columns.value);
@@ -78,7 +68,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
})
);
const jsonData = { columns: columns.value, sprites: spritesData.filter(Boolean) };
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);
@@ -96,6 +90,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
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) {
@@ -147,7 +142,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
}
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing();
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const canvas = document.createElement('canvas');
@@ -161,8 +156,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach(sprite => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, cellWidth, cellHeight);
ctx.drawImage(sprite.img, Math.floor(negativeSpacing + sprite.x), Math.floor(negativeSpacing + sprite.y));
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
});
@@ -187,7 +180,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
const zip = new JSZip();
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
const negativeSpacing = calculateNegativeSpacing();
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
const cellWidth = maxWidth + negativeSpacing;
const cellHeight = maxHeight + negativeSpacing;
const canvas = document.createElement('canvas');
@@ -199,8 +192,6 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
sprites.value.forEach((sprite, index) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, cellWidth, cellHeight);
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]);

View 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 };
};

View File

@@ -1,9 +1,9 @@
import { ref, type Ref } from 'vue';
import { ref, type Ref, type ComputedRef } from 'vue';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
export interface FileDropOptions {
sprites: Ref<Sprite[]> | Sprite[];
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
onAddSprite: (file: File) => void;
onAddSpriteWithResize: (file: File) => void;
}

View 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);
};

View 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);
}

View File

@@ -4,6 +4,7 @@ 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') {
@@ -56,14 +57,20 @@ export const useSettingsStore = defineStore('settings', () => {
negativeSpacingEnabled.value = !negativeSpacingEnabled.value;
}
function setBackgroundColor(color: string) {
backgroundColor.value = color;
}
return {
pixelPerfect,
darkMode,
negativeSpacingEnabled,
backgroundColor,
togglePixelPerfect,
setPixelPerfect,
toggleDarkMode,
setDarkMode,
toggleNegativeSpacing,
setBackgroundColor,
};
});

View File

@@ -16,3 +16,11 @@ export interface SpriteFile {
width: number;
height: number;
}
export interface Layer {
id: string;
name: string;
sprites: Sprite[];
visible: boolean;
locked: boolean;
}