完善图片画布素材与项目封面交互
新增画布素材导出能力并补充 JSZip 依赖 优化素材上传占位进度、拖拽添加和文件夹移动交互 接入未登录项目访问弹窗并完善项目卡片封面缩略图 补充图片画布与项目页回归测试
This commit is contained in:
180
package-lock.json
generated
180
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"cannon-es": "^0.20.0",
|
"cannon-es": "^0.20.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -2749,6 +2750,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3925,6 +3932,12 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -3964,8 +3977,7 @@
|
|||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
"node_modules/ini": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
@@ -4029,6 +4041,12 @@
|
|||||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4203,6 +4221,48 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4225,6 +4285,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -4886,6 +4955,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -5114,6 +5189,12 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/psl": {
|
"node_modules/psl": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
@@ -5463,6 +5544,12 @@
|
|||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5996,9 +6083,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/v8-to-istanbul": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
@@ -9503,6 +9588,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||||
},
|
},
|
||||||
|
"core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
|
},
|
||||||
"cross-spawn": {
|
"cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -10331,6 +10421,11 @@
|
|||||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
|
},
|
||||||
"import-fresh": {
|
"import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -10360,8 +10455,7 @@
|
|||||||
"inherits": {
|
"inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
@@ -10408,6 +10502,11 @@
|
|||||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
|
},
|
||||||
"isexe": {
|
"isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -10536,6 +10635,46 @@
|
|||||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"requires": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"requires": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
|
"string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"requires": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"keyv": {
|
"keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -10555,6 +10694,14 @@
|
|||||||
"type-check": "~0.4.0"
|
"type-check": "~0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"requires": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"lightningcss": {
|
"lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -10944,6 +11091,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
||||||
},
|
},
|
||||||
|
"pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"parent-module": {
|
"parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -11099,6 +11251,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||||
|
},
|
||||||
"psl": {
|
"psl": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
@@ -11334,6 +11491,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||||
},
|
},
|
||||||
|
"setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||||
|
},
|
||||||
"shebang-command": {
|
"shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -11701,9 +11863,7 @@
|
|||||||
"util-deprecate": {
|
"util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"v8-to-istanbul": {
|
"v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"cannon-es": "^0.20.0",
|
"cannon-es": "^0.20.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ export type SidebarMediaItemProps = {
|
|||||||
primaryClassName?: string;
|
primaryClassName?: string;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
titleNode?: ReactNode;
|
titleNode?: ReactNode;
|
||||||
|
previewOverlay?: ReactNode;
|
||||||
|
footerNode?: ReactNode;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
onDragStart?: DragEventHandler<HTMLDivElement>;
|
onDragStart?: DragEventHandler<HTMLElement>;
|
||||||
onDragEnd?: DragEventHandler<HTMLDivElement>;
|
onDragEnd?: DragEventHandler<HTMLElement>;
|
||||||
onDragOver?: DragEventHandler<HTMLDivElement>;
|
onDragOver?: DragEventHandler<HTMLDivElement>;
|
||||||
onDrop?: DragEventHandler<HTMLDivElement>;
|
onDrop?: DragEventHandler<HTMLDivElement>;
|
||||||
onPointerDown?: PointerEventHandler<HTMLDivElement>;
|
onPointerDown?: PointerEventHandler<HTMLDivElement>;
|
||||||
@@ -93,6 +95,8 @@ export function SidebarMediaItem({
|
|||||||
primaryClassName,
|
primaryClassName,
|
||||||
actions,
|
actions,
|
||||||
titleNode,
|
titleNode,
|
||||||
|
previewOverlay,
|
||||||
|
footerNode,
|
||||||
draggable,
|
draggable,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
@@ -119,6 +123,9 @@ export function SidebarMediaItem({
|
|||||||
className={primaryClassName}
|
className={primaryClassName}
|
||||||
onClick={onPrimaryClick}
|
onClick={onPrimaryClick}
|
||||||
aria-label={primaryLabel}
|
aria-label={primaryLabel}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<PlatformMediaFrame
|
<PlatformMediaFrame
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
@@ -127,11 +134,13 @@ export function SidebarMediaItem({
|
|||||||
aspect="square"
|
aspect="square"
|
||||||
surface="none"
|
surface="none"
|
||||||
className={thumbnailClassName}
|
className={thumbnailClassName}
|
||||||
|
previewOverlay={previewOverlay}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className={metaClassName}>
|
<div className={metaClassName}>
|
||||||
{titleNode ?? <span>{title}</span>}
|
{titleNode ?? <span>{title}</span>}
|
||||||
<span>{detail}</span>
|
<span>{detail}</span>
|
||||||
|
{footerNode}
|
||||||
</div>
|
</div>
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ApiClientError } from '../../services/apiClient';
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
@@ -19,7 +21,69 @@ const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
|
|||||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||||
|
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||||
|
const openLoginModalMock = vi.hoisted(() => vi.fn());
|
||||||
|
const authUiMockState = vi.hoisted(() => ({
|
||||||
|
value: null as {
|
||||||
|
user: AuthUser | null;
|
||||||
|
canAccessProtectedData: boolean;
|
||||||
|
openLoginModal: typeof openLoginModalMock;
|
||||||
|
requireAuth: ReturnType<typeof vi.fn>;
|
||||||
|
openSettingsModal: ReturnType<typeof vi.fn>;
|
||||||
|
openAccountModal: ReturnType<typeof vi.fn>;
|
||||||
|
setCurrentUser: ReturnType<typeof vi.fn>;
|
||||||
|
logout: ReturnType<typeof vi.fn>;
|
||||||
|
musicVolume: number;
|
||||||
|
setMusicVolume: ReturnType<typeof vi.fn>;
|
||||||
|
platformTheme: 'light';
|
||||||
|
setPlatformTheme: ReturnType<typeof vi.fn>;
|
||||||
|
isHydratingSettings: boolean;
|
||||||
|
isPersistingSettings: boolean;
|
||||||
|
settingsError: string | null;
|
||||||
|
} | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../auth/AuthUiContext', () => ({
|
||||||
|
useAuthUi: () => authUiMockState.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const imageEditorTestUser: AuthUser = {
|
||||||
|
id: 'user-editor-test',
|
||||||
|
publicUserCode: 'UEDITOR',
|
||||||
|
displayName: '画布测试用户',
|
||||||
|
avatarUrl: null,
|
||||||
|
phoneNumberMasked: '139****4806',
|
||||||
|
loginMethod: 'password',
|
||||||
|
bindingStatus: 'active',
|
||||||
|
wechatBound: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthUiMock(user: AuthUser | null = imageEditorTestUser) {
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
canAccessProtectedData: Boolean(user),
|
||||||
|
openLoginModal: openLoginModalMock,
|
||||||
|
requireAuth: vi.fn((action: () => void) => {
|
||||||
|
if (user) {
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openLoginModalMock(action);
|
||||||
|
}),
|
||||||
|
openSettingsModal: vi.fn(),
|
||||||
|
openAccountModal: vi.fn(),
|
||||||
|
setCurrentUser: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
musicVolume: 0.5,
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
|
platformTheme: 'light' as const,
|
||||||
|
setPlatformTheme: vi.fn(),
|
||||||
|
isHydratingSettings: false,
|
||||||
|
isPersistingSettings: false,
|
||||||
|
settingsError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const defaultProjectLayers = [
|
const defaultProjectLayers = [
|
||||||
{
|
{
|
||||||
@@ -116,6 +180,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|||||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||||
loadEditorProject: loadEditorProjectMock,
|
loadEditorProject: loadEditorProjectMock,
|
||||||
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||||||
|
renameEditorProject: renameEditorProjectMock,
|
||||||
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
||||||
updateEditorAsset: updateEditorAssetMock,
|
updateEditorAsset: updateEditorAssetMock,
|
||||||
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
||||||
@@ -123,16 +188,20 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function dispatchPointerEvent(
|
function dispatchPointerEvent(
|
||||||
target: Element,
|
target: Element | Window,
|
||||||
type: string,
|
type: string,
|
||||||
init: MouseEventInit & { pointerId: number },
|
init: PointerEventInit & { pointerId: number },
|
||||||
) {
|
) {
|
||||||
const event = new MouseEvent(type, {
|
const PointerEventConstructor =
|
||||||
|
typeof PointerEvent === 'undefined' ? MouseEvent : PointerEvent;
|
||||||
|
const event = new PointerEventConstructor(type, {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
|
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
|
||||||
|
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
|
||||||
|
Object.defineProperty(event, 'isPrimary', { value: true });
|
||||||
fireEvent(target, event);
|
fireEvent(target, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,8 +230,25 @@ async function renderLoadedEditor() {
|
|||||||
await screen.findByRole('button', { name: '添加拼图素材' });
|
await screen.findByRole('button', { name: '添加拼图素材' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readZipText(zip: JSZip, path: string) {
|
||||||
|
const file = zip.file(path);
|
||||||
|
expect(file).toBeTruthy();
|
||||||
|
return file!.async('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
reject = promiseReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('ImageCanvasEditorView', () => {
|
describe('ImageCanvasEditorView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
authUiMockState.value = createAuthUiMock();
|
||||||
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
||||||
projectId: 'editor-project-default',
|
projectId: 'editor-project-default',
|
||||||
title: '默认项目',
|
title: '默认项目',
|
||||||
@@ -192,6 +278,14 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
height: input.height,
|
height: input.height,
|
||||||
sourceType: input.sourceType,
|
sourceType: input.sourceType,
|
||||||
}));
|
}));
|
||||||
|
renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
|
layers: defaultProjectLayers,
|
||||||
|
resources: [],
|
||||||
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||||
|
}));
|
||||||
createEditorAssetFolderMock.mockResolvedValue({
|
createEditorAssetFolderMock.mockResolvedValue({
|
||||||
folderId: 'folder-role-persisted',
|
folderId: 'folder-role-persisted',
|
||||||
label: '角色上传',
|
label: '角色上传',
|
||||||
@@ -252,7 +346,9 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
loadEditorAssetLibraryMock.mockReset();
|
loadEditorAssetLibraryMock.mockReset();
|
||||||
loadEditorProjectMock.mockReset();
|
loadEditorProjectMock.mockReset();
|
||||||
loadOrCreateRecentEditorProjectMock.mockReset();
|
loadOrCreateRecentEditorProjectMock.mockReset();
|
||||||
|
renameEditorProjectMock.mockReset();
|
||||||
saveEditorProjectLayoutMock.mockReset();
|
saveEditorProjectLayoutMock.mockReset();
|
||||||
|
openLoginModalMock.mockReset();
|
||||||
window.history.replaceState(null, '', '/editor/canvas');
|
window.history.replaceState(null, '', '/editor/canvas');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,6 +375,29 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the login modal when direct project loading is unauthorized', async () => {
|
||||||
|
authUiMockState.value = createAuthUiMock(null);
|
||||||
|
loadEditorProjectMock.mockRejectedValueOnce(
|
||||||
|
new ApiClientError({
|
||||||
|
message: '未授权访问',
|
||||||
|
status: 401,
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/editor/canvas?projectid=editor-project-private',
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openLoginModalMock).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
});
|
||||||
|
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('offers a topbar entry back to the project page', async () => {
|
it('offers a topbar entry back to the project page', async () => {
|
||||||
await renderLoadedEditor();
|
await renderLoadedEditor();
|
||||||
|
|
||||||
@@ -294,6 +413,237 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
|
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renames the current project from the canvas topbar', async () => {
|
||||||
|
await renderLoadedEditor();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
|
||||||
|
const titleInput = screen.getByLabelText('项目名称');
|
||||||
|
fireEvent.change(titleInput, { target: { value: '新画布项目' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '保存项目名称' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(renameEditorProjectMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
'新画布项目',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText('项目名称')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels project rename editing with Escape', async () => {
|
||||||
|
await renderLoadedEditor();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
|
||||||
|
const titleInput = screen.getByLabelText('项目名称');
|
||||||
|
fireEvent.change(titleInput, { target: { value: '不会保存' } });
|
||||||
|
fireEvent.keyDown(titleInput, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(renameEditorProjectMock).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText('项目名称')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports valid canvas assets as a zip from the topbar', async () => {
|
||||||
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||||
|
projectId: 'editor-project-export',
|
||||||
|
title: '导出项目',
|
||||||
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
layerId: 'layer-data-a',
|
||||||
|
resourceId: 'resource-data-a',
|
||||||
|
title: '素材/A',
|
||||||
|
src: 'data:image/png;base64,YQ==',
|
||||||
|
x: 12,
|
||||||
|
y: 24,
|
||||||
|
width: 320,
|
||||||
|
height: 220,
|
||||||
|
originalWidth: 640,
|
||||||
|
originalHeight: 440,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
sourceAssetId: 'asset-data-a',
|
||||||
|
groupId: 'group-a',
|
||||||
|
hidden: true,
|
||||||
|
locked: true,
|
||||||
|
flipX: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layerId: 'layer-data-a-copy',
|
||||||
|
resourceId: 'resource-data-a-copy',
|
||||||
|
title: '素材/A 副本',
|
||||||
|
src: 'data:image/png;base64,YQ==',
|
||||||
|
x: 42,
|
||||||
|
y: 54,
|
||||||
|
width: 320,
|
||||||
|
height: 220,
|
||||||
|
originalWidth: 640,
|
||||||
|
originalHeight: 440,
|
||||||
|
zIndex: 2,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
sourceAssetId: 'asset-data-a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layerId: 'layer-generated',
|
||||||
|
resourceId: 'resource-generated',
|
||||||
|
title: '生成图',
|
||||||
|
src: '/generated-ok.png',
|
||||||
|
x: 70,
|
||||||
|
y: 80,
|
||||||
|
width: 360,
|
||||||
|
height: 360,
|
||||||
|
originalWidth: 1024,
|
||||||
|
originalHeight: 1024,
|
||||||
|
zIndex: 3,
|
||||||
|
sourceType: 'generated',
|
||||||
|
prompt: '明亮主视觉',
|
||||||
|
model: 'gpt-image-2',
|
||||||
|
provider: 'VectorEngine',
|
||||||
|
taskId: 'task-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layerId: 'layer-failed',
|
||||||
|
resourceId: 'resource-failed',
|
||||||
|
title: '失败图',
|
||||||
|
src: '/missing.png',
|
||||||
|
x: 90,
|
||||||
|
y: 100,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
originalWidth: 120,
|
||||||
|
originalHeight: 120,
|
||||||
|
zIndex: 4,
|
||||||
|
sourceType: 'generated',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [],
|
||||||
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
assetId: 'asset-data-a',
|
||||||
|
folderId: 'project',
|
||||||
|
label: '素材/A',
|
||||||
|
imageSrc: 'data:image/png;base64,YQ==',
|
||||||
|
width: 640,
|
||||||
|
height: 440,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchMock = vi.fn(async (url: string) => {
|
||||||
|
if (url === '/generated-ok.png') {
|
||||||
|
return new Response(new Blob(['generated'], { type: 'image/png' }));
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
const originalCreateObjectUrl = URL.createObjectURL;
|
||||||
|
const originalRevokeObjectUrl = URL.revokeObjectURL;
|
||||||
|
const originalAnchorClick = HTMLAnchorElement.prototype.click;
|
||||||
|
let exportedBlob: Blob | null = null;
|
||||||
|
let downloadName = '';
|
||||||
|
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||||
|
exportedBlob = blob;
|
||||||
|
return 'blob:editor-export';
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
HTMLAnchorElement.prototype.click = vi.fn(function click(this: HTMLAnchorElement) {
|
||||||
|
downloadName = this.download;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
await screen.findByRole('heading', { name: '导出项目' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下载画布素材' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(exportedBlob).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u);
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(exportedBlob!);
|
||||||
|
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
|
||||||
|
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
|
||||||
|
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
|
||||||
|
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
await readZipText(zip, '导出项目-画布素材/metadata.json'),
|
||||||
|
);
|
||||||
|
expect(metadata.projectId).toBe('editor-project-export');
|
||||||
|
expect(metadata.layers).toHaveLength(4);
|
||||||
|
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
|
||||||
|
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
|
||||||
|
expect(metadata.layers[0].canvas.hidden).toBe(true);
|
||||||
|
expect(metadata.layers[0].canvas.locked).toBe(true);
|
||||||
|
expect(metadata.layers[0].canvas.flipX).toBe(true);
|
||||||
|
expect(metadata.layers[0].canvas.groupId).toBe('group-a');
|
||||||
|
expect(metadata.layers[2].sourceType).toBe('generated');
|
||||||
|
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
|
||||||
|
expect(metadata.layers[3].file).toBeNull();
|
||||||
|
expect(metadata.layers[3].exportError).toContain('404');
|
||||||
|
expect(metadata.failedImages).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
|
||||||
|
).toContain('失败素材数量:1');
|
||||||
|
expect(screen.getByText('部分素材未能导出')).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
URL.createObjectURL = originalCreateObjectUrl;
|
||||||
|
URL.revokeObjectURL = originalRevokeObjectUrl;
|
||||||
|
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the canvas asset export entry when there are no valid layers', async () => {
|
||||||
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||||
|
projectId: 'editor-project-empty',
|
||||||
|
title: '空项目',
|
||||||
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
|
layers: [],
|
||||||
|
resources: [],
|
||||||
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
await screen.findByRole('heading', { name: '空项目' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not inject built-in mock assets or layers when persistence returns empty data', async () => {
|
it('does not inject built-in mock assets or layers when persistence returns empty data', async () => {
|
||||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||||
projectId: 'editor-project-empty',
|
projectId: 'editor-project-empty',
|
||||||
@@ -326,7 +676,7 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.getByRole('region', { name: '项目素材' })).toBeTruthy();
|
expect(screen.getByRole('region', { name: '项目素材' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes invalid uploaded layers when the canvas opens', async () => {
|
it('keeps canvas layers when their account asset has been removed from the library', async () => {
|
||||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||||
projectId: 'editor-project-invalid-layer',
|
projectId: 'editor-project-invalid-layer',
|
||||||
title: '包含失效素材的项目',
|
title: '包含失效素材的项目',
|
||||||
@@ -393,21 +743,67 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
|
expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy();
|
||||||
expect(screen.queryByAltText('画布图片:已删除素材')).toBeNull();
|
expect(screen.getByAltText('画布图片:已删除素材')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith(
|
||||||
|
'editor-project-invalid-layer',
|
||||||
|
expect.objectContaining({
|
||||||
|
layers: [
|
||||||
|
expect.objectContaining({
|
||||||
|
layerId: 'layer-valid-asset',
|
||||||
|
sourceAssetId: 'asset-a',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps resource-backed canvas layers when their account asset is not loaded', async () => {
|
||||||
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||||
|
projectId: 'editor-project-resource-layer',
|
||||||
|
title: '历史工程资源项目',
|
||||||
|
viewport: { x: 0, y: 0, scale: 1 },
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
layerId: 'layer-resource-backed',
|
||||||
|
resourceId: 'resource-backed',
|
||||||
|
title: '历史画布图片',
|
||||||
|
src: 'data:image/png;base64,aGlzdG9yeQ==',
|
||||||
|
x: 120,
|
||||||
|
y: 120,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 320,
|
||||||
|
originalHeight: 240,
|
||||||
|
zIndex: 1,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [],
|
||||||
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
expect(screen.getByAltText('画布图片:历史画布图片')).toBeTruthy();
|
||||||
'editor-project-invalid-layer',
|
|
||||||
expect.objectContaining({
|
|
||||||
layers: [
|
|
||||||
expect.objectContaining({
|
|
||||||
layerId: 'layer-valid-asset',
|
|
||||||
sourceAssetId: 'asset-a',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith(
|
||||||
|
'editor-project-resource-layer',
|
||||||
|
expect.objectContaining({ layers: [] }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles the shared sidebar from canvas panel buttons', async () => {
|
it('toggles the shared sidebar from canvas panel buttons', async () => {
|
||||||
@@ -623,6 +1019,12 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
fireEvent.dragOver(roleFolder, {
|
fireEvent.dragOver(roleFolder, {
|
||||||
dataTransfer,
|
dataTransfer,
|
||||||
});
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('添加到素材')).toBeNull();
|
||||||
|
expect(roleFolder.className).toContain(
|
||||||
|
'image-canvas-editor__asset-folder--move-target',
|
||||||
|
);
|
||||||
|
});
|
||||||
fireEvent.drop(roleFolder, {
|
fireEvent.drop(roleFolder, {
|
||||||
dataTransfer,
|
dataTransfer,
|
||||||
});
|
});
|
||||||
@@ -637,6 +1039,107 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pins the asset move target when the target folder name is outside the asset panel viewport', async () => {
|
||||||
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
folderId: 'project',
|
||||||
|
label: '项目素材',
|
||||||
|
sortOrder: 0,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderId: 'folder-role',
|
||||||
|
label: '角色',
|
||||||
|
sortOrder: 100,
|
||||||
|
collapsed: false,
|
||||||
|
systemDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
assetId: 'asset-puzzle',
|
||||||
|
folderId: 'project',
|
||||||
|
label: '拼图素材',
|
||||||
|
imageSrc: '/creation-type-references/puzzle.webp',
|
||||||
|
width: 640,
|
||||||
|
height: 640,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
const sourceAsset = await screen.findByRole('button', { name: '添加拼图素材' });
|
||||||
|
const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row');
|
||||||
|
const roleFolder = screen.getByRole('region', { name: '角色' });
|
||||||
|
const assetList = document.querySelector('.image-canvas-editor__asset-list');
|
||||||
|
const roleHeader = roleFolder.querySelector(
|
||||||
|
'[data-asset-folder-header-id="folder-role"]',
|
||||||
|
);
|
||||||
|
const dataTransfer = createDataTransferStub();
|
||||||
|
|
||||||
|
if (!sourceAssetRow || !assetList || !roleHeader) {
|
||||||
|
throw new Error('asset drag elements should exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 260,
|
||||||
|
bottom: 200,
|
||||||
|
width: 260,
|
||||||
|
height: 200,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
const roleHeaderRect = vi
|
||||||
|
.spyOn(roleHeader, 'getBoundingClientRect')
|
||||||
|
.mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 260,
|
||||||
|
top: 260,
|
||||||
|
left: 0,
|
||||||
|
right: 260,
|
||||||
|
bottom: 288,
|
||||||
|
width: 260,
|
||||||
|
height: 28,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
|
||||||
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
||||||
|
fireEvent.dragOver(roleFolder, { dataTransfer });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
document.querySelector(
|
||||||
|
'.image-canvas-editor__asset-folder-sticky-target',
|
||||||
|
)?.textContent,
|
||||||
|
).toContain('角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
roleHeaderRect.mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 40,
|
||||||
|
top: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 260,
|
||||||
|
bottom: 68,
|
||||||
|
width: 260,
|
||||||
|
height: 28,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
fireEvent.dragOver(roleFolder, { dataTransfer });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
document.querySelector('.image-canvas-editor__asset-folder-sticky-target'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
|
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
@@ -654,6 +1157,55 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
|
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows an uploading placeholder card before restoring the normal asset card', async () => {
|
||||||
|
const deferredAsset = createDeferred<{
|
||||||
|
assetId: string;
|
||||||
|
folderId: string;
|
||||||
|
label: string;
|
||||||
|
imageSrc: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sourceType: 'uploaded';
|
||||||
|
}>();
|
||||||
|
createEditorAssetMock.mockImplementationOnce(async (input) => {
|
||||||
|
await deferredAsset.promise;
|
||||||
|
return {
|
||||||
|
assetId: 'asset-uploading-finished',
|
||||||
|
folderId: input.folderId,
|
||||||
|
label: input.label,
|
||||||
|
imageSrc: input.imageSrc,
|
||||||
|
width: input.width,
|
||||||
|
height: input.height,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
await userEvent.upload(
|
||||||
|
screen.getByLabelText('上传图片文件'),
|
||||||
|
new File(['image'], '上传进度.png', { type: 'image/png' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: '上传中上传进度.png' })).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText('素材上传进度.png上传进度')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('button', { name: '添加上传进度.png' })).toBeNull();
|
||||||
|
|
||||||
|
deferredAsset.resolve({
|
||||||
|
assetId: 'asset-uploading-finished',
|
||||||
|
folderId: 'project',
|
||||||
|
label: '上传进度.png',
|
||||||
|
imageSrc: 'data:image/png;base64,aW1hZ2U=',
|
||||||
|
width: 420,
|
||||||
|
height: 315,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: '添加上传进度.png' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('button', { name: '上传中上传进度.png' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('supports asset selection mode and batch delete with shared toolbar', async () => {
|
it('supports asset selection mode and batch delete with shared toolbar', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||||
@@ -1028,6 +1580,101 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.getByRole('button', { name: '选择图层测试上传B.png' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: '选择图层测试上传B.png' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds an asset library image to the canvas by dragging it onto the viewport', async () => {
|
||||||
|
await renderLoadedEditor();
|
||||||
|
|
||||||
|
const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' });
|
||||||
|
const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row');
|
||||||
|
const viewport = screen.getByLabelText('画布工作区');
|
||||||
|
const dataTransfer = createDataTransferStub();
|
||||||
|
|
||||||
|
if (!sourceAssetRow) {
|
||||||
|
throw new Error('asset row should exist');
|
||||||
|
}
|
||||||
|
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
|
||||||
|
fireEvent.dragOver(viewport, {
|
||||||
|
clientX: 520,
|
||||||
|
clientY: 300,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('添加到画布')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.drop(viewport, {
|
||||||
|
clientX: 520,
|
||||||
|
clientY: 300,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('添加到画布')).toBeNull();
|
||||||
|
});
|
||||||
|
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: '选择抓大鹅素材' })).toBeTruthy();
|
||||||
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSrc: '/creation-type-references/match3d.webp',
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds an asset library image to the canvas with pointer dragging', async () => {
|
||||||
|
await renderLoadedEditor();
|
||||||
|
|
||||||
|
const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' });
|
||||||
|
const viewport = screen.getByLabelText('画布工作区');
|
||||||
|
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 320,
|
||||||
|
y: 80,
|
||||||
|
top: 80,
|
||||||
|
left: 320,
|
||||||
|
right: 1120,
|
||||||
|
bottom: 680,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
|
||||||
|
dispatchPointerEvent(sourceAsset, 'pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
pointerId: 81,
|
||||||
|
clientX: 160,
|
||||||
|
clientY: 220,
|
||||||
|
});
|
||||||
|
dispatchPointerEvent(window, 'pointermove', {
|
||||||
|
pointerId: 81,
|
||||||
|
clientX: 520,
|
||||||
|
clientY: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('添加到画布')).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText('抓大鹅素材').length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
dispatchPointerEvent(window, 'pointerup', {
|
||||||
|
pointerId: 81,
|
||||||
|
clientX: 520,
|
||||||
|
clientY: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('添加到画布')).toBeNull();
|
||||||
|
expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy();
|
||||||
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSrc: '/creation-type-references/match3d.webp',
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a canvas drop overlay while dragging uploaded images over the canvas', async () => {
|
it('shows a canvas drop overlay while dragging uploaded images over the canvas', async () => {
|
||||||
await renderLoadedEditor();
|
await renderLoadedEditor();
|
||||||
|
|
||||||
@@ -1057,6 +1704,42 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText('画布图片:画布提示.png')).toBeTruthy();
|
expect(screen.getByAltText('画布图片:画布提示.png')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
expect.objectContaining({
|
||||||
|
layers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: '画布提示.png',
|
||||||
|
resourceId: 'resource-editor-project-default-420',
|
||||||
|
sourceAssetId: 'persisted-画布提示.png',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a dropped canvas upload when project resource persistence fails', async () => {
|
||||||
|
createEditorProjectResourceMock.mockRejectedValueOnce(new Error('unauthorized'));
|
||||||
|
await renderLoadedEditor();
|
||||||
|
|
||||||
|
const viewport = screen.getByLabelText('画布工作区');
|
||||||
|
fireEvent.drop(viewport, {
|
||||||
|
clientX: 430,
|
||||||
|
clientY: 260,
|
||||||
|
dataTransfer: {
|
||||||
|
files: [new File(['image'], '未保存图片.png', { type: 'image/png' })],
|
||||||
|
types: ['Files'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createEditorProjectResourceMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByAltText('画布图片:未保存图片.png')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops files into the asset panel only once without creating canvas layers', async () => {
|
it('drops files into the asset panel only once without creating canvas layers', async () => {
|
||||||
@@ -1258,6 +1941,7 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
|
|
||||||
it('adds assets from the sidebar and supports zoom buttons', async () => {
|
it('adds assets from the sidebar and supports zoom buttons', async () => {
|
||||||
await renderLoadedEditor();
|
await renderLoadedEditor();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: '当前缩放比例 100%' }).className,
|
screen.getByRole('button', { name: '当前缩放比例 100%' }).className,
|
||||||
@@ -1267,7 +1951,7 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
|
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
|
||||||
expect(screen.getByRole('button', { name: '当前缩放比例 116%' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: '当前缩放比例 116%' })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
await user.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
||||||
|
|
||||||
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
||||||
expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy();
|
expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy();
|
||||||
@@ -1311,10 +1995,42 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy();
|
expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' }));
|
fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' }));
|
||||||
expect(screen.getByRole('menu', { name: '画布背景色菜单' })).toBeTruthy();
|
expect(screen.getByRole('dialog', { name: '画布背景设置' })).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: '切换画布背景色为暖灰' }));
|
expect(screen.getByText('画布背景色')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: '关闭画布背景设置' })).toBeTruthy();
|
||||||
|
|
||||||
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(243, 240, 234)');
|
fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' }));
|
||||||
|
|
||||||
|
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('自定义画布背景色'), {
|
||||||
|
target: { value: '#ffffff' },
|
||||||
|
});
|
||||||
|
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(255, 255, 255)');
|
||||||
|
|
||||||
|
const hexInput = screen.getByLabelText('画布背景十六进制颜色') as HTMLInputElement;
|
||||||
|
fireEvent.change(hexInput, {
|
||||||
|
target: { value: '#abc' },
|
||||||
|
});
|
||||||
|
expect(hexInput.value).toBe('AABBCC');
|
||||||
|
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)');
|
||||||
|
|
||||||
|
fireEvent.change(hexInput, {
|
||||||
|
target: { value: 'not-a-color' },
|
||||||
|
});
|
||||||
|
expect(hexInput.value).toBe('not-a-color');
|
||||||
|
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' }));
|
||||||
|
expect(hexInput.value).toBe('F8FAFC');
|
||||||
|
expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭画布背景设置' }));
|
||||||
|
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' }));
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
|
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' }));
|
fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' }));
|
||||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||||
@@ -1549,7 +2265,7 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain(
|
expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain(
|
||||||
'image-canvas-editor__generation-submit',
|
'image-canvas-editor__generation-submit',
|
||||||
);
|
);
|
||||||
expect(screen.queryByRole('toolbar', { name: 'AI画布工具栏' })).toBeNull();
|
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||||
target: { value: '一张明亮的拼图主视觉' },
|
target: { value: '一张明亮的拼图主视觉' },
|
||||||
@@ -1580,6 +2296,21 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||||
expect(screen.getByRole('button', { name: /添加生成图片/u })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /添加生成图片/u })).toBeTruthy();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||||
|
'editor-project-default',
|
||||||
|
expect.objectContaining({
|
||||||
|
layers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.stringMatching(/^生成图片/u),
|
||||||
|
sourceType: 'generated',
|
||||||
|
sourceAssetId: 'persisted-生成图片 3',
|
||||||
|
resourceId: 'resource-editor-project-default-1024',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
||||||
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
|
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
|
||||||
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { ContextType } from 'react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||||
import { ProjectGalleryView } from './ProjectGalleryView';
|
import { ProjectGalleryView } from './ProjectGalleryView';
|
||||||
|
|
||||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -11,6 +14,29 @@ const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
|||||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||||
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
|
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||||
|
|
||||||
|
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
canAccessProtectedData: false,
|
||||||
|
openLoginModal: vi.fn(),
|
||||||
|
requireAuth: vi.fn((action: () => void) => action()),
|
||||||
|
openSettingsModal: vi.fn(),
|
||||||
|
openAccountModal: vi.fn(),
|
||||||
|
setCurrentUser: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
musicVolume: 0.5,
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
|
platformTheme: 'light' as const,
|
||||||
|
setPlatformTheme: vi.fn(),
|
||||||
|
isHydratingSettings: false,
|
||||||
|
isPersistingSettings: false,
|
||||||
|
settingsError: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||||
listEditorProjects: listEditorProjectsMock,
|
listEditorProjects: listEditorProjectsMock,
|
||||||
createEditorProject: createEditorProjectMock,
|
createEditorProject: createEditorProjectMock,
|
||||||
@@ -67,6 +93,115 @@ describe('ProjectGalleryView', () => {
|
|||||||
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
|
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses canvas-center layer composition as the project cover', async () => {
|
||||||
|
listEditorProjectsMock.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
projectId: 'editor-project-cover',
|
||||||
|
title: '封面项目',
|
||||||
|
viewport: { x: 120, y: -60, scale: 0.8 },
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
layerId: 'layer-cover-back',
|
||||||
|
resourceId: 'resource-back',
|
||||||
|
title: '背景图',
|
||||||
|
x: 200,
|
||||||
|
y: 160,
|
||||||
|
width: 300,
|
||||||
|
height: 180,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layerId: 'layer-cover-front',
|
||||||
|
resourceId: 'resource-front',
|
||||||
|
title: '前景图',
|
||||||
|
x: 420,
|
||||||
|
y: 260,
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
resourceId: 'resource-front',
|
||||||
|
projectId: 'editor-project-cover',
|
||||||
|
imageSrc: 'data:image/png;base64,front',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: 'resource-back',
|
||||||
|
projectId: 'editor-project-cover',
|
||||||
|
imageSrc: 'data:image/png;base64,back',
|
||||||
|
width: 300,
|
||||||
|
height: 180,
|
||||||
|
sourceType: 'uploaded',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: '2026-06-12T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
||||||
|
|
||||||
|
await screen.findByText('封面项目');
|
||||||
|
const cover = document.querySelector('.project-gallery__canvas-cover');
|
||||||
|
const coverLayers = document.querySelectorAll(
|
||||||
|
'.project-gallery__canvas-cover-layer',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cover).toBeTruthy();
|
||||||
|
expect(coverLayers).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
document.querySelector('.project-gallery__preview img')?.getAttribute('src'),
|
||||||
|
).toBe('data:image/png;base64,back');
|
||||||
|
expect((coverLayers[0] as HTMLElement).style.zIndex).toBe('1');
|
||||||
|
expect((coverLayers[1] as HTMLElement).style.zIndex).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login modal when project list loading is unauthorized', async () => {
|
||||||
|
const openLoginModal = vi.fn();
|
||||||
|
listEditorProjectsMock.mockRejectedValueOnce(
|
||||||
|
new ApiClientError({
|
||||||
|
message: '未授权访问',
|
||||||
|
status: 401,
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
||||||
|
<ProjectGalleryView onOpenProject={vi.fn()} />
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires login before opening a project card while logged out', async () => {
|
||||||
|
const onOpenProject = vi.fn();
|
||||||
|
const requireAuth = vi.fn();
|
||||||
|
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
|
||||||
|
<ProjectGalleryView onOpenProject={onOpenProject} />
|
||||||
|
</AuthUiContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText('角色设定板');
|
||||||
|
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
||||||
|
|
||||||
|
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
expect(onOpenProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders project loading errors through the shared status message', async () => {
|
it('renders project loading errors through the shared status message', async () => {
|
||||||
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));
|
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ import {
|
|||||||
deleteEditorProject,
|
deleteEditorProject,
|
||||||
listEditorProjects,
|
listEditorProjects,
|
||||||
renameEditorProject,
|
renameEditorProject,
|
||||||
|
type EditorProjectLayerSnapshot,
|
||||||
|
type EditorProjectResourceSnapshot,
|
||||||
type EditorProjectSnapshot,
|
type EditorProjectSnapshot,
|
||||||
} from '../../services/image-editor/editorProjectClient';
|
} from '../../services/image-editor/editorProjectClient';
|
||||||
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
||||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
@@ -29,6 +34,9 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|||||||
import { PlatformTextField } from '../common/PlatformTextField';
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
import { UnifiedModal } from '../common/UnifiedModal';
|
import { UnifiedModal } from '../common/UnifiedModal';
|
||||||
|
|
||||||
|
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
|
||||||
|
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
|
||||||
|
|
||||||
type ProjectGalleryViewProps = {
|
type ProjectGalleryViewProps = {
|
||||||
onOpenProject: (projectId: string) => void;
|
onOpenProject: (projectId: string) => void;
|
||||||
};
|
};
|
||||||
@@ -38,16 +46,110 @@ type RenameDraft = {
|
|||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveProjectPreview(project: EditorProjectSnapshot) {
|
function isUnauthorizedError(error: unknown) {
|
||||||
const layerResourceIds = new Set(
|
return error instanceof ApiClientError && error.status === 401;
|
||||||
project.layers
|
}
|
||||||
.map((layer) => layer.resourceId)
|
|
||||||
.filter((resourceId) => resourceId.trim().length > 0),
|
type ProjectCoverLayer = {
|
||||||
|
layerId: string;
|
||||||
|
title: string;
|
||||||
|
imageSrc: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
zIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
|
||||||
|
const value = layer[key];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
|
||||||
|
const value = layer[key];
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
|
||||||
|
return layer.hidden === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
|
||||||
|
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
|
||||||
|
project.resources.map((resource) => [resource.resourceId, resource]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return project.layers
|
||||||
|
.filter((layer) => !isLayerHidden(layer))
|
||||||
|
.map((layer) => {
|
||||||
|
const resource = resourcesById.get(layer.resourceId);
|
||||||
|
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||||
|
if (!imageSrc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
layerId: layer.layerId,
|
||||||
|
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||||
|
imageSrc,
|
||||||
|
x: numberFromLayer(layer, 'x', 0),
|
||||||
|
y: numberFromLayer(layer, 'y', 0),
|
||||||
|
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
|
||||||
|
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
|
||||||
|
zIndex: numberFromLayer(layer, 'zIndex', 0),
|
||||||
|
} satisfies ProjectCoverLayer;
|
||||||
|
})
|
||||||
|
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
|
||||||
|
.sort((left, right) => left.zIndex - right.zIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
|
||||||
|
const coverLayers = resolveProjectCoverLayers(project);
|
||||||
|
if (!coverLayers.length) {
|
||||||
|
return <span className="project-gallery__preview-empty" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeScale =
|
||||||
|
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
|
||||||
|
? project.viewport.scale
|
||||||
|
: 1;
|
||||||
|
const viewportCenterX =
|
||||||
|
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
|
||||||
|
const viewportCenterY =
|
||||||
|
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
|
||||||
|
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
|
||||||
|
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
|
||||||
|
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||||
|
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||||
|
const scale = Math.min(
|
||||||
|
PROJECT_COVER_SIZE.width / worldPreviewWidth,
|
||||||
|
PROJECT_COVER_SIZE.height / worldPreviewHeight,
|
||||||
|
);
|
||||||
|
const offsetX = -previewMinX * scale;
|
||||||
|
const offsetY = -previewMinY * scale;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ??
|
<span className="project-gallery__canvas-cover" aria-hidden="true">
|
||||||
project.resources[0] ??
|
{coverLayers.map((layer) => (
|
||||||
null
|
<span
|
||||||
|
key={layer.layerId}
|
||||||
|
className="project-gallery__canvas-cover-layer"
|
||||||
|
style={{
|
||||||
|
left: offsetX + layer.x * scale,
|
||||||
|
top: offsetY + layer.y * scale,
|
||||||
|
width: layer.width * scale,
|
||||||
|
height: layer.height * scale,
|
||||||
|
zIndex: layer.zIndex,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={layer.imageSrc}
|
||||||
|
alt=""
|
||||||
|
className="project-gallery__canvas-cover-image"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +167,7 @@ function formatProjectUpdatedAt(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||||
|
const authUi = useAuthUi();
|
||||||
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
|
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
@@ -94,18 +197,26 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
if (isUnauthorizedError(error)) {
|
||||||
|
authUi?.openLoginModal(refreshProjects);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
error instanceof Error ? error.message : '读取项目列表失败',
|
error instanceof Error ? error.message : '读取项目列表失败',
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, [authUi]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshProjects();
|
refreshProjects();
|
||||||
}, [refreshProjects]);
|
}, [refreshProjects]);
|
||||||
|
|
||||||
const createProject = useCallback(() => {
|
const createProject = useCallback(() => {
|
||||||
|
if (authUi && !authUi.user) {
|
||||||
|
authUi.openLoginModal(createProject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
createEditorProject()
|
createEditorProject()
|
||||||
.then((project) => onOpenProject(project.projectId))
|
.then((project) => onOpenProject(project.projectId))
|
||||||
@@ -114,7 +225,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
|||||||
error instanceof Error ? error.message : '创建项目失败',
|
error instanceof Error ? error.message : '创建项目失败',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [onOpenProject]);
|
}, [authUi, onOpenProject]);
|
||||||
|
|
||||||
const closeSelectionMode = useCallback(() => {
|
const closeSelectionMode = useCallback(() => {
|
||||||
setIsSelectionMode(false);
|
setIsSelectionMode(false);
|
||||||
@@ -191,7 +302,6 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
|||||||
const projectCards = useMemo(
|
const projectCards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
projects.map((project) => {
|
projects.map((project) => {
|
||||||
const preview = resolveProjectPreview(project);
|
|
||||||
const selected = selectedProjectIds.has(project.projectId);
|
const selected = selectedProjectIds.has(project.projectId);
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -211,18 +321,22 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
|||||||
toggleProjectSelection(project.projectId);
|
toggleProjectSelection(project.projectId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (authUi) {
|
||||||
|
authUi.requireAuth(() => onOpenProject(project.projectId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
onOpenProject(project.projectId);
|
onOpenProject(project.projectId);
|
||||||
}}
|
}}
|
||||||
aria-label={`打开项目${project.title}`}
|
aria-label={`打开项目${project.title}`}
|
||||||
>
|
>
|
||||||
<PlatformMediaFrame
|
<PlatformMediaFrame
|
||||||
src={preview?.imageSrc}
|
src={null}
|
||||||
alt=""
|
alt=""
|
||||||
fallbackLabel="项目"
|
fallbackLabel="项目"
|
||||||
aspect="standard"
|
aspect="standard"
|
||||||
surface="bright"
|
surface="bright"
|
||||||
className="project-gallery__preview"
|
className="project-gallery__preview"
|
||||||
fallbackContent={<span className="project-gallery__preview-empty" />}
|
fallbackContent={<ProjectCanvasCover project={project} />}
|
||||||
>
|
>
|
||||||
{isSelectionMode ? (
|
{isSelectionMode ? (
|
||||||
<span className="project-gallery__checkbox">
|
<span className="project-gallery__checkbox">
|
||||||
@@ -279,6 +393,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeMenuProjectId,
|
activeMenuProjectId,
|
||||||
|
authUi,
|
||||||
deleteProjects,
|
deleteProjects,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
onOpenProject,
|
onOpenProject,
|
||||||
|
|||||||
423
src/index.css
423
src/index.css
@@ -3071,6 +3071,40 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-gallery__preview .platform-media-frame {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__canvas-cover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__canvas-cover::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent 46%),
|
||||||
|
radial-gradient(circle at 50% 44%, rgba(37, 99, 235, 0.08), transparent 58%);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__canvas-cover-layer {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.18rem;
|
||||||
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gallery__canvas-cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.project-gallery__preview-empty {
|
.project-gallery__preview-empty {
|
||||||
width: 36%;
|
width: 36%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
@@ -3388,9 +3422,23 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.12rem 0.18rem;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-folder--move-target
|
||||||
|
> .image-canvas-editor__asset-folder-header {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__asset-folder-header > span:first-of-type,
|
.image-canvas-editor__asset-folder-header > span:first-of-type,
|
||||||
@@ -3446,6 +3494,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-folder-sticky-target {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
box-shadow: 0 8px 18px rgb(15 23 42 / 0.1);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 850;
|
||||||
|
padding: 0.38rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-folder-sticky-target span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.image-canvas-editor__asset-row {
|
.image-canvas-editor__asset-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 4.4rem minmax(0, 1fr) auto;
|
grid-template-columns: 4.4rem minmax(0, 1fr) auto;
|
||||||
@@ -3469,6 +3542,41 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
background: #eef5ff;
|
background: #eef5ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-row--uploading {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-row--uploading:hover {
|
||||||
|
transform: none;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-row--upload-failed {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fff7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-drag-preview {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 80;
|
||||||
|
max-width: 11rem;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(0.75rem, 0.75rem);
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12px 28px rgb(15 23 42 / 0.16);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 850;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.42rem 0.55rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.image-canvas-editor__asset-row--selected {
|
.image-canvas-editor__asset-row--selected {
|
||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
background: #dbeafe;
|
background: #dbeafe;
|
||||||
@@ -3566,6 +3674,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
background-size: 1rem 1rem;
|
background-size: 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-overlay {
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
background: rgb(248 250 252 / 0.72);
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
.image-canvas-editor__layer img {
|
.image-canvas-editor__layer img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -3586,6 +3704,56 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-status {
|
||||||
|
display: grid;
|
||||||
|
min-width: 2.4rem;
|
||||||
|
justify-items: end;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-status strong {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-progress > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-progress progress {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 0.34rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-progress progress::-webkit-progress-bar {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-progress progress::-webkit-progress-value {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__asset-upload-progress progress::-moz-progress-bar {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.image-canvas-editor__asset-rename-input {
|
.image-canvas-editor__asset-rename-input {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 1.8rem;
|
height: 1.8rem;
|
||||||
@@ -3741,7 +3909,97 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__title-block {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
row-gap: 0.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-row {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-button {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(24rem, 42vw);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-button h1 {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-rename-button {
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
opacity: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-row:hover .image-canvas-editor__project-rename-button,
|
||||||
|
.image-canvas-editor__project-rename-button:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-rename-button:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-form {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(34rem, 58vw);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-input {
|
||||||
|
width: clamp(9rem, 28vw, 18rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__project-title-error {
|
||||||
|
max-width: 10rem;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__title-block h1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__topbar-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__topbar-actions .platform-status-message {
|
||||||
|
max-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__project-back-button {
|
.image-canvas-editor__project-back-button {
|
||||||
@@ -4090,28 +4348,165 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
height: 0.9rem;
|
height: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__background-menu {
|
.image-canvas-editor__background-panel {
|
||||||
display: inline-flex;
|
position: absolute;
|
||||||
gap: 0.25rem;
|
left: 0;
|
||||||
padding: 0.32rem;
|
bottom: calc(100% + 0.65rem);
|
||||||
|
z-index: 35;
|
||||||
|
display: grid;
|
||||||
|
width: min(15.5rem, calc(100vw - 1.5rem));
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0 0.65rem 0.65rem;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.16);
|
||||||
|
color: #111827;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__background-menu-item {
|
.image-canvas-editor__background-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
margin: 0 -0.65rem 0;
|
||||||
|
padding: 0 0.65rem;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
color: #111827;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-close-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #111827;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-close-button:hover,
|
||||||
|
.image-canvas-editor__background-close-button:focus-visible {
|
||||||
|
background: #f1f5f9;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-color-field {
|
||||||
|
position: relative;
|
||||||
|
height: 8rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0), #000000),
|
||||||
|
linear-gradient(90deg, #ffffff, #ff0000);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-color-field input[type='color'] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0.001;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-color-field::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.3rem;
|
||||||
|
top: 0.3rem;
|
||||||
|
width: 0.72rem;
|
||||||
|
height: 0.72rem;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.18);
|
||||||
|
content: '';
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-hue-track {
|
||||||
|
height: 0.66rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#ff0000,
|
||||||
|
#ffff00,
|
||||||
|
#00ff00,
|
||||||
|
#00ffff,
|
||||||
|
#0000ff,
|
||||||
|
#ff00ff,
|
||||||
|
#ff0000
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-preset-grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-preset {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 1.75rem;
|
width: 1.75rem;
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
border-radius: 0.4rem;
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
justify-content: center;
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__background-menu-item[aria-pressed='true'] {
|
.image-canvas-editor__background-preset[aria-pressed='true'] {
|
||||||
border-color: #38bdf8;
|
border-color: #0ea5e9;
|
||||||
background: #e0f2fe;
|
box-shadow: 0 0 0 2px #0ea5e9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__background-swatch {
|
.image-canvas-editor__background-preset span {
|
||||||
width: 1rem;
|
width: 100%;
|
||||||
height: 1rem;
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-hex-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0 0.65rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-hex-row input {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 760;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-canvas-editor__background-preset:hover,
|
||||||
|
.image-canvas-editor__background-preset:focus-visible {
|
||||||
|
border-color: #0ea5e9;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-canvas-editor__panel-dock button:hover,
|
.image-canvas-editor__panel-dock button:hover,
|
||||||
|
|||||||
Reference in New Issue
Block a user