Compare commits
7 Commits
c4a0024ec0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aa01dd044 | |||
| c738f28867 | |||
| a381900356 | |||
| 883c93b7ff | |||
| b255acce40 | |||
| f52530d058 | |||
| bf95f4fbee |
@@ -26,7 +26,7 @@
|
||||
<meta name="title" content="Spritesheet generator - Create Game Spritesheets Online">
|
||||
<meta name="description" content="Free online tool to create spritesheets for game development. Upload sprites, arrange them, and export as a spritesheet with animation preview.">
|
||||
<meta name="keywords" content="Spritesheet generator, sprite sheet maker, game development, pixel art, sprite animation, game assets, 2D game tools">
|
||||
<meta name="author" content="nu11ed">
|
||||
<meta name="author" content="streetshadow">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://spritesheetgenerator.online/">
|
||||
|
||||
@@ -55,9 +55,6 @@
|
||||
data-session-replay="true"
|
||||
defer
|
||||
></script>
|
||||
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3715260871588949"
|
||||
crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
401
package-lock.json
generated
401
package-lock.json
generated
@@ -535,9 +535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz",
|
||||
"integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==",
|
||||
"version": "2.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
|
||||
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
|
||||
"devOptional": true,
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
@@ -1696,9 +1696,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
|
||||
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -1707,36 +1707,36 @@
|
||||
"lightningcss": "1.30.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.17"
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
|
||||
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.17",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.17",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.17",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.17",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.17",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.17",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
|
||||
"integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1750,9 +1750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
|
||||
"integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1766,9 +1766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
|
||||
"integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1782,9 +1782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
|
||||
"integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1798,9 +1798,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
|
||||
"integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1814,9 +1814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
|
||||
"integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1830,9 +1830,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
|
||||
"integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1846,9 +1846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
|
||||
"integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1862,9 +1862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
|
||||
"integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1878,9 +1878,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
|
||||
"integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -1895,10 +1895,10 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.6.0",
|
||||
"@emnapi/runtime": "^1.6.0",
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.0.7",
|
||||
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
@@ -1907,9 +1907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
|
||||
"integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1923,9 +1923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
|
||||
"integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1952,14 +1952,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
|
||||
"integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
|
||||
"integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.1.17",
|
||||
"@tailwindcss/oxide": "4.1.17",
|
||||
"tailwindcss": "4.1.17"
|
||||
"@tailwindcss/node": "4.1.18",
|
||||
"@tailwindcss/oxide": "4.1.18",
|
||||
"tailwindcss": "4.1.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
@@ -1996,9 +1996,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
|
||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2441,9 +2441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
||||
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2461,10 +2461,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.27.0",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001760",
|
||||
"fraction.js": "^5.3.4",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
@@ -2506,9 +2505,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
|
||||
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
|
||||
"version": "2.9.7",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz",
|
||||
"integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2516,9 +2515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz",
|
||||
"integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -2548,9 +2547,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
|
||||
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2568,11 +2567,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
"electron-to-chromium": "^1.5.249",
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.1.4"
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -2629,9 +2628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"version": "1.0.30001760",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
|
||||
"integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2835,16 +2834,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.260",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
|
||||
"integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
||||
"version": "5.18.4",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
@@ -2947,9 +2946,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
|
||||
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3842,16 +3841,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-range": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
|
||||
@@ -4050,9 +4039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pocketbase": {
|
||||
"version": "0.26.3",
|
||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.3.tgz",
|
||||
"integrity": "sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==",
|
||||
"version": "0.26.5",
|
||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.5.tgz",
|
||||
"integrity": "sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
@@ -4262,9 +4251,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.3.tgz",
|
||||
"integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.96.0.tgz",
|
||||
"integrity": "sha512-8u4xqqUeugGNCYwr9ARNtQKTOj4KmYiJAVKXf2CTIivTCR51j96htbMKWDru8H5SaQWpyVgTfOF8Ylyf5pun1Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -4283,9 +4272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.93.3.tgz",
|
||||
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.96.0.tgz",
|
||||
"integrity": "sha512-z9PQ7owvdhn7UuZGrpPccdkcH9xJd9iCv+UQhcPqppBslYEp0R9LRQVyyPTZg7jfA77bGxz/I8V48LXJR5LjXQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4305,30 +4294,30 @@
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded-all-unknown": "1.93.3",
|
||||
"sass-embedded-android-arm": "1.93.3",
|
||||
"sass-embedded-android-arm64": "1.93.3",
|
||||
"sass-embedded-android-riscv64": "1.93.3",
|
||||
"sass-embedded-android-x64": "1.93.3",
|
||||
"sass-embedded-darwin-arm64": "1.93.3",
|
||||
"sass-embedded-darwin-x64": "1.93.3",
|
||||
"sass-embedded-linux-arm": "1.93.3",
|
||||
"sass-embedded-linux-arm64": "1.93.3",
|
||||
"sass-embedded-linux-musl-arm": "1.93.3",
|
||||
"sass-embedded-linux-musl-arm64": "1.93.3",
|
||||
"sass-embedded-linux-musl-riscv64": "1.93.3",
|
||||
"sass-embedded-linux-musl-x64": "1.93.3",
|
||||
"sass-embedded-linux-riscv64": "1.93.3",
|
||||
"sass-embedded-linux-x64": "1.93.3",
|
||||
"sass-embedded-unknown-all": "1.93.3",
|
||||
"sass-embedded-win32-arm64": "1.93.3",
|
||||
"sass-embedded-win32-x64": "1.93.3"
|
||||
"sass-embedded-all-unknown": "1.96.0",
|
||||
"sass-embedded-android-arm": "1.96.0",
|
||||
"sass-embedded-android-arm64": "1.96.0",
|
||||
"sass-embedded-android-riscv64": "1.96.0",
|
||||
"sass-embedded-android-x64": "1.96.0",
|
||||
"sass-embedded-darwin-arm64": "1.96.0",
|
||||
"sass-embedded-darwin-x64": "1.96.0",
|
||||
"sass-embedded-linux-arm": "1.96.0",
|
||||
"sass-embedded-linux-arm64": "1.96.0",
|
||||
"sass-embedded-linux-musl-arm": "1.96.0",
|
||||
"sass-embedded-linux-musl-arm64": "1.96.0",
|
||||
"sass-embedded-linux-musl-riscv64": "1.96.0",
|
||||
"sass-embedded-linux-musl-x64": "1.96.0",
|
||||
"sass-embedded-linux-riscv64": "1.96.0",
|
||||
"sass-embedded-linux-x64": "1.96.0",
|
||||
"sass-embedded-unknown-all": "1.96.0",
|
||||
"sass-embedded-win32-arm64": "1.96.0",
|
||||
"sass-embedded-win32-x64": "1.96.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-all-unknown": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.93.3.tgz",
|
||||
"integrity": "sha512-3okGgnE41eg+CPLtAPletu6nQ4N0ij7AeW+Sl5Km4j29XcmqZQeFwYjHe1AlKTEgLi/UAONk1O8i8/lupeKMbw==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.96.0.tgz",
|
||||
"integrity": "sha512-UfUHoWZtxmsDjDfK+fKCy0aJe6zThu7oaIQx0c/vnHgvprcddEPIay01qTXhiUa3cFcsMmvlBvPTVw0gjKVtVQ==",
|
||||
"cpu": [
|
||||
"!arm",
|
||||
"!arm64",
|
||||
@@ -4338,13 +4327,13 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"sass": "1.93.3"
|
||||
"sass": "1.96.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-android-arm": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.93.3.tgz",
|
||||
"integrity": "sha512-8xOw9bywfOD6Wv24BgCmgjkk6tMrsOTTHcb28KDxeJtFtoxiUyMbxo0vChpPAfp2Hyg2tFFKS60s0s4JYk+Raw==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.96.0.tgz",
|
||||
"integrity": "sha512-0mwVRBFig9hH8vFcRExBuBoR+CfUOcWdwarZwbxIFGI1IyH4BLBGiX85vVn6ssSCVNydpE6lFGm45CN8O0tQig==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4358,9 +4347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-android-arm64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.93.3.tgz",
|
||||
"integrity": "sha512-uqUl3Kt1IqdGVAcAdbmC+NwuUJy8tM+2ZnB7/zrt6WxWVShVCRdFnWR9LT8HJr7eJN7AU8kSXxaVX/gedanPsg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.96.0.tgz",
|
||||
"integrity": "sha512-TJiebTo4TBF5Wrn+lFkUfSN3wazvl8kkFm9a1nA9ZtRdaE0nsJLGnMM6KLQLP2Vl+IOf6ovetZseISkClRoGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4374,9 +4363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-android-riscv64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.93.3.tgz",
|
||||
"integrity": "sha512-2jNJDmo+3qLocjWqYbXiBDnfgwrUeZgZFHJIwAefU7Fn66Ot7rsXl+XPwlokaCbTpj7eMFIqsRAZ/uDueXNCJg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.96.0.tgz",
|
||||
"integrity": "sha512-7AVu/EeJqKN3BGNhm+tc1XzmoqbOtCwHG2VgN6j6Lyqh1JZlx0dglRtyQuKDZ7odTKiWmotEIuYZ6OxLmr2Ejg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4390,9 +4379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-android-x64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.93.3.tgz",
|
||||
"integrity": "sha512-y0RoAU6ZenQFcjM9PjQd3cRqRTjqwSbtWLL/p68y2oFyh0QGN0+LQ826fc0ZvU/AbqCsAizkqjzOn6cRZJxTTQ==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.96.0.tgz",
|
||||
"integrity": "sha512-ei/UsT0q8rF5JzWhn1A7B0M1y/IiWVY3l4zibQrXk5MGaOXHlCM6ffZD+2j7C613Jm9/KAQ7yX1NIIu72LPgDQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4406,9 +4395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-darwin-arm64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.93.3.tgz",
|
||||
"integrity": "sha512-7zb/hpdMOdKteK17BOyyypemglVURd1Hdz6QGsggy60aUFfptTLQftLRg8r/xh1RbQAUKWFbYTNaM47J9yPxYg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.96.0.tgz",
|
||||
"integrity": "sha512-OMvN5NWcrrisC24ZR3GyaWJ1uFxw25qLnUkpEso9TSlaMWiomjU82/uQ/AkQvIMl+EMlJqeYLxZWvq/byLH5Xg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4422,9 +4411,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-darwin-x64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.93.3.tgz",
|
||||
"integrity": "sha512-Ek1Vp8ZDQEe327Lz0b7h3hjvWH3u9XjJiQzveq74RPpJQ2q6d9LfWpjiRRohM4qK6o4XOHw1X10OMWPXJtdtWg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.96.0.tgz",
|
||||
"integrity": "sha512-J/R5sv0eW+/DU98rccHPO1f3lsTFjVTpdkU9d3P1yB7BFmQjw5PYde9BVRlXeOawPwfgT3p/hvY4RELScICdww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4438,9 +4427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-arm": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.93.3.tgz",
|
||||
"integrity": "sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.96.0.tgz",
|
||||
"integrity": "sha512-XuQvV6gNld5Bz3rX0SFLtKPGMu4UQdXNp//9A+bDmtVGZ6yu8REIqphQBxOMpgkAKsA4JZLKKk1N97woeVsIlA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4454,9 +4443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-arm64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.93.3.tgz",
|
||||
"integrity": "sha512-RBrHWgfd8Dd8w4fbmdRVXRrhh8oBAPyeWDTKAWw8ZEmuXfVl4ytjDuyxaVilh6rR1xTRTNpbaA/YWApBlLrrNw==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.96.0.tgz",
|
||||
"integrity": "sha512-VcbVjK0/O/mru0h0FC1WSUWIzMqRrzuJ8eZNMXTs4vApfkh28pxNaUodwU81f1L1nngJ3vpFDBniUKpW6NwJhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4470,9 +4459,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-musl-arm": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.93.3.tgz",
|
||||
"integrity": "sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.96.0.tgz",
|
||||
"integrity": "sha512-qK7FrnczCVECZXtyYOoI3azFlMDZn70GI1yJPPuZLpWvwIPYoZOLv3u6JSec5o3wT6KeKyWG3ZpGIpigLUjPig==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4486,9 +4475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-musl-arm64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.93.3.tgz",
|
||||
"integrity": "sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.96.0.tgz",
|
||||
"integrity": "sha512-lVyLObEeu8Wgw8riC6dSMlkF7jVNAjdZ1jIBhvX1yDsrQwwaI60pM21YXmnZSFyCE6KVFkKAgwRQNO/IkoCwMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4502,9 +4491,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-musl-riscv64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.93.3.tgz",
|
||||
"integrity": "sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.96.0.tgz",
|
||||
"integrity": "sha512-Y+DuGVRsM2zGl268QN5aF/Y6OFYTILb3f+6huEXKlGL6FK2MXadsmeoVbmKVrTamQHzyA2bWWMU1C0jhVFtlzg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4518,9 +4507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-musl-x64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.93.3.tgz",
|
||||
"integrity": "sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.96.0.tgz",
|
||||
"integrity": "sha512-sAQtUQ8fFNxnxSf3fncOh892Hfxa4PW4e5qrnSE0Y1IGV/wsTzk7m5Z6IeT7sa3BsvXh5TFN6+JGbUoOJ5RigA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4534,9 +4523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-riscv64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.93.3.tgz",
|
||||
"integrity": "sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.96.0.tgz",
|
||||
"integrity": "sha512-Bf6bAjuUm6sfGHo0XoZEstjVkEWwmmtOSomGoPuAwXFS9GQnFcqDz9EXKNkZEOsQi2D+aDeDxs8HcU9/OLMT9g==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4550,9 +4539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-linux-x64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.93.3.tgz",
|
||||
"integrity": "sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.96.0.tgz",
|
||||
"integrity": "sha512-U4GROkS0XM6ekqs/ubroWwFAGY9N35wqrt5q6Y+MJCpTK5bHPHlgFo7J75ZUSaEObL+UrDqvMDQkCdYEFiiQbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4566,9 +4555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-unknown-all": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.93.3.tgz",
|
||||
"integrity": "sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.96.0.tgz",
|
||||
"integrity": "sha512-OHzGEr2VElK2SaQdkkTX0O0KwTbiv1N/EhnHgzXYaZWOTvv0gxEfR7q7x/oScCBIZc2x8dSfvThfBnohIClo/w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4578,13 +4567,13 @@
|
||||
"!win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"sass": "1.93.3"
|
||||
"sass": "1.96.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-win32-arm64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.93.3.tgz",
|
||||
"integrity": "sha512-0dOfT9moy9YmBolodwYYXtLwNr4jL4HQC9rBfv6mVrD7ud8ue2kDbn+GVzj1hEJxvEexVSmDCf7MHUTLcGs9xQ==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.96.0.tgz",
|
||||
"integrity": "sha512-KKz1h5pr45fwrKcxrxHsujo3f/HgVkX64YNJ9PRPuOuX7lU8g18IEgDxoTGQ64PPBQ5RXOt6jxpT+x2OLPVnCw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4598,9 +4587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-embedded-win32-x64": {
|
||||
"version": "1.93.3",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.93.3.tgz",
|
||||
"integrity": "sha512-wHFVfxiS9hU/sNk7KReD+lJWRp3R0SLQEX4zfOnRP2zlvI2X4IQR5aZr9GNcuMP6TmNpX0nQPZTegS8+h9RrEg==",
|
||||
"version": "1.96.0",
|
||||
"resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.96.0.tgz",
|
||||
"integrity": "sha512-MDreKaWcgiyKD5YPShaRvUBoe5dC2y8IPJK49G7iQjoMfw9INDCBkDdLcz00Mn0eJq4nJJp5UEE98M6ljIrBRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4765,9 +4754,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
|
||||
"integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==",
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
@@ -4816,9 +4805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -4969,9 +4958,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
||||
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
|
||||
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5232,9 +5221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
||||
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.9.0] - 2025-12-14
|
||||
- You can now share spritesheets to edit them later or share them with others
|
||||
|
||||
## [1.8.0] - 2025-11-23
|
||||
- Fix context menu location
|
||||
- You can now reposition all sprites in current frame
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
google.com, pub-3715260871588949, DIRECT, f08c47fec0942fa0
|
||||
@@ -78,7 +78,7 @@
|
||||
import Breadcrumbs from './components/Breadcrumbs.vue';
|
||||
import { useLayers } from './composables/useLayers';
|
||||
|
||||
const { layers } = useLayers();
|
||||
const { layers, hasSprites } = useLayers();
|
||||
|
||||
const isHelpModalOpen = ref(false);
|
||||
const isFeedbackModalOpen = ref(false);
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
/* Dark mode transition */
|
||||
html,
|
||||
body {
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on mobile */
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<div class="max-w-none text-gray-700 dark:text-gray-300">
|
||||
<p class="mb-4 text-base leading-relaxed">Spritesheet generator is a free, open-source tool for creating spritesheets for game development and animation projects. This tool allows you to upload individual sprite images and arrange them into a spritesheet with customizable layout.</p>
|
||||
<p class="mb-4 text-base leading-relaxed">Matrix: root@adhd.sh, Discord: nu11ed</p>
|
||||
<p class="mb-4 text-base leading-relaxed">Matrix: root@adhd.sh, Discord: streetshadow</p>
|
||||
<h4 class="mt-6 mb-3 text-lg font-medium text-gray-900 dark:text-gray-100">How to use:</h4>
|
||||
<ol class="list-decimal pl-6 space-y-2 mb-4">
|
||||
<li>Upload your sprite images by dragging and dropping them or clicking the upload area</li>
|
||||
@@ -56,6 +56,16 @@
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Ko-fi -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center mb-3">
|
||||
<h4 class="text-md font-medium">Ko-fi</h4>
|
||||
</div>
|
||||
<a href="https://ko-fi.com/X8X416D44P" target="_blank" rel="noopener noreferrer" data-rybbit-event="open-kofi">
|
||||
<img height="36" style="border: 0px; height: 36px" src="https://storage.ko-fi.com/cdn/kofi6.png?v=6" alt="Buy Me a Coffee at ko-fi.com" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- PayPal -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div class="flex items-center mb-3">
|
||||
|
||||
119
src/components/ShareModal.vue
Normal file
119
src/components/ShareModal.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="close" title="Share spritesheet" :initialWidth="500" :initialHeight="280">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex-1 overflow-auto p-4 space-y-4 dark:bg-gray-800">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-8">
|
||||
<i class="fas fa-circle-notch fa-spin text-3xl text-gray-400 dark:text-gray-500 mb-3"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">Uploading spritesheet...</p>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div v-else-if="shareUrl" class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">Your spritesheet is ready to share! Copy the link below:</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" :value="shareUrl" readonly class="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm font-mono" @focus="($event.target as HTMLInputElement).select()" />
|
||||
<button @click="copyToClipboard" class="px-4 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition-colors" :title="copied ? 'Copied!' : 'Copy to clipboard'">
|
||||
<i :class="copied ? 'fas fa-check' : 'fas fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="copied" class="text-sm text-green-600 dark:text-green-400">Link copied to clipboard!</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="space-y-4">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<button @click="retry" class="px-4 py-2 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 text-sm font-medium transition-colors"><i class="fas fa-redo mr-2"></i>Try again</button>
|
||||
</div>
|
||||
|
||||
<!-- Initial state (shouldn't normally be visible) -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-8">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">Preparing to share...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 p-3 flex justify-end">
|
||||
<button type="button" class="px-4 py-2 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100" @click="close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
shareFunction: () => Promise<{ id: string; url: string }>;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const shareUrl = ref('');
|
||||
const error = ref('');
|
||||
const copied = ref(false);
|
||||
|
||||
const close = () => emit('close');
|
||||
|
||||
const performShare = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
shareUrl.value = '';
|
||||
copied.value = false;
|
||||
|
||||
try {
|
||||
const result = await props.shareFunction();
|
||||
shareUrl.value = result.url;
|
||||
} catch (e: any) {
|
||||
console.error('Failed to share spritesheet:', e);
|
||||
error.value = 'Failed to share spritesheet. Please try again later.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
performShare();
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const input = document.createElement('input');
|
||||
input.value = shareUrl.value;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Start sharing when modal opens
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
isOpen => {
|
||||
if (isOpen) {
|
||||
performShare();
|
||||
} else {
|
||||
// Reset state when closing
|
||||
loading.value = false;
|
||||
shareUrl.value = '';
|
||||
error.value = '';
|
||||
copied.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="showContextMenu" class="fixed bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||
<div v-if="showContextMenu" @click.stop class="fixed bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-xl shadow-2xl z-50 py-2 min-w-[200px] overflow-hidden" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }">
|
||||
<button @click="addSprite" class="w-full px-5 py-3 text-left hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-3 transition-colors font-medium">
|
||||
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
|
||||
<span>Add Sprite</span>
|
||||
@@ -448,6 +448,11 @@
|
||||
});
|
||||
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
// If the click originated from an interactive element (button, link, input), ignore drag handling
|
||||
const target = event.target as HTMLElement;
|
||||
if (target && target.closest('button, a, input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
if (!gridContainerRef.value) return;
|
||||
|
||||
// Hide context menu if open
|
||||
@@ -476,8 +481,22 @@
|
||||
dragStart(event);
|
||||
};
|
||||
|
||||
const pendingDrag = ref(false);
|
||||
const latestEvent = ref<MouseEvent | null>(null);
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
dragMove(event);
|
||||
// Store the latest event and schedule a single animation frame update
|
||||
latestEvent.value = event;
|
||||
if (!pendingDrag.value) {
|
||||
pendingDrag.value = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (latestEvent.value) {
|
||||
dragMove(latestEvent.value);
|
||||
}
|
||||
pendingDrag.value = false;
|
||||
latestEvent.value = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeSprite = () => {
|
||||
@@ -534,12 +553,11 @@
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Hide context menu when clicking elsewhere
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', hideContextMenu);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
});
|
||||
|
||||
// Watch for background color changes
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="spritesheet-preview w-full h-full">
|
||||
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
|
||||
<div class="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<div
|
||||
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[200px] sm:min-h-[300px] max-h-[50vh] lg:max-h-none shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@@ -82,10 +82,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div class="lg:w-80 xl:w-96 flex-shrink-0 lg:h-full lg:min-h-0 flex flex-col">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden flex-1 flex flex-col lg:overflow-y-auto">
|
||||
<!-- Playback Controls -->
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700 flex-shrink-0">
|
||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Playback</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="togglePlayback" class="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-lg transition-all cursor-pointer flex-1 shadow-sm active:scale-95">
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Animation Settings -->
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5">
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5 flex-shrink-0">
|
||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
|
||||
|
||||
<!-- Frame Navigation -->
|
||||
@@ -131,7 +131,7 @@
|
||||
</div>
|
||||
|
||||
<!-- View Options -->
|
||||
<div class="p-4 space-y-5">
|
||||
<div class="p-4 space-y-5 flex-1">
|
||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">View Options</h3>
|
||||
|
||||
<!-- Zoom Control -->
|
||||
@@ -185,7 +185,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Current frame offset display -->
|
||||
<div v-if="currentFrameSprite" class="px-4 pb-4">
|
||||
<div v-if="currentFrameSprite" class="px-4 pb-4 flex-shrink-0">
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Offset</span>
|
||||
<span class="text-xs font-mono font-bold text-gray-700 dark:text-gray-200">X: {{ currentFrameSprite.x }} <span class="text-gray-300 dark:text-gray-600 mx-1">|</span> Y: {{ currentFrameSprite.y }}</span>
|
||||
@@ -193,7 +193,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Frame Selection (when Compare sprites is enabled) -->
|
||||
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4">
|
||||
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4 flex-shrink-0">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Visible Frames</h3>
|
||||
<div class="flex gap-1">
|
||||
@@ -513,4 +513,36 @@
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for controls panel */
|
||||
.custom-scrollbar::-webkit-scrollbar,
|
||||
.lg\:overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track,
|
||||
.lg\:overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
.lg\:overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
.lg\:overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.8);
|
||||
}
|
||||
|
||||
:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
:global(.dark) .lg\:overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
:global(.dark) .lg\:overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,92 @@
|
||||
<template>
|
||||
<Modal :is-open="isOpen" @close="cancel" title="Split spritesheet">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-48 sm:max-h-64 border border-gray-300 dark:border-gray-600 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="detection-method" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Detection Method</label>
|
||||
<select
|
||||
id="detection-method"
|
||||
v-model="detectionMethod"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
data-rybbit-event="spritesheet-detection-method"
|
||||
>
|
||||
<option value="irregular">Auto-detect</option>
|
||||
<option value="manual">Manual (specify rows and columns)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'auto' || detectionMethod === 'irregular'" class="space-y-2">
|
||||
<label for="sensitivity" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Detection Sensitivity</label>
|
||||
<input type="range" id="sensitivity" v-model="sensitivity" min="1" max="100" class="w-full dark:accent-blue-400" data-rybbit-event="spritesheet-sensitivity" />
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex justify-between">
|
||||
<span>Low</span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'manual'" class="space-y-2">
|
||||
<label for="rows" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Rows</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rows"
|
||||
v-model.number="rows"
|
||||
min="1"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
data-rybbit-event="spritesheet-rows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="detectionMethod === 'manual'" class="space-y-2">
|
||||
<label for="columns" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Columns</label>
|
||||
<input
|
||||
type="number"
|
||||
id="columns"
|
||||
v-model.number="columns"
|
||||
min="1"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
data-rybbit-event="spritesheet-columns"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Preview Image -->
|
||||
<div class="flex items-center justify-center">
|
||||
<img :src="imageUrl" alt="Spritesheet" class="max-w-full max-h-48 sm:max-h-64 border border-gray-300 dark:border-gray-600 rounded-lg" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
|
||||
<!-- Detection Mode -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 dark:text-blue-400 focus:ring-blue-500 dark:focus:ring-blue-400 border-gray-300 dark:border-gray-600 rounded" data-rybbit-event="spritesheet-remove-empty" />
|
||||
<label for="remove-empty" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Remove empty sprites (transparent/background color) </label>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Mode </label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button @click="detectionMode = 'grid'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'grid' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Grid</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Split by cell size</div>
|
||||
</button>
|
||||
<button @click="detectionMode = 'auto'" :class="['px-4 py-3 rounded-lg border-2 text-left transition-all', detectionMode === 'auto' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500']">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Auto-detect</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Find individual sprites</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewSprites.length > 0" class="space-y-2">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Preview ({{ previewSprites.length }} sprites)</h3>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-96 overflow-y-auto p-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800">
|
||||
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 dark:border-gray-600 rounded bg-gray-100 dark:bg-gray-700 flex items-center justify-center" :style="{ width: '80px', height: '80px' }">
|
||||
<img :src="sprite.url" alt="Sprite preview" class="max-w-full max-h-full" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
<!-- Grid Mode Options -->
|
||||
<div v-if="detectionMode === 'grid'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label for="cell-width" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Cell Width (px) </label>
|
||||
<input type="number" id="cell-width" v-model.number="cellWidth" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="cell-height" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Cell Height (px) </label>
|
||||
<input type="number" id="cell-height" v-model.number="cellHeight" min="1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="preserve-cell-size" v-model="preserveCellSize" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label for="preserve-cell-size" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Preserve cell size (keep transparent padding) </label>
|
||||
</div>
|
||||
|
||||
<div v-if="imageElement" class="text-xs text-gray-500 dark:text-gray-400">Image: {{ imageElement.width }}×{{ imageElement.height }}px → {{ gridCols }}×{{ gridRows }} cells</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-detect Options -->
|
||||
<div v-if="detectionMode === 'auto'" class="space-y-2">
|
||||
<label for="sensitivity" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Detection Sensitivity </label>
|
||||
<input type="range" id="sensitivity" v-model.number="sensitivity" min="1" max="100" class="w-full dark:accent-blue-400" />
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex justify-between">
|
||||
<span>Low (more sprites)</span>
|
||||
<span>High (fewer sprites)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Options -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="remove-empty" v-model="removeEmpty" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label for="remove-empty" class="ml-2 block text-sm text-gray-700 dark:text-gray-300"> Remove empty sprites </label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-if="previewSprites.length > 0 || isProcessing" class="space-y-2">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<span v-if="isProcessing">Detecting sprites...</span>
|
||||
<span v-else>Preview ({{ previewSprites.length }} sprites)</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2 max-h-80 overflow-y-auto p-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div v-for="(sprite, index) in previewSprites" :key="index" class="relative border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 flex items-center justify-center aspect-square">
|
||||
<img :src="sprite.url" alt="Sprite" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end space-y-3 space-y-reverse sm:space-y-0 sm:space-x-3">
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="cancel"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800"
|
||||
data-rybbit-event="spritesheet-cancel"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800"
|
||||
:disabled="previewSprites.length === 0 || isProcessing"
|
||||
data-rybbit-event="spritesheet-split"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Split spritesheet
|
||||
{{ isProcessing ? 'Processing...' : 'Split spritesheet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,19 +94,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
import { ref, watch, computed, onUnmounted } from 'vue';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import { useSpritesheetSplitter } from '@/composables/useSpritesheetSplitter';
|
||||
import type { SpriteFile } from '@/types/sprites';
|
||||
|
||||
interface SpritePreview {
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
import type { DetectionMode, SpritePreview } from '@/types/spritesheet';
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean;
|
||||
@@ -115,93 +109,80 @@
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'split', sprites: SpriteFile[]): void; // Change from File[] to SpriteFile[]
|
||||
(e: 'split', sprites: SpriteFile[]): void;
|
||||
}>();
|
||||
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
const splitter = useSpritesheetSplitter();
|
||||
|
||||
// State
|
||||
const detectionMethod = ref<'manual' | 'auto' | 'irregular'>('irregular');
|
||||
const rows = ref(1);
|
||||
const columns = ref(1);
|
||||
const detectionMode = ref<DetectionMode>('grid');
|
||||
const cellWidth = ref(64);
|
||||
const cellHeight = ref(64);
|
||||
const sensitivity = ref(50);
|
||||
const removeEmpty = ref(true);
|
||||
const preserveCellSize = ref(false);
|
||||
const previewSprites = ref<SpritePreview[]>([]);
|
||||
const isProcessing = ref(false);
|
||||
const imageElement = ref<HTMLImageElement | null>(null);
|
||||
const irregularWorker = ref<Worker | null>(null);
|
||||
|
||||
// Cache for sprite detection results
|
||||
const detectionCache = new Map<string, SpritePreview[]>();
|
||||
// Computed
|
||||
const gridCols = computed(() => (imageElement.value && cellWidth.value > 0 ? Math.floor(imageElement.value.width / cellWidth.value) : 0));
|
||||
|
||||
// Generate cache key for current detection settings
|
||||
function getCacheKey(url: string, method: string, sensitivity: number, removeEmpty: boolean): string {
|
||||
return `${url}-${method}-${sensitivity}-${removeEmpty}`;
|
||||
}
|
||||
const gridRows = computed(() => (imageElement.value && cellHeight.value > 0 ? Math.floor(imageElement.value.height / cellHeight.value) : 0));
|
||||
|
||||
// Load the image when the component is mounted or the URL changes
|
||||
watch(() => props.imageUrl, loadImage, { immediate: true });
|
||||
// Load image and set initial cell size
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
url => {
|
||||
if (!url) return;
|
||||
|
||||
function loadImage() {
|
||||
if (!props.imageUrl) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageElement.value = img;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageElement.value = img;
|
||||
// Set suggested cell size
|
||||
const suggested = splitter.getSuggestedCellSize(img.width, img.height);
|
||||
cellWidth.value = suggested.width;
|
||||
cellHeight.value = suggested.height;
|
||||
|
||||
// Set default rows and columns based on image dimensions
|
||||
// This is a simple heuristic - for pixel art, we might want to detect sprite size
|
||||
const aspectRatio = img.width / img.height;
|
||||
generatePreview();
|
||||
};
|
||||
img.src = url;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape orientation - likely more columns than rows
|
||||
columns.value = Math.min(Math.ceil(Math.sqrt(aspectRatio * 4)), 8);
|
||||
rows.value = Math.ceil(4 / columns.value);
|
||||
} else {
|
||||
// Portrait orientation - likely more rows than columns
|
||||
rows.value = Math.min(Math.ceil(Math.sqrt(4 / aspectRatio)), 8);
|
||||
columns.value = Math.ceil(4 / rows.value);
|
||||
}
|
||||
|
||||
// Generate initial preview
|
||||
// Regenerate preview when options change
|
||||
watch([detectionMode, cellWidth, cellHeight, sensitivity, removeEmpty, preserveCellSize], () => {
|
||||
if (imageElement.value) {
|
||||
generatePreview();
|
||||
};
|
||||
img.src = props.imageUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Generate preview of split sprites with caching
|
||||
// Generate preview
|
||||
async function generatePreview() {
|
||||
if (!imageElement.value) return;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(props.imageUrl, detectionMethod.value, sensitivity.value, removeEmpty.value);
|
||||
|
||||
if (detectionCache.has(cacheKey)) {
|
||||
previewSprites.value = detectionCache.get(cacheKey)!;
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
previewSprites.value = [];
|
||||
|
||||
try {
|
||||
const img = imageElement.value;
|
||||
|
||||
if (detectionMethod.value === 'auto') {
|
||||
await autoDetectSprites(img);
|
||||
} else if (detectionMethod.value === 'irregular') {
|
||||
await detectIrregularSprites(img);
|
||||
if (detectionMode.value === 'grid') {
|
||||
previewSprites.value = await splitter.splitByGrid(img, {
|
||||
cellWidth: cellWidth.value,
|
||||
cellHeight: cellHeight.value,
|
||||
preserveCellSize: preserveCellSize.value,
|
||||
removeEmpty: removeEmpty.value,
|
||||
});
|
||||
} else {
|
||||
await splitSpritesheet(img, rows.value, columns.value);
|
||||
previewSprites.value = await splitter.detectSprites(img, {
|
||||
sensitivity: sensitivity.value,
|
||||
removeEmpty: removeEmpty.value,
|
||||
});
|
||||
}
|
||||
|
||||
// Cache results (limit cache size to prevent memory issues)
|
||||
if (detectionCache.size > 10) {
|
||||
const firstKey = detectionCache.keys().next().value;
|
||||
detectionCache.delete(firstKey || '');
|
||||
}
|
||||
detectionCache.set(cacheKey, previewSprites.value);
|
||||
} catch (error) {
|
||||
console.error('Error generating preview:', error);
|
||||
} finally {
|
||||
@@ -209,636 +190,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getSpriteBoundingBox(ctx: CanvasRenderingContext2D, width: number, height: number) {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
let minX = width;
|
||||
let minY = height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let hasContent = false;
|
||||
|
||||
// Scan through all pixels to find the bounding box
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
// Check if pixel is not transparent (alpha > 0)
|
||||
if (data[idx + 3] > 10) {
|
||||
// Allow some tolerance for compression artifacts
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-transparent pixels found, return null
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return bounding box with a small padding, ensuring it stays within bounds
|
||||
const bx = Math.max(0, minX - 1);
|
||||
const by = Math.max(0, minY - 1);
|
||||
const bw = Math.min(width - bx, maxX - minX + 3); // +1 for inclusive bounds, +2 for padding
|
||||
const bh = Math.min(height - by, maxY - minY + 3);
|
||||
|
||||
return {
|
||||
x: bx,
|
||||
y: by,
|
||||
width: Math.max(1, bw),
|
||||
height: Math.max(1, bh),
|
||||
};
|
||||
}
|
||||
|
||||
// Split spritesheet manually based on rows and columns
|
||||
async function splitSpritesheet(img: HTMLImageElement, rows: number, columns: number) {
|
||||
const safeColumns = Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 1;
|
||||
const safeRows = Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1;
|
||||
const spriteWidth = Math.max(1, Math.floor(img.width / safeColumns));
|
||||
const spriteHeight = Math.max(1, Math.floor(img.height / safeRows));
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
// Create a canvas for processing the full sprite
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Create a second canvas for the cropped sprite
|
||||
const croppedCanvas = document.createElement('canvas');
|
||||
const croppedCtx = croppedCanvas.getContext('2d');
|
||||
|
||||
if (!ctx || !croppedCtx) return;
|
||||
|
||||
canvas.width = spriteWidth;
|
||||
canvas.height = spriteHeight;
|
||||
|
||||
// Split the image into individual sprites
|
||||
for (let row = 0; row < safeRows; row++) {
|
||||
for (let col = 0; col < safeColumns; col++) {
|
||||
// Clear the canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw the portion of the spritesheet
|
||||
ctx.drawImage(img, col * spriteWidth, row * spriteHeight, spriteWidth, spriteHeight, 0, 0, spriteWidth, spriteHeight);
|
||||
|
||||
// Check if the sprite is empty (all transparent or same color)
|
||||
const isEmpty = removeEmpty.value ? isCanvasEmpty(ctx, spriteWidth, spriteHeight) : false;
|
||||
|
||||
// If we're not removing empty sprites or the sprite is not empty
|
||||
if (!removeEmpty.value || !isEmpty) {
|
||||
// Get bounding box of non-transparent pixels
|
||||
const boundingBox = getSpriteBoundingBox(ctx, spriteWidth, spriteHeight);
|
||||
|
||||
let url;
|
||||
let x = 0; // Default position (will be updated if we have a bounding box)
|
||||
let y = 0;
|
||||
let width = spriteWidth;
|
||||
let height = spriteHeight;
|
||||
|
||||
if (boundingBox) {
|
||||
// The key change: preserve the original position where the sprite was found
|
||||
x = boundingBox.x;
|
||||
y = boundingBox.y;
|
||||
width = boundingBox.width;
|
||||
height = boundingBox.height;
|
||||
|
||||
// Set dimensions for the cropped sprite
|
||||
croppedCanvas.width = boundingBox.width;
|
||||
croppedCanvas.height = boundingBox.height;
|
||||
|
||||
// Draw only the non-transparent part
|
||||
croppedCtx.clearRect(0, 0, croppedCanvas.width, croppedCanvas.height);
|
||||
croppedCtx.drawImage(canvas, boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height, 0, 0, boundingBox.width, boundingBox.height);
|
||||
|
||||
// Convert to data URL
|
||||
url = croppedCanvas.toDataURL('image/png');
|
||||
} else {
|
||||
// No non-transparent pixels found, use the original sprite
|
||||
url = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
sprites.push({
|
||||
url,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewSprites.value = sprites;
|
||||
}
|
||||
|
||||
// Auto-detect sprites based on transparency/color differences
|
||||
async function autoDetectSprites(img: HTMLImageElement) {
|
||||
// This is a simplified implementation
|
||||
// A more sophisticated algorithm would analyze the image to find sprite boundaries
|
||||
|
||||
// For now, we'll use a simple approach:
|
||||
// 1. Try to detect the sprite size by looking for repeating patterns
|
||||
// 2. Then use that size to split the spritesheet
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Get image data for analysis
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Simple detection of sprite size based on transparency patterns
|
||||
// This is a very basic implementation and might not work for all spritesheets
|
||||
const { detectedWidth, detectedHeight } = detectSpriteSize(data, canvas.width, canvas.height);
|
||||
|
||||
if (detectedWidth > 0 && detectedHeight > 0) {
|
||||
// Sanity thresholds to avoid absurdly tiny tiles/huge counts
|
||||
const MIN_TILE = 8;
|
||||
const MAX_SPRITES = 1024;
|
||||
if (detectedWidth < MIN_TILE || detectedHeight < MIN_TILE) {
|
||||
// Fallback if tile is too small
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
const detectedRows = Math.max(1, Math.floor(img.height / detectedHeight));
|
||||
const detectedColumns = Math.max(1, Math.floor(img.width / detectedWidth));
|
||||
|
||||
// If the detected combination is unreasonably high, fallback to estimate
|
||||
if (detectedRows * detectedColumns > MAX_SPRITES) {
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(MIN_TILE, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the detected size to split the spritesheet
|
||||
await splitSpritesheet(img, detectedRows, detectedColumns);
|
||||
} else {
|
||||
// Fallback to manual splitting with a reasonable guess based on sensitivity
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
const divisor = 3 + Math.round(s / 20); // 3..8
|
||||
const estimatedSize = Math.max(8, Math.floor(Math.min(img.width, img.height) / divisor));
|
||||
const estimatedRows = Math.max(1, Math.floor(img.height / estimatedSize));
|
||||
const estimatedColumns = Math.max(1, Math.floor(img.width / estimatedSize));
|
||||
|
||||
await splitSpritesheet(img, estimatedRows, estimatedColumns);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to detect sprite size based on transparency/color gutters and edge periodicity
|
||||
function detectSpriteSize(data: Uint8ClampedArray, width: number, height: number) {
|
||||
// Map sensitivity (1-100) to thresholds:
|
||||
// Higher sensitivity -> allows stricter background matching and lower gutter proportion thresholds
|
||||
const s = Math.max(1, Math.min(100, sensitivity.value));
|
||||
|
||||
// Background/color thresholds
|
||||
const alphaBgThresh = Math.round(255 * (0.15 + (100 - s) * 0.001)); // 15%-25% depending on sensitivity
|
||||
const colorTol = Math.round(10 + (100 - s) * 0.8); // 10..90 Euclidean approx
|
||||
const gutterPropThresh = 0.92 - s * 0.004; // 0.92 down to ~0.52
|
||||
|
||||
function getPixel(x: number, y: number) {
|
||||
const idx = (y * width + x) * 4;
|
||||
return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]] as [number, number, number, number];
|
||||
}
|
||||
|
||||
// Estimate background color from corners (median of corners)
|
||||
const corners: [number, number, number, number][] = [getPixel(0, 0), getPixel(width - 1, 0), getPixel(0, height - 1), getPixel(width - 1, height - 1)];
|
||||
function median(arr: number[]) {
|
||||
const a = arr.slice().sort((a, b) => a - b);
|
||||
const mid = (a.length - 1) / 2;
|
||||
return (a[Math.floor(mid)] + a[Math.ceil(mid)]) / 2;
|
||||
}
|
||||
const bg = [median(corners.map(c => c[0])), median(corners.map(c => c[1])), median(corners.map(c => c[2])), median(corners.map(c => c[3]))] as [number, number, number, number];
|
||||
|
||||
function isBg(r: number, g: number, b: number, a: number) {
|
||||
if (a <= alphaBgThresh) return true;
|
||||
const dr = r - bg[0];
|
||||
const dg = g - bg[1];
|
||||
const db = b - bg[2];
|
||||
// Use Manhattan distance approximation for speed
|
||||
const manhattan = Math.abs(dr) + Math.abs(dg) + Math.abs(db);
|
||||
// Normalize approx to 0..~765 and compare to scaled tolerance
|
||||
return manhattan <= colorTol * 3;
|
||||
}
|
||||
|
||||
// Sample stride to speed up scanning large sheets
|
||||
const rowSample = Math.max(1, Math.floor(height / 64));
|
||||
const colSample = Math.max(1, Math.floor(width / 64));
|
||||
|
||||
// Compute background proportion per column and row
|
||||
const colBgProp: number[] = new Array(width).fill(0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let y = 0; y < height; y += rowSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
colBgProp[x] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
const rowBgProp: number[] = new Array(height).fill(0);
|
||||
for (let y = 0; y < height; y++) {
|
||||
let bgCount = 0;
|
||||
let total = 0;
|
||||
for (let x = 0; x < width; x += colSample) {
|
||||
const [r, g, b, a] = getPixel(x, y);
|
||||
if (isBg(r, g, b, a)) bgCount++;
|
||||
total++;
|
||||
}
|
||||
rowBgProp[y] = total > 0 ? bgCount / total : 1;
|
||||
}
|
||||
|
||||
function extractRuns(bgProp: number[]): { emptyRuns: [number, number][]; segSizes: number[] } {
|
||||
const emptyRuns: [number, number][] = [];
|
||||
const segSizes: number[] = [];
|
||||
let inEmpty = false;
|
||||
let runStart = 0;
|
||||
let lastSeparatorEnd = -1;
|
||||
for (let i = 0; i < bgProp.length; i++) {
|
||||
const isEmpty = bgProp[i] >= gutterPropThresh;
|
||||
if (isEmpty && !inEmpty) {
|
||||
inEmpty = true;
|
||||
runStart = i;
|
||||
if (lastSeparatorEnd >= 0) {
|
||||
const seg = runStart - lastSeparatorEnd - 1;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
} else if (!isEmpty && inEmpty) {
|
||||
inEmpty = false;
|
||||
emptyRuns.push([runStart, i - 1]);
|
||||
lastSeparatorEnd = i - 1;
|
||||
}
|
||||
}
|
||||
if (inEmpty) {
|
||||
emptyRuns.push([runStart, bgProp.length - 1]);
|
||||
lastSeparatorEnd = bgProp.length - 1;
|
||||
}
|
||||
// Trailing segment after last empty run
|
||||
if (lastSeparatorEnd >= 0 && lastSeparatorEnd < bgProp.length - 1) {
|
||||
const seg = bgProp.length - 1 - lastSeparatorEnd;
|
||||
if (seg > 0) segSizes.push(seg);
|
||||
}
|
||||
return { emptyRuns, segSizes };
|
||||
}
|
||||
|
||||
function modeWithTolerance(values: number[], tol = 2): number {
|
||||
if (values.length === 0) return 0;
|
||||
values.sort((a, b) => a - b);
|
||||
let bestCount = 0;
|
||||
let bestVal = values[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const base = values[i];
|
||||
let count = 1;
|
||||
for (let j = i + 1; j < values.length; j++) {
|
||||
if (Math.abs(values[j] - base) <= tol) count++;
|
||||
else break;
|
||||
}
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestVal = base;
|
||||
}
|
||||
}
|
||||
return bestVal;
|
||||
}
|
||||
|
||||
const colRuns = extractRuns(colBgProp);
|
||||
const rowRuns = extractRuns(rowBgProp);
|
||||
|
||||
let detectedWidth = modeWithTolerance(colRuns.segSizes, 2);
|
||||
let detectedHeight = modeWithTolerance(rowRuns.segSizes, 2);
|
||||
|
||||
// Fallback: use edge periodicity via autocorrelation if gutters not found
|
||||
function edgeAutocorrLength(axis: 'x' | 'y'): number {
|
||||
const maxLen = axis === 'x' ? width : height;
|
||||
const otherLen = axis === 'x' ? height : width;
|
||||
const sampleStepMajor = Math.max(1, Math.floor(maxLen / 512));
|
||||
const sampleStepMinor = Math.max(1, Math.floor(otherLen / 64));
|
||||
const energy: number[] = new Array(maxLen).fill(0);
|
||||
if (axis === 'x') {
|
||||
for (let x = 0; x < maxLen - 1; x += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let y = 0; y < otherLen; y += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x + 1, y);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[x] = e;
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < maxLen - 1; y += sampleStepMajor) {
|
||||
let e = 0;
|
||||
for (let x = 0; x < otherLen; x += sampleStepMinor) {
|
||||
const [r1, g1, b1, a1] = getPixel(x, y);
|
||||
const [r2, g2, b2, a2] = getPixel(x, y + 1);
|
||||
e += Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) + Math.abs(a1 - a2);
|
||||
}
|
||||
energy[y] = e;
|
||||
}
|
||||
}
|
||||
const minTile = Math.max(3, Math.floor(Math.min(maxLen / 32, 128)));
|
||||
const maxTile = Math.max(minTile + 1, Math.floor(Math.min(maxLen / 2, 512)));
|
||||
let bestLag = 0;
|
||||
let bestVal = -Infinity;
|
||||
for (let lag = minTile; lag <= maxTile; lag++) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i + lag < energy.length; i++) {
|
||||
const e1 = energy[i] || 0;
|
||||
const e2 = energy[i + lag] || 0;
|
||||
sum += e1 * e2;
|
||||
}
|
||||
if (sum > bestVal) {
|
||||
bestVal = sum;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
return bestLag;
|
||||
}
|
||||
|
||||
if (detectedWidth <= 0 || detectedWidth > width) {
|
||||
const lagX = edgeAutocorrLength('x');
|
||||
if (lagX > 0 && lagX <= width) detectedWidth = lagX;
|
||||
}
|
||||
if (detectedHeight <= 0 || detectedHeight > height) {
|
||||
const lagY = edgeAutocorrLength('y');
|
||||
if (lagY > 0 && lagY <= height) detectedHeight = lagY;
|
||||
}
|
||||
|
||||
// Sanity checks
|
||||
if (!Number.isFinite(detectedWidth) || detectedWidth <= 0 || detectedWidth > width) detectedWidth = 0;
|
||||
if (!Number.isFinite(detectedHeight) || detectedHeight <= 0 || detectedHeight > height) detectedHeight = 0;
|
||||
|
||||
return { detectedWidth, detectedHeight };
|
||||
}
|
||||
|
||||
// Detect irregular sprites using Web Worker
|
||||
async function detectIrregularSprites(img: HTMLImageElement): Promise<void> {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Could not get canvas context');
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Initialize worker lazily using Vite worker syntax
|
||||
if (!irregularWorker.value) {
|
||||
try {
|
||||
irregularWorker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
// Fallback to auto detection if worker fails
|
||||
await autoDetectSprites(img);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const worker = irregularWorker.value!;
|
||||
|
||||
const handleMessage = async (e: MessageEvent) => {
|
||||
clearTimeout(timeout);
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
worker.removeEventListener('error', handleError);
|
||||
|
||||
if (e.data.type === 'spritesDetected') {
|
||||
try {
|
||||
await processDetectedSprites(img, e.data.sprites, e.data.backgroundColor);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set timeout for worker processing
|
||||
const timeout = setTimeout(() => {
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
worker.removeEventListener('error', handleError);
|
||||
console.warn('Worker timeout, falling back to auto detection');
|
||||
// Fallback to auto detection
|
||||
autoDetectSprites(img).then(resolve).catch(reject);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
const handleError = (error: ErrorEvent) => {
|
||||
clearTimeout(timeout);
|
||||
worker.removeEventListener('message', handleMessage);
|
||||
worker.removeEventListener('error', handleError);
|
||||
console.error('Worker error, falling back to auto detection:', error);
|
||||
// Fallback to auto detection
|
||||
autoDetectSprites(img).then(resolve).catch(reject);
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handleMessage);
|
||||
worker.addEventListener('error', handleError);
|
||||
|
||||
try {
|
||||
worker.postMessage({
|
||||
type: 'detectIrregularSprites',
|
||||
imageData,
|
||||
sensitivity: sensitivity.value,
|
||||
maxSize: 2048, // Limit processing size for performance
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
console.error('Failed to post message to worker:', error);
|
||||
// Fallback to auto detection
|
||||
autoDetectSprites(img).then(resolve).catch(reject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process sprites detected by the worker (optimized)
|
||||
async function processDetectedSprites(img: HTMLImageElement, detectedSprites: any[], backgroundColor?: [number, number, number, number]): Promise<void> {
|
||||
if (!detectedSprites?.length) {
|
||||
previewSprites.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
const sourceCanvas = document.createElement('canvas');
|
||||
const sourceCtx = sourceCanvas.getContext('2d');
|
||||
const spriteCanvas = document.createElement('canvas');
|
||||
const spriteCtx = spriteCanvas.getContext('2d');
|
||||
|
||||
if (!sourceCtx || !spriteCtx) return;
|
||||
|
||||
// Setup source canvas once
|
||||
sourceCanvas.width = img.width;
|
||||
sourceCanvas.height = img.height;
|
||||
sourceCtx.drawImage(img, 0, 0);
|
||||
|
||||
// Process sprites in batches to avoid blocking
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < detectedSprites.length; i += batchSize) {
|
||||
const batch = detectedSprites.slice(i, i + batchSize);
|
||||
|
||||
for (const sprite of batch) {
|
||||
const { x, y, width, height } = sprite;
|
||||
|
||||
// Skip invalid sprites
|
||||
if (width <= 0 || height <= 0) continue;
|
||||
|
||||
spriteCanvas.width = width;
|
||||
spriteCanvas.height = height;
|
||||
spriteCtx.clearRect(0, 0, width, height);
|
||||
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
|
||||
|
||||
// Remove background and make transparent for irregular sprites
|
||||
if (detectionMethod.value === 'irregular' && backgroundColor) {
|
||||
removeBackgroundFromSprite(spriteCtx, width, height, backgroundColor, sensitivity.value);
|
||||
}
|
||||
|
||||
const isEmpty = removeEmpty.value ? isCanvasEmpty(spriteCtx, width, height) : false;
|
||||
|
||||
if (!removeEmpty.value || !isEmpty) {
|
||||
sprites.push({
|
||||
url: spriteCanvas.toDataURL('image/png'),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Yield control periodically for large batches
|
||||
if (i > 0 && i % 100 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
previewSprites.value = sprites;
|
||||
}
|
||||
|
||||
// Remove background color and make it transparent
|
||||
function removeBackgroundFromSprite(ctx: CanvasRenderingContext2D, width: number, height: number, backgroundColor: [number, number, number, number], sensitivity: number): void {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
const [bgR, bgG, bgB, bgA] = backgroundColor;
|
||||
const colorTolerance = Math.round(50 - sensitivity * 0.45); // Same as worker
|
||||
const alphaTolerance = Math.round(40 - sensitivity * 0.35);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
// Handle fully transparent pixels
|
||||
if (a < 10) {
|
||||
data[i + 3] = 0; // Make fully transparent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate color difference using Euclidean distance
|
||||
const rDiff = r - bgR;
|
||||
const gDiff = g - bgG;
|
||||
const bDiff = b - bgB;
|
||||
const aDiff = a - bgA;
|
||||
|
||||
const colorDistance = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
|
||||
const alphaDistance = Math.abs(aDiff);
|
||||
|
||||
// If pixel matches background color, make it transparent
|
||||
if (colorDistance <= colorTolerance && alphaDistance <= alphaTolerance) {
|
||||
data[i + 3] = 0; // Set alpha to 0 (transparent)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
// Check if a canvas is empty (all transparent or same color)
|
||||
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
// Check if all pixels are transparent
|
||||
let allTransparent = true;
|
||||
let allSameColor = true;
|
||||
|
||||
// Reference values from first pixel
|
||||
const firstR = data[0];
|
||||
const firstG = data[1];
|
||||
const firstB = data[2];
|
||||
const firstA = data[3];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
|
||||
// Check transparency
|
||||
if (alpha > 10) {
|
||||
// Allow some tolerance for compression artifacts
|
||||
allTransparent = false;
|
||||
}
|
||||
|
||||
// Check if all pixels are the same color
|
||||
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
|
||||
allSameColor = false;
|
||||
}
|
||||
|
||||
// Early exit if we've determined it's not empty
|
||||
if (!allTransparent && !allSameColor) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allTransparent || allSameColor;
|
||||
}
|
||||
|
||||
// Convert preview sprites to actual files
|
||||
async function createSpriteFiles(): Promise<SpriteFile[]> {
|
||||
const spriteFiles: SpriteFile[] = [];
|
||||
|
||||
for (let i = 0; i < previewSprites.value.length; i++) {
|
||||
const sprite = previewSprites.value[i];
|
||||
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(sprite.url);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create file from blob
|
||||
const fileName = `sprite_${i + 1}.png`;
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
|
||||
// Create sprite file with position information
|
||||
spriteFiles.push({
|
||||
file,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
});
|
||||
}
|
||||
|
||||
return spriteFiles;
|
||||
}
|
||||
|
||||
// Actions
|
||||
function cancel() {
|
||||
emit('close');
|
||||
@@ -850,8 +201,24 @@
|
||||
isProcessing.value = true;
|
||||
|
||||
try {
|
||||
const files = await createSpriteFiles();
|
||||
emit('split', files);
|
||||
const spriteFiles: SpriteFile[] = [];
|
||||
|
||||
for (let i = 0; i < previewSprites.value.length; i++) {
|
||||
const sprite = previewSprites.value[i];
|
||||
const response = await fetch(sprite.url);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `sprite_${i + 1}.png`, { type: 'image/png' });
|
||||
|
||||
spriteFiles.push({
|
||||
file,
|
||||
x: sprite.x,
|
||||
y: sprite.y,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
});
|
||||
}
|
||||
|
||||
emit('split', spriteFiles);
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error('Error creating sprite files:', error);
|
||||
@@ -860,19 +227,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Add these watchers to automatically update preview
|
||||
watch([rows, columns, removeEmpty, detectionMethod, sensitivity], () => {
|
||||
if (imageElement.value) {
|
||||
generatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up worker and cache on component unmount
|
||||
onUnmounted(() => {
|
||||
if (irregularWorker.value) {
|
||||
irregularWorker.value.terminate();
|
||||
irregularWorker.value = null;
|
||||
}
|
||||
detectionCache.clear();
|
||||
splitter.cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,8 @@ export const useLayers = () => {
|
||||
|
||||
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
||||
|
||||
const hasSprites = computed(() => layers.value.some(l => l.sprites.length > 0));
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
@@ -238,6 +240,7 @@ export const useLayers = () => {
|
||||
addLayer,
|
||||
removeLayer,
|
||||
moveLayer,
|
||||
hasSprites,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
135
src/composables/useShare.ts
Normal file
135
src/composables/useShare.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Ref } from 'vue';
|
||||
import type { Layer } from '@/types/sprites';
|
||||
|
||||
const POCKETBASE_URL = 'https://pb1.adhd.sh';
|
||||
const COLLECTION = 'spritesheets';
|
||||
|
||||
export interface SpritesheetConfig {
|
||||
version: number;
|
||||
columns: number;
|
||||
negativeSpacingEnabled: boolean;
|
||||
backgroundColor: string;
|
||||
manualCellSizeEnabled: boolean;
|
||||
manualCellWidth: number;
|
||||
manualCellHeight: number;
|
||||
}
|
||||
|
||||
export interface SpritesheetRecord {
|
||||
id: string;
|
||||
config: SpritesheetConfig;
|
||||
sprites: any[];
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ShareResult {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shareable URL for a spritesheet
|
||||
*/
|
||||
export const buildShareUrl = (id: string): string => {
|
||||
return `${window.location.origin}/share/${id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Share a spritesheet by uploading to PocketBase
|
||||
*/
|
||||
export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>): Promise<ShareResult> => {
|
||||
// Build layers data with base64 sprites (same format as exportSpritesheetJSON)
|
||||
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 config: SpritesheetConfig = {
|
||||
version: 2,
|
||||
columns: columns.value,
|
||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||
backgroundColor: backgroundColor?.value || 'transparent',
|
||||
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||
manualCellWidth: manualCellWidth?.value || 64,
|
||||
manualCellHeight: manualCellHeight?.value || 64,
|
||||
};
|
||||
|
||||
const response = await fetch(`${POCKETBASE_URL}/api/collections/${COLLECTION}/records`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config,
|
||||
sprites: layersData,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const record = await response.json();
|
||||
return {
|
||||
id: record.id,
|
||||
url: buildShareUrl(record.id),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a shared spritesheet from PocketBase
|
||||
*/
|
||||
export const fetchSpritesheet = async (id: string): Promise<SpritesheetRecord> => {
|
||||
const response = await fetch(`${POCKETBASE_URL}/api/collections/${COLLECTION}/records/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Spritesheet not found');
|
||||
}
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable hook for share functionality
|
||||
*/
|
||||
export const useShare = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||
const share = () => shareSpritesheet(layersRef, columns, negativeSpacingEnabled, backgroundColor, manualCellSizeEnabled, manualCellWidth, manualCellHeight);
|
||||
|
||||
return {
|
||||
share,
|
||||
fetchSpritesheet,
|
||||
buildShareUrl,
|
||||
};
|
||||
};
|
||||
316
src/composables/useSpritesheetSplitter.ts
Normal file
316
src/composables/useSpritesheetSplitter.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import type { SpritePreview, SpriteRegion, GridSplitOptions, AutoDetectOptions, SpriteWorkerResponse } from '@/types/spritesheet';
|
||||
|
||||
/**
|
||||
* Composable for spritesheet splitting logic
|
||||
* Handles grid-based and auto-detection sprite extraction
|
||||
*/
|
||||
export function useSpritesheetSplitter() {
|
||||
const isProcessing = ref(false);
|
||||
const previewSprites: Ref<SpritePreview[]> = ref([]);
|
||||
const worker: Ref<Worker | null> = ref(null);
|
||||
|
||||
/**
|
||||
* Split image into grid cells
|
||||
*/
|
||||
async function splitByGrid(img: HTMLImageElement, options: GridSplitOptions): Promise<SpritePreview[]> {
|
||||
const { cellWidth, cellHeight, preserveCellSize, removeEmpty } = options;
|
||||
|
||||
if (cellWidth <= 0 || cellHeight <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cols = Math.floor(img.width / cellWidth);
|
||||
const rows = Math.floor(img.height / cellHeight);
|
||||
const sprites: SpritePreview[] = [];
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
const cropCtx = cropCanvas.getContext('2d');
|
||||
|
||||
if (!ctx || !cropCtx) return [];
|
||||
|
||||
canvas.width = cellWidth;
|
||||
canvas.height = cellHeight;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
ctx.clearRect(0, 0, cellWidth, cellHeight);
|
||||
ctx.drawImage(img, col * cellWidth, row * cellHeight, cellWidth, cellHeight, 0, 0, cellWidth, cellHeight);
|
||||
|
||||
const isEmpty = removeEmpty ? isCanvasEmpty(ctx, cellWidth, cellHeight) : false;
|
||||
|
||||
if (!removeEmpty || !isEmpty) {
|
||||
let url: string;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let width = cellWidth;
|
||||
let height = cellHeight;
|
||||
|
||||
if (preserveCellSize) {
|
||||
// Keep full cell with transparent padding
|
||||
url = canvas.toDataURL('image/png');
|
||||
} else {
|
||||
// Crop to sprite bounds
|
||||
const bounds = getSpriteBounds(ctx, cellWidth, cellHeight);
|
||||
if (bounds) {
|
||||
x = bounds.x;
|
||||
y = bounds.y;
|
||||
width = bounds.width;
|
||||
height = bounds.height;
|
||||
|
||||
cropCanvas.width = width;
|
||||
cropCanvas.height = height;
|
||||
cropCtx.clearRect(0, 0, width, height);
|
||||
cropCtx.drawImage(canvas, x, y, width, height, 0, 0, width, height);
|
||||
url = cropCanvas.toDataURL('image/png');
|
||||
} else {
|
||||
url = canvas.toDataURL('image/png');
|
||||
}
|
||||
}
|
||||
|
||||
sprites.push({ url, x, y, width, height, isEmpty });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect sprites using web worker for irregular layouts
|
||||
*/
|
||||
async function detectSprites(img: HTMLImageElement, options: AutoDetectOptions): Promise<SpritePreview[]> {
|
||||
const { sensitivity, removeEmpty } = options;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return [];
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
// Initialize worker lazily
|
||||
if (!worker.value) {
|
||||
try {
|
||||
worker.value = new Worker(new URL('../workers/irregularSpriteDetection.worker.ts', import.meta.url), { type: 'module' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create worker:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<SpritePreview[]>(resolve => {
|
||||
const w = worker.value!;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
w.removeEventListener('message', handleMessage);
|
||||
console.warn('Worker timeout');
|
||||
resolve([]);
|
||||
}, 30000);
|
||||
|
||||
const handleMessage = async (e: MessageEvent<SpriteWorkerResponse>) => {
|
||||
clearTimeout(timeout);
|
||||
w.removeEventListener('message', handleMessage);
|
||||
|
||||
if (e.data.type === 'spritesDetected') {
|
||||
const sprites = await processDetectedSprites(img, e.data.sprites, e.data.backgroundColor, removeEmpty);
|
||||
resolve(sprites);
|
||||
}
|
||||
};
|
||||
|
||||
w.addEventListener('message', handleMessage);
|
||||
|
||||
w.postMessage({
|
||||
type: 'detectIrregularSprites',
|
||||
imageData,
|
||||
sensitivity,
|
||||
maxSize: 2048,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process sprites detected by worker into preview format
|
||||
*/
|
||||
async function processDetectedSprites(img: HTMLImageElement, regions: SpriteRegion[], backgroundColor: [number, number, number, number], removeEmpty: boolean): Promise<SpritePreview[]> {
|
||||
if (!regions?.length) return [];
|
||||
|
||||
const sprites: SpritePreview[] = [];
|
||||
const sourceCanvas = document.createElement('canvas');
|
||||
const sourceCtx = sourceCanvas.getContext('2d');
|
||||
const spriteCanvas = document.createElement('canvas');
|
||||
const spriteCtx = spriteCanvas.getContext('2d');
|
||||
|
||||
if (!sourceCtx || !spriteCtx) return [];
|
||||
|
||||
sourceCanvas.width = img.width;
|
||||
sourceCanvas.height = img.height;
|
||||
sourceCtx.drawImage(img, 0, 0);
|
||||
|
||||
for (const region of regions) {
|
||||
const { x, y, width, height } = region;
|
||||
if (width <= 0 || height <= 0) continue;
|
||||
|
||||
spriteCanvas.width = width;
|
||||
spriteCanvas.height = height;
|
||||
spriteCtx.clearRect(0, 0, width, height);
|
||||
spriteCtx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
|
||||
|
||||
// Remove background color
|
||||
removeBackground(spriteCtx, width, height, backgroundColor);
|
||||
|
||||
const isEmpty = removeEmpty ? isCanvasEmpty(spriteCtx, width, height) : false;
|
||||
|
||||
if (!removeEmpty || !isEmpty) {
|
||||
sprites.push({
|
||||
url: spriteCanvas.toDataURL('image/png'),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box of non-transparent pixels
|
||||
*/
|
||||
function getSpriteBounds(ctx: CanvasRenderingContext2D, width: number, height: number): { x: number; y: number; width: number; height: number } | null {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
let minX = width,
|
||||
minY = height,
|
||||
maxX = 0,
|
||||
maxY = 0;
|
||||
let hasContent = false;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const alpha = data[(y * width + x) * 4 + 3];
|
||||
if (alpha > 10) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent) return null;
|
||||
|
||||
// Add small padding
|
||||
const pad = 1;
|
||||
return {
|
||||
x: Math.max(0, minX - pad),
|
||||
y: Math.max(0, minY - pad),
|
||||
width: Math.min(width - Math.max(0, minX - pad), maxX - minX + 1 + pad * 2),
|
||||
height: Math.min(height - Math.max(0, minY - pad), maxY - minY + 1 + pad * 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if canvas is empty (all transparent or same color)
|
||||
*/
|
||||
function isCanvasEmpty(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
let allTransparent = true;
|
||||
let allSameColor = true;
|
||||
|
||||
const [firstR, firstG, firstB, firstA] = [data[0], data[1], data[2], data[3]];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 10) allTransparent = false;
|
||||
if (data[i] !== firstR || data[i + 1] !== firstG || data[i + 2] !== firstB || Math.abs(data[i + 3] - firstA) > 10) {
|
||||
allSameColor = false;
|
||||
}
|
||||
if (!allTransparent && !allSameColor) break;
|
||||
}
|
||||
|
||||
return allTransparent || allSameColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove background color from sprite, making it transparent
|
||||
*/
|
||||
function removeBackground(ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: [number, number, number, number]): void {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
const [bgR, bgG, bgB, bgA] = bgColor;
|
||||
const tolerance = 30;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a < 10) {
|
||||
data[i + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rDiff = data[i] - bgR;
|
||||
const gDiff = data[i + 1] - bgG;
|
||||
const bDiff = data[i + 2] - bgB;
|
||||
const dist = Math.sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff);
|
||||
|
||||
if (dist <= tolerance && Math.abs(a - bgA) <= 40) {
|
||||
data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup worker on unmount
|
||||
*/
|
||||
function cleanup() {
|
||||
if (worker.value) {
|
||||
worker.value.terminate();
|
||||
worker.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate suggested cell size based on image dimensions
|
||||
*/
|
||||
function getSuggestedCellSize(width: number, height: number): { width: number; height: number } {
|
||||
const commonSizes = [256, 192, 128, 96, 64, 48, 32, 16];
|
||||
|
||||
let cellWidth = 64;
|
||||
let cellHeight = 64;
|
||||
|
||||
for (const size of commonSizes) {
|
||||
if (width % size === 0 && width / size <= 16 && width / size >= 1) {
|
||||
cellWidth = size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const size of commonSizes) {
|
||||
if (height % size === 0 && height / size <= 16 && height / size >= 1) {
|
||||
cellHeight = size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { width: cellWidth, height: cellHeight };
|
||||
}
|
||||
|
||||
return {
|
||||
isProcessing,
|
||||
previewSprites,
|
||||
splitByGrid,
|
||||
detectSprites,
|
||||
getSuggestedCellSize,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,11 @@ const router = createRouter({
|
||||
name: 'blog-detail',
|
||||
component: () => import('../views/BlogDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/share/:id',
|
||||
name: 'share',
|
||||
component: () => import('../views/ShareView.vue'),
|
||||
},
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
|
||||
54
src/types/spritesheet.ts
Normal file
54
src/types/spritesheet.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Spritesheet splitting types
|
||||
*/
|
||||
|
||||
/** A detected sprite region with bounds and position */
|
||||
export interface SpriteRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pixelCount?: number;
|
||||
}
|
||||
|
||||
/** A sprite preview with data URL for display */
|
||||
export interface SpritePreview {
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Detection mode for sprite splitting */
|
||||
export type DetectionMode = 'grid' | 'auto';
|
||||
|
||||
/** Options for grid-based splitting */
|
||||
export interface GridSplitOptions {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
preserveCellSize: boolean;
|
||||
removeEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Options for auto-detection splitting */
|
||||
export interface AutoDetectOptions {
|
||||
sensitivity: number;
|
||||
removeEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Worker message for irregular sprite detection */
|
||||
export interface SpriteWorkerMessage {
|
||||
type: 'detectIrregularSprites';
|
||||
imageData: ImageData;
|
||||
sensitivity: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
/** Worker response with detected sprites */
|
||||
export interface SpriteWorkerResponse {
|
||||
type: 'spritesDetected';
|
||||
sprites: SpriteRegion[];
|
||||
backgroundColor: [number, number, number, number];
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
{ property: 'og:description', content: pageDescription },
|
||||
{ property: 'og:image', content: pageImage },
|
||||
{ property: 'og:site_name', content: 'Spritesheet Generator' },
|
||||
{ property: 'article:author', content: computed(() => post.value?.author || 'nu11ed') },
|
||||
{ property: 'article:author', content: computed(() => post.value?.author || 'streetshadow') },
|
||||
{ property: 'article:published_time', content: computed(() => post.value?.date || '') },
|
||||
|
||||
// Twitter
|
||||
@@ -95,7 +95,7 @@
|
||||
image: `https://spritesheetgenerator.online${post.value.image}`,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.value.author || 'nu11ed',
|
||||
name: post.value.author || 'streetshadow',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<!-- Welcome state -->
|
||||
<div v-if="!layers.some(l => l.sprites.length)" class="p-6 sm:p-10">
|
||||
<div class="mb-8">
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload sprites or single image</p>
|
||||
<div class="flex items-center justify-between gap-4 mb-1">
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-100">Upload sprites or single image</p>
|
||||
<a href="https://ko-fi.com/X8X416D44P" target="_blank" rel="noopener noreferrer">
|
||||
<img height="36" style="border: 0px; height: 36px" src="https://storage.ko-fi.com/cdn/kofi6.png?v=6" alt="Buy Me a Coffee at ko-fi.com" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
|
||||
</div>
|
||||
<file-uploader @upload-sprites="handleSpritesUpload" />
|
||||
@@ -40,9 +45,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Two-column layout: Left controls, Right preview -->
|
||||
<div v-if="layers.some(l => l.sprites.length)" class="flex flex-col flex-1 lg:grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] lg:overflow-hidden">
|
||||
<div v-if="layers.some(l => l.sprites.length)" class="flex flex-col flex-1 lg:grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] lg:overflow-hidden min-h-0">
|
||||
<!-- Left sidebar - Controls -->
|
||||
<div class="p-6 bg-gray-50/50 dark:bg-gray-900/30 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700 lg:overflow-y-auto lg:overflow-x-auto">
|
||||
<div class="p-6 bg-gray-50/50 dark:bg-gray-900/30 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700 lg:overflow-y-auto lg:overflow-x-auto lg:min-h-0">
|
||||
<div class="space-y-8">
|
||||
<!-- Upload Section -->
|
||||
<section>
|
||||
@@ -198,42 +203,58 @@
|
||||
<i class="fas fa-file-archive"></i>
|
||||
<span>ZIP</span>
|
||||
</button>
|
||||
<button @click="openShareModal" class="btn btn-dark btn-sm col-span-2" data-rybbit-event="share-spritesheet">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel - Tabs -->
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<div class="flex flex-col overflow-hidden min-h-0">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="bg-gray-50/50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex gap-1 p-2">
|
||||
<button
|
||||
@click="activeTab = 'canvas'"
|
||||
class="border-gray-600 border"
|
||||
:class="['flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer', activeTab === 'canvas' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50']"
|
||||
>
|
||||
<i class="fas fa-th"></i>
|
||||
<span>Canvas</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'preview'"
|
||||
class="border-gray-600 border"
|
||||
:class="['flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer', activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50']"
|
||||
data-rybbit-event="preview-animation"
|
||||
>
|
||||
<i class="fas fa-play"></i>
|
||||
<span>Preview</span>
|
||||
<div class="flex items-center justify-between gap-1 p-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
@click="activeTab = 'canvas'"
|
||||
class="border-gray-600 border"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
|
||||
activeTab === 'canvas' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-th"></i>
|
||||
<span>Canvas</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'preview'"
|
||||
class="border-gray-600 border"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
|
||||
activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
|
||||
]"
|
||||
data-rybbit-event="preview-animation"
|
||||
>
|
||||
<i class="fas fa-play"></i>
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="openShareModal" class="flex items-center gap-2 px-4 py-2 mr-2.5 text-sm font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-sm hover:shadow-md transition-all cursor-pointer" data-rybbit-event="share-spritesheet-header">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
<span>Share spritesheet</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="p-6 lg:flex-1 lg:overflow-auto">
|
||||
<div v-if="activeTab === 'canvas'">
|
||||
<div class="p-6 lg:flex-1 lg:overflow-auto lg:min-h-0">
|
||||
<div v-if="activeTab === 'canvas'" class="h-full">
|
||||
<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" />
|
||||
</div>
|
||||
<div v-if="activeTab === 'preview'">
|
||||
<div v-if="activeTab === 'preview'" class="h-full">
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,6 +263,7 @@
|
||||
|
||||
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
||||
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||||
<ShareModal :is-open="isShareModalOpen" :share-function="shareFunction" @close="closeShareModal" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -252,7 +274,9 @@
|
||||
import SpritePreview from '@/components/SpritePreview.vue';
|
||||
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from '@/components/GifFpsModal.vue';
|
||||
import ShareModal from '@/components/ShareModal.vue';
|
||||
import { useExportLayers } from '@/composables/useExportLayers';
|
||||
import { useShare } from '@/composables/useShare';
|
||||
import { useLayers } from '@/composables/useLayers';
|
||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
@@ -294,6 +318,7 @@
|
||||
const activeTab = ref<'canvas' | 'preview'>('canvas');
|
||||
const isSpritesheetSplitterOpen = ref(false);
|
||||
const isGifFpsModalOpen = ref(false);
|
||||
const isShareModalOpen = ref(false);
|
||||
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||
const spritesheetImageUrl = ref('');
|
||||
@@ -373,6 +398,23 @@
|
||||
isGifFpsModalOpen.value = false;
|
||||
};
|
||||
|
||||
// Share functionality
|
||||
const { share } = useShare(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), toRef(settingsStore, 'backgroundColor'), toRef(settingsStore, 'manualCellSizeEnabled'), toRef(settingsStore, 'manualCellWidth'), toRef(settingsStore, 'manualCellHeight'));
|
||||
|
||||
const shareFunction = () => share();
|
||||
|
||||
const openShareModal = () => {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites before sharing.');
|
||||
return;
|
||||
}
|
||||
isShareModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeShareModal = () => {
|
||||
isShareModalOpen.value = false;
|
||||
};
|
||||
|
||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||
processImageFiles(spriteFiles.map(s => s.file));
|
||||
};
|
||||
|
||||
286
src/views/ShareView.vue
Normal file
286
src/views/ShareView.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<main class="flex-1 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 transition-all duration-300">
|
||||
<div class="p-6 sm:p-10 max-w-4xl mx-auto">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
|
||||
<div class="relative">
|
||||
<div class="w-16 h-16 border-4 border-gray-200 dark:border-gray-700 rounded-full"></div>
|
||||
<div class="absolute top-0 left-0 w-16 h-16 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<p class="mt-6 text-lg font-medium text-gray-600 dark:text-gray-300">Loading shared spritesheet...</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Please wait while we fetch your spritesheet</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="flex flex-col items-center justify-center py-24">
|
||||
<div class="w-20 h-20 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
|
||||
<i class="fas fa-exclamation-triangle text-3xl text-red-500 dark:text-red-400"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">Oops! Something went wrong</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">{{ error }}</p>
|
||||
<router-link to="/" class="btn btn-dark px-6 py-3">
|
||||
<i class="fas fa-home mr-2"></i>
|
||||
Back to Home
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Success state - show spritesheet info -->
|
||||
<div v-else-if="spritesheetData">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-10">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg mb-4">
|
||||
<i class="fas fa-share-alt text-2xl text-white"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 mb-2">Shared spritesheet</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Shared {{ formatDate(spritesheetData.created) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<div class="group p-5 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-2xl border border-blue-200/50 dark:border-blue-700/30">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-blue-500/20 dark:bg-blue-500/30 rounded-xl mb-3">
|
||||
<i class="fas fa-columns text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<p class="text-xs font-medium text-blue-600/80 dark:text-blue-400/80 uppercase tracking-wider">Columns</p>
|
||||
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ spritesheetData.config.columns }}</p>
|
||||
</div>
|
||||
<div class="group p-5 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-2xl border border-purple-200/50 dark:border-purple-700/30">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-purple-500/20 dark:bg-purple-500/30 rounded-xl mb-3">
|
||||
<i class="fas fa-layer-group text-purple-600 dark:text-purple-400"></i>
|
||||
</div>
|
||||
<p class="text-xs font-medium text-purple-600/80 dark:text-purple-400/80 uppercase tracking-wider">Layers</p>
|
||||
<p class="text-2xl font-bold text-purple-700 dark:text-purple-300">{{ spritesheetData.sprites.length }}</p>
|
||||
</div>
|
||||
<div class="group p-5 bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-900/20 dark:to-emerald-800/20 rounded-2xl border border-emerald-200/50 dark:border-emerald-700/30">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-emerald-500/20 dark:bg-emerald-500/30 rounded-xl mb-3">
|
||||
<i class="fas fa-images text-emerald-600 dark:text-emerald-400"></i>
|
||||
</div>
|
||||
<p class="text-xs font-medium text-emerald-600/80 dark:text-emerald-400/80 uppercase tracking-wider">Sprites</p>
|
||||
<p class="text-2xl font-bold text-emerald-700 dark:text-emerald-300">{{ totalSprites }}</p>
|
||||
</div>
|
||||
<div class="group p-5 bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20 rounded-2xl border border-amber-200/50 dark:border-amber-700/30">
|
||||
<div class="flex items-center justify-center w-10 h-10 bg-amber-500/20 dark:bg-amber-500/30 rounded-xl mb-3">
|
||||
<i class="fas fa-fill-drip text-amber-600 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<p class="text-xs font-medium text-amber-600/80 dark:text-amber-400/80 uppercase tracking-wider">Background</p>
|
||||
<p class="text-lg font-bold text-amber-700 dark:text-amber-300 truncate">{{ spritesheetData.config.backgroundColor }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="flex items-center gap-2 text-lg font-bold text-gray-800 dark:text-gray-100 mb-4">
|
||||
<i class="fas fa-eye text-gray-500 dark:text-gray-400"></i>
|
||||
Sprite preview
|
||||
</h3>
|
||||
<div class="p-6 bg-gray-50 dark:bg-gray-800/50 rounded-2xl border border-gray-200 dark:border-gray-700">
|
||||
<div v-for="(layer, layerIndex) in spritesheetData.sprites" :key="layerIndex" class="mb-6 last:mb-0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded-full text-xs font-bold text-gray-600 dark:text-gray-300">
|
||||
{{ layerIndex + 1 }}
|
||||
</span>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ layer.name || `Layer ${layerIndex + 1}` }}</p>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">({{ layer.sprites.length }} sprites)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="(sprite, spriteIndex) in layer.sprites.slice(0, 12)" :key="spriteIndex" class="relative group">
|
||||
<div class="w-16 h-16 bg-checkerboard rounded-lg border-2 border-gray-200 dark:border-gray-600 overflow-hidden transition-transform group-hover:scale-110 group-hover:border-blue-400 group-hover:shadow-lg">
|
||||
<img :src="sprite.base64" :alt="`Sprite ${spriteIndex + 1}`" class="w-full h-full object-contain" style="image-rendering: pixelated" />
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 w-5 h-5 bg-gray-800 dark:bg-gray-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-[10px] font-bold text-white">{{ spriteIndex + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="layer.sprites.length > 12" class="w-16 h-16 flex flex-col items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<span class="text-lg font-bold text-gray-500 dark:text-gray-400">+{{ layer.sprites.length - 12 }}</span>
|
||||
<span class="text-[10px] text-gray-400 dark:text-gray-500">more</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button @click="loadIntoEditor" class="btn btn-dark px-8 py-3 text-base font-semibold shadow-lg hover:shadow-xl transition-shadow">
|
||||
<i class="fas fa-edit mr-2"></i>
|
||||
Open in editor
|
||||
</button>
|
||||
<button @click="downloadJSON" class="btn btn-secondary px-8 py-3 text-base font-semibold">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download JSON
|
||||
</button>
|
||||
<router-link to="/" class="btn btn-ghost px-8 py-3 text-base font-semibold text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
<i class="fas fa-plus-circle mr-2"></i>
|
||||
Create new
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { fetchSpritesheet, type SpritesheetRecord } from '@/composables/useShare';
|
||||
import { useLayers } from '@/composables/useLayers';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, activeLayerId, columns } = useLayers();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const spritesheetData = ref<SpritesheetRecord | null>(null);
|
||||
|
||||
const totalSprites = computed(() => {
|
||||
if (!spritesheetData.value) return 0;
|
||||
return spritesheetData.value.sprites.reduce((sum, layer) => sum + (layer.sprites?.length || 0), 0);
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const loadIntoEditor = async () => {
|
||||
if (!spritesheetData.value) return;
|
||||
|
||||
const data = spritesheetData.value;
|
||||
|
||||
// Apply config settings
|
||||
columns.value = data.config.columns;
|
||||
settingsStore.negativeSpacingEnabled = data.config.negativeSpacingEnabled;
|
||||
settingsStore.backgroundColor = data.config.backgroundColor;
|
||||
settingsStore.manualCellSizeEnabled = data.config.manualCellSizeEnabled;
|
||||
settingsStore.manualCellWidth = data.config.manualCellWidth;
|
||||
settingsStore.manualCellHeight = data.config.manualCellHeight;
|
||||
|
||||
// Load sprites into layers
|
||||
const loadSprite = (spriteData: any): Promise<Sprite> =>
|
||||
new Promise(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;
|
||||
});
|
||||
|
||||
const newLayers: Layer[] = [];
|
||||
for (const layerData of data.sprites) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
layers.value = newLayers;
|
||||
if (newLayers.length > 0) {
|
||||
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
||||
}
|
||||
|
||||
// Navigate to home
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const downloadJSON = () => {
|
||||
if (!spritesheetData.value) return;
|
||||
|
||||
const json = {
|
||||
version: spritesheetData.value.config.version,
|
||||
columns: spritesheetData.value.config.columns,
|
||||
negativeSpacingEnabled: spritesheetData.value.config.negativeSpacingEnabled,
|
||||
backgroundColor: spritesheetData.value.config.backgroundColor,
|
||||
manualCellSizeEnabled: spritesheetData.value.config.manualCellSizeEnabled,
|
||||
manualCellWidth: spritesheetData.value.config.manualCellWidth,
|
||||
manualCellHeight: spritesheetData.value.config.manualCellHeight,
|
||||
layers: spritesheetData.value.sprites,
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string;
|
||||
if (!id) {
|
||||
error.value = 'No spritesheet ID provided';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
spritesheetData.value = await fetchSpritesheet(id);
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load spritesheet:', e);
|
||||
error.value = e.message || 'Failed to load spritesheet';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-checkerboard {
|
||||
background-color: #fff;
|
||||
background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%);
|
||||
background-size: 12px 12px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 6px,
|
||||
6px -6px,
|
||||
-6px 0px;
|
||||
}
|
||||
|
||||
.dark .bg-checkerboard {
|
||||
background-color: #374151;
|
||||
background-image: linear-gradient(45deg, #4b5563 25%, transparent 25%), linear-gradient(-45deg, #4b5563 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #4b5563 75%), linear-gradient(-45deg, transparent 75%, #4b5563 75%);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user