接入画布
127
package-lock.json
generated
@@ -30,6 +30,7 @@
|
|||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-class-component": "^8.0.0-0",
|
"vue-class-component": "^8.0.0-0",
|
||||||
"vue-cropper": "^1.0.5",
|
"vue-cropper": "^1.0.5",
|
||||||
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"vue-i18n": "^9.6.1",
|
"vue-i18n": "^9.6.1",
|
||||||
"vue-router": "^4.0.3",
|
"vue-router": "^4.0.3",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
@@ -99,9 +100,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ant-design/icons-svg": {
|
"node_modules/@ant-design/icons-svg": {
|
||||||
"version": "4.2.1",
|
"version": "4.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
||||||
"integrity": "sha512-EB0iwlKDGpG93hW8f85CTJTs4SvMX7tt5ceupvhALp1IF44SeUFOMhKUOYqpsoYWQKAOuTRDMqn75rEaKDp0Xw=="
|
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
|
||||||
},
|
},
|
||||||
"node_modules/@ant-design/icons-vue": {
|
"node_modules/@ant-design/icons-vue": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
@@ -1764,9 +1765,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ctrl/tinycolor": {
|
"node_modules/@ctrl/tinycolor": {
|
||||||
"version": "3.4.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
|
||||||
"integrity": "sha512-ej5oVy6lykXsvieQtqZxCOaLT+xD4+QNarq78cIYISHmZXshCvROLudpQN3lfL8G0NL7plMSSK+zlyvCaIJ4Iw==",
|
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -2432,6 +2433,11 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/sortablejs": {
|
||||||
|
"version": "1.15.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.8.tgz",
|
||||||
|
"integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg=="
|
||||||
|
},
|
||||||
"node_modules/@types/stats.js": {
|
"node_modules/@types/stats.js": {
|
||||||
"version": "0.17.3",
|
"version": "0.17.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.3.tgz",
|
||||||
@@ -3816,9 +3822,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ant-design-vue": {
|
"node_modules/ant-design-vue": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.20",
|
||||||
"resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-3.2.12.tgz",
|
"resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-3.2.20.tgz",
|
||||||
"integrity": "sha512-CPsoWJ3t+sqq/EPINPXb4fC5/9iKkUdYOfK9M9kLKbXlRN3MAoVwWUbaFnUqc+ngtbEpn/d69hTF/Eh7MeWMhQ==",
|
"integrity": "sha512-YWpMfGaGoRastIXEYfCoJiaRiDHk4chqtYhlKQM5GqPt6NfvrM1Vg2e60yHtjxlZjed91wCMm0rAmyUr7Hwzdg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^6.0.0",
|
"@ant-design/colors": "^6.0.0",
|
||||||
"@ant-design/icons-vue": "^6.1.0",
|
"@ant-design/icons-vue": "^6.1.0",
|
||||||
@@ -3841,6 +3847,10 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.22.0"
|
"node": ">=12.22.0"
|
||||||
},
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ant-design-vue"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": ">=3.2.0"
|
"vue": ">=3.2.0"
|
||||||
}
|
}
|
||||||
@@ -4672,9 +4682,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/compute-scroll-into-view": {
|
"node_modules/compute-scroll-into-view": {
|
||||||
"version": "1.0.17",
|
"version": "1.0.20",
|
||||||
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
|
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
||||||
"integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg=="
|
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -5413,9 +5423,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-align": {
|
"node_modules/dom-align": {
|
||||||
"version": "1.12.3",
|
"version": "1.12.4",
|
||||||
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.3.tgz",
|
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
|
||||||
"integrity": "sha512-Gj9hZN3a07cbR6zviMUBOMPdWxYhbMI+x+WS0NAIu2zFZmbK8ys9R79g+iG9qLnlCwpFoaB+fKy8Pdv470GsPA=="
|
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="
|
||||||
},
|
},
|
||||||
"node_modules/dom-converter": {
|
"node_modules/dom-converter": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
@@ -8518,9 +8528,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanopop": {
|
"node_modules/nanopop": {
|
||||||
"version": "2.2.0",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.4.2.tgz",
|
||||||
"integrity": "sha512-E9JaHcxh3ere8/BEZHAcnuD10RluTSPyTToBvoFWS9/7DcCx6gyKjbn7M7Bx7E1veCxCuY1iO6h4+gdAf1j73Q=="
|
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw=="
|
||||||
},
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
@@ -10217,11 +10227,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scroll-into-view-if-needed": {
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
"version": "2.2.29",
|
"version": "2.2.31",
|
||||||
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz",
|
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
||||||
"integrity": "sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==",
|
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"compute-scroll-into-view": "^1.0.17"
|
"compute-scroll-into-view": "^1.0.20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/select-hose": {
|
"node_modules/select-hose": {
|
||||||
@@ -11534,6 +11544,22 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.0.5.tgz",
|
||||||
"integrity": "sha512-D4XXdqWmMWRLOIV9LIh7/mkH6OBOMQDFbRjwntkxmAtxOtwpC9U5ZZ6lSXw5F5cbd4g8znDjk6MuCwIL+fZSrA=="
|
"integrity": "sha512-D4XXdqWmMWRLOIV9LIh7/mkH6OBOMQDFbRjwntkxmAtxOtwpC9U5ZZ6lSXw5F5cbd4g8znDjk6MuCwIL+fZSrA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-draggable-plus": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-draggable-plus/-/vue-draggable-plus-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/sortablejs": "^1.15.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/sortablejs": "^1.15.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-eslint-parser": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
|
||||||
@@ -12572,9 +12598,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@ant-design/icons-svg": {
|
"@ant-design/icons-svg": {
|
||||||
"version": "4.2.1",
|
"version": "4.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
||||||
"integrity": "sha512-EB0iwlKDGpG93hW8f85CTJTs4SvMX7tt5ceupvhALp1IF44SeUFOMhKUOYqpsoYWQKAOuTRDMqn75rEaKDp0Xw=="
|
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
|
||||||
},
|
},
|
||||||
"@ant-design/icons-vue": {
|
"@ant-design/icons-vue": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
@@ -13729,9 +13755,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@ctrl/tinycolor": {
|
"@ctrl/tinycolor": {
|
||||||
"version": "3.4.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
|
||||||
"integrity": "sha512-ej5oVy6lykXsvieQtqZxCOaLT+xD4+QNarq78cIYISHmZXshCvROLudpQN3lfL8G0NL7plMSSK+zlyvCaIJ4Iw=="
|
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA=="
|
||||||
},
|
},
|
||||||
"@element-plus/icons-vue": {
|
"@element-plus/icons-vue": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
@@ -14301,6 +14327,11 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/sortablejs": {
|
||||||
|
"version": "1.15.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.8.tgz",
|
||||||
|
"integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg=="
|
||||||
|
},
|
||||||
"@types/stats.js": {
|
"@types/stats.js": {
|
||||||
"version": "0.17.3",
|
"version": "0.17.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.3.tgz",
|
||||||
@@ -15383,9 +15414,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ant-design-vue": {
|
"ant-design-vue": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.20",
|
||||||
"resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-3.2.12.tgz",
|
"resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-3.2.20.tgz",
|
||||||
"integrity": "sha512-CPsoWJ3t+sqq/EPINPXb4fC5/9iKkUdYOfK9M9kLKbXlRN3MAoVwWUbaFnUqc+ngtbEpn/d69hTF/Eh7MeWMhQ==",
|
"integrity": "sha512-YWpMfGaGoRastIXEYfCoJiaRiDHk4chqtYhlKQM5GqPt6NfvrM1Vg2e60yHtjxlZjed91wCMm0rAmyUr7Hwzdg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@ant-design/colors": "^6.0.0",
|
"@ant-design/colors": "^6.0.0",
|
||||||
"@ant-design/icons-vue": "^6.1.0",
|
"@ant-design/icons-vue": "^6.1.0",
|
||||||
@@ -16089,9 +16120,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compute-scroll-into-view": {
|
"compute-scroll-into-view": {
|
||||||
"version": "1.0.17",
|
"version": "1.0.20",
|
||||||
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
|
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
||||||
"integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg=="
|
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
|
||||||
},
|
},
|
||||||
"concat-map": {
|
"concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -16660,9 +16691,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dom-align": {
|
"dom-align": {
|
||||||
"version": "1.12.3",
|
"version": "1.12.4",
|
||||||
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.3.tgz",
|
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
|
||||||
"integrity": "sha512-Gj9hZN3a07cbR6zviMUBOMPdWxYhbMI+x+WS0NAIu2zFZmbK8ys9R79g+iG9qLnlCwpFoaB+fKy8Pdv470GsPA=="
|
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="
|
||||||
},
|
},
|
||||||
"dom-converter": {
|
"dom-converter": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
@@ -19144,9 +19175,9 @@
|
|||||||
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
|
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
|
||||||
},
|
},
|
||||||
"nanopop": {
|
"nanopop": {
|
||||||
"version": "2.2.0",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.4.2.tgz",
|
||||||
"integrity": "sha512-E9JaHcxh3ere8/BEZHAcnuD10RluTSPyTToBvoFWS9/7DcCx6gyKjbn7M7Bx7E1veCxCuY1iO6h4+gdAf1j73Q=="
|
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw=="
|
||||||
},
|
},
|
||||||
"natural-compare": {
|
"natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
@@ -20424,11 +20455,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scroll-into-view-if-needed": {
|
"scroll-into-view-if-needed": {
|
||||||
"version": "2.2.29",
|
"version": "2.2.31",
|
||||||
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz",
|
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
||||||
"integrity": "sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==",
|
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"compute-scroll-into-view": "^1.0.17"
|
"compute-scroll-into-view": "^1.0.20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"select-hose": {
|
"select-hose": {
|
||||||
@@ -21496,6 +21527,14 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.0.5.tgz",
|
||||||
"integrity": "sha512-D4XXdqWmMWRLOIV9LIh7/mkH6OBOMQDFbRjwntkxmAtxOtwpC9U5ZZ6lSXw5F5cbd4g8znDjk6MuCwIL+fZSrA=="
|
"integrity": "sha512-D4XXdqWmMWRLOIV9LIh7/mkH6OBOMQDFbRjwntkxmAtxOtwpC9U5ZZ6lSXw5F5cbd4g8znDjk6MuCwIL+fZSrA=="
|
||||||
},
|
},
|
||||||
|
"vue-draggable-plus": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-draggable-plus/-/vue-draggable-plus-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==",
|
||||||
|
"requires": {
|
||||||
|
"@types/sortablejs": "^1.15.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"vue-eslint-parser": {
|
"vue-eslint-parser": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
"vue-class-component": "^8.0.0-0",
|
"vue-class-component": "^8.0.0-0",
|
||||||
"vue-cropper": "^1.0.5",
|
"vue-cropper": "^1.0.5",
|
||||||
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"vue-i18n": "^9.6.1",
|
"vue-i18n": "^9.6.1",
|
||||||
"vue-router": "^4.0.3",
|
"vue-router": "^4.0.3",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
|
|||||||
1
src/assets/icons/CBrush.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746587839944" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9024" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M884.641557 138.571009C811.672662 65.565275 755.277234 56.513108 726.075145 85.715197l-79.284741 79.283718 211.422224 211.422224 79.31851-79.284741C966.698434 267.936355 957.646268 211.538881 884.641557 138.571009z" p-id="9025"></path><path d="M144.589584 667.200758c-14.600533 14.564717-68.333318 226.898707-68.333318 226.898707-3.193739 8.709359-10.073426 42.179658 0 52.855812 7.039323 7.460925 25.424042 10.638291 52.855812 0 0 0 212.33399-53.730739 226.938616-68.333318l449.309192-449.345008L593.934592 217.85575 144.589584 667.200758zM132.177903 891.034662 185.764356 708.376553l129.110543 129.071657L132.177903 891.034662z" p-id="9026"></path></svg>
|
||||||
|
After Width: | Height: | Size: 991 B |
1
src/assets/icons/CClose.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747900146850" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5591" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 64C264.8 64 64 264.8 64 512s200.8 448 448 448 448-200.8 448-448S759.2 64 512 64z m238.4 641.6l-45.6 45.6L512 557.6 318.4 750.4l-45.6-45.6L467.2 512 273.6 318.4l45.6-45.6L512 467.2l193.6-193.6 45.6 45.6L557.6 512l192.8 193.6z" p-id="5592"></path></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
1
src/assets/icons/CDelete.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746501838166" class="icon" viewBox="0 0 1029 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14477" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.9765625" height="200"><path d="M133.076426 1013.469886c-1.621429-1.358835-3.271525-3.124748-4.892955-4.891808 1.79458 1.765913 3.271525 3.532972 4.892955 4.891808z m-10.939485-6.270137c-1.724632-1.574414-3.427476-3.147682-5.039732-4.892954 1.590468 1.745272 3.313953 3.31854 5.039732 4.892954zM1017.567032 158.745227c-7.794096-7.79295-19.126898-12.137783-30.123717-12.137782H776.022289V80.738903c0-44.54572-36.192036-80.738903-80.738903-80.738903H334.387028c-44.54572 0-80.738903 36.193183-80.738903 80.738903v65.868542H41.151498c-22.553227 0-41.008161 18.319624-41.008161 41.007014s18.32077 41.008161 41.008161 41.008161h36.192036v713.661492c0 44.546867 36.193183 80.738903 80.738903 80.738903h725.017227c44.546867 0 80.738903-36.192036 80.738903-80.738903V228.62262h23.584107c22.553227 0 42.149124-18.32077 42.149124-41.008161 0.022934-10.884443-4.21067-21.075135-12.003619-28.868085zM334.252865 80.986589h360.671605v66.315754h-360.670459z m548.734423 861.18744H158.083583V228.62262H882.965501v713.550262zM514.29798 366.047319c-22.799767 0-41.231767 18.432-41.231767 41.231767v382.552869c0 22.799767 18.432 41.231767 41.231767 41.231767s41.231767-18.432 41.231767-41.231767V407.279086c0-22.776833-18.432-41.231767-41.231767-41.231767z m-223.337496 0c-22.799767 0-41.231767 18.432-41.231767 41.231767v382.552869c0 22.799767 18.432 41.231767 41.231767 41.231767s41.231767-18.432 41.231767-41.231767V407.279086c0-22.776833-18.453787-41.231767-41.231767-41.231767z m444.390772 0c-22.799767 0-41.232914 18.432-41.232913 41.231767v382.552869c0 22.799767 18.433147 41.231767 41.231767 41.231767s41.231767-18.432 41.231767-41.231767V407.279086c0-22.776833-18.432-41.231767-41.231767-41.231767z" fill="#000000" p-id="14478"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/assets/icons/CEllipse.svg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
1
src/assets/icons/CEraser.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746587925372" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14649" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M969.806769 938.771692H370.477949l232.474256-298.614154 272.541539-354.829128a65.641026 65.641026 0 0 0-8.349539-91.871179L639.264821 13.548308a64.459487 64.459487 0 0 0-90.584616 14.25723L273.670564 381.768205 23.236923 701.80759a101.848615 101.848615 0 0 0 13.758359 142.572307l174.867692 137.58359a93.630359 93.630359 0 0 0 54.272 19.718564h703.671795c17.302974 0 31.323897-14.073436 31.323898-31.455179 0-17.381744-14.020923-31.455179-31.323898-31.45518zM73.307897 798.693744a42.010256 42.010256 0 0 1-4.174769-61.650052l213.700923-274.72082 239.143385 187.890872-213.674667 274.72082c-4.673641 5.77641-10.502564 10.502564-17.119179 13.837128h-21.267693a31.166359 31.166359 0 0 0-10.870153 2.100513 36.653949 36.653949 0 0 1-9.609847-5.041231L73.307897 798.72z" fill="#333333" p-id="14650"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
src/assets/icons/CEye.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746501311332" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9011" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1018.737778 480.711111s-4.266667-12.8-8.533334-17.066667l-21.333333-42.666666c-21.333333-34.133333-51.2-76.8-93.866667-123.733334C813.937778 207.644444 685.937778 113.777778 511.004444 113.777778s-302.933333 93.866667-384 183.466666c-42.666667 46.933333-72.533333 89.6-93.866666 123.733334-8.533333 17.066667-17.066667 29.866667-21.333334 42.666666-4.266667 4.266667-8.533333 17.066667-8.533333 17.066667-4.266667 12.8-4.266667 25.6 0 34.133333 0 0 4.266667 12.8 8.533333 17.066667l21.333334 42.666667c21.333333 34.133333 51.2 76.8 93.866666 123.733333 81.066667 89.6 209.066667 183.466667 384 183.466667s302.933333-93.866667 384-183.466667c42.666667-46.933333 72.533333-89.6 93.866667-123.733333 8.533333-17.066667 17.066667-29.866667 21.333333-42.666667 4.266667-4.266667 8.533333-17.066667 8.533334-17.066667 4.266667-12.8 4.266667-21.333333 0-34.133333z m-102.4 46.933333c-17.066667 29.866667-46.933333 72.533333-81.066667 110.933334-76.8 81.066667-183.466667 157.866667-324.266667 157.866666s-247.466667-76.8-324.266666-157.866666c-38.4-38.4-64-81.066667-81.066667-110.933334-8.533333-12.8-12.8-21.333333-17.066667-29.866666 4.266667-8.533333 8.533333-17.066667 17.066667-29.866667 21.333333-29.866667 46.933333-72.533333 81.066667-110.933333C263.537778 275.911111 370.204444 199.111111 511.004444 199.111111s247.466667 76.8 324.266667 157.866667c38.4 38.4 64 81.066667 81.066667 110.933333 8.533333 12.8 12.8 21.333333 17.066666 29.866667-4.266667 8.533333-12.8 17.066667-17.066666 29.866666z" fill="#000000" p-id="9012"></path><path d="M511.004444 344.177778c-76.8 0-145.066667 68.266667-145.066666 153.6 0 85.333333 68.266667 153.6 145.066666 153.6 76.8 0 145.066667-68.266667 145.066667-153.6 0-85.333333-68.266667-153.6-145.066667-153.6z m-230.4 153.6c0-128 102.4-238.933333 230.4-238.933334s230.4 110.933333 230.4 238.933334-102.4 238.933333-230.4 238.933333-230.4-110.933333-230.4-238.933333z" fill="#000000" p-id="9013"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
1
src/assets/icons/CFont.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746611285722" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="29545" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M32 0 972.651987 0 972.651987 316.651576 931.05337 316.651576C931.05337 316.651576 851.664627 123.301587 757.99696 106.426488 664.326945 89.551388 599.217138 101.163665 599.217138 101.163665L598.048275 908.460659C598.048275 908.460659 624.046382 963.027206 666.957181 966.897965L761.948781 966.897965 761.948781 1022.63337 242.777214 1022.63337 244.09057 965.511776 327.28898 964.19607C327.28898 964.19607 389.765027 944.689555 389.765027 899.258944 389.765027 853.896474 391.083083 107.668185 391.083083 107.668185 391.083083 107.668185 288.327649 93.496156 234.939369 106.496972 181.625097 119.427304 87.881073 197.297106 73.598618 311.462763L32 312.778468 32 0 32 0 32 0Z" fill="#272636" p-id="29546"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/assets/icons/CFree.svg
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
1
src/assets/icons/CHand.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746587879086" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11961" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M385.1 926.9c-11 0-21.4-4.3-29.3-12l-0.6-0.6c-0.7-0.7-65.6-70.4-108.4-112.7-42.8-42.2-118.6-111.3-119.3-112l-0.6-0.5c-15.9-15.7-24.6-36.6-24.5-58.9 0.1-22.3 9-43.1 25-58.6 28.6-27.7 72.2-31 104.6-8.2l90.5 44-83.1-290.1c-4.9-17.1-4.2-34.9 2.1-51.6s17.5-30.5 32.5-40.1c22-14.1 47.7-17.7 70.3-10 22.6 7.7 40.7 26.3 49.5 50.9l37.3 104.1V177.5c0-43.4 35.3-78.7 78.7-78.7 20.7 0 40.2 7.9 55 22.4 14.8 14.4 23.2 33.8 23.7 54.4v0.2l2.4 165.5 34.3-111.5 0.1-0.4c8.2-23.2 26.2-41.1 49.4-49.3 23.2-8.2 48.5-5.5 69.4 7.3 15.6 9.6 27.7 24.3 33.9 41.6s6.5 36.3 0.6 53.7l-42.5 127.5 42.9-48.6 0.3-0.3c15.7-16.2 34.4-25.7 54.1-27.3 19.8-1.6 39.1 4.7 56 18.1 33 26.4 40.8 60.1 22.7 97.5l-0.5 1.1-0.6 1c-41.8 65.2-107.1 171.9-115.8 199-12.4 38.6-41 140.7-41.3 141.7l-0.2 0.7-34.5 107.2-0.6 1.2c-6.8 14.3-21.5 23.7-37.4 23.8l-295.9 1.6h-0.2z m-1-40.3c0.3 0.2 0.7 0.4 1 0.4l295.9-1.6c0.4 0 0.8-0.2 1.1-0.5l33.3-103.6c2.2-7.9 29.2-104.3 41.6-142.8 12.8-40 105.9-185.9 119.6-207.3 9.3-19.9 5.9-33.4-12.2-47.8-18.1-14.5-38.6-12.6-56.1 5.4l-83.9 95c-8.7 9.8-22.6 12.1-34 5.6-11.3-6.5-16.4-19.8-12.2-32.2L740.6 270c6.2-18.4-1-38.3-17.5-48.4-10.6-6.5-23.5-7.8-35.2-3.7-11.6 4.1-20.7 13.1-24.9 24.7l-44.4 144.5C614 402 601 411.4 586 411.4c-1.7 0-3.4-0.1-5.2-0.4-17.2-2.5-29.3-16.3-29.6-33.6l-2.9-200.7c-0.5-21.1-17.5-37.7-38.7-37.7-21.3 0-38.7 17.4-38.7 38.7v225c0 17.1-11.7 31-28.5 33.9-16.8 2.9-32.6-6.2-38.3-22.3L356 280.1c-4.8-13.3-13.6-22.7-24.8-26.5s-23.9-1.7-35.8 5.9c-15.5 9.9-22.8 29.2-17.7 46.9l86.6 302.1c3.8 13.2-0.4 27-10.9 35.8-10.5 8.9-24.8 10.6-37.2 4.6l-104.9-51-1.5-1.1c-16.7-12.4-39.6-10.9-54.5 3.6-8.2 8-12.8 18.7-12.8 30.1 0 11.3 4.3 22 12.3 30 5.5 5 78.2 71.5 120.2 112.9 41.4 40.7 103.2 106.8 109.1 113.2z" p-id="11962"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/assets/icons/CLasso.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746610183982" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="20245" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 85.333333a426.666667 426.666667 0 0 0-110.421333 14.506667 42.666667 42.666667 0 0 0 22.058666 82.474667A341.333333 341.333333 0 0 1 512 170.666667a42.666667 42.666667 0 1 0 0-85.333334z m221.610667 62.08a42.666667 42.666667 0 0 0-44.288 72.917334c23.168 14.08 44.672 30.976 64 50.346666a42.666667 42.666667 0 1 0 60.373333-60.373333 426.666667 426.666667 0 0 0-80.085333-62.890667zM270.677333 270.634667A42.666667 42.666667 0 0 0 210.261333 210.346667 426.666667 426.666667 0 0 0 146.261333 292.266667a42.666667 42.666667 0 1 0 73.130667 43.946666 341.333333 341.333333 0 0 1 51.242667-65.578666z m625.237334 55.168a42.666667 42.666667 0 1 0-76.8 37.248 341.333333 341.333333 0 0 1 22.613333 60.586666 42.666667 42.666667 0 0 0 82.389333-22.058666 426.794667 426.794667 0 0 0-28.202666-75.776zM170.666667 512a42.666667 42.666667 0 0 0-85.333334 0 426.666667 426.666667 0 0 0 14.506667 110.421333 42.666667 42.666667 0 0 0 82.474667-22.058666A341.333333 341.333333 0 0 1 170.666667 512z m99.968 241.365333A42.666667 42.666667 0 1 0 210.346667 813.653333a426.709333 426.709333 0 0 0 191.274666 110.421334 42.666667 42.666667 0 0 0 22.058667-82.432 341.376 341.376 0 0 1-153.002667-88.32z m276.608-303.189333l-1.408-0.426667a324.565333 324.565333 0 0 0-26.581334-8.064c-7.765333-1.792-22.314667-4.565333-37.845333 0.981334a64 64 0 0 0-38.741333 38.741333c-5.546667 15.530667-2.773333 30.08-0.981334 37.845333 1.877333 8.021333 4.992 17.408 8.021334 26.581334l0.469333 1.408 116.224 350.976 0.469333 1.493333c3.498667 10.538667 6.954667 21.034667 10.538667 29.226667 3.242667 7.509333 10.197333 22.4 25.472 31.829333a64 64 0 0 0 57.258667 4.992c16.682667-6.656 26.112-20.096 30.592-26.88 4.949333-7.509333 10.154667-17.237333 15.445333-27.008l0.725333-1.408 71.253334-132.266667 132.309333-71.253333 1.408-0.768c9.813333-5.290667 19.498667-10.496 26.965333-15.445333 6.826667-4.48 20.266667-13.909333 26.922667-30.592a64 64 0 0 0-4.992-57.258667c-9.429333-15.274667-24.32-22.229333-31.829333-25.472a376.192 376.192 0 0 0-29.226667-10.538667l-1.493333-0.469333-350.976-116.224z m93.909333 402.432l-104.618667-316.074667 316.074667 104.661334-117.162667 63.061333-0.64 0.341333a64.298667 64.298667 0 0 0-22.613333 18.133334c-3.626667 4.608-6.314667 9.642667-7.594667 12.074666l-0.341333 0.64-63.104 117.162667z" p-id="20246"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
1
src/assets/icons/CLassoArea.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746588005687" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16633" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M138.666667 765.781333H85.333333v84.608C85.333333 899.157333 121.173333 938.666667 165.333333 938.666667h57.770667v-58.88H165.333333c-14.72 0-26.666667-13.141333-26.666666-29.397334v-84.608z m0-169.173333H85.333333v-169.216h53.333334v169.216z m0-338.389333H85.333333V173.610667C85.333333 124.842667 121.173333 85.333333 165.333333 85.333333h57.770667v58.88H165.333333c-14.72 0-26.666667 13.141333-26.666666 29.397334v84.608z m200.021333-114.048V85.333333h115.541333v58.88H338.645333z m231.082667 0V85.333333h115.584v58.88h-115.584z m231.125333 0V85.333333h57.770667C902.826667 85.333333 938.666667 124.842667 938.666667 173.610667v84.608h-53.333334V173.610667c0-16.213333-11.946667-29.44-26.666666-29.44h-57.770667z m84.437333 283.221333H938.666667V554.666667h-53.333334v-127.274667zM454.229333 879.829333V938.666667H338.645333v-58.88h115.584z" fill="#1E2226" p-id="16634"></path><path d="M597.333333 725.333333h341.333334v85.333334h-341.333334z" fill="#1E2226" p-id="16635"></path><path d="M725.333333 938.666667v-341.333334h85.333334v341.333334z" fill="#1E2226" p-id="16636"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/icons/CLayout.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746500420404" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8000" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M938.633323 570.21589 554.672082 742.800573c-36.761735 15.990183-51.399706 15.990183-85.329837 0L85.366677 570.21589c-20.383048-10.82658-25.879451-50.886971-25.879451-72.745814 25.630777 14.637372 61.674122 30.959106 60.334559 30.131251l392.178726 180.787506 392.19203-180.787506c0.662106-0.165776 35.628888-17.786085 60.320232-30.131251C964.512773 519.839549 957.57959 561.377594 938.633323 570.21589zM938.633323 389.413034 554.672082 562.012044c-36.761735 15.991206-51.399706 15.991206-85.329837 0L85.366677 389.413034c-30.160116-16.004509-29.221706-66.407456 0-85.229127L469.342245 101.453646c33.930131-16.943904 55.155394-17.882276 85.329837 0l383.961241 202.730261C967.854005 320.188416 966.887964 376.225687 938.633323 389.413034zM511.999488 135.865387 119.820762 346.798471l392.178726 165.252695L904.192541 346.798471 511.999488 135.865387zM511.999488 135.865387M511.999488 889.202944l392.19203-180.815135c0.662106-0.165776 35.628888-17.786085 60.320232-30.130228 0 22.369473-6.93216 63.907519-25.879451 72.745814L554.672082 923.615709c-36.761735 15.990183-51.399706 15.990183-85.329837 0L85.366677 751.002372c-20.383048-10.82658-25.879451-50.872644-25.879451-72.745814 25.630777 14.637372 61.674122 30.959106 60.334559 30.130228L511.999488 889.202944z" fill="#272636" p-id="8001"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1
src/assets/icons/CLiquefying.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746587793915" class="icon" viewBox="0 0 1049 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7023" xmlns:xlink="http://www.w3.org/1999/xlink" width="204.8828125" height="200"><path d="M199.8848 263.8848a76.8 76.8 0 0 1 76.8-76.8h496.2304a76.8 76.8 0 0 1 76.8 76.8v496.2304a76.8 76.8 0 0 1-76.8 76.8H276.6848a76.8 76.8 0 0 1-76.8-76.8V263.8848z m466.816-12.8v230.528h119.04v-217.728a12.8 12.8 0 0 0-12.8-12.8h-106.24z m-64 214.4256V251.0848h-149.12l-28.7488 101.1712c-2.4064 8.4224-4.4544 16.9216-6.144 25.4976 18.1504 2.6112 36.0448 8.3712 52.8896 17.408l131.1232 70.3488z m-190.2848-23.9616c0 14.848 1.024 29.7472 3.072 44.544l39.6032 286.8224h147.584v-234.7776l-161.3824-86.6048a95.872 95.872 0 0 0-28.8768-9.984z m-59.4688-59.5456c2.4576-15.9232 5.888-31.6928 10.3168-47.232l23.7824-83.712h-110.3616a12.8 12.8 0 0 0-12.8 12.8v177.3568l20.8896-20.1984a159.6928 159.6928 0 0 1 68.1728-39.0144z m-89.088 148.1728v229.9392a12.8 12.8 0 0 0 12.8 12.8h113.8176l-38.4-278.0416c-1.9456-14.0544-3.0976-28.16-3.5072-42.24-6.912 3.8912-13.4144 8.704-19.328 14.4384l-65.3568 63.104z m402.8416 15.4368v227.328h106.24a12.8 12.8 0 0 0 12.8-12.8v-214.528h-119.04z" fill="#555555" p-id="7024"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/icons/CLock.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747121371122" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="57034" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M800 448H704V320c0-106.4-85.6-192-192-192S320 213.6 320 320v128H224c-17.6 0-32 14.4-32 32v384c0 17.6 14.4 32 32 32h576c17.6 0 32-14.4 32-32V480c0-17.6-14.4-32-32-32zM512 736c-35.2 0-64-28.8-64-64s28.8-64 64-64 64 28.8 64 64-28.8 64-64 64z m128-288H384V320c0-70.4 57.6-128 128-128s128 57.6 128 128v128z" p-id="57035"></path></svg>
|
||||||
|
After Width: | Height: | Size: 663 B |
1
src/assets/icons/CMiniMap.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746611649007" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="45073" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M960 495.424V128a32 32 0 0 0-32-32H96a32 32 0 0 0-32 32v768a32 32 0 0 0 32 32h433.68a30.32 30.32 0 1 0 0-60.64H127.824V157.968h768.688v337.44a31.744 31.744 0 1 0 63.488 0zM630.512 928c-5.968 0-11.696-2.448-15.92-6.8A23.568 23.568 0 0 1 608 904.8V583.216c0-6.16 2.368-12.064 6.592-16.416s9.952-6.8 15.936-6.8h311.84c5.984 0 11.712 2.448 15.936 6.8s6.592 10.24 6.592 16.416v321.376c0 6.16-2.368 12.064-6.592 16.432a22.176 22.176 0 0 1-15.936 6.784L630.528 928zM672 624v240h224V624H672z m-358.096-150.432l93.392-0.24L184.16 250.176l40.848-40.848 223.136 223.136 0.24-93.392 57.68-0.16-0.48 162.912a28.96 28.96 0 0 1-28.928 28.928l-162.928 0.48 0.16-57.664z" fill="#2C2C2C" p-id="45074"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/assets/icons/CPaste.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1748837485524" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3310" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M469.12 80.64h128a32 32 0 0 0 32-32 32 32 0 0 0-32-32h-128a32 32 0 0 0-32 32 32 32 0 0 0 32 32zM276.48 174.08a32 32 0 0 0 32-32v-29.44a32 32 0 0 1 31.36-32 32 32 0 0 0 32-32 33.28 33.28 0 0 0-32.64-32 96.64 96.64 0 0 0-94.72 96v29.44a32 32 0 0 0 32 32zM727.68 80.64h128a32 32 0 0 0 32-32 32 32 0 0 0-32-32h-128a32 32 0 0 0-32 32 32 32 0 0 0 32 32zM976 571.52a32 32 0 0 0-32 32v128a32.64 32.64 0 0 0 32 32 32 32 0 0 0 32-32v-128a32 32 0 0 0-32-32zM912 794.88h-128a32.64 32.64 0 0 0-32 32 32 32 0 0 0 32 32h128a31.36 31.36 0 0 0 31.36-32 33.28 33.28 0 0 0-31.36-32zM1000.32 74.24A32 32 0 0 0 960 57.6a32.64 32.64 0 0 0-16.64 42.24 28.8 28.8 0 0 1 0 12.8v103.04a32.64 32.64 0 0 0 32 32 32 32 0 0 0 32-32V112.64a97.92 97.92 0 0 0-7.04-38.4zM976 312.96a32 32 0 0 0-32 32v128a32.64 32.64 0 0 0 32 32 32 32 0 0 0 32-32v-128a32 32 0 0 0-32-32z" p-id="3311"></path><path d="M683.52 1006.08H112a96 96 0 0 1-96-96V259.84a96 96 0 0 1 96-96h571.52a96 96 0 0 1 96 96v650.24a96 96 0 0 1-96 96zM112 227.84a32 32 0 0 0-32 32v650.24a32 32 0 0 0 32 32h571.52a32 32 0 0 0 32-32V259.84a32 32 0 0 0-32-32z" p-id="3312"></path><path d="M604.16 423.68H192a32 32 0 0 1-32-32 32 32 0 0 1 32-32h412.16a32 32 0 0 1 32 32 32.64 32.64 0 0 1-32 32zM604.16 616.96H192a32 32 0 0 1 0-64h412.16a32 32 0 0 1 0 64zM604.16 810.24H192a32 32 0 0 1-32-32 32 32 0 0 1 32-32h412.16a32.64 32.64 0 0 1 32 32 32 32 0 0 1-32 32z" p-id="3313"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/assets/icons/CPicture.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746503112377" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="17415" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M938.666667 553.92V768c0 64.8-52.533333 117.333333-117.333334 117.333333H202.666667c-64.8 0-117.333333-52.533333-117.333334-117.333333V256c0-64.8 52.533333-117.333333 117.333334-117.333333h618.666666c64.8 0 117.333333 52.533333 117.333334 117.333333v297.92z m-64-74.624V256a53.333333 53.333333 0 0 0-53.333334-53.333333H202.666667a53.333333 53.333333 0 0 0-53.333334 53.333333v344.48A290.090667 290.090667 0 0 1 192 597.333333a286.88 286.88 0 0 1 183.296 65.845334C427.029333 528.384 556.906667 437.333333 704 437.333333c65.706667 0 126.997333 16.778667 170.666667 41.962667z m0 82.24c-5.333333-8.32-21.130667-21.653333-43.648-32.917333C796.768 511.488 753.045333 501.333333 704 501.333333c-121.770667 0-229.130667 76.266667-270.432 188.693334-2.730667 7.445333-7.402667 20.32-13.994667 38.581333-7.68 21.301333-34.453333 28.106667-51.370666 13.056-16.437333-14.634667-28.554667-25.066667-36.138667-31.146667A222.890667 222.890667 0 0 0 192 661.333333c-14.464 0-28.725333 1.365333-42.666667 4.053334V768a53.333333 53.333333 0 0 0 53.333334 53.333333h618.666666a53.333333 53.333333 0 0 0 53.333334-53.333333V561.525333zM320 480a96 96 0 1 1 0-192 96 96 0 0 1 0 192z m0-64a32 32 0 1 0 0-64 32 32 0 0 0 0 64z" fill="#000000" p-id="17416"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/assets/icons/CRectangle.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1748768875438" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5079" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M876.4664713541666 302.9680989583333H776.4029947916665V202.90462239583334c0-11.865234374999998-9.8876953125-21.7529296875-21.7529296875-21.7529296875s-21.7529296875 9.8876953125-21.7529296875 21.7529296875v100.0634765625H632.8336588541666c-11.865234374999998 0-21.7529296875 9.8876953125-21.7529296875 21.7529296875s9.8876953125 21.7529296875 21.7529296875 21.7529296875h100.0634765625v100.0634765625c0 11.865234374999998 9.8876953125 21.7529296875 21.7529296875 21.7529296875s21.7529296875-9.8876953125 21.7529296875-21.7529296875V346.4739583333333H876.4664713541666c11.865234374999998 0 21.7529296875-9.8876953125 21.7529296875-21.7529296875 0-12.2607421875-9.8876953125-21.7529296875-21.7529296875-21.7529296875zM146.75455729166666 386.0247395833333c11.07421875 0 19.775390625-8.701171874999998 19.775390625-19.775390625h0.7910156249999999v-21.7529296875h36.38671875c10.678710937499998 0 19.775390625-8.701171874999998 19.775390625-19.775390625s-9.0966796875-19.775390625-19.775390625-19.775390625l-56.953125-0.39550781249999994c-11.07421875 0-19.775390625 8.701171874999998-19.775390625 19.775390625v42.71484374999999c0 9.8876953125 8.701171874999998 18.984374999999996 19.775390625 18.984374999999996z m135.26367187500003-41.92382812499999h98.876953125c11.07421875 0 19.775390625-8.701171874999998 19.775390625-19.775390625s-8.701171874999998-19.775390625-19.775390625-19.775390625h-98.876953125c-11.07421875 0-19.775390625 9.0966796875-19.775390625 19.775390625 0 11.07421875 9.0966796875 19.775390625 19.775390625 19.775390625z m177.1875 0h98.876953125c11.07421875 0 19.775390625-8.701171874999998 19.775390625-19.775390625s-8.701171874999998-19.775390625-19.775390625-19.775390625h-98.876953125c-11.07421875 0-19.775390625 9.0966796875-19.775390625 19.775390625 0.7910156249999999 11.07421875 8.701171874999998 19.775390625 19.775390625 19.775390625zM774.8209635416665 560.8391927083335c0-11.07421875-8.701171874999998-19.775390625-19.775390625-19.775390625s-19.775390625 8.701171874999998-19.775390625 19.775390625v98.876953125c0 11.07421875 8.701171874999998 19.775390625 19.775390625 19.775390625s19.775390625-8.701171874999998 19.775390625-19.775390625v-98.876953125z m-19.775390625 157.41210937500003c-11.07421875 0-19.775390625 9.0966796875-19.775390625 19.775390625v64.072265625h-79.1015625v1.1865234374999998c-11.07421875 0-19.775390625 8.701171874999998-19.775390625 19.775390625s8.701171874999998 19.775390625 19.775390625 19.775390625h98.876953125c11.07421875 0 19.775390625-8.701171874999998 19.775390625-19.775390625V738.8177083333334c0-11.865234374999998-9.0966796875-20.56640625-19.775390625-20.56640625zM581.0221354166666 802.0989583333333L166.52994791666666 801.3079427083333V423.2024739583333h-0.7910156249999999c0-11.07421875-8.701171874999998-19.775390625-19.775390625-19.775390625s-19.775390625 8.701171874999998-19.775390625 19.775390625V821.8743489583334c0 11.07421875 8.701171874999998 19.775390625 19.775390625 19.775390625H579.8356119791666c11.07421875 0 19.775390625-9.0966796875 19.775390625-19.775390625 0-11.07421875-9.0966796875-19.775390625-18.5888671875-19.775390625z" p-id="5080"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
1
src/assets/icons/CRedo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746612002161" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2290" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M730.026667 146.346667c-25.173333-25.173333-25.173333-65.706667 0-90.88 25.173333-25.173333 66.56-25.173333 91.733333 0l183.466667 181.76c25.173333 25.173333 25.173333 65.706667 0 90.88l-183.466667 181.76-1.706667 1.706666c-25.6 24.746667-66.986667 23.893333-91.733333-1.706666-24.746667-25.6-24.32-66.133333 1.706667-90.88l75.093333-74.24H389.12c-143.36 0-259.413333 115.2-259.413333 257.28s116.053333 257.28 259.413333 257.28h518.826667c35.84 0 64.853333 28.586667 64.853333 64.426666 0 35.413333-29.013333 64.426667-64.853333 64.426667H389.12C174.08 987.306667 0 814.933333 0 601.6c0-212.906667 174.08-385.706667 389.12-385.706667h411.306667l-70.4-69.546666z" p-id="2291"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1021 B |
1
src/assets/icons/CSelect.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747121290756" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="51598" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M829.888 739.392L547.84 457.28 706.112 364.8l-512-170.624L364.8 706.112l92.48-158.336 282.112 282.112z" fill="#040000" p-id="51599"></path></svg>
|
||||||
|
After Width: | Height: | Size: 479 B |
1
src/assets/icons/CUnEye.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746501321533" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9159" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 266.723556c-19.996444 0-39.196444 1.422222-57.628444 4.124444-23.324444 3.356444-45.056-12.088889-48.554667-34.531556-3.527111-22.442667 12.515556-43.377778 35.84-46.734222 22.613333-3.299556 46.08-5.034667 70.343111-5.034666 175.388444 0 303.36 91.022222 385.308444 177.777777a761.059556 761.059556 0 0 1 114.858667 159.175111c2.56 4.864 5.063111 9.756444 7.480889 14.705778l0.426667 0.938667 0.170666 0.341333v0.085334l0.028445 0.028444-38.968889 16.725333 39.025778 16.725334-0.085334 0.113777-0.085333 0.227556-0.341333 0.739556a624.355556 624.355556 0 0 1-22.613334 41.642666 770.389333 770.389333 0 0 1-67.84 96.227556 43.804444 43.804444 0 0 1-59.591111 6.144 40.078222 40.078222 0 0 1-7.196444-57.287111 686.478222 686.478222 0 0 0 71.168-104.533334 678.968889 678.968889 0 0 0-99.555556-136.675555C760.945778 340.053333 654.165333 266.723556 512 266.723556z m469.333333 287.601777l38.968889 16.725334a39.822222 39.822222 0 0 0 0-33.450667l-38.968889 16.725333zM186.965333 359.594667c8.391111 7.338667 13.368889 17.578667 13.909334 28.444444 0.512 10.894222-3.470222 21.532444-11.093334 29.582222a679.168 679.168 0 0 0-99.555555 136.704 679.367111 679.367111 0 0 0 99.555555 136.704C263.111111 768.568889 369.834667 841.927111 512 841.927111a394.808889 394.808889 0 0 0 134.4-23.239111c21.902222-7.395556 45.909333 3.470222 54.044444 24.405333 8.106667 20.935111-2.616889 44.259556-24.177777 52.536889A483.185778 483.185778 0 0 1 512 924.103111c-175.388444 0-303.36-91.022222-385.308444-177.777778a760.917333 760.917333 0 0 1-114.858667-159.175111c-2.56-4.835556-5.063111-9.756444-7.480889-14.705778l-0.426667-0.938666-0.170666-0.341334-0.028445-0.056888v-0.056889l38.968889-16.725334L3.640889 537.6l0.056889-0.028444 0.028444-0.085334 0.142222-0.341333 0.426667-0.938667 1.621333-3.185778c8.817778-17.464889 18.488889-34.531556 28.842667-51.171555a763.505778 763.505778 0 0 1 91.875556-119.523556 43.349333 43.349333 0 0 1 29.582222-13.368889c11.292444-0.540444 22.357333 3.299556 30.72 10.638223zM42.666667 554.325333L3.697778 537.6a39.822222 39.822222 0 0 0 0 33.450667l38.968889-16.725334z" fill="#000000" p-id="9160"></path><path d="M469.816889 364.544c0-22.784 19.000889-41.272889 42.439111-41.272889 128.113778 0 229.233778 104.391111 229.233778 229.916445 0 14.734222-8.106667 28.359111-21.219556 35.754666a43.52 43.52 0 0 1-42.467555 0 41.073778 41.073778 0 0 1-21.219556-35.754666c0-82.915556-66.133333-147.370667-144.327111-147.370667-23.438222 0-42.439111-18.488889-42.439111-41.272889z m-130.929778 107.008c22.926222 4.807111 37.489778 26.737778 32.568889 49.038222a151.04 151.04 0 0 0-3.527111 32.597334c0 82.915556 66.133333 147.370667 144.327111 147.370666 11.207111 0 22.072889-1.28 32.483556-3.697778 22.755556-5.376 45.710222 8.248889 51.2 30.407112 5.546667 22.158222-8.476444 44.458667-31.260445 49.806222a229.063111 229.063111 0 0 1-52.423111 5.973333c-128.113778 0-229.233778-104.391111-229.233778-229.916444 0-17.066667 1.877333-33.848889 5.432889-49.92 2.360889-10.695111 9.016889-20.053333 18.488889-25.998223 9.443556-5.973333 20.935111-7.992889 31.943111-5.688888v0.028444zM100.209778 111.303111a43.320889 43.320889 0 0 1 60.017778 0L924.302222 854.186667c16.099556 16.213333 15.872 41.955556-0.512 57.856a43.320889 43.320889 0 0 1-59.505778 0.512L100.209778 169.671111a40.476444 40.476444 0 0 1 0-58.368z" fill="#000000" p-id="9161"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
1
src/assets/icons/CUnLock.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1747121366655" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="56887" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M800 448H704V320c0-106.4-85.6-192-192-192S320 213.6 320 320h64c0-70.4 57.6-128 128-128s128 57.6 128 128v128H224c-17.6 0-32 14.4-32 32v384c0 17.6 14.4 32 32 32h576c17.6 0 32-14.4 32-32V480c0-17.6-14.4-32-32-32zM512 736c-35.2 0-64-28.8-64-64s28.8-64 64-64 64 28.8 64 64-28.8 64-64 64z" p-id="56888"></path></svg>
|
||||||
|
After Width: | Height: | Size: 644 B |
1
src/assets/icons/CUndo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746611998442" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="46418" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M223.300267 221.320533h410.555733c214.493867 0 388.437333 173.192533 388.437333 386.798934 0 213.674667-173.943467 386.8672-388.437333 386.8672H116.053333a64.580267 64.580267 0 0 1-64.7168-64.512c0-35.566933 29.013333-64.443733 64.7168-64.443734h517.802667a258.389333 258.389333 0 0 0 258.935467-257.911466 258.389333 258.389333 0 0 0-258.935467-257.8432h-415.061333L293.546667 424.823467a64.3072 64.3072 0 0 1-28.672 108.7488 64.853333 64.853333 0 0 1-62.941867-17.6128L19.114667 333.687467a64.375467 64.375467 0 0 1 0-91.204267L201.9328 60.074667a64.9216 64.9216 0 0 1 91.613867 0c25.258667 25.122133 25.258667 65.9456 0 91.136l-70.314667 70.0416z"p-id="46419"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1010 B |
1
src/assets/icons/CUpload.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746611247277" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28462" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M857.6 956.8H166.4c-54.4 0-102.4-48-102.4-105.6v-182.4c0-19.2 12.8-32 32-32s32 12.8 32 32v182.4c0 22.4 16 41.6 38.4 41.6h694.4c19.2 0 38.4-19.2 38.4-41.6v-182.4c0-19.2 12.8-32 32-32s32 12.8 32 32v182.4c-3.2 57.6-48 105.6-105.6 105.6z" fill="#333333" p-id="28463"></path><path d="M512 764.8c-19.2 0-32-12.8-32-32v-640c0-19.2 12.8-32 32-32s32 12.8 32 32v640c0 19.2-12.8 32-32 32z" fill="#333333" p-id="28464"></path><path d="M720 326.4c-9.6 0-16-3.2-22.4-9.6L512 131.2l-185.6 185.6c-12.8 12.8-32 12.8-44.8 0s-12.8-32 0-44.8L489.6 64c12.8-12.8 32-12.8 44.8 0l208 208c12.8 12.8 12.8 32 0 44.8-6.4 6.4-12.8 9.6-22.4 9.6z" fill="#333333" p-id="28465"></path></svg>
|
||||||
|
After Width: | Height: | Size: 992 B |
1
src/assets/icons/CWave.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746587727411" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4759" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M396.909714 211.090286c-21.869714-27.355429-25.6-79.872 0-93.805715 25.6-13.897143 61.842286-13.897143 110.226286 46.043429 31.195429 38.692571 52.370286 87.405714 76.434286 142.848 13.202286 30.427429 27.318857 62.902857 44.434285 96.877714 48.347429 95.890286 12.068571 191.780571-36.278857 287.670857-15.36 30.464-24.32 53.906286-33.645714 78.08a877.202286 877.202286 0 0 1-38.875429 89.746286c-30.793143 38.765714-72.411429 59.977143-102.765714 46.445714-66.121143-29.44-7.606857-104.740571 5.558857-121.673142l1.682286-2.157715c23.003429-30.500571 99.437714-140.141714 134.436571-204.324571 32.256-64.841143 0.804571-131.181714-26.806857-173.787429-17.042286-26.294857-112.530286-164.571429-134.4-191.963428z" p-id="4760"></path><path d="M591.725714 199.314286c-24.137143-59.977143 61.805714-90.88 96.731429-35.986286 22.674286 35.620571 35.913143 78.774857 49.298286 122.221714 7.204571 23.478857 14.445714 47.067429 23.222857 69.558857l2.925714 7.460572c22.966857 58.88 33.353143 85.504 33.353143 148.370286 0 95.890286-60.452571 255.670857-108.8 359.606857-41.801143 89.819429-138.678857 24.868571-96.731429-47.908572 62.902857-109.165714 96.694857-215.771429 108.8-287.707428 8.667429-51.492571-16.054857-111.542857-34.633143-156.672-5.412571-13.165714-10.313143-25.088-13.714285-35.108572-12.032-35.693714-22.564571-58.624-34.230857-83.968a1268.553143 1268.553143 0 0 1-26.185143-59.904z" p-id="4761"></path><path d="M808.996571 304.969143c20.662857 44.141714 41.691429 126.573714 41.691429 207.030857 0 102.509714-20.004571 181.028571-70.070857 261.229714-25.014857 29.622857-29.988571 49.371429-10.020572 49.371429 20.004571 0 116.370286-77.385143 159.597715-191.780572 36.278857-95.926857 25.124571-237.275429-36.242286-311.661714-7.899429-9.581714-14.445714-18.212571-20.589714-26.258286-12.397714-16.347429-23.076571-30.390857-39.862857-45.714285-25.014857-22.784-70.034286-51.858286-41.691429 18.907428 5.924571 14.811429 11.702857 27.172571 17.188571 38.875429zM209.810286 251.977143c41.179429 40.704 166.729143 176.64 188.525714 211.017143 21.76 34.377143 2.742857 85.357714-12.105143 107.885714-12.982857 19.748571-58.88 61.988571-97.828571 97.865143-29.44 27.062857-54.893714 50.541714-59.318857 57.965714a79.981714 79.981714 0 0 1-5.010286 6.765714c-11.446857 14.628571-34.450286 44.141714-19.163429 77.165715 18.249143 39.387429 93.110857 13.348571 132.973715-23.990857 67.145143-62.902857 149.394286-196.754286 164.571428-221.366858l1.828572-3.035428c23.625143-38.034286 22.674286-90.258286-33.426286-159.195429-17.956571-22.052571-35.547429-45.970286-53.430857-70.217143-37.961143-51.565714-77.165714-104.813714-123.465143-145.554285-68.132571-59.940571-125.330286 23.990857-84.114286 64.694857z" p-id="4762"></path><path d="M132.388571 331.117714c-18.212571 16.128-24.173714 35.986286-12.068571 71.936 2.779429 8.374857 5.741714 15.689143 8.594286 22.674286 8.265143 20.48 15.542857 38.546286 15.542857 73.216 0 34.816 0 59.940571-17.188572 102.875429-10.678857 26.843429-29.513143 77.750857-6.948571 88.905142 48.310857 23.990857 144.603429-68.205714 144.603429-68.205714s88.027429-60.233143 80.457142-124.269714c-3.657143-30.72-69.376-95.963429-106.166857-132.461714a571.574857 571.574857 0 0 1-22.198857-22.674286c-16.822857-19.419429-68.973714-25.856-84.626286-11.995429z" p-id="4763"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
1
src/assets/icons/CZoomIn.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746610782756" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="24757" data-spm-anchor-id="a313x.search_index.0.i11.1f9d3a81ZFYXJK" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M929.476923 854.646154l-212.676923-200.861539c47.261538-59.076923 78.769231-137.846154 78.769231-220.553846 0-196.923077-157.538462-354.461538-354.461539-354.461538s-354.461538 157.538462-354.461538 354.461538 157.538462 354.461538 354.461538 354.461539c90.584615 0 173.292308-35.446154 236.307693-90.584616l212.676923 200.861539c3.938462 3.938462 11.815385 7.876923 19.692307 7.876923s15.753846-3.938462 19.692308-7.876923c11.815385-11.815385 11.815385-31.507692 0-43.323077z m-488.369231-126.030769c-161.476923 0-295.384615-133.907692-295.384615-295.384616s133.907692-295.384615 295.384615-295.384615 295.384615 133.907692 295.384616 295.384615-133.907692 295.384615-295.384616 295.384616z" fill="#1A1311" p-id="24758"></path><path d="M598.646154 401.723077h-129.969231V271.753846c0-15.753846-11.815385-31.507692-31.507692-31.507692s-31.507692 11.815385-31.507693 31.507692v129.969231H279.630769c-15.753846 0-31.507692 11.815385-31.507692 31.507692s11.815385 31.507692 31.507692 31.507693h129.969231V590.769231c0 15.753846 11.815385 31.507692 31.507692 31.507692s31.507692-11.815385 31.507693-31.507692v-129.969231h129.96923c15.753846 0 31.507692-11.815385 31.507693-31.507692s-15.753846-27.569231-35.446154-27.569231z" fill="#1A1311" p-id="24759" data-spm-anchor-id="a313x.search_index.0.i10.1f9d3a81ZFYXJK" class="selected"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/assets/icons/CZoomOut.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1746611054145" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="27439" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M969.28 940.16l-175.36-175.36c78.72-82.24 123.84-196.16 114.56-318.72-16.96-218.56-199.04-384.64-414.72-384.64-10.56 0-21.12 0.32-32 1.28C232.96 80 61.12 279.68 78.72 508.8c16.64 218.24 199.04 384.64 414.4 384.64 10.56 0 21.12-0.32 32-1.28 83.52-6.4 159.36-37.12 221.44-84.8l177.92 177.92c6.4 6.4 14.4 9.28 22.72 9.28s16.32-3.2 22.72-9.28c11.84-12.48 11.84-32.64-0.64-45.12z m-449.28-111.68c-8.96 0.64-18.24 0.96-27.2 0.96-182.72 0-336.64-143.04-350.4-325.44-7.04-93.76 22.72-184.64 83.84-256a348.768 348.768 0 0 1 267.52-122.56c182.72 0 336.64 143.04 350.4 325.44 7.04 93.76-22.72 184.64-83.84 256a351.36 351.36 0 0 1-240.32 121.6z" fill="#070001" p-id="27440"></path><path d="M685.44 445.44h-384c-17.6 0-32 14.4-32 32s14.4 32 32 32h384c17.6 0 32-14.4 32-32s-14.4-32-32-32z" fill="#040000" p-id="27441"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/homePage/defaultModel.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/assets/redGreenPic/clothing_base_image.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/assets/redGreenPic/clothing_mask_image.png
Normal file
|
After Width: | Height: | Size: 869 B |
@@ -707,7 +707,7 @@ tr > .ant-picker-cell-in-view.ant-picker-cell-range-hover-start:last-child::afte
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 5rem;
|
margin-right: 5rem;
|
||||||
height: 6rem;
|
height: 5rem;
|
||||||
}
|
}
|
||||||
.generalModel_state .generalModel_state_item.smail > input {
|
.generalModel_state .generalModel_state_item.smail > input {
|
||||||
padding: 1rem 2rem !important;
|
padding: 1rem 2rem !important;
|
||||||
@@ -725,7 +725,7 @@ tr > .ant-picker-cell-in-view.ant-picker-cell-range-hover-start:last-child::afte
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.generalModel_state .generalModel_state_item > input {
|
.generalModel_state .generalModel_state_item > input {
|
||||||
height: 6rem !important;
|
height: 5rem !important;
|
||||||
padding: 1rem !important;
|
padding: 1rem !important;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -780,7 +780,7 @@ tr > .ant-picker-cell-in-view.ant-picker-cell-range-hover-start:last-child::afte
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 5rem;
|
margin-right: 5rem;
|
||||||
height: 6rem;
|
height: 5rem;
|
||||||
&.smail{
|
&.smail{
|
||||||
>input{
|
>input{
|
||||||
padding: 1rem 2rem !important;
|
padding: 1rem 2rem !important;
|
||||||
@@ -797,7 +797,7 @@ tr > .ant-picker-cell-in-view.ant-picker-cell-range-hover-start:last-child::afte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>input{
|
>input{
|
||||||
height: 6rem !important;
|
height: 5rem !important;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1rem !important;
|
padding: 1rem !important;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
|||||||
BIN
src/assets/texture/texture0.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/assets/texture/texture1.webp
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
src/assets/texture/texture10.webp
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
src/assets/texture/texture11.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
src/assets/texture/texture12.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
src/assets/texture/texture13.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
src/assets/texture/texture14.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
src/assets/texture/texture15.webp
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
src/assets/texture/texture16.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
src/assets/texture/texture17.webp
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src/assets/texture/texture18.webp
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
src/assets/texture/texture19.webp
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
src/assets/texture/texture2.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
src/assets/texture/texture20.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
src/assets/texture/texture3.webp
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
src/assets/texture/texture4.webp
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
src/assets/texture/texture5.webp
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
src/assets/texture/texture6.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/texture/texture7.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/texture/texture8.webp
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/assets/texture/texture9.webp
Normal file
|
After Width: | Height: | Size: 176 KiB |
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
589
src/component/Canvas/CanvasEditor/commands/BackgroundCommands.js
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import { Command } from "./Command";
|
||||||
|
//import { fabric } from "fabric-with-all";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建背景图层命令
|
||||||
|
*/
|
||||||
|
export class CreateBackgroundLayerCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "创建背景图层",
|
||||||
|
saveState: true,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.backgroundLayer = options.backgroundLayer;
|
||||||
|
this.canvasManager = options.canvasManager;
|
||||||
|
this.historyManager = options.historyManager;
|
||||||
|
|
||||||
|
this.beforeLayers = [...this.layers.value]; // 备份原图层列表
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 检查是否已经存在背景图层
|
||||||
|
const existingBgLayer = this.layers.value.find(
|
||||||
|
(layer) => layer.isBackground
|
||||||
|
);
|
||||||
|
if (existingBgLayer) {
|
||||||
|
console.warn("已存在背景层,不重复创建");
|
||||||
|
return existingBgLayer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建背景矩形对象
|
||||||
|
const bgObject = this._createBackgroundObject();
|
||||||
|
|
||||||
|
// 将背景对象添加到图层中
|
||||||
|
this.backgroundLayer.fabricObject = bgObject;
|
||||||
|
|
||||||
|
// 添加图层到最底部
|
||||||
|
this.layers.value.push(this.backgroundLayer);
|
||||||
|
|
||||||
|
// 添加到画布
|
||||||
|
this.canvas.add(bgObject);
|
||||||
|
|
||||||
|
// 渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return this.backgroundLayer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 从图层列表中删除背景图层
|
||||||
|
const bgLayerIndex = this.layers.value.findIndex(
|
||||||
|
(layer) => layer.isBackground
|
||||||
|
);
|
||||||
|
if (bgLayerIndex !== -1) {
|
||||||
|
this.layers.value.splice(bgLayerIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从画布中移除背景对象
|
||||||
|
if (this.backgroundLayer.fabricObject) {
|
||||||
|
this.canvas.remove(this.backgroundLayer.fabricObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建背景矩形对象
|
||||||
|
* @returns {Object} fabric.js 矩形对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createBackgroundObject() {
|
||||||
|
// 计算画布尺寸
|
||||||
|
const canvasWidth = this.canvas.width;
|
||||||
|
const canvasHeight = this.canvas.height;
|
||||||
|
|
||||||
|
// 确保背景色为白色,如果没有设置或者是透明的话
|
||||||
|
const backgroundColor =
|
||||||
|
this.backgroundLayer.backgroundColor &&
|
||||||
|
this.backgroundLayer.backgroundColor !== "transparent"
|
||||||
|
? this.backgroundLayer.backgroundColor
|
||||||
|
: "#ffffff";
|
||||||
|
|
||||||
|
const rect = new fabric.Rect({
|
||||||
|
left: canvasWidth / 2,
|
||||||
|
top: canvasHeight / 2,
|
||||||
|
width: this.backgroundLayer.canvasWidth,
|
||||||
|
height: this.backgroundLayer.canvasHeight,
|
||||||
|
fill: backgroundColor,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
hoverCursor: "default",
|
||||||
|
id: `bg_object_${this.backgroundLayer.id}`,
|
||||||
|
layerId: this.backgroundLayer.id,
|
||||||
|
layerName: this.backgroundLayer.name,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
isBackground: true, // 标记为背景对象
|
||||||
|
});
|
||||||
|
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
layerId: this.backgroundLayer.id,
|
||||||
|
layerName: this.backgroundLayer.name,
|
||||||
|
width: this.backgroundLayer.canvasWidth,
|
||||||
|
height: this.backgroundLayer.canvasHeight,
|
||||||
|
backgroundColor: this.backgroundLayer.backgroundColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新背景属性命令(如背景颜色)
|
||||||
|
*/
|
||||||
|
export class UpdateBackgroundCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "更新背景属性",
|
||||||
|
saveState: true,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.backgroundColor = options.backgroundColor;
|
||||||
|
this.historyManager = options.historyManager;
|
||||||
|
|
||||||
|
// 查找背景图层
|
||||||
|
this.bgLayer = this.layers.value.find((layer) => layer.isBackground);
|
||||||
|
this.oldBackgroundColor = this.bgLayer
|
||||||
|
? this.bgLayer.backgroundColor
|
||||||
|
: "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
if (!this.bgLayer) {
|
||||||
|
console.error("未找到背景图层");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新背景图层属性
|
||||||
|
this.bgLayer.backgroundColor = this.backgroundColor;
|
||||||
|
|
||||||
|
// 更新背景对象属性
|
||||||
|
if (this.bgLayer.fabricObject) {
|
||||||
|
this.bgLayer.fabricObject.set("fill", this.backgroundColor);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.bgLayer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复背景图层属性
|
||||||
|
this.bgLayer.backgroundColor = this.oldBackgroundColor;
|
||||||
|
|
||||||
|
// 恢复背景对象属性
|
||||||
|
if (this.bgLayer.fabricObject) {
|
||||||
|
this.bgLayer.fabricObject.set("fill", this.oldBackgroundColor);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
layerId: this.bgLayer?.id,
|
||||||
|
oldColor: this.oldBackgroundColor,
|
||||||
|
newColor: this.backgroundColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整画布和背景大小命令
|
||||||
|
*/
|
||||||
|
export class BackgroundSizeCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "调整背景大小",
|
||||||
|
saveState: true,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.canvasManager = options.canvasManager;
|
||||||
|
this.newWidth = options.newWidth;
|
||||||
|
this.newHeight = options.newHeight;
|
||||||
|
this.historyManager = options.historyManager;
|
||||||
|
|
||||||
|
// 记录原尺寸
|
||||||
|
this.oldWidth = this.canvas.width;
|
||||||
|
this.oldHeight = this.canvas.height;
|
||||||
|
|
||||||
|
// 查找背景图层
|
||||||
|
this.bgLayer = this.layers.value.find((layer) => layer.isBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 调整画布大小
|
||||||
|
this.canvas.setWidth(this.newWidth);
|
||||||
|
this.canvas.setHeight(this.newHeight);
|
||||||
|
|
||||||
|
// 如果使用 CanvasManager,通知它画布大小变化
|
||||||
|
if (
|
||||||
|
this.canvasManager &&
|
||||||
|
typeof this.canvasManager.updateCanvasSize === "function"
|
||||||
|
) {
|
||||||
|
this.canvasManager.updateCanvasSize(this.newWidth, this.newHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整背景对象大小
|
||||||
|
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||||
|
// 保持原有的背景颜色,如果没有设置则使用白色
|
||||||
|
const currentFill =
|
||||||
|
this.bgLayer.fabricObject.fill ||
|
||||||
|
this.bgLayer.backgroundColor ||
|
||||||
|
"#ffffff";
|
||||||
|
|
||||||
|
this.bgLayer.fabricObject.set({
|
||||||
|
width: this.newWidth,
|
||||||
|
height: this.newHeight,
|
||||||
|
fill: currentFill, // 保持原有颜色
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新图层记录的尺寸
|
||||||
|
this.bgLayer.canvasWidth = this.newWidth;
|
||||||
|
this.bgLayer.canvasHeight = this.newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 恢复画布大小
|
||||||
|
this.canvas.setWidth(this.oldWidth);
|
||||||
|
this.canvas.setHeight(this.oldHeight);
|
||||||
|
|
||||||
|
// 如果使用 CanvasManager,通知它画布大小恢复
|
||||||
|
if (
|
||||||
|
this.canvasManager &&
|
||||||
|
typeof this.canvasManager.updateCanvasSize === "function"
|
||||||
|
) {
|
||||||
|
this.canvasManager.updateCanvasSize(this.oldWidth, this.oldHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复背景对象大小
|
||||||
|
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||||
|
this.bgLayer.fabricObject.set({
|
||||||
|
width: this.oldWidth,
|
||||||
|
height: this.oldHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 恢复图层记录的尺寸
|
||||||
|
this.bgLayer.canvasWidth = this.oldWidth;
|
||||||
|
this.bgLayer.canvasHeight = this.oldHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
oldWidth: this.oldWidth,
|
||||||
|
oldHeight: this.oldHeight,
|
||||||
|
newWidth: this.newWidth,
|
||||||
|
newHeight: this.newHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整背景大小并等比缩放所有其他元素的命令
|
||||||
|
*/
|
||||||
|
export class BackgroundSizeWithScaleCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "调整背景大小并缩放元素",
|
||||||
|
saveState: true,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.canvasManager = options.canvasManager;
|
||||||
|
this.newWidth = options.newWidth;
|
||||||
|
this.newHeight = options.newHeight;
|
||||||
|
this.historyManager = options.historyManager;
|
||||||
|
|
||||||
|
// 缩放策略:'uniform' | 'fill' | 'fit' | 'stretch'
|
||||||
|
this.scaleStrategy = options.scaleStrategy || "uniform";
|
||||||
|
|
||||||
|
// 记录原尺寸
|
||||||
|
this.oldWidth = this.canvas.width;
|
||||||
|
this.oldHeight = this.canvas.height;
|
||||||
|
|
||||||
|
// 查找背景图层
|
||||||
|
this.bgLayer = this.layers.value.find((layer) => layer.isBackground);
|
||||||
|
|
||||||
|
// 计算缩放比例
|
||||||
|
const scaleXRatio = this.newWidth / this.oldWidth;
|
||||||
|
const scaleYRatio = this.newHeight / this.oldHeight;
|
||||||
|
|
||||||
|
// 根据策略计算缩放比例和偏移
|
||||||
|
this._calculateScaleAndOffset(scaleXRatio, scaleYRatio);
|
||||||
|
|
||||||
|
// 存储所有非背景对象的原始状态
|
||||||
|
this.objectStates = [];
|
||||||
|
this._saveOriginalStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存所有非背景对象的原始状态
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_saveOriginalStates() {
|
||||||
|
this.canvas.getObjects().forEach((obj) => {
|
||||||
|
if (!obj.isBackground) {
|
||||||
|
// 检查对象是否已经有原始状态记录
|
||||||
|
if (!obj._originalState) {
|
||||||
|
// 第一次记录原始状态
|
||||||
|
obj._originalState = {
|
||||||
|
left: obj.left,
|
||||||
|
top: obj.top,
|
||||||
|
scaleX: obj.scaleX || 1,
|
||||||
|
scaleY: obj.scaleY || 1,
|
||||||
|
width: obj.width,
|
||||||
|
height: obj.height,
|
||||||
|
// 记录基准画布尺寸
|
||||||
|
baseCanvasWidth: this.oldWidth,
|
||||||
|
baseCanvasHeight: this.oldHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.objectStates.push({
|
||||||
|
obj: obj,
|
||||||
|
// 使用原始状态而不是当前状态
|
||||||
|
left: obj._originalState.left,
|
||||||
|
top: obj._originalState.top,
|
||||||
|
scaleX: obj._originalState.scaleX,
|
||||||
|
scaleY: obj._originalState.scaleY,
|
||||||
|
width: obj._originalState.width,
|
||||||
|
height: obj._originalState.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据缩放策略计算缩放比例和偏移量
|
||||||
|
* @param {number} scaleXRatio X轴缩放比例
|
||||||
|
* @param {number} scaleYRatio Y轴缩放比例
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_calculateScaleAndOffset(scaleXRatio, scaleYRatio) {
|
||||||
|
switch (this.scaleStrategy) {
|
||||||
|
case "uniform":
|
||||||
|
// 统一缩放:使用平均值,保持相对比例的同时允许适度的形变
|
||||||
|
this.uniformScale = Math.sqrt(scaleXRatio * scaleYRatio);
|
||||||
|
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||||
|
this.offsetY =
|
||||||
|
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fit":
|
||||||
|
// 适应模式:使用较小值,确保所有内容都在画布内,可能有留白
|
||||||
|
this.uniformScale = Math.min(scaleXRatio, scaleYRatio);
|
||||||
|
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||||
|
this.offsetY =
|
||||||
|
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fill":
|
||||||
|
// 填充模式:使用较大值,填满画布,可能有部分内容被裁切
|
||||||
|
this.uniformScale = Math.max(scaleXRatio, scaleYRatio);
|
||||||
|
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||||
|
this.offsetY =
|
||||||
|
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stretch":
|
||||||
|
// 拉伸模式:不保持宽高比,完全适应新尺寸
|
||||||
|
this.scaleX = scaleXRatio;
|
||||||
|
this.scaleY = scaleYRatio;
|
||||||
|
this.offsetX = 0;
|
||||||
|
this.offsetY = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 默认使用uniform模式
|
||||||
|
this.uniformScale = Math.sqrt(scaleXRatio * scaleYRatio);
|
||||||
|
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||||
|
this.offsetY =
|
||||||
|
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 调整画布大小
|
||||||
|
this.canvas.setWidth(this.newWidth);
|
||||||
|
this.canvas.setHeight(this.newHeight);
|
||||||
|
|
||||||
|
// 如果使用 CanvasManager,通知它画布大小变化
|
||||||
|
if (
|
||||||
|
this.canvasManager &&
|
||||||
|
typeof this.canvasManager.updateCanvasSize === "function"
|
||||||
|
) {
|
||||||
|
this.canvasManager.updateCanvasSize(this.newWidth, this.newHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整背景对象大小和位置
|
||||||
|
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||||
|
// 保持原有的背景颜色,如果没有设置则使用白色
|
||||||
|
const currentFill =
|
||||||
|
this.bgLayer.fabricObject.fill ||
|
||||||
|
this.bgLayer.backgroundColor ||
|
||||||
|
"#ffffff";
|
||||||
|
|
||||||
|
this.bgLayer.fabricObject.set({
|
||||||
|
width: this.newWidth,
|
||||||
|
height: this.newHeight,
|
||||||
|
left: this.newWidth / 2,
|
||||||
|
top: this.newHeight / 2,
|
||||||
|
fill: currentFill, // 保持原有颜色
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新图层记录的尺寸
|
||||||
|
this.bgLayer.canvasWidth = this.newWidth;
|
||||||
|
this.bgLayer.canvasHeight = this.newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算基于原始画布的缩放比例
|
||||||
|
const baseScaleX =
|
||||||
|
this.newWidth /
|
||||||
|
this.objectStates[0]?.obj._originalState?.baseCanvasWidth ||
|
||||||
|
this.newWidth / this.oldWidth;
|
||||||
|
const baseScaleY =
|
||||||
|
this.newHeight /
|
||||||
|
this.objectStates[0]?.obj._originalState?.baseCanvasHeight ||
|
||||||
|
this.newHeight / this.oldHeight;
|
||||||
|
|
||||||
|
// 根据策略缩放所有非背景对象
|
||||||
|
this.objectStates.forEach((state) => {
|
||||||
|
const obj = state.obj;
|
||||||
|
|
||||||
|
if (this.scaleStrategy === "stretch") {
|
||||||
|
// 拉伸模式:使用不同的X和Y缩放比例
|
||||||
|
obj.set({
|
||||||
|
left: state.left * baseScaleX,
|
||||||
|
top: state.top * baseScaleY,
|
||||||
|
scaleX: state.scaleX * baseScaleX,
|
||||||
|
scaleY: state.scaleY * baseScaleY,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 其他模式:计算基于原始状态的统一缩放比例
|
||||||
|
const baseUniformScale = Math.sqrt(baseScaleX * baseScaleY);
|
||||||
|
const baseOffsetX =
|
||||||
|
(this.newWidth -
|
||||||
|
(obj._originalState?.baseCanvasWidth || this.oldWidth) *
|
||||||
|
baseUniformScale) /
|
||||||
|
2;
|
||||||
|
const baseOffsetY =
|
||||||
|
(this.newHeight -
|
||||||
|
(obj._originalState?.baseCanvasHeight || this.oldHeight) *
|
||||||
|
baseUniformScale) /
|
||||||
|
2;
|
||||||
|
|
||||||
|
obj.set({
|
||||||
|
left: state.left * baseUniformScale + baseOffsetX,
|
||||||
|
top: state.top * baseUniformScale + baseOffsetY,
|
||||||
|
scaleX: state.scaleX * baseUniformScale,
|
||||||
|
scaleY: state.scaleY * baseUniformScale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.setCoords();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 恢复画布大小
|
||||||
|
this.canvas.setWidth(this.oldWidth);
|
||||||
|
this.canvas.setHeight(this.oldHeight);
|
||||||
|
|
||||||
|
// 如果使用 CanvasManager,通知它画布大小恢复
|
||||||
|
if (
|
||||||
|
this.canvasManager &&
|
||||||
|
typeof this.canvasManager.updateCanvasSize === "function"
|
||||||
|
) {
|
||||||
|
this.canvasManager.updateCanvasSize(this.oldWidth, this.oldHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复背景对象大小和位置
|
||||||
|
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||||
|
this.bgLayer.fabricObject.set({
|
||||||
|
width: this.oldWidth,
|
||||||
|
height: this.oldHeight,
|
||||||
|
left: this.oldWidth / 2,
|
||||||
|
top: this.oldHeight / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 恢复图层记录的尺寸
|
||||||
|
this.bgLayer.canvasWidth = this.oldWidth;
|
||||||
|
this.bgLayer.canvasHeight = this.oldHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复所有非背景对象的当前状态(而不是原始状态,因为可能有其他操作)
|
||||||
|
this.objectStates.forEach((state) => {
|
||||||
|
// 计算恢复到之前画布尺寸时的状态
|
||||||
|
const obj = state.obj;
|
||||||
|
const originalState = obj._originalState;
|
||||||
|
|
||||||
|
if (originalState) {
|
||||||
|
const baseScaleX = this.oldWidth / originalState.baseCanvasWidth;
|
||||||
|
const baseScaleY = this.oldHeight / originalState.baseCanvasHeight;
|
||||||
|
const baseUniformScale = Math.sqrt(baseScaleX * baseScaleY);
|
||||||
|
const baseOffsetX =
|
||||||
|
(this.oldWidth - originalState.baseCanvasWidth * baseUniformScale) /
|
||||||
|
2;
|
||||||
|
const baseOffsetY =
|
||||||
|
(this.oldHeight - originalState.baseCanvasHeight * baseUniformScale) /
|
||||||
|
2;
|
||||||
|
|
||||||
|
obj.set({
|
||||||
|
left: originalState.left * baseUniformScale + baseOffsetX,
|
||||||
|
top: originalState.top * baseUniformScale + baseOffsetY,
|
||||||
|
scaleX: originalState.scaleX * baseUniformScale,
|
||||||
|
scaleY: originalState.scaleY * baseUniformScale,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 降级到原来的逻辑
|
||||||
|
obj.set({
|
||||||
|
left: state.left,
|
||||||
|
top: state.top,
|
||||||
|
scaleX: state.scaleX,
|
||||||
|
scaleY: state.scaleY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.setCoords();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
const info = {
|
||||||
|
name: this.name,
|
||||||
|
oldWidth: this.oldWidth,
|
||||||
|
oldHeight: this.oldHeight,
|
||||||
|
newWidth: this.newWidth,
|
||||||
|
newHeight: this.newHeight,
|
||||||
|
scaleStrategy: this.scaleStrategy,
|
||||||
|
objectCount: this.objectStates.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.scaleStrategy === "stretch") {
|
||||||
|
info.scaleX = this.scaleX;
|
||||||
|
info.scaleY = this.scaleY;
|
||||||
|
} else {
|
||||||
|
info.uniformScale = this.uniformScale;
|
||||||
|
info.offsetX = this.offsetX;
|
||||||
|
info.offsetY = this.offsetY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
698
src/component/Canvas/CanvasEditor/commands/BrushCommands.js
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
import { Command } from "./Command";
|
||||||
|
import { BrushStore } from "../store/BrushStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷属性设置基类命令
|
||||||
|
* 所有笔刷属性设置命令的基类
|
||||||
|
*/
|
||||||
|
class BaseBrushCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super(options);
|
||||||
|
this.brushStore = options.brushStore || BrushStore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷大小设置命令
|
||||||
|
*/
|
||||||
|
export class BrushSizeCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {Number} options.size 要设置的笔刷大小
|
||||||
|
* @param {Number} options.previousSize 之前的笔刷大小(可选)
|
||||||
|
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `设置笔刷大小: ${options.size}`,
|
||||||
|
description: `将笔刷大小从 ${options.previousSize || "?"} 设为 ${
|
||||||
|
options.size
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.size = options.size;
|
||||||
|
this.previousSize = options.previousSize || this.brushStore.state.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前大小用于撤销
|
||||||
|
if (this.previousSize === null) {
|
||||||
|
this.previousSize = this.brushStore.state.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行设置
|
||||||
|
this.brushStore.setBrushSize(this.size);
|
||||||
|
return this.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.brushStore.setBrushSize(this.previousSize);
|
||||||
|
return this.previousSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷颜色设置命令
|
||||||
|
*/
|
||||||
|
export class BrushColorCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {String} options.color 要设置的颜色
|
||||||
|
* @param {String} options.previousColor 之前的颜色(可选)
|
||||||
|
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `设置笔刷颜色: ${options.color}`,
|
||||||
|
description: `将笔刷颜色从 ${options.previousColor || "?"} 设为 ${
|
||||||
|
options.color
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.color = options.color;
|
||||||
|
this.previousColor = options.previousColor || this.brushStore.state.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前颜色用于撤销
|
||||||
|
if (this.previousColor === null) {
|
||||||
|
this.previousColor = this.brushStore.state.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行设置
|
||||||
|
this.brushStore.setBrushColor(this.color);
|
||||||
|
return this.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.brushStore.setBrushColor(this.previousColor);
|
||||||
|
return this.previousColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷透明度设置命令
|
||||||
|
*/
|
||||||
|
export class BrushOpacityCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {Number} options.opacity 要设置的透明度 (0-1)
|
||||||
|
* @param {Number} options.previousOpacity 之前的透明度(可选)
|
||||||
|
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `设置笔刷透明度: ${options.opacity}`,
|
||||||
|
description: `将笔刷透明度从 ${options.previousOpacity || "?"} 设为 ${
|
||||||
|
options.opacity
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.opacity = options.opacity;
|
||||||
|
this.previousOpacity =
|
||||||
|
options.previousOpacity || this.brushStore.state.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前透明度用于撤销
|
||||||
|
if (this.previousOpacity === null) {
|
||||||
|
this.previousOpacity = this.brushStore.state.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行设置
|
||||||
|
this.brushStore.setBrushOpacity(this.opacity);
|
||||||
|
return this.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.brushStore.setBrushOpacity(this.previousOpacity);
|
||||||
|
return this.previousOpacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷类型设置命令
|
||||||
|
*/
|
||||||
|
export class BrushTypeCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {String} options.brushType 要设置的笔刷类型
|
||||||
|
* @param {String} options.previousType 之前的笔刷类型(可选)
|
||||||
|
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `设置笔刷类型: ${options.brushType}`,
|
||||||
|
description: `将笔刷类型从 ${options.previousType || "?"} 设为 ${
|
||||||
|
options.brushType
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.brushType = options.brushType;
|
||||||
|
this.previousType = options.previousType || this.brushStore.state.type;
|
||||||
|
this.brushManager = options.brushManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前类型用于撤销
|
||||||
|
if (this.previousType === null) {
|
||||||
|
this.previousType = this.brushStore.state.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行设置
|
||||||
|
// this.brushStore.setBrushType(this.brushType);
|
||||||
|
this.brushManager.setBrushType(this.brushType);
|
||||||
|
return this.brushType;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// this.brushStore.setBrushType(this.previousType);
|
||||||
|
this.brushManager.setBrushType(this.previousType);
|
||||||
|
return this.previousType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 材质设置命令
|
||||||
|
*/
|
||||||
|
export class TextureCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {Boolean} options.enabled 是否启用材质
|
||||||
|
* @param {String} options.path 材质路径
|
||||||
|
* @param {Number} options.scale 材质缩放
|
||||||
|
* @param {Object} options.previous 之前的材质设置(可选)
|
||||||
|
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: options.enabled ? "启用笔刷材质" : "禁用笔刷材质",
|
||||||
|
description: options.enabled
|
||||||
|
? `启用材质: ${options.path || "[默认]"}`
|
||||||
|
: "禁用笔刷材质",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.enabled = options.enabled;
|
||||||
|
this.path = options.path;
|
||||||
|
this.scale = options.scale;
|
||||||
|
|
||||||
|
// 保存之前状态用于撤销
|
||||||
|
this.previous = options.previous || {
|
||||||
|
enabled: this.brushStore.state.textureEnabled,
|
||||||
|
path: this.brushStore.state.texturePath,
|
||||||
|
scale: this.brushStore.state.textureScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前状态用于撤销
|
||||||
|
if (!this.previous) {
|
||||||
|
this.previous = {
|
||||||
|
enabled: this.brushStore.state.textureEnabled,
|
||||||
|
path: this.brushStore.state.texturePath,
|
||||||
|
scale: this.brushStore.state.textureScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行设置
|
||||||
|
this.brushStore.setTextureEnabled(this.enabled);
|
||||||
|
|
||||||
|
if (this.path) {
|
||||||
|
this.brushStore.setTexturePath(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scale !== undefined) {
|
||||||
|
this.brushStore.setTextureScale(this.scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
path: this.path,
|
||||||
|
scale: this.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.previous) return null;
|
||||||
|
|
||||||
|
this.brushStore.setTextureEnabled(this.previous.enabled);
|
||||||
|
this.brushStore.setTexturePath(this.previous.path);
|
||||||
|
this.brushStore.setTextureScale(this.previous.scale);
|
||||||
|
|
||||||
|
return this.previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设应用命令
|
||||||
|
*/
|
||||||
|
export class BrushPresetCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {Number|Object} options.preset 预设索引或预设对象
|
||||||
|
* @param {Object} options.previousState 之前的状态(可选)
|
||||||
|
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
const presetName =
|
||||||
|
typeof options.preset === "object"
|
||||||
|
? options.preset.name
|
||||||
|
: `预设 ${options.preset}`;
|
||||||
|
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `应用笔刷预设: ${presetName}`,
|
||||||
|
description: `应用预设: ${presetName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.preset = options.preset;
|
||||||
|
|
||||||
|
// 保存之前状态用于撤销
|
||||||
|
this.previousState = options.previousState || {
|
||||||
|
size: this.brushStore.state.size,
|
||||||
|
color: this.brushStore.state.color,
|
||||||
|
opacity: this.brushStore.state.opacity,
|
||||||
|
type: this.brushStore.state.type,
|
||||||
|
textureEnabled: this.brushStore.state.textureEnabled,
|
||||||
|
textureScale: this.brushStore.state.textureScale,
|
||||||
|
texturePath: this.brushStore.state.texturePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前状态用于撤销
|
||||||
|
if (!this.previousState) {
|
||||||
|
this.previousState = {
|
||||||
|
size: this.brushStore.state.size,
|
||||||
|
color: this.brushStore.state.color,
|
||||||
|
opacity: this.brushStore.state.opacity,
|
||||||
|
type: this.brushStore.state.type,
|
||||||
|
textureEnabled: this.brushStore.state.textureEnabled,
|
||||||
|
textureScale: this.brushStore.state.textureScale,
|
||||||
|
texturePath: this.brushStore.state.texturePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用预设
|
||||||
|
if (typeof this.preset === "number") {
|
||||||
|
this.brushStore.applyPreset(this.preset);
|
||||||
|
} else if (typeof this.preset === "object") {
|
||||||
|
// 应用自定义预设对象
|
||||||
|
if (this.preset.size !== undefined)
|
||||||
|
this.brushStore.setBrushSize(this.preset.size);
|
||||||
|
if (this.preset.color !== undefined)
|
||||||
|
this.brushStore.setBrushColor(this.preset.color);
|
||||||
|
if (this.preset.opacity !== undefined)
|
||||||
|
this.brushStore.setBrushOpacity(this.preset.opacity);
|
||||||
|
if (this.preset.type !== undefined)
|
||||||
|
this.brushStore.setBrushType(this.preset.type);
|
||||||
|
|
||||||
|
if (this.preset.textureEnabled !== undefined) {
|
||||||
|
this.brushStore.setTextureEnabled(this.preset.textureEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.preset.texturePath !== undefined) {
|
||||||
|
this.brushStore.setTexturePath(this.preset.texturePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.preset.textureScale !== undefined) {
|
||||||
|
this.brushStore.setTextureScale(this.preset.textureScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.previousState) return false;
|
||||||
|
|
||||||
|
// 恢复之前的状态
|
||||||
|
this.brushStore.setBrushSize(this.previousState.size);
|
||||||
|
this.brushStore.setBrushColor(this.previousState.color);
|
||||||
|
this.brushStore.setBrushOpacity(this.previousState.opacity);
|
||||||
|
this.brushStore.setBrushType(this.previousState.type);
|
||||||
|
this.brushStore.setTextureEnabled(this.previousState.textureEnabled);
|
||||||
|
this.brushStore.setTextureScale(this.previousState.textureScale);
|
||||||
|
this.brushStore.setTexturePath(this.previousState.texturePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷属性命令
|
||||||
|
* 用于修改笔刷的任意属性,包括特殊属性
|
||||||
|
*/
|
||||||
|
export class BrushPropertyCommand extends Command {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {String} options.propertyId 属性ID
|
||||||
|
* @param {any} options.value 新的属性值
|
||||||
|
* @param {Object} options.brushStore BrushStore实例
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "笔刷属性更改",
|
||||||
|
description: `更改笔刷属性 ${options.propertyId}`,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.propertyId = options.propertyId;
|
||||||
|
this.newValue = options.value;
|
||||||
|
this.oldValue = null;
|
||||||
|
this.brushStore = options.brushStore || BrushStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令
|
||||||
|
* @returns {Boolean} 是否执行成功
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
if (!this.brushStore) {
|
||||||
|
console.error("BrushStore不可用");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存旧值用于撤销
|
||||||
|
this.oldValue = this.brushStore.getPropertyValue(this.propertyId);
|
||||||
|
|
||||||
|
// 更新属性值
|
||||||
|
this.brushStore.updatePropertyValue(this.propertyId, this.newValue);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销命令
|
||||||
|
* @param {Object} context 命令上下文
|
||||||
|
* @returns {Boolean} 是否撤销成功
|
||||||
|
*/
|
||||||
|
undo() {
|
||||||
|
if (!this.brushStore || this.oldValue === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复旧值
|
||||||
|
this.brushStore.updatePropertyValue(this.propertyId, this.oldValue);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 材质选择命令
|
||||||
|
*/
|
||||||
|
export class TextureSelectionCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {String} options.textureId 要设置的材质ID
|
||||||
|
* @param {String} options.previousTextureId 之前的材质ID(可选)
|
||||||
|
* @param {Object} options.brushManager 笔刷管理器实例
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `切换纹理材质`,
|
||||||
|
description: `切换到材质: ${options.textureId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.textureId = options.textureId;
|
||||||
|
this.previousTextureId = options.previousTextureId;
|
||||||
|
this.brushManager = options.brushManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 记录当前材质用于撤销
|
||||||
|
const currentBrush = this.brushManager.activeBrush;
|
||||||
|
if (currentBrush && currentBrush.getCurrentTexture) {
|
||||||
|
const currentTexture = currentBrush.getCurrentTexture();
|
||||||
|
this.previousTextureId = currentTexture ? currentTexture.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保当前是材质笔刷
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置材质
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (activeBrush && activeBrush.setTextureById) {
|
||||||
|
activeBrush.setTextureById(this.textureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.textureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.previousTextureId) return false;
|
||||||
|
|
||||||
|
// 确保当前是材质笔刷
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复之前的材质
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (activeBrush && activeBrush.setTextureById) {
|
||||||
|
activeBrush.setTextureById(this.previousTextureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.previousTextureId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 材质属性设置命令
|
||||||
|
*/
|
||||||
|
export class TexturePropertyCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {String} options.property 要设置的属性名称 (scale, rotation, offsetX, offsetY等)
|
||||||
|
* @param {any} options.value 要设置的属性值
|
||||||
|
* @param {any} options.previousValue 之前的属性值(可选)
|
||||||
|
* @param {Object} options.brushManager 笔刷管理器实例
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `设置材质属性: ${options.property}`,
|
||||||
|
description: `将材质${options.property}从 ${
|
||||||
|
options.previousValue || "?"
|
||||||
|
} 设为 ${options.value}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.property = options.property;
|
||||||
|
this.value = options.value;
|
||||||
|
this.previousValue = options.previousValue;
|
||||||
|
this.brushManager = options.brushManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 确保当前是材质笔刷
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (!activeBrush || !activeBrush.setTextureProperty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录当前值用于撤销
|
||||||
|
if (this.previousValue === undefined) {
|
||||||
|
this.previousValue = activeBrush.getTextureProperty(this.property);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新值
|
||||||
|
activeBrush.setTextureProperty(this.property, this.value);
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (this.previousValue === undefined) return false;
|
||||||
|
|
||||||
|
// 确保当前是材质笔刷
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (activeBrush && activeBrush.setTextureProperty) {
|
||||||
|
activeBrush.setTextureProperty(this.property, this.previousValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.previousValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 材质预设应用命令
|
||||||
|
*/
|
||||||
|
export class TexturePresetCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {String|Object} options.preset 预设ID或预设对象
|
||||||
|
* @param {Object} options.previousState 之前的材质状态(可选)
|
||||||
|
* @param {Object} options.brushManager 笔刷管理器实例
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
const presetName =
|
||||||
|
typeof options.preset === "object" ? options.preset.name : options.preset;
|
||||||
|
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `应用材质预设: ${presetName}`,
|
||||||
|
description: `应用材质预设: ${presetName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.preset = options.preset;
|
||||||
|
this.previousState = options.previousState;
|
||||||
|
this.brushManager = options.brushManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 确保当前是材质笔刷
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (!activeBrush || !activeBrush.applyTexturePreset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录当前状态用于撤销
|
||||||
|
if (!this.previousState) {
|
||||||
|
this.previousState = activeBrush.getCurrentTextureState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用预设
|
||||||
|
activeBrush.applyTexturePreset(this.preset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.previousState) return false;
|
||||||
|
|
||||||
|
// 确保当前是材质笔刷
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (activeBrush && activeBrush.restoreTextureState) {
|
||||||
|
activeBrush.restoreTextureState(this.previousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纹理上传命令
|
||||||
|
*/
|
||||||
|
export class TextureUploadCommand extends BaseBrushCommand {
|
||||||
|
/**
|
||||||
|
* @param {Object} options 命令选项
|
||||||
|
* @param {File} options.file 要上传的纹理文件
|
||||||
|
* @param {String} options.name 纹理名称(可选)
|
||||||
|
* @param {String} options.category 纹理分类(可选)
|
||||||
|
* @param {Object} options.texturePresetManager 纹理预设管理器实例
|
||||||
|
* @param {Object} options.brushManager 笔刷管理器实例
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `上传纹理: ${options.name || options.file?.name || '未知'}`,
|
||||||
|
description: `上传自定义纹理文件`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.file = options.file;
|
||||||
|
this.name = options.name || options.file?.name?.replace(/\.[^/.]+$/, "") || "自定义纹理";
|
||||||
|
this.category = options.category || "自定义材质";
|
||||||
|
this.texturePresetManager = options.texturePresetManager;
|
||||||
|
this.brushManager = options.brushManager;
|
||||||
|
this.uploadedTextureId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.file || !this.texturePresetManager) {
|
||||||
|
throw new Error('缺少必要的文件或纹理预设管理器');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建文件 data URL
|
||||||
|
const dataUrl = await this._fileToDataUrl(this.file);
|
||||||
|
|
||||||
|
// 添加到纹理预设管理器
|
||||||
|
this.uploadedTextureId = this.texturePresetManager.addCustomTexture({
|
||||||
|
name: this.name,
|
||||||
|
category: this.category,
|
||||||
|
file: this.file,
|
||||||
|
dataUrl: dataUrl,
|
||||||
|
preview: dataUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果是纹理笔刷,自动应用新上传的纹理
|
||||||
|
if (this.brushManager) {
|
||||||
|
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||||
|
this.brushManager.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBrush = this.brushManager.activeBrush;
|
||||||
|
if (activeBrush && activeBrush.updateProperty) {
|
||||||
|
activeBrush.updateProperty("textureSelector", this.uploadedTextureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
textureId: this.uploadedTextureId,
|
||||||
|
dataUrl: dataUrl,
|
||||||
|
name: this.name
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('纹理上传失败:', error);
|
||||||
|
throw new Error(`纹理上传失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.uploadedTextureId || !this.texturePresetManager) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从纹理预设管理器中移除上传的纹理
|
||||||
|
try {
|
||||||
|
this.texturePresetManager.removeCustomTexture(this.uploadedTextureId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('撤销纹理上传失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件转换为 data URL
|
||||||
|
* @private
|
||||||
|
* @param {File} file
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
_fileToDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
resolve(event.target.result);
|
||||||
|
};
|
||||||
|
reader.onerror = (error) => {
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
296
src/component/Canvas/CanvasEditor/commands/Command.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* 基础命令类
|
||||||
|
* 所有命令都应该继承这个类
|
||||||
|
*/
|
||||||
|
export class Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.name = options.name || "未命名命令";
|
||||||
|
this.description = options.description || "";
|
||||||
|
this.undoable = options.undoable !== false; // 默认可撤销
|
||||||
|
this.timestamp = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令
|
||||||
|
* @returns {*} 执行结果,可以是Promise
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
throw new Error("子类必须实现execute方法");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销命令
|
||||||
|
* @returns {*} 撤销结果,可以是Promise
|
||||||
|
*/
|
||||||
|
undo() {
|
||||||
|
if (!this.undoable) {
|
||||||
|
throw new Error("此命令不支持撤销");
|
||||||
|
}
|
||||||
|
throw new Error("可撤销命令必须实现undo方法");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取命令信息
|
||||||
|
*/
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
undoable: this.undoable,
|
||||||
|
timestamp: this.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复合命令类
|
||||||
|
* 用于批量执行多个命令,替代事务系统
|
||||||
|
*/
|
||||||
|
export class CompositeCommand extends Command {
|
||||||
|
constructor(commands = [], options = {}) {
|
||||||
|
super({
|
||||||
|
name: options.name || "复合命令",
|
||||||
|
description: options.description || "批量执行多个命令",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commands = Array.isArray(commands) ? commands : [];
|
||||||
|
this.executedCommands = []; // 记录已执行的命令,用于撤销
|
||||||
|
this.isExecuting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加子命令
|
||||||
|
*/
|
||||||
|
addCommand(command) {
|
||||||
|
if (!command || typeof command.execute !== "function") {
|
||||||
|
throw new Error("无效的命令对象");
|
||||||
|
}
|
||||||
|
this.commands.push(command);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加子命令
|
||||||
|
*/
|
||||||
|
addCommands(commands) {
|
||||||
|
if (Array.isArray(commands)) {
|
||||||
|
commands.forEach((cmd) => this.addCommand(cmd));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行所有子命令(串行执行)
|
||||||
|
*/
|
||||||
|
async execute() {
|
||||||
|
if (this.isExecuting) {
|
||||||
|
throw new Error("复合命令正在执行中");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExecuting = true;
|
||||||
|
this.executedCommands = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// 串行执行所有子命令
|
||||||
|
for (const command of this.commands) {
|
||||||
|
try {
|
||||||
|
console.log(`📦 复合命令执行子命令: ${command.constructor.name}`);
|
||||||
|
|
||||||
|
const result = command.execute();
|
||||||
|
|
||||||
|
// 如果是异步命令,等待完成
|
||||||
|
const finalResult = this._isPromise(result) ? await result : result;
|
||||||
|
|
||||||
|
results.push(finalResult);
|
||||||
|
this.executedCommands.push(command);
|
||||||
|
|
||||||
|
console.log(`✅ 子命令执行成功: ${command.constructor.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ 子命令执行失败: ${command.constructor.name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行失败时,撤销已执行的命令
|
||||||
|
await this._rollbackExecutedCommands();
|
||||||
|
throw new Error(`复合命令执行失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExecuting = false;
|
||||||
|
console.log(`✅ 复合命令执行完成,共执行 ${results.length} 个子命令`);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
this.isExecuting = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销所有已执行的子命令(逆序撤销)
|
||||||
|
*/
|
||||||
|
async undo() {
|
||||||
|
if (this.isExecuting) {
|
||||||
|
throw new Error("复合命令正在执行中,无法撤销");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.executedCommands.length === 0) {
|
||||||
|
console.warn("没有已执行的子命令需要撤销");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`↩️ 开始撤销复合命令,共 ${this.executedCommands.length} 个子命令`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 逆序撤销已执行的命令
|
||||||
|
const commands = [...this.executedCommands].reverse();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
if (typeof command.undo === "function") {
|
||||||
|
try {
|
||||||
|
console.log(`↩️ 撤销子命令: ${command.constructor.name}`);
|
||||||
|
|
||||||
|
const result = command.undo();
|
||||||
|
|
||||||
|
// 如果是异步撤销,等待完成
|
||||||
|
const finalResult = this._isPromise(result) ? await result : result;
|
||||||
|
|
||||||
|
results.push(finalResult);
|
||||||
|
console.log(`✅ 子命令撤销成功: ${command.constructor.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ 子命令撤销失败: ${command.constructor.name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// 撤销失败不中断整个撤销过程,但要记录错误
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ 子命令不支持撤销: ${command.constructor.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executedCommands = [];
|
||||||
|
console.log(`✅ 复合命令撤销完成`);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 复合命令撤销过程中发生错误:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚已执行的命令(内部使用)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _rollbackExecutedCommands() {
|
||||||
|
console.log(`🔄 开始回滚已执行的 ${this.executedCommands.length} 个子命令`);
|
||||||
|
|
||||||
|
const commands = [...this.executedCommands].reverse();
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
if (typeof command.undo === "function") {
|
||||||
|
try {
|
||||||
|
console.log(`🔄 回滚子命令: ${command.constructor.name}`);
|
||||||
|
const result = command.undo();
|
||||||
|
if (this._isPromise(result)) {
|
||||||
|
await result;
|
||||||
|
}
|
||||||
|
console.log(`✅ 子命令回滚成功: ${command.constructor.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`❌ 子命令回滚失败: ${command.constructor.name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// 回滚失败不中断整个回滚过程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executedCommands = [];
|
||||||
|
console.log(`✅ 回滚完成`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查返回值是否为Promise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_isPromise(value) {
|
||||||
|
return (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
typeof value.then === "function" &&
|
||||||
|
typeof value.catch === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取复合命令信息
|
||||||
|
*/
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
...super.getInfo(),
|
||||||
|
commandCount: this.commands.length,
|
||||||
|
executedCount: this.executedCommands.length,
|
||||||
|
isExecuting: this.isExecuting,
|
||||||
|
subCommands: this.commands.map((cmd) =>
|
||||||
|
cmd.getInfo ? cmd.getInfo() : cmd.constructor.name
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 函数命令包装器
|
||||||
|
* 将普通函数包装为命令对象
|
||||||
|
*/
|
||||||
|
export class FunctionCommand extends Command {
|
||||||
|
constructor(executeFn, undoFn = null, options = {}) {
|
||||||
|
super({
|
||||||
|
name: options.name || "函数命令",
|
||||||
|
undoable: typeof undoFn === "function",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof executeFn !== "function") {
|
||||||
|
throw new Error("执行函数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executeFn = executeFn;
|
||||||
|
this.undoFn = undoFn;
|
||||||
|
this.executeResult = null; // 保存执行结果用于撤销
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
const result = this.executeFn();
|
||||||
|
this.executeResult = this._isPromise(result) ? await result : result;
|
||||||
|
return this.executeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.undoFn) {
|
||||||
|
throw new Error("此函数命令不支持撤销");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.undoFn(this.executeResult);
|
||||||
|
return this._isPromise(result) ? await result : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查返回值是否为Promise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_isPromise(value) {
|
||||||
|
return (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
typeof value.then === "function" &&
|
||||||
|
typeof value.catch === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
491
src/component/Canvas/CanvasEditor/commands/LassoCutoutCommand.js
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
import { CompositeCommand } from "./Command.js";
|
||||||
|
import { AddLayerCommand, CreateImageLayerCommand } from "./LayerCommands.js";
|
||||||
|
import { ToolCommand } from "./ToolCommands.js";
|
||||||
|
import { ClearSelectionCommand } from "./SelectionCommands.js";
|
||||||
|
import { createLayer, LayerType, OperationType } from "../utils/layerHelper.js";
|
||||||
|
//import { fabric } from "fabric-with-all";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 套索抠图命令
|
||||||
|
* 实现将选区内容抠图到新图层的功能
|
||||||
|
*/
|
||||||
|
export class LassoCutoutCommand extends CompositeCommand {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super([], {
|
||||||
|
name: "套索抠图",
|
||||||
|
description: "将选区抠图到新图层",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.toolManager = options.toolManager;
|
||||||
|
this.sourceLayerId = options.sourceLayerId;
|
||||||
|
this.newLayerName = options.newLayerName || "抠图";
|
||||||
|
this.newLayerId = null;
|
||||||
|
this.cutoutImageUrl = null;
|
||||||
|
this.fabricImage = null;
|
||||||
|
this.executedCommands = [];
|
||||||
|
// 高清截图选项
|
||||||
|
this.highResolutionEnabled = options.highResolutionEnabled !== false; // 默认启用
|
||||||
|
this.baseResolutionScale = options.baseResolutionScale || 4; // 基础分辨率倍数,提高到4倍获得更清晰的图像
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||||
|
console.error("无法执行套索抠图:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.executedCommands = [];
|
||||||
|
|
||||||
|
// 获取选区
|
||||||
|
const selectionObject = this.selectionManager.getSelectionObject();
|
||||||
|
if (!selectionObject) {
|
||||||
|
console.error("无法执行套索抠图:当前没有选区");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定源图层
|
||||||
|
const sourceLayer = this.layerManager.getActiveLayer();
|
||||||
|
if (!sourceLayer || sourceLayer.fabricObjects.length === 0) {
|
||||||
|
console.error("无法执行套索抠图:源图层无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取选区边界信息用于后续定位
|
||||||
|
const selectionBounds = selectionObject.getBoundingRect(true, true);
|
||||||
|
|
||||||
|
// 执行在当前画布上的抠图操作
|
||||||
|
this.cutoutImageUrl = await this._performCutout(
|
||||||
|
sourceLayer,
|
||||||
|
selectionObject
|
||||||
|
);
|
||||||
|
if (!this.cutoutImageUrl) {
|
||||||
|
console.error("抠图失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建fabric图像对象,传递选区边界信息
|
||||||
|
this.fabricImage = await this._createFabricImage(
|
||||||
|
this.cutoutImageUrl,
|
||||||
|
selectionBounds
|
||||||
|
);
|
||||||
|
if (!this.fabricImage) {
|
||||||
|
console.error("创建图像对象失败");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 创建图像图层命令
|
||||||
|
const createImageLayerCmd = new CreateImageLayerCommand({
|
||||||
|
layerManager: this.layerManager,
|
||||||
|
fabricImage: this.fabricImage,
|
||||||
|
toolManager: this.toolManager,
|
||||||
|
layerName: this.newLayerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行创建图像图层命令
|
||||||
|
const result = await createImageLayerCmd.execute();
|
||||||
|
this.newLayerId = createImageLayerCmd.newLayerId;
|
||||||
|
this.executedCommands.push(createImageLayerCmd);
|
||||||
|
|
||||||
|
// 2. 清除选区命令
|
||||||
|
const clearSelectionCmd = new ClearSelectionCommand({
|
||||||
|
canvas: this.canvas,
|
||||||
|
selectionManager: this.selectionManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行清除选区命令
|
||||||
|
await clearSelectionCmd.execute();
|
||||||
|
this.executedCommands.push(clearSelectionCmd);
|
||||||
|
|
||||||
|
console.log(`套索抠图完成,新图层ID: ${this.newLayerId}`);
|
||||||
|
return {
|
||||||
|
newLayerId: this.newLayerId,
|
||||||
|
cutoutImageUrl: this.cutoutImageUrl,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("套索抠图过程中出错:", error);
|
||||||
|
|
||||||
|
// 如果已经创建了新图层,需要进行清理
|
||||||
|
if (this.newLayerId) {
|
||||||
|
try {
|
||||||
|
await this.layerManager.removeLayer(this.newLayerId);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn("清理新图层失败:", cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
try {
|
||||||
|
// 逆序撤销所有已执行的命令
|
||||||
|
for (let i = this.executedCommands.length - 1; i >= 0; i--) {
|
||||||
|
const command = this.executedCommands[i];
|
||||||
|
if (command && typeof command.undo === "function") {
|
||||||
|
await command.undo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.executedCommands = [];
|
||||||
|
this.newLayerId = null;
|
||||||
|
this.cutoutImageUrl = null;
|
||||||
|
this.fabricImage = null;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("撤销套索抠图失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前画布上执行抠图操作
|
||||||
|
* @param {Object} sourceLayer 源图层
|
||||||
|
* @param {Object} selectionObject 选区对象
|
||||||
|
* @returns {String} 抠图结果的DataURL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _performCutout(sourceLayer, selectionObject) {
|
||||||
|
try {
|
||||||
|
console.log("=== 开始在当前画布执行抠图 ===");
|
||||||
|
|
||||||
|
// 获取选区边界
|
||||||
|
const selectionBounds = selectionObject.getBoundingRect(true, true);
|
||||||
|
console.log(
|
||||||
|
`选区边界: left=${selectionBounds.left}, top=${selectionBounds.top}, width=${selectionBounds.width}, height=${selectionBounds.height}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存画布当前状态
|
||||||
|
const originalActiveObject = this.canvas.getActiveObject();
|
||||||
|
const originalSelection = this.canvas.selection;
|
||||||
|
|
||||||
|
// 临时禁用画布选择
|
||||||
|
this.canvas.selection = false;
|
||||||
|
this.canvas.discardActiveObject();
|
||||||
|
|
||||||
|
let tempGroup = null;
|
||||||
|
let originalObjects = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 收集源图层中的可见对象
|
||||||
|
const visibleObjects = sourceLayer.fabricObjects.filter(
|
||||||
|
(obj) => obj.visible
|
||||||
|
);
|
||||||
|
console.log(`源图层可见对象数量: ${visibleObjects.length}`);
|
||||||
|
|
||||||
|
if (visibleObjects.length === 0) {
|
||||||
|
throw new Error("源图层没有可见对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个对象且已经是组,直接使用
|
||||||
|
if (visibleObjects.length === 1 && visibleObjects[0].type === "group") {
|
||||||
|
tempGroup = visibleObjects[0];
|
||||||
|
console.log("使用现有组对象");
|
||||||
|
} else {
|
||||||
|
// 创建临时组
|
||||||
|
console.log("创建临时组...");
|
||||||
|
|
||||||
|
// 记录原始对象的位置和状态,用于后续恢复
|
||||||
|
originalObjects = visibleObjects.map((obj) => ({
|
||||||
|
object: obj,
|
||||||
|
originalLeft: obj.left,
|
||||||
|
originalTop: obj.top,
|
||||||
|
originalAngle: obj.angle,
|
||||||
|
originalScaleX: obj.scaleX,
|
||||||
|
originalScaleY: obj.scaleY,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 不需要从画布移除原对象,直接创建组
|
||||||
|
// 克隆对象来创建组,避免影响原对象
|
||||||
|
const clonedObjects = [];
|
||||||
|
for (const obj of visibleObjects) {
|
||||||
|
const cloned = await this._cloneObject(obj);
|
||||||
|
clonedObjects.push(cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建组
|
||||||
|
tempGroup = new fabric.Group(clonedObjects, {
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加组到画布
|
||||||
|
this.canvas.add(tempGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置选区为裁剪路径
|
||||||
|
const clipPath = await this._cloneObject(selectionObject);
|
||||||
|
clipPath.set({
|
||||||
|
fill: "",
|
||||||
|
stroke: "",
|
||||||
|
absolutePositioned: true,
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用裁剪路径到组
|
||||||
|
tempGroup.set({
|
||||||
|
clipPath: clipPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
// 计算渲染区域
|
||||||
|
const renderBounds = {
|
||||||
|
left: selectionBounds.left,
|
||||||
|
top: selectionBounds.top,
|
||||||
|
width: selectionBounds.width,
|
||||||
|
height: selectionBounds.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置高分辨率倍数,用于提高图像清晰度
|
||||||
|
let highResolutionScale = 1;
|
||||||
|
|
||||||
|
if (this.highResolutionEnabled) {
|
||||||
|
// 结合设备像素比和配置的基础倍数,确保在所有设备上都有最佳效果
|
||||||
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
|
// 使用更激进的缩放策略,确保高清晰度
|
||||||
|
highResolutionScale = Math.max(
|
||||||
|
this.baseResolutionScale,
|
||||||
|
devicePixelRatio * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`设备像素比: ${devicePixelRatio}, 基础倍数: ${this.baseResolutionScale}, 最终放大倍数: ${highResolutionScale}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("高分辨率渲染已禁用,使用1x倍数");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用于导出的高分辨率canvas
|
||||||
|
const exportCanvas = document.createElement("canvas");
|
||||||
|
const exportCtx = exportCanvas.getContext("2d", {
|
||||||
|
alpha: true,
|
||||||
|
willReadFrequently: false,
|
||||||
|
colorSpace: "srgb",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置canvas的实际像素尺寸(放大倍数)
|
||||||
|
const actualWidth = Math.round(
|
||||||
|
renderBounds.width * highResolutionScale
|
||||||
|
);
|
||||||
|
const actualHeight = Math.round(
|
||||||
|
renderBounds.height * highResolutionScale
|
||||||
|
);
|
||||||
|
|
||||||
|
exportCanvas.width = actualWidth;
|
||||||
|
exportCanvas.height = actualHeight;
|
||||||
|
|
||||||
|
// 设置canvas的显示尺寸(CSS尺寸,保持与选区一致)
|
||||||
|
exportCanvas.style.width = renderBounds.width + "px";
|
||||||
|
exportCanvas.style.height = renderBounds.height + "px";
|
||||||
|
|
||||||
|
// 启用最高质量渲染设置
|
||||||
|
exportCtx.imageSmoothingEnabled = true;
|
||||||
|
exportCtx.imageSmoothingQuality = "high";
|
||||||
|
|
||||||
|
// 设置文本渲染质量
|
||||||
|
if (exportCtx.textRenderingOptimization) {
|
||||||
|
exportCtx.textRenderingOptimization = "optimizeQuality";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置线条和图形的渲染质量
|
||||||
|
exportCtx.lineCap = "round";
|
||||||
|
exportCtx.lineJoin = "round";
|
||||||
|
exportCtx.miterLimit = 10;
|
||||||
|
|
||||||
|
// 设置画布背景为透明
|
||||||
|
exportCtx.clearRect(0, 0, actualWidth, actualHeight);
|
||||||
|
|
||||||
|
// 获取画布当前的变换矩阵
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
const zoom = this.canvas.getZoom();
|
||||||
|
|
||||||
|
// 保存当前变换状态
|
||||||
|
exportCtx.save();
|
||||||
|
|
||||||
|
// 应用高分辨率缩放
|
||||||
|
exportCtx.scale(highResolutionScale, highResolutionScale);
|
||||||
|
|
||||||
|
// 应用偏移,只渲染选区部分
|
||||||
|
exportCtx.translate(-renderBounds.left, -renderBounds.top);
|
||||||
|
|
||||||
|
// 如果画布有缩放和平移,需要应用相应变换
|
||||||
|
if (zoom !== 1 || vpt[4] !== 0 || vpt[5] !== 0) {
|
||||||
|
exportCtx.transform(vpt[0], vpt[1], vpt[2], vpt[3], vpt[4], vpt[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染被裁剪的组
|
||||||
|
tempGroup.render(exportCtx);
|
||||||
|
|
||||||
|
exportCtx.restore();
|
||||||
|
|
||||||
|
// 获取结果 - 使用最高质量设置
|
||||||
|
const dataUrl = exportCanvas.toDataURL("image/png", 1.0);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`抠图完成,选区尺寸: ${renderBounds.width}x${renderBounds.height}, 实际渲染尺寸: ${actualWidth}x${actualHeight}, 放大倍数: ${highResolutionScale}x`
|
||||||
|
);
|
||||||
|
|
||||||
|
return dataUrl;
|
||||||
|
} finally {
|
||||||
|
// 清理和恢复
|
||||||
|
if (tempGroup) {
|
||||||
|
// 移除裁剪路径
|
||||||
|
tempGroup.set({ clipPath: null });
|
||||||
|
|
||||||
|
// 如果是我们创建的临时组,需要移除它
|
||||||
|
if (originalObjects.length > 0) {
|
||||||
|
this.canvas.remove(tempGroup);
|
||||||
|
|
||||||
|
// 恢复原始对象的状态(位置等信息保持不变)
|
||||||
|
originalObjects.forEach(
|
||||||
|
({
|
||||||
|
object,
|
||||||
|
originalLeft,
|
||||||
|
originalTop,
|
||||||
|
originalAngle,
|
||||||
|
originalScaleX,
|
||||||
|
originalScaleY,
|
||||||
|
}) => {
|
||||||
|
// 确保对象仍然在画布上且状态正确
|
||||||
|
if (!this.canvas.getObjects().includes(object)) {
|
||||||
|
this.canvas.add(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复原始变换状态(如果需要的话)
|
||||||
|
object.set({
|
||||||
|
left: originalLeft,
|
||||||
|
top: originalTop,
|
||||||
|
angle: originalAngle,
|
||||||
|
scaleX: originalScaleX,
|
||||||
|
scaleY: originalScaleY,
|
||||||
|
});
|
||||||
|
object.setCoords();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复画布状态
|
||||||
|
this.canvas.selection = originalSelection;
|
||||||
|
if (originalActiveObject) {
|
||||||
|
this.canvas.setActiveObject(originalActiveObject);
|
||||||
|
}
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("在当前画布执行抠图失败:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从DataURL创建fabric图像对象
|
||||||
|
* @param {String} dataUrl 图像DataURL
|
||||||
|
* @param {Object} selectionBounds 选区边界信息
|
||||||
|
* @returns {fabric.Image} fabric图像对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _createFabricImage(dataUrl, selectionBounds) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fabric.Image.fromURL(
|
||||||
|
dataUrl,
|
||||||
|
(img) => {
|
||||||
|
if (!img) {
|
||||||
|
reject(new Error("无法从DataURL创建图像"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算画布中心位置
|
||||||
|
const canvasCenter = this.canvas.getCenter();
|
||||||
|
|
||||||
|
// 如果有选区边界信息,使用选区的原始位置和尺寸
|
||||||
|
let targetLeft = canvasCenter.left;
|
||||||
|
let targetTop = canvasCenter.top;
|
||||||
|
let targetWidth = img.width;
|
||||||
|
let targetHeight = img.height;
|
||||||
|
|
||||||
|
if (selectionBounds) {
|
||||||
|
// 使用选区的原始位置
|
||||||
|
targetLeft = selectionBounds.left + selectionBounds.width / 2;
|
||||||
|
targetTop = selectionBounds.top + selectionBounds.height / 2;
|
||||||
|
|
||||||
|
// 确保图像显示尺寸与选区尺寸一致
|
||||||
|
targetWidth = selectionBounds.width;
|
||||||
|
targetHeight = selectionBounds.height;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`设置图像位置: left=${targetLeft}, top=${targetTop}, 尺寸: ${targetWidth}x${targetHeight}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算缩放比例以匹配目标尺寸
|
||||||
|
const scaleX = targetWidth / img.width;
|
||||||
|
const scaleY = targetHeight / img.height;
|
||||||
|
|
||||||
|
// 设置图像属性
|
||||||
|
img.set({
|
||||||
|
left: targetLeft,
|
||||||
|
top: targetTop,
|
||||||
|
scaleX: scaleX,
|
||||||
|
scaleY: scaleY,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
selectable: true,
|
||||||
|
evented: true,
|
||||||
|
hasControls: true,
|
||||||
|
hasBorders: true,
|
||||||
|
cornerStyle: "circle",
|
||||||
|
cornerColor: "#007aff",
|
||||||
|
cornerSize: 10,
|
||||||
|
transparentCorners: false,
|
||||||
|
borderColor: "#007aff",
|
||||||
|
borderScaleFactor: 2,
|
||||||
|
// 优化图像渲染质量
|
||||||
|
objectCaching: false, // 禁用缓存以确保最佳质量
|
||||||
|
statefullCache: true,
|
||||||
|
noScaleCache: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新坐标
|
||||||
|
img.setCoords();
|
||||||
|
|
||||||
|
resolve(img);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
// 确保图像以最高质量加载
|
||||||
|
quality: 1.0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆fabric对象
|
||||||
|
* @param {Object} obj fabric对象
|
||||||
|
* @returns {Object} 克隆的对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _cloneObject(obj) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!obj) {
|
||||||
|
reject(new Error("对象无效,无法克隆"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
obj.clone((cloned) => {
|
||||||
|
resolve(cloned);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
2801
src/component/Canvas/CanvasEditor/commands/LayerCommands.js
Normal file
578
src/component/Canvas/CanvasEditor/commands/LiquifyCommands.js
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { Command, FunctionCommand } from "./Command";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 液化命令基类
|
||||||
|
* 所有液化相关命令的基类
|
||||||
|
*/
|
||||||
|
export class LiquifyCommand extends Command {
|
||||||
|
/**
|
||||||
|
* 创建液化命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {Object} options.canvas Fabric.js画布实例
|
||||||
|
* @param {Object} options.layerManager 图层管理器实例
|
||||||
|
* @param {Object} options.liquifyManager 液化管理器实例
|
||||||
|
* @param {String} options.mode 液化模式
|
||||||
|
* @param {Object} options.params 液化参数
|
||||||
|
* @param {Object} options.targetObject 目标对象
|
||||||
|
* @param {ImageData} options.originalData 原始图像数据
|
||||||
|
* @param {ImageData} options.resultData 变形后图像数据
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: options.name || `液化操作: ${options.mode || "未知模式"}`,
|
||||||
|
description:
|
||||||
|
options.description ||
|
||||||
|
`使用${options.mode || "未知模式"}模式进行液化操作`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.liquifyManager = options.liquifyManager;
|
||||||
|
this.mode = options.mode;
|
||||||
|
this.params = options.params || {};
|
||||||
|
this.targetObject = options.targetObject;
|
||||||
|
this.targetLayerId = options.targetLayerId;
|
||||||
|
this.originalData = options.originalData; // 操作前的图像数据
|
||||||
|
this.resultData = options.resultData; // 操作后的图像数据
|
||||||
|
this.savedState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行液化操作
|
||||||
|
* @returns {Promise} 执行结果
|
||||||
|
*/
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.targetObject) {
|
||||||
|
throw new Error("液化命令缺少必要的画布或目标对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.resultData) {
|
||||||
|
// 如果没有预先计算的结果数据,现场执行变形
|
||||||
|
this.resultData = await this.liquifyManager.applyLiquify(
|
||||||
|
this.targetObject,
|
||||||
|
this.mode,
|
||||||
|
this.params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存执行前的状态
|
||||||
|
this.savedState = await this._saveObjectState();
|
||||||
|
|
||||||
|
// 更新画布上的对象
|
||||||
|
await this._updateObjectWithResult();
|
||||||
|
|
||||||
|
// 刷新Canvas
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return this.resultData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销液化操作
|
||||||
|
* @returns {Promise} 撤销结果
|
||||||
|
*/
|
||||||
|
async undo() {
|
||||||
|
if (!this.canvas || !this.targetObject || !this.savedState) {
|
||||||
|
throw new Error("无法撤销:缺少必要的状态信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复对象到原始状态
|
||||||
|
await this._restoreObjectState();
|
||||||
|
|
||||||
|
// 刷新Canvas
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存对象状态
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _saveObjectState() {
|
||||||
|
if (!this.targetObject) return null;
|
||||||
|
|
||||||
|
// 对于图像对象,我们需要保存src和元数据
|
||||||
|
const state = {
|
||||||
|
src: this.targetObject.getSrc ? this.targetObject.getSrc() : null,
|
||||||
|
element: this.targetObject._element
|
||||||
|
? this.targetObject._element.cloneNode(true)
|
||||||
|
: null,
|
||||||
|
filters: this.targetObject.filters ? [...this.targetObject.filters] : [],
|
||||||
|
originalData: this.originalData,
|
||||||
|
targetLayerId: this.targetLayerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复对象状态
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _restoreObjectState() {
|
||||||
|
if (!this.targetObject || !this.savedState) return false;
|
||||||
|
|
||||||
|
// 获取当前图层对象
|
||||||
|
const layer = this.layerManager.getLayerById(this.savedState.targetLayerId);
|
||||||
|
if (!layer) return false;
|
||||||
|
|
||||||
|
// 恢复原始图像
|
||||||
|
if (this.savedState.element && this.targetObject.setElement) {
|
||||||
|
this.targetObject.setElement(this.savedState.element);
|
||||||
|
|
||||||
|
// 恢复滤镜
|
||||||
|
if (this.savedState.filters) {
|
||||||
|
this.targetObject.filters = [...this.savedState.filters];
|
||||||
|
this.targetObject.applyFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用变形结果更新对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _updateObjectWithResult() {
|
||||||
|
if (!this.targetObject || !this.resultData) return false;
|
||||||
|
|
||||||
|
// 创建临时canvas来渲染结果数据
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
tempCanvas.width = this.resultData.width;
|
||||||
|
tempCanvas.height = this.resultData.height;
|
||||||
|
const tempCtx = tempCanvas.getContext("2d");
|
||||||
|
tempCtx.putImageData(this.resultData, 0, 0);
|
||||||
|
console.log("临时Canvas创建成功 _updateObjectWithResult", this.resultData);
|
||||||
|
// 更新Fabric图像
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
fabric.Image.fromURL(tempCanvas.toDataURL(), (img) => {
|
||||||
|
// 保留原对象的属性
|
||||||
|
img.set({
|
||||||
|
left: this.targetObject.left,
|
||||||
|
top: this.targetObject.top,
|
||||||
|
scaleX: this.targetObject.scaleX,
|
||||||
|
scaleY: this.targetObject.scaleY,
|
||||||
|
angle: this.targetObject.angle,
|
||||||
|
flipX: this.targetObject.flipX,
|
||||||
|
flipY: this.targetObject.flipY,
|
||||||
|
opacity: this.targetObject.opacity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 替换Canvas上的对象
|
||||||
|
const index = this.canvas.getObjects().indexOf(this.targetObject);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.canvas.remove(this.targetObject);
|
||||||
|
this.canvas.insertAt(img, index);
|
||||||
|
this.targetObject = img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保图层引用更新
|
||||||
|
const layer = this.layerManager.getLayerById(this.targetLayerId);
|
||||||
|
if (layer) {
|
||||||
|
if (
|
||||||
|
layer.type === "background" &&
|
||||||
|
layer.fabricObject === this.targetObject
|
||||||
|
) {
|
||||||
|
layer.fabricObject = img;
|
||||||
|
} else if (layer.fabricObjects) {
|
||||||
|
const objIndex = layer.fabricObjects.indexOf(this.targetObject);
|
||||||
|
if (objIndex !== -1) {
|
||||||
|
layer.fabricObjects[objIndex] = img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层栅格化命令
|
||||||
|
* 用于将复杂图层栅格化为单一图像,以便进行液化操作
|
||||||
|
*/
|
||||||
|
export class RasterizeForLiquifyCommand extends Command {
|
||||||
|
/**
|
||||||
|
* 创建栅格化命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {Object} options.canvas Fabric.js画布实例
|
||||||
|
* @param {Object} options.layerManager 图层管理器实例
|
||||||
|
* @param {String} options.layerId 需要栅格化的图层ID
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: options.name || "栅格化图层",
|
||||||
|
description: options.description || "将图层栅格化为单一图像以便液化操作",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.layerId = options.layerId;
|
||||||
|
this.originalLayer = null;
|
||||||
|
this.rasterizedImageObj = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行栅格化操作
|
||||||
|
* @returns {Promise<Object>} 栅格化后的图像对象
|
||||||
|
*/
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.layerManager || !this.layerId) {
|
||||||
|
throw new Error("栅格化命令缺少必要参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始图层信息
|
||||||
|
this.originalLayer = this.layerManager.getLayerById(this.layerId);
|
||||||
|
if (!this.originalLayer) {
|
||||||
|
throw new Error(`图层ID不存在: ${this.layerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 栅格化图层
|
||||||
|
const rasterizedImage = await this.layerManager.rasterizeLayer(
|
||||||
|
this.layerId
|
||||||
|
);
|
||||||
|
if (!rasterizedImage) {
|
||||||
|
throw new Error("栅格化图层失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rasterizedImageObj = rasterizedImage;
|
||||||
|
return rasterizedImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销栅格化操作
|
||||||
|
* 注意:完整撤销栅格化是复杂的,这里提供近似还原
|
||||||
|
* @returns {Promise<boolean>} 撤销结果
|
||||||
|
*/
|
||||||
|
async undo() {
|
||||||
|
if (!this.canvas || !this.layerManager || !this.originalLayer) {
|
||||||
|
throw new Error("无法撤销:缺少必要的状态信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复图层为原始状态是复杂的,这里可能需要与LayerManager协作
|
||||||
|
// 这个实现可能需要根据实际的LayerManager功能来调整
|
||||||
|
const restored = await this.layerManager.restoreLayerFromBackup(
|
||||||
|
this.layerId
|
||||||
|
);
|
||||||
|
if (!restored) {
|
||||||
|
console.warn("无法完全还原栅格化前的图层状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 液化工具初始化命令
|
||||||
|
* 用于初始化液化工具的状态,不执行实际操作
|
||||||
|
*/
|
||||||
|
export class InitLiquifyToolCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "初始化液化工具",
|
||||||
|
description: "准备液化工具工作环境",
|
||||||
|
undoable: false, // 这个命令不需要撤销
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.liquifyManager = options.liquifyManager;
|
||||||
|
this.toolManager = options.toolManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行初始化
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
if (this.liquifyManager) {
|
||||||
|
this.liquifyManager.initialize({
|
||||||
|
canvas: this.canvas,
|
||||||
|
layerManager: this.layerManager,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知各管理器进入液化模式
|
||||||
|
this.toolManager?.notifyObservers("LIQUIFY");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 不需要撤销初始化
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 液化操作命令 - 针对单次变形操作
|
||||||
|
* 用于实现可撤销的单次液化变形
|
||||||
|
*/
|
||||||
|
export class LiquifyDeformCommand extends LiquifyCommand {
|
||||||
|
/**
|
||||||
|
* 创建液化变形命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {Number} options.x 变形中心X坐标
|
||||||
|
* @param {Number} options.y 变形中心Y坐标
|
||||||
|
* @param {ImageData} options.beforeData 变形前的图像数据
|
||||||
|
* @param {ImageData} options.afterData 变形后的图像数据
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `液化变形: ${options.mode || "未知模式"}`,
|
||||||
|
description: `在(${options.x}, ${options.y})应用${
|
||||||
|
options.mode || "未知模式"
|
||||||
|
}变形`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.x = options.x;
|
||||||
|
this.y = options.y;
|
||||||
|
this.beforeData = options.beforeData;
|
||||||
|
this.afterData = options.afterData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.afterData) {
|
||||||
|
// 如果没有预计算的结果,实时计算
|
||||||
|
await this.liquifyManager.prepareForLiquify(this.targetObject);
|
||||||
|
this.afterData = await this.liquifyManager.applyLiquify(
|
||||||
|
this.targetObject,
|
||||||
|
this.mode,
|
||||||
|
this.params,
|
||||||
|
this.x,
|
||||||
|
this.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前状态
|
||||||
|
this.savedState = await this._saveObjectState();
|
||||||
|
|
||||||
|
// 应用变形结果
|
||||||
|
await this._updateObjectWithImageData(this.afterData);
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return this.afterData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.beforeData) {
|
||||||
|
throw new Error("无法撤销:缺少变形前的数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复到变形前的状态
|
||||||
|
await this._updateObjectWithImageData(this.beforeData);
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用图像数据更新对象
|
||||||
|
* @param {ImageData} imageData 图像数据
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _updateObjectWithImageData(imageData) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// 创建临时canvas
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
tempCanvas.width = imageData.width;
|
||||||
|
tempCanvas.height = imageData.height;
|
||||||
|
const tempCtx = tempCanvas.getContext("2d");
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
// 从canvas创建新的fabric图像
|
||||||
|
fabric.Image.fromURL(tempCanvas.toDataURL(), (img) => {
|
||||||
|
// 保留原对象的变换属性
|
||||||
|
img.set({
|
||||||
|
left: this.targetObject.left,
|
||||||
|
top: this.targetObject.top,
|
||||||
|
scaleX: this.targetObject.scaleX,
|
||||||
|
scaleY: this.targetObject.scaleY,
|
||||||
|
angle: this.targetObject.angle,
|
||||||
|
flipX: this.targetObject.flipX,
|
||||||
|
flipY: this.targetObject.flipY,
|
||||||
|
opacity: this.targetObject.opacity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 替换canvas上的对象
|
||||||
|
const index = this.canvas.getObjects().indexOf(this.targetObject);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.canvas.remove(this.targetObject);
|
||||||
|
this.canvas.insertAt(img, index);
|
||||||
|
this.targetObject = img;
|
||||||
|
|
||||||
|
// 更新图层引用
|
||||||
|
const layer = this.layerManager.getLayerById(this.targetLayerId);
|
||||||
|
if (layer) {
|
||||||
|
if (
|
||||||
|
layer.type === "background" &&
|
||||||
|
(layer.fabricObject === this.savedState?.originalObject ||
|
||||||
|
layer.fabricObject === this.targetObject)
|
||||||
|
) {
|
||||||
|
layer.fabricObject = img;
|
||||||
|
} else if (layer.fabricObjects) {
|
||||||
|
const objIndex = layer.fabricObjects.findIndex(
|
||||||
|
(obj) =>
|
||||||
|
obj === this.savedState?.originalObject ||
|
||||||
|
obj === this.targetObject
|
||||||
|
);
|
||||||
|
if (objIndex !== -1) {
|
||||||
|
layer.fabricObjects[objIndex] = img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复合液化命令 - 用于组合多个液化操作
|
||||||
|
* 支持一次撤销多个相关的液化变形
|
||||||
|
*/
|
||||||
|
export class CompositeLiquifyCommand extends Command {
|
||||||
|
/**
|
||||||
|
* 创建复合液化命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {Array} options.commands 子命令列表
|
||||||
|
* @param {String} options.name 命令名称
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: options.name || "液化操作组合",
|
||||||
|
description:
|
||||||
|
options.description || `包含${options.commands?.length || 0}个液化操作`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commands = options.commands || [];
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.liquifyManager = options.liquifyManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加子命令
|
||||||
|
* @param {Command} command 要添加的命令
|
||||||
|
*/
|
||||||
|
addCommand(command) {
|
||||||
|
this.commands.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const command of this.commands) {
|
||||||
|
try {
|
||||||
|
const result = await command.execute();
|
||||||
|
results.push(result);
|
||||||
|
} catch (error) {
|
||||||
|
// 如果有命令失败,尝试回滚已执行的命令
|
||||||
|
console.error("复合液化命令执行失败:", error);
|
||||||
|
await this.undo();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
// 逆序撤销所有子命令
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
await this.commands[i].undo();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`撤销子命令${i}失败:`, error);
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`复合命令撤销部分失败: ${errors.length}个错误`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查命令是否可以执行
|
||||||
|
* @returns {Boolean} 是否可执行
|
||||||
|
*/
|
||||||
|
canExecute() {
|
||||||
|
return (
|
||||||
|
this.commands.length > 0 &&
|
||||||
|
this.commands.every((cmd) => (cmd.canExecute ? cmd.canExecute() : true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 液化重置命令 - 将图像恢复到原始状态
|
||||||
|
*/
|
||||||
|
export class LiquifyResetCommand extends LiquifyCommand {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: "重置液化",
|
||||||
|
description: "将图像恢复到液化前的原始状态",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.liquifyManager || !this.targetObject) {
|
||||||
|
throw new Error("无法重置:缺少必要的管理器或目标对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前状态
|
||||||
|
this.savedState = await this._saveObjectState();
|
||||||
|
|
||||||
|
// 重置液化管理器
|
||||||
|
const resetData = this.liquifyManager.reset();
|
||||||
|
if (!resetData) {
|
||||||
|
throw new Error("重置失败:没有原始数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用重置结果
|
||||||
|
await this._updateObjectWithResult();
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return resetData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助函数:创建液化重置命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @returns {LiquifyResetCommand} 重置命令实例
|
||||||
|
*/
|
||||||
|
export function createLiquifyResetCommand(options) {
|
||||||
|
return new LiquifyResetCommand(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助函数:创建液化变形命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @returns {LiquifyDeformCommand} 变形命令实例
|
||||||
|
*/
|
||||||
|
export function createLiquifyDeformCommand(options) {
|
||||||
|
return new LiquifyDeformCommand(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助函数:创建复合液化命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @returns {CompositeLiquifyCommand} 复合命令实例
|
||||||
|
*/
|
||||||
|
export function createCompositeLiquifyCommand(options) {
|
||||||
|
return new CompositeLiquifyCommand(options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 查询类命令示例 - 不需要撤销
|
||||||
|
*/
|
||||||
|
export class GetCanvasInfoCommand {
|
||||||
|
constructor(options) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.undoable = false; // 明确标记为不可撤销
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
return {
|
||||||
|
width: this.canvas.getWidth(),
|
||||||
|
height: this.canvas.getHeight(),
|
||||||
|
zoom: this.canvas.getZoom(),
|
||||||
|
layers: this.layerManager?.getLayers()?.length || 0,
|
||||||
|
objects: this.canvas.getObjects().length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询类命令不需要实现 undo 方法
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出类命令示例 - 不需要撤销
|
||||||
|
*/
|
||||||
|
export class ExportCanvasCommand {
|
||||||
|
constructor(options) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.format = options.format || "png";
|
||||||
|
this.quality = options.quality || 1;
|
||||||
|
this.undoable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
return this.canvas.toDataURL(`image/${this.format}`, this.quality);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证类命令示例 - 不需要撤销
|
||||||
|
*/
|
||||||
|
export class ValidateCanvasCommand {
|
||||||
|
constructor(options) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.undoable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
const objects = this.canvas.getObjects();
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
objects.forEach((obj, index) => {
|
||||||
|
if (!obj.left || !obj.top) {
|
||||||
|
errors.push(`对象 ${index} 位置无效`);
|
||||||
|
}
|
||||||
|
if (obj.width <= 0 || obj.height <= 0) {
|
||||||
|
errors.push(`对象 ${index} 尺寸无效`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
objectCount: objects.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统清理命令示例 - 不可逆操作,不需要撤销
|
||||||
|
*/
|
||||||
|
export class CleanupTempDataCommand {
|
||||||
|
constructor(options) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.undoable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 清理临时数据
|
||||||
|
const cleaned = [];
|
||||||
|
|
||||||
|
// 移除无效对象
|
||||||
|
const objects = this.canvas.getObjects();
|
||||||
|
objects.forEach((obj, index) => {
|
||||||
|
if (obj._isTemp || obj._invalid) {
|
||||||
|
this.canvas.remove(obj);
|
||||||
|
cleaned.push(`临时对象 ${index}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理缓存
|
||||||
|
if (this.canvas._clearCache) {
|
||||||
|
this.canvas._clearCache();
|
||||||
|
cleaned.push("画布缓存");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleaned,
|
||||||
|
count: cleaned.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
import { OperationType } from "../utils/layerHelper";
|
||||||
|
import { Command } from "./Command";
|
||||||
|
import { generateId } from "../utils/helper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置活动图层命令
|
||||||
|
*/
|
||||||
|
export class SetActiveLayerCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "设置活动图层",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.activeLayerId = options.activeLayerId;
|
||||||
|
this.layerId = options.layerId;
|
||||||
|
this.oldActiveLayerId = this.activeLayerId.value;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.oldActiveObjects = [];
|
||||||
|
this.newLayer = null;
|
||||||
|
this.editorMode = options.editorMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.newLayer = this.layers.value.find(
|
||||||
|
(layer) => layer.id === this.layerId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.newLayer) {
|
||||||
|
console.error(`图层 ${this.layerId} 不存在`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是背景层,不设置为活动图层
|
||||||
|
if (this.newLayer.isBackground) {
|
||||||
|
console.warn("背景层不能设为活动图层");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果图层已锁定,不设置为活动图层
|
||||||
|
if (this.newLayer.locked) {
|
||||||
|
console.warn("锁定图层不能设为活动图层");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.oldActiveObjects = this.canvas.getActiveObjects();
|
||||||
|
|
||||||
|
// 设置为活动图层
|
||||||
|
this.activeLayerId.value = this.layerId;
|
||||||
|
|
||||||
|
// 如果在选择模式下,取消所有选择
|
||||||
|
if (this.editorMode === OperationType.SELECT && this.canvas) {
|
||||||
|
this.canvas.discardActiveObject();
|
||||||
|
|
||||||
|
// 设置为新的图层下的对象为激活,但需要确保对象存在于画布上
|
||||||
|
if (
|
||||||
|
this.newLayer.fabricObjects &&
|
||||||
|
this.newLayer.fabricObjects.length > 0
|
||||||
|
) {
|
||||||
|
const canvasObjects = this.canvas.getObjects();
|
||||||
|
const validObjects = this.newLayer.fabricObjects.filter(
|
||||||
|
(obj) => obj && canvasObjects.includes(obj)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validObjects.length > 0) {
|
||||||
|
if (validObjects.length === 1) {
|
||||||
|
// 只有一个对象时直接设置
|
||||||
|
this.canvas.setActiveObject(validObjects[0]);
|
||||||
|
} else {
|
||||||
|
// 多个对象时创建活动选择组
|
||||||
|
const activeSelection = new fabric.ActiveSelection(validObjects, {
|
||||||
|
canvas: this.canvas,
|
||||||
|
});
|
||||||
|
this.canvas.setActiveObject(activeSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 恢复原活动图层ID
|
||||||
|
this.activeLayerId.value = this.oldActiveLayerId;
|
||||||
|
|
||||||
|
// 如果在选择模式下,恢复取消的所有选择
|
||||||
|
if (this.editorMode === OperationType.SELECT && this.canvas) {
|
||||||
|
this.canvas.discardActiveObject();
|
||||||
|
|
||||||
|
// 修复:确保对象存在于画布上才激活
|
||||||
|
if (this.oldActiveObjects && this.oldActiveObjects.length > 0) {
|
||||||
|
const canvasObjects = this.canvas.getObjects();
|
||||||
|
const validObjects = this.oldActiveObjects.filter(
|
||||||
|
(obj) => obj && canvasObjects.includes(obj)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validObjects.length > 0) {
|
||||||
|
if (validObjects.length > 1) {
|
||||||
|
// 如果有多个对象,需要创建一个活动选择组
|
||||||
|
const activeSelection = new fabric.ActiveSelection(validObjects, {
|
||||||
|
canvas: this.canvas,
|
||||||
|
});
|
||||||
|
this.canvas.setActiveObject(activeSelection);
|
||||||
|
} else {
|
||||||
|
// 只有一个对象时直接设置
|
||||||
|
this.canvas.setActiveObject(validObjects[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
layerId: this.layerId,
|
||||||
|
oldActiveLayerId: this.oldActiveLayerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加对象到图层命令
|
||||||
|
*/
|
||||||
|
export class AddObjectToLayerCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "添加对象到图层",
|
||||||
|
saveState: true,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.layerId = options.layerId;
|
||||||
|
this.fabricObject = options.fabricObject;
|
||||||
|
|
||||||
|
// 保存对象原始状态和ID
|
||||||
|
this.objectId =
|
||||||
|
this.fabricObject.id ||
|
||||||
|
`obj_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
|
this.originalObjectState = this.fabricObject.toObject([
|
||||||
|
"id",
|
||||||
|
"layerId",
|
||||||
|
"layerName",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
// 查找目标图层
|
||||||
|
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||||
|
|
||||||
|
if (!layer) {
|
||||||
|
console.error(`图层 ${this.layerId} 不存在`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是背景层,不允许添加对象
|
||||||
|
if (layer.isBackground) {
|
||||||
|
console.warn("不能向背景层添加对象");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为对象生成唯一ID
|
||||||
|
this.fabricObject.id = this.objectId;
|
||||||
|
|
||||||
|
// 设置对象与图层的关联
|
||||||
|
this.fabricObject.layerId = this.layerId;
|
||||||
|
this.fabricObject.layerName = layer.name;
|
||||||
|
|
||||||
|
// 设置对象可操作可选择
|
||||||
|
this.fabricObject.selectable = true;
|
||||||
|
this.fabricObject.evented = true;
|
||||||
|
|
||||||
|
// 将对象添加到画布
|
||||||
|
this.canvas.add(this.fabricObject);
|
||||||
|
|
||||||
|
// 将对象添加到图层的fabricObjects数组
|
||||||
|
layer.fabricObjects = layer.fabricObjects || [];
|
||||||
|
layer.fabricObjects.push(this.fabricObject);
|
||||||
|
|
||||||
|
this.canvas.discardActiveObject();
|
||||||
|
|
||||||
|
// 确保对象确实存在于画布上才激活
|
||||||
|
const canvasObjects = this.canvas.getObjects();
|
||||||
|
const validObjects = layer.fabricObjects.filter(
|
||||||
|
(obj) => obj && canvasObjects.includes(obj)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validObjects.length > 0) {
|
||||||
|
if (validObjects.length === 1) {
|
||||||
|
// 只有一个对象时直接设置
|
||||||
|
this.canvas.setActiveObject(validObjects[0]);
|
||||||
|
} else {
|
||||||
|
// 多个对象时创建活动选择组
|
||||||
|
const activeSelection = new fabric.ActiveSelection(validObjects, {
|
||||||
|
canvas: this.canvas,
|
||||||
|
});
|
||||||
|
this.canvas.setActiveObject(activeSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return this.fabricObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 查找图层
|
||||||
|
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||||
|
|
||||||
|
if (!layer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从图层的fabricObjects数组中移除对象
|
||||||
|
if (layer.fabricObjects) {
|
||||||
|
layer.fabricObjects = layer.fabricObjects.filter(
|
||||||
|
(obj) => obj.id !== this.objectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 从画布移除对象
|
||||||
|
const object = this.canvas
|
||||||
|
.getObjects()
|
||||||
|
.find((obj) => obj.id === this.objectId);
|
||||||
|
if (object) {
|
||||||
|
// 先丢弃活动对象,避免控制点渲染错误
|
||||||
|
this.canvas.discardActiveObject();
|
||||||
|
this.canvas.remove(object);
|
||||||
|
// 更新画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
layerId: this.layerId,
|
||||||
|
objectId: this.objectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从图层中移除对象命令
|
||||||
|
*/
|
||||||
|
export class RemoveObjectFromLayerCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "从图层中移除对象",
|
||||||
|
saveState: true,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layers = options.layers;
|
||||||
|
this.objectId = options.objectId;
|
||||||
|
|
||||||
|
// 查找对象和图层
|
||||||
|
this.object =
|
||||||
|
typeof options.objectOrId === "object"
|
||||||
|
? options.objectOrId
|
||||||
|
: this.canvas.getObjects().find((obj) => obj.id === this.objectId);
|
||||||
|
|
||||||
|
if (this.object) {
|
||||||
|
this.layerId = this.object.layerId;
|
||||||
|
this.objectData = this.object.toObject(["id", "layerId", "layerName"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
if (!this.object) {
|
||||||
|
console.error(`对象 ${this.objectId} 不存在`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.layerId) {
|
||||||
|
console.error(`对象 ${this.objectId} 未关联到任何图层`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找图层
|
||||||
|
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||||
|
if (!layer) {
|
||||||
|
console.error(`图层 ${this.layerId} 不存在`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从画布移除对象
|
||||||
|
this.canvas.remove(this.object);
|
||||||
|
|
||||||
|
// 从图层的fabricObjects数组移除对象
|
||||||
|
if (layer.fabricObjects) {
|
||||||
|
layer.fabricObjects = layer.fabricObjects.filter(
|
||||||
|
(obj) => obj.id !== this.objectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.objectData || !this.layerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找图层
|
||||||
|
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||||
|
if (!layer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复对象到画布
|
||||||
|
fabric.util.enlivenObjects([this.objectData], (objects) => {
|
||||||
|
const restoredObject = objects[0];
|
||||||
|
|
||||||
|
// 将对象添加到画布
|
||||||
|
this.canvas.add(restoredObject);
|
||||||
|
|
||||||
|
// 将对象添加回图层
|
||||||
|
layer.fabricObjects = layer.fabricObjects || [];
|
||||||
|
layer.fabricObjects.push(restoredObject);
|
||||||
|
|
||||||
|
// 更新画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
objectId: this.objectId,
|
||||||
|
layerId: this.layerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更换固定图层图像命令
|
||||||
|
* 专门用于更换固定图层(如背景图层)的图像
|
||||||
|
*/
|
||||||
|
export class ChangeFixedImageCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.imageUrl = options.imageUrl;
|
||||||
|
this.targetLayerType = options.targetLayerType || "background"; // 'background', 'fixed', etc.
|
||||||
|
this.position = options.position || { x: 0, y: 0 };
|
||||||
|
this.scale = options.scale || { x: 1, y: 1 };
|
||||||
|
this.preserveTransform = options.preserveTransform !== false; // 默认保留变换
|
||||||
|
|
||||||
|
// 用于回滚的状态
|
||||||
|
this.previousImage = null;
|
||||||
|
this.previousTransform = null;
|
||||||
|
this.targetLayer = null;
|
||||||
|
this.isExecuted = false;
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
this.maxRetries = options.maxRetries || 3;
|
||||||
|
this.retryCount = 0;
|
||||||
|
this.timeoutMs = options.timeoutMs || 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
try {
|
||||||
|
this.validateInputs();
|
||||||
|
|
||||||
|
// 查找目标图层
|
||||||
|
this.targetLayer = this.findTargetLayer();
|
||||||
|
if (!this.targetLayer) {
|
||||||
|
throw new Error(`找不到目标图层类型: ${this.targetLayerType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前状态用于回滚
|
||||||
|
await this.saveCurrentState();
|
||||||
|
|
||||||
|
// 加载新图像
|
||||||
|
const newImage = await this.loadImageWithRetry();
|
||||||
|
|
||||||
|
// 应用图像到图层
|
||||||
|
await this.applyImageToLayer(newImage);
|
||||||
|
|
||||||
|
this.isExecuted = true;
|
||||||
|
|
||||||
|
// 触发成功事件
|
||||||
|
this.emitEvent("image:changed", {
|
||||||
|
layerId: this.targetLayer.id,
|
||||||
|
newImageUrl: this.imageUrl,
|
||||||
|
command: this,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
layerId: this.targetLayer.id,
|
||||||
|
imageUrl: this.imageUrl,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ChangeFixedImageCommand执行失败:", error);
|
||||||
|
|
||||||
|
// 如果已经执行了部分操作,尝试回滚
|
||||||
|
if (this.isExecuted) {
|
||||||
|
try {
|
||||||
|
await this.undo();
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error("回滚失败:", rollbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.isExecuted || !this.targetLayer) {
|
||||||
|
throw new Error("命令未执行或目标图层不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.previousImage) {
|
||||||
|
// 恢复之前的图像
|
||||||
|
await this.restorePreviousImage();
|
||||||
|
} else {
|
||||||
|
// 如果没有之前的图像,移除当前图像
|
||||||
|
await this.removeCurrentImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExecuted = false;
|
||||||
|
|
||||||
|
// 触发撤销事件
|
||||||
|
this.emitEvent("image:reverted", {
|
||||||
|
layerId: this.targetLayer.id,
|
||||||
|
command: this,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: "reverted",
|
||||||
|
layerId: this.targetLayer.id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ChangeFixedImageCommand撤销失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateInputs() {
|
||||||
|
if (!this.canvas) throw new Error("Canvas实例是必需的");
|
||||||
|
if (!this.layerManager) throw new Error("LayerManager实例是必需的");
|
||||||
|
if (!this.imageUrl) throw new Error("图像URL是必需的");
|
||||||
|
|
||||||
|
// 验证URL格式
|
||||||
|
try {
|
||||||
|
new URL(this.imageUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("无效的图像URL格式");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findTargetLayer() {
|
||||||
|
const layers = this.layerManager.layers?.value || [];
|
||||||
|
|
||||||
|
switch (this.targetLayerType) {
|
||||||
|
case "background":
|
||||||
|
return layers.find((layer) => layer.isBackground);
|
||||||
|
case "fixed":
|
||||||
|
return layers.find((layer) => layer.isFixed);
|
||||||
|
default:
|
||||||
|
return layers.find((layer) => layer.type === this.targetLayerType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCurrentState() {
|
||||||
|
if (!this.targetLayer.fabricObject) return;
|
||||||
|
|
||||||
|
const currentObj = this.targetLayer.fabricObject;
|
||||||
|
|
||||||
|
// 保存当前图像URL(如果存在)
|
||||||
|
this.previousImage = {
|
||||||
|
url: currentObj.getSrc ? currentObj.getSrc() : null,
|
||||||
|
element: currentObj._element ? currentObj._element.cloneNode() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存变换状态
|
||||||
|
this.previousTransform = {
|
||||||
|
left: currentObj.left,
|
||||||
|
top: currentObj.top,
|
||||||
|
scaleX: currentObj.scaleX,
|
||||||
|
scaleY: currentObj.scaleY,
|
||||||
|
angle: currentObj.angle,
|
||||||
|
flipX: currentObj.flipX,
|
||||||
|
flipY: currentObj.flipY,
|
||||||
|
opacity: currentObj.opacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadImageWithRetry() {
|
||||||
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await this.loadImage();
|
||||||
|
} catch (error) {
|
||||||
|
this.retryCount = attempt;
|
||||||
|
|
||||||
|
if (attempt === this.maxRetries) {
|
||||||
|
throw new Error(
|
||||||
|
`图像加载失败,已重试${this.maxRetries}次: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数退避重试
|
||||||
|
const delay = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`图像加载重试 ${attempt + 1}/${this.maxRetries}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(`图像加载超时 (${this.timeoutMs}ms): ${this.imageUrl}`)
|
||||||
|
);
|
||||||
|
}, this.timeoutMs);
|
||||||
|
|
||||||
|
fabric.Image.fromURL(
|
||||||
|
this.imageUrl,
|
||||||
|
(img) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!img || !img.getElement()) {
|
||||||
|
reject(new Error("图像加载失败或无效"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(img);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyImageToLayer(newImage) {
|
||||||
|
const currentObj = this.targetLayer.fabricObject;
|
||||||
|
|
||||||
|
// 设置基本属性
|
||||||
|
newImage.set({
|
||||||
|
id: currentObj?.id || generateId(),
|
||||||
|
layerId: this.targetLayer.id,
|
||||||
|
layerName: this.targetLayer.name,
|
||||||
|
isBackground: this.targetLayer.isBackground,
|
||||||
|
isFixed: this.targetLayer.isFixed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用位置和变换
|
||||||
|
if (this.preserveTransform && this.previousTransform) {
|
||||||
|
newImage.set(this.previousTransform);
|
||||||
|
} else {
|
||||||
|
newImage.set({
|
||||||
|
left: this.position.x,
|
||||||
|
top: this.position.y,
|
||||||
|
scaleX: this.scale.x,
|
||||||
|
scaleY: this.scale.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除旧对象(如果存在)
|
||||||
|
if (currentObj) {
|
||||||
|
this.canvas.remove(currentObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新图像
|
||||||
|
this.canvas.add(newImage);
|
||||||
|
newImage.setCoords();
|
||||||
|
|
||||||
|
// 更新图层引用
|
||||||
|
this.targetLayer.fabricObject = newImage;
|
||||||
|
|
||||||
|
// 更新图层管理器
|
||||||
|
this.layerManager.updateLayerObject(this.targetLayer.id, newImage);
|
||||||
|
|
||||||
|
// 重新渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async restorePreviousImage() {
|
||||||
|
if (!this.previousImage.url) return;
|
||||||
|
|
||||||
|
const restoredImage = await this.loadImageFromUrl(this.previousImage.url);
|
||||||
|
|
||||||
|
// 恢复之前的变换
|
||||||
|
if (this.previousTransform) {
|
||||||
|
restoredImage.set(this.previousTransform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置图层属性
|
||||||
|
restoredImage.set({
|
||||||
|
id: this.targetLayer.fabricObject?.id || generateId(),
|
||||||
|
layerId: this.targetLayer.id,
|
||||||
|
layerName: this.targetLayer.name,
|
||||||
|
isBackground: this.targetLayer.isBackground,
|
||||||
|
isFixed: this.targetLayer.isFixed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 替换当前对象
|
||||||
|
if (this.targetLayer.fabricObject) {
|
||||||
|
this.canvas.remove(this.targetLayer.fabricObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.add(restoredImage);
|
||||||
|
restoredImage.setCoords();
|
||||||
|
|
||||||
|
// 更新引用
|
||||||
|
this.targetLayer.fabricObject = restoredImage;
|
||||||
|
this.layerManager.updateLayerObject(this.targetLayer.id, restoredImage);
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCurrentImage() {
|
||||||
|
if (this.targetLayer.fabricObject) {
|
||||||
|
this.canvas.remove(this.targetLayer.fabricObject);
|
||||||
|
this.targetLayer.fabricObject = null;
|
||||||
|
this.layerManager.updateLayerObject(this.targetLayer.id, null);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImageFromUrl(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fabric.Image.fromURL(
|
||||||
|
url,
|
||||||
|
(img) => {
|
||||||
|
if (!img || !img.getElement()) {
|
||||||
|
reject(new Error("恢复图像加载失败"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(img);
|
||||||
|
},
|
||||||
|
{ crossOrigin: "anonymous" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent(eventName, data) {
|
||||||
|
if (this.canvas && this.canvas.fire) {
|
||||||
|
this.canvas.fire(eventName, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取命令信息用于调试
|
||||||
|
getCommandInfo() {
|
||||||
|
return {
|
||||||
|
type: "ChangeFixedImageCommand",
|
||||||
|
targetLayerType: this.targetLayerType,
|
||||||
|
imageUrl: this.imageUrl,
|
||||||
|
isExecuted: this.isExecuted,
|
||||||
|
retryCount: this.retryCount,
|
||||||
|
targetLayerId: this.targetLayer?.id,
|
||||||
|
preserveTransform: this.preserveTransform,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向图层添加图像命令
|
||||||
|
* 用于向指定图层添加新的图像对象
|
||||||
|
*/
|
||||||
|
export class AddImageToLayerCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.imageUrl = options.imageUrl;
|
||||||
|
this.layerId = options.layerId;
|
||||||
|
this.position = options.position || { x: 100, y: 100 };
|
||||||
|
this.scale = options.scale || { x: 1, y: 1 };
|
||||||
|
this.zIndex = options.zIndex || null; // 可选的层级控制
|
||||||
|
|
||||||
|
// 用于回滚的状态
|
||||||
|
this.addedObject = null;
|
||||||
|
this.targetLayer = null;
|
||||||
|
this.isExecuted = false;
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
this.maxRetries = options.maxRetries || 3;
|
||||||
|
this.retryCount = 0;
|
||||||
|
this.timeoutMs = options.timeoutMs || 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
try {
|
||||||
|
this.validateInputs();
|
||||||
|
|
||||||
|
// 查找目标图层
|
||||||
|
this.targetLayer = this.findTargetLayer();
|
||||||
|
if (!this.targetLayer) {
|
||||||
|
throw new Error(`找不到目标图层: ${this.layerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查图层是否可编辑
|
||||||
|
this.validateLayerEditability();
|
||||||
|
|
||||||
|
// 加载新图像
|
||||||
|
const newImage = await this.loadImageWithRetry();
|
||||||
|
|
||||||
|
// 添加图像到图层
|
||||||
|
await this.addImageToLayer(newImage);
|
||||||
|
|
||||||
|
this.isExecuted = true;
|
||||||
|
|
||||||
|
// 触发成功事件
|
||||||
|
this.emitEvent("image:added", {
|
||||||
|
layerId: this.layerId,
|
||||||
|
objectId: this.addedObject.id,
|
||||||
|
imageUrl: this.imageUrl,
|
||||||
|
command: this,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
layerId: this.layerId,
|
||||||
|
objectId: this.addedObject.id,
|
||||||
|
imageUrl: this.imageUrl,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AddImageToLayerCommand执行失败:", error);
|
||||||
|
|
||||||
|
// 如果已经添加了对象,尝试移除
|
||||||
|
if (this.addedObject) {
|
||||||
|
try {
|
||||||
|
await this.undo();
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error("回滚失败:", rollbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.isExecuted || !this.addedObject) {
|
||||||
|
throw new Error("命令未执行或没有添加的对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 移除添加的对象
|
||||||
|
this.canvas.remove(this.addedObject);
|
||||||
|
|
||||||
|
// 从图层管理器中移除
|
||||||
|
this.layerManager.removeObjectFromLayer(
|
||||||
|
this.addedObject.id,
|
||||||
|
this.layerId
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isExecuted = false;
|
||||||
|
|
||||||
|
// 触发撤销事件
|
||||||
|
this.emitEvent("image:removed", {
|
||||||
|
layerId: this.layerId,
|
||||||
|
objectId: this.addedObject.id,
|
||||||
|
command: this,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新渲染
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: "removed",
|
||||||
|
layerId: this.layerId,
|
||||||
|
objectId: this.addedObject.id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AddImageToLayerCommand撤销失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateInputs() {
|
||||||
|
if (!this.canvas) throw new Error("Canvas实例是必需的");
|
||||||
|
if (!this.layerManager) throw new Error("LayerManager实例是必需的");
|
||||||
|
if (!this.imageUrl) throw new Error("图像URL是必需的");
|
||||||
|
if (!this.layerId) throw new Error("图层ID是必需的");
|
||||||
|
|
||||||
|
// 验证URL格式
|
||||||
|
try {
|
||||||
|
new URL(this.imageUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("无效的图像URL格式");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findTargetLayer() {
|
||||||
|
const layers = this.layerManager.layers?.value || [];
|
||||||
|
return layers.find((layer) => layer.id === this.layerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateLayerEditability() {
|
||||||
|
if (this.targetLayer.locked) {
|
||||||
|
throw new Error("目标图层已锁定,无法添加对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.targetLayer.visible) {
|
||||||
|
console.warn("目标图层不可见,添加的对象可能不会显示");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadImageWithRetry() {
|
||||||
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await this.loadImage();
|
||||||
|
} catch (error) {
|
||||||
|
this.retryCount = attempt;
|
||||||
|
|
||||||
|
if (attempt === this.maxRetries) {
|
||||||
|
throw new Error(
|
||||||
|
`图像加载失败,已重试${this.maxRetries}次: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数退避重试
|
||||||
|
const delay = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`图像加载重试 ${attempt + 1}/${this.maxRetries}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(`图像加载超时 (${this.timeoutMs}ms): ${this.imageUrl}`)
|
||||||
|
);
|
||||||
|
}, this.timeoutMs);
|
||||||
|
|
||||||
|
fabric.Image.fromURL(
|
||||||
|
this.imageUrl,
|
||||||
|
(img) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!img || !img.getElement()) {
|
||||||
|
reject(new Error("图像加载失败或无效"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(img);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addImageToLayer(newImage) {
|
||||||
|
// 生成唯一ID
|
||||||
|
const objectId = generateId();
|
||||||
|
|
||||||
|
// 设置图像属性
|
||||||
|
newImage.set({
|
||||||
|
id: objectId,
|
||||||
|
layerId: this.layerId,
|
||||||
|
layerName: this.targetLayer.name,
|
||||||
|
left: this.position.x,
|
||||||
|
top: this.position.y,
|
||||||
|
scaleX: this.scale.x,
|
||||||
|
scaleY: this.scale.y,
|
||||||
|
selectable: true,
|
||||||
|
evented: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加到画布
|
||||||
|
this.canvas.add(newImage);
|
||||||
|
|
||||||
|
// 设置层级
|
||||||
|
if (this.zIndex !== null) {
|
||||||
|
this.setObjectZIndex(newImage, this.zIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
newImage.setCoords();
|
||||||
|
|
||||||
|
// 保存引用用于回滚
|
||||||
|
this.addedObject = newImage;
|
||||||
|
|
||||||
|
// 添加到图层管理器
|
||||||
|
this.layerManager.addObjectToLayer(newImage, this.layerId);
|
||||||
|
|
||||||
|
// 重新渲染画布
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
setObjectZIndex(object, zIndex) {
|
||||||
|
if (zIndex === "top") {
|
||||||
|
object.bringToFront();
|
||||||
|
} else if (zIndex === "bottom") {
|
||||||
|
object.sendToBack();
|
||||||
|
} else if (typeof zIndex === "number") {
|
||||||
|
object.moveTo(zIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent(eventName, data) {
|
||||||
|
if (this.canvas && this.canvas.fire) {
|
||||||
|
this.canvas.fire(eventName, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取命令信息用于调试
|
||||||
|
getCommandInfo() {
|
||||||
|
return {
|
||||||
|
type: "AddImageToLayerCommand",
|
||||||
|
layerId: this.layerId,
|
||||||
|
imageUrl: this.imageUrl,
|
||||||
|
position: this.position,
|
||||||
|
scale: this.scale,
|
||||||
|
isExecuted: this.isExecuted,
|
||||||
|
retryCount: this.retryCount,
|
||||||
|
addedObjectId: this.addedObject?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
379
src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { OperationType } from "../utils/layerHelper.js";
|
||||||
|
import { Command, CompositeCommand } from "./Command.js";
|
||||||
|
//import { fabric } from "fabric-with-all";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量初始化红绿图模式命令
|
||||||
|
* 将衣服底图添加到背景层、红绿图添加到固定图层、调整位置和大小,以及设置画布背景为白色等操作合并到一个命令中
|
||||||
|
* 减少页面闪烁,一次性渲染完成
|
||||||
|
*/
|
||||||
|
export class BatchInitializeRedGreenModeCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "批量初始化红绿图模式",
|
||||||
|
description: "一次性完成红绿图模式的所有初始化操作",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.toolManager = options.toolManager;
|
||||||
|
this.clothingImageUrl = options.clothingImageUrl;
|
||||||
|
this.redGreenImageUrl = options.redGreenImageUrl;
|
||||||
|
this.onImageGenerated = options.onImageGenerated;
|
||||||
|
this.normalLayerOpacity = options.normalLayerOpacity || 0.4;
|
||||||
|
|
||||||
|
// 存储原始状态以便撤销
|
||||||
|
this.originalCanvasBackground = null;
|
||||||
|
this.originalBackgroundObject = null;
|
||||||
|
this.originalFixedObjects = null;
|
||||||
|
this.originalNormalObjects = null;
|
||||||
|
this.originalNormalOpacities = new Map();
|
||||||
|
this.originalToolState = null;
|
||||||
|
|
||||||
|
// 存储加载的图片对象
|
||||||
|
this.clothingImage = null;
|
||||||
|
this.redGreenImage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
try {
|
||||||
|
// 禁用画布渲染以避免闪烁
|
||||||
|
this.canvas.renderOnAddRemove = false;
|
||||||
|
|
||||||
|
// 1. 设置画布背景为白色
|
||||||
|
this.originalCanvasBackground = this.canvas.backgroundColor;
|
||||||
|
this.canvas.setBackgroundColor('#ffffff', () => {});
|
||||||
|
|
||||||
|
// 2. 查找图层结构
|
||||||
|
const layers = this.layerManager.layers?.value || [];
|
||||||
|
const backgroundLayer = layers.find((layer) => layer.isBackground);
|
||||||
|
const fixedLayer = layers.find((layer) => layer.isFixed);
|
||||||
|
const normalLayers = layers.filter(
|
||||||
|
(layer) => !layer.isBackground && !layer.isFixed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!backgroundLayer || !fixedLayer || normalLayers.length === 0) {
|
||||||
|
throw new Error("缺少必要的图层结构");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalLayer = normalLayers[0]; // 使用第一个普通图层
|
||||||
|
|
||||||
|
// 3. 保存原始状态
|
||||||
|
this.originalBackgroundObject = backgroundLayer.fabricObject ? {
|
||||||
|
...backgroundLayer.fabricObject.toObject(),
|
||||||
|
ref: backgroundLayer.fabricObject
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
this.originalFixedObjects = fixedLayer.fabricObject
|
||||||
|
? [fixedLayer.fabricObject]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
this.originalNormalObjects = normalLayer.fabricObjects
|
||||||
|
? [...normalLayer.fabricObjects]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 保存普通图层透明度
|
||||||
|
normalLayers.forEach((layer) => {
|
||||||
|
this.originalNormalOpacities.set(layer.id, layer.opacity || 1);
|
||||||
|
if (layer.fabricObjects) {
|
||||||
|
layer.fabricObjects.forEach((obj) => {
|
||||||
|
this.originalNormalOpacities.set(
|
||||||
|
`${layer.id}_${obj.id || "unknown"}`,
|
||||||
|
obj.opacity || 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存工具状态
|
||||||
|
if (this.toolManager) {
|
||||||
|
this.originalToolState = {
|
||||||
|
currentTool: this.toolManager.getCurrentTool(),
|
||||||
|
isRedGreenMode: this.toolManager.isRedGreenMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 确保背景图层大小正确
|
||||||
|
await this._setupBackgroundLayer(backgroundLayer);
|
||||||
|
|
||||||
|
// 5. 并行加载两个图片
|
||||||
|
const [clothingImg, redGreenImg] = await Promise.all([
|
||||||
|
this._loadImage(this.clothingImageUrl),
|
||||||
|
this._loadImage(this.redGreenImageUrl)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6. 设置衣服底图到固定图层
|
||||||
|
await this._setupClothingImage(clothingImg, fixedLayer);
|
||||||
|
|
||||||
|
// 7. 设置红绿图到普通图层,位置和大小与衣服底图一致
|
||||||
|
await this._setupRedGreenImage(redGreenImg, normalLayer, this.clothingImage);
|
||||||
|
|
||||||
|
// 8. 设置普通图层透明度
|
||||||
|
this._setupNormalLayerOpacity(normalLayers);
|
||||||
|
|
||||||
|
// 9. 配置工具管理器
|
||||||
|
this._setupToolManager();
|
||||||
|
|
||||||
|
// 10. 重新启用渲染并执行一次性渲染
|
||||||
|
this.canvas.renderOnAddRemove = true;
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
console.log("批量红绿图模式初始化完成", {
|
||||||
|
衣服底图: this.clothingImageUrl,
|
||||||
|
红绿图: this.redGreenImageUrl,
|
||||||
|
普通图层透明度: `${Math.round(this.normalLayerOpacity * 100)}%`,
|
||||||
|
画布背景: "白色",
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// 恢复渲染
|
||||||
|
this.canvas.renderOnAddRemove = true;
|
||||||
|
console.error("批量红绿图模式初始化失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
try {
|
||||||
|
// 禁用渲染
|
||||||
|
this.canvas.renderOnAddRemove = false;
|
||||||
|
|
||||||
|
// 1. 恢复画布背景
|
||||||
|
if (this.originalCanvasBackground !== null) {
|
||||||
|
this.canvas.setBackgroundColor(this.originalCanvasBackground, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 恢复图层对象
|
||||||
|
const layers = this.layerManager.layers?.value || [];
|
||||||
|
const backgroundLayer = layers.find((layer) => layer.isBackground);
|
||||||
|
const fixedLayer = layers.find((layer) => layer.isFixed);
|
||||||
|
const normalLayers = layers.filter(
|
||||||
|
(layer) => !layer.isBackground && !layer.isFixed
|
||||||
|
);
|
||||||
|
|
||||||
|
// 移除当前添加的对象
|
||||||
|
if (this.clothingImage) {
|
||||||
|
this.canvas.remove(this.clothingImage);
|
||||||
|
}
|
||||||
|
if (this.redGreenImage) {
|
||||||
|
this.canvas.remove(this.redGreenImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复背景图层
|
||||||
|
if (backgroundLayer && this.originalBackgroundObject) {
|
||||||
|
if (this.originalBackgroundObject.ref) {
|
||||||
|
backgroundLayer.fabricObject = this.originalBackgroundObject.ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复固定图层
|
||||||
|
if (fixedLayer) {
|
||||||
|
fixedLayer.fabricObject = this.originalFixedObjects.length > 0
|
||||||
|
? this.originalFixedObjects[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (fixedLayer.fabricObject) {
|
||||||
|
this.canvas.add(fixedLayer.fabricObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复普通图层
|
||||||
|
if (normalLayers.length > 0) {
|
||||||
|
const normalLayer = normalLayers[0];
|
||||||
|
normalLayer.fabricObjects = [...this.originalNormalObjects];
|
||||||
|
this.originalNormalObjects.forEach((obj) => {
|
||||||
|
this.canvas.add(obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 恢复透明度
|
||||||
|
normalLayers.forEach((layer) => {
|
||||||
|
if (this.originalNormalOpacities.has(layer.id)) {
|
||||||
|
layer.opacity = this.originalNormalOpacities.get(layer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layer.fabricObjects) {
|
||||||
|
layer.fabricObjects.forEach((obj) => {
|
||||||
|
const key = `${layer.id}_${obj.id || "unknown"}`;
|
||||||
|
if (this.originalNormalOpacities.has(key)) {
|
||||||
|
obj.opacity = this.originalNormalOpacities.get(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 恢复工具状态
|
||||||
|
if (this.toolManager && this.originalToolState) {
|
||||||
|
this.toolManager.isRedGreenMode = this.originalToolState.isRedGreenMode;
|
||||||
|
if (this.originalToolState.currentTool) {
|
||||||
|
this.toolManager.setTool(this.originalToolState.currentTool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 重新启用渲染
|
||||||
|
this.canvas.renderOnAddRemove = true;
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.canvas.renderOnAddRemove = true;
|
||||||
|
console.error("撤销批量红绿图模式初始化失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置背景图层
|
||||||
|
*/
|
||||||
|
async _setupBackgroundLayer(backgroundLayer) {
|
||||||
|
let backgroundObject = backgroundLayer.fabricObject;
|
||||||
|
|
||||||
|
if (!backgroundObject) {
|
||||||
|
// 创建白色背景矩形
|
||||||
|
backgroundObject = new fabric.Rect({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: this.canvas.width,
|
||||||
|
height: this.canvas.height,
|
||||||
|
fill: "#ffffff",
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
isBackground: true,
|
||||||
|
layerId: backgroundLayer.id,
|
||||||
|
layerName: backgroundLayer.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.add(backgroundObject);
|
||||||
|
this.canvas.sendToBack(backgroundObject);
|
||||||
|
backgroundLayer.fabricObject = backgroundObject;
|
||||||
|
} else {
|
||||||
|
// 更新现有背景对象大小
|
||||||
|
backgroundObject.set({
|
||||||
|
width: this.canvas.width,
|
||||||
|
height: this.canvas.height,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
fill: "#ffffff", // 确保背景是白色
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载图片
|
||||||
|
*/
|
||||||
|
async _loadImage(imageUrl) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fabric.Image.fromURL(
|
||||||
|
imageUrl,
|
||||||
|
(img) => {
|
||||||
|
if (!img) {
|
||||||
|
reject(new Error(`无法加载图片: ${imageUrl}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(img);
|
||||||
|
},
|
||||||
|
{ crossOrigin: "anonymous" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置衣服底图
|
||||||
|
*/
|
||||||
|
async _setupClothingImage(img, fixedLayer) {
|
||||||
|
// 计算图片缩放,保持上下留边距
|
||||||
|
const margin = 50;
|
||||||
|
const maxWidth = this.canvas.width - margin * 2;
|
||||||
|
const maxHeight = this.canvas.height - margin * 2;
|
||||||
|
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
|
||||||
|
|
||||||
|
img.set({
|
||||||
|
scaleX: scale,
|
||||||
|
scaleY: scale,
|
||||||
|
left: this.canvas.width / 2,
|
||||||
|
top: this.canvas.height / 2,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
layerId: fixedLayer.id,
|
||||||
|
layerName: fixedLayer.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除固定图层原有内容
|
||||||
|
if (fixedLayer.fabricObject) {
|
||||||
|
this.canvas.remove(fixedLayer.fabricObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到画布和固定图层
|
||||||
|
this.canvas.add(img);
|
||||||
|
fixedLayer.fabricObject = img;
|
||||||
|
this.clothingImage = img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置红绿图
|
||||||
|
*/
|
||||||
|
async _setupRedGreenImage(img, normalLayer, clothingImage) {
|
||||||
|
if (!clothingImage) {
|
||||||
|
throw new Error("衣服底图未加载,无法设置红绿图位置");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用与衣服底图完全相同的属性
|
||||||
|
img.set({
|
||||||
|
scaleX: clothingImage.scaleX,
|
||||||
|
scaleY: clothingImage.scaleY,
|
||||||
|
left: clothingImage.left,
|
||||||
|
top: clothingImage.top,
|
||||||
|
originX: clothingImage.originX,
|
||||||
|
originY: clothingImage.originY,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
layerId: normalLayer.id,
|
||||||
|
layerName: normalLayer.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除普通图层原有内容
|
||||||
|
if (normalLayer.fabricObjects) {
|
||||||
|
normalLayer.fabricObjects.forEach((obj) => {
|
||||||
|
this.canvas.remove(obj);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到画布和普通图层
|
||||||
|
this.canvas.add(img);
|
||||||
|
normalLayer.fabricObjects = [img];
|
||||||
|
this.redGreenImage = img;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置普通图层透明度
|
||||||
|
*/
|
||||||
|
_setupNormalLayerOpacity(normalLayers) {
|
||||||
|
normalLayers.forEach((layer) => {
|
||||||
|
// 设置图层透明度
|
||||||
|
layer.opacity = this.normalLayerOpacity;
|
||||||
|
|
||||||
|
// 更新图层中所有对象的透明度
|
||||||
|
if (layer.fabricObjects) {
|
||||||
|
layer.fabricObjects.forEach((obj) => {
|
||||||
|
obj.opacity = this.normalLayerOpacity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工具管理器
|
||||||
|
*/
|
||||||
|
_setupToolManager() {
|
||||||
|
if (this.toolManager) {
|
||||||
|
// 设置红绿图模式
|
||||||
|
this.toolManager.isRedGreenMode = true;
|
||||||
|
|
||||||
|
// 切换到红色笔刷工具
|
||||||
|
this.toolManager.setTool(OperationType.RED_BRUSH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
578
src/component/Canvas/CanvasEditor/commands/SelectionCommands.js
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
import { Command, CompositeCommand } from "./Command.js";
|
||||||
|
//import { fabric } from "fabric-with-all";
|
||||||
|
import { createLayer, LayerType } from "../utils/layerHelper.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建选区命令
|
||||||
|
*/
|
||||||
|
export class CreateSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: options.name || "创建选区",
|
||||||
|
description: "在画布上创建选区",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.selectionObject = options.selectionObject;
|
||||||
|
this.selectionType = options.selectionType || "rectangle";
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.selectionManager || !this.selectionObject) {
|
||||||
|
console.error("无法创建选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将选择对象添加到选区管理器
|
||||||
|
this.selectionManager.setSelectionObject(this.selectionObject);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.selectionManager) return false;
|
||||||
|
this.selectionManager.clearSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反转选区命令
|
||||||
|
*/
|
||||||
|
export class InvertSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "反转选区",
|
||||||
|
description: "反转当前选区",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.originalSelection = options.selectionManager
|
||||||
|
? options.selectionManager.getSelectionPath()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.selectionManager) {
|
||||||
|
console.error("无法反转选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始选区
|
||||||
|
if (!this.originalSelection) {
|
||||||
|
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反转选区
|
||||||
|
const result = await this.selectionManager.invertSelection();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.selectionManager || !this.originalSelection) return false;
|
||||||
|
|
||||||
|
// 恢复原始选区
|
||||||
|
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加到选区命令
|
||||||
|
*/
|
||||||
|
export class AddToSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "添加到选区",
|
||||||
|
description: "将新的选区添加到现有选区",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.newSelection = options.newSelection;
|
||||||
|
this.originalSelection = options.selectionManager
|
||||||
|
? options.selectionManager.getSelectionPath()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.selectionManager || !this.newSelection) {
|
||||||
|
console.error("无法添加到选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始选区
|
||||||
|
if (!this.originalSelection) {
|
||||||
|
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到选区
|
||||||
|
const result = await this.selectionManager.addToSelection(
|
||||||
|
this.newSelection
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.selectionManager || !this.originalSelection) return false;
|
||||||
|
|
||||||
|
// 恢复原始选区
|
||||||
|
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从选区中移除命令
|
||||||
|
*/
|
||||||
|
export class RemoveFromSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "从选区中移除",
|
||||||
|
description: "从现有选区中移除指定区域",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.removeSelection = options.removeSelection;
|
||||||
|
this.originalSelection = options.selectionManager
|
||||||
|
? options.selectionManager.getSelectionPath()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.selectionManager || !this.removeSelection) {
|
||||||
|
console.error("无法从选区中移除:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始选区
|
||||||
|
if (!this.originalSelection) {
|
||||||
|
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从选区中移除
|
||||||
|
const result = await this.selectionManager.removeFromSelection(
|
||||||
|
this.removeSelection
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.selectionManager || !this.originalSelection) return false;
|
||||||
|
|
||||||
|
// 恢复原始选区
|
||||||
|
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除选区命令
|
||||||
|
*/
|
||||||
|
export class ClearSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "清除选区",
|
||||||
|
description: "清除当前选区",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.originalSelection = options.selectionManager
|
||||||
|
? options.selectionManager.getSelectionPath()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.selectionManager) {
|
||||||
|
console.error("无法清除选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始选区
|
||||||
|
if (!this.originalSelection) {
|
||||||
|
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除选区
|
||||||
|
this.selectionManager.clearSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.selectionManager || !this.originalSelection) return false;
|
||||||
|
|
||||||
|
// 恢复原始选区
|
||||||
|
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 羽化选区命令
|
||||||
|
*/
|
||||||
|
export class FeatherSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "羽化选区",
|
||||||
|
description: "对当前选区应用羽化效果",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.featherAmount = options.featherAmount || 5;
|
||||||
|
this.originalSelection = options.selectionManager
|
||||||
|
? options.selectionManager.getSelectionPath()
|
||||||
|
: null;
|
||||||
|
this.originalFeatherAmount = options.selectionManager
|
||||||
|
? options.selectionManager.getFeatherAmount()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.selectionManager) {
|
||||||
|
console.error("无法羽化选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始选区和羽化值
|
||||||
|
if (!this.originalSelection) {
|
||||||
|
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||||
|
this.originalFeatherAmount = this.selectionManager.getFeatherAmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用羽化
|
||||||
|
const result = await this.selectionManager.featherSelection(
|
||||||
|
this.featherAmount
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.selectionManager || !this.originalSelection) return false;
|
||||||
|
|
||||||
|
// 恢复原始选区和羽化值
|
||||||
|
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||||
|
this.selectionManager.setFeatherAmount(this.originalFeatherAmount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填充选区命令
|
||||||
|
*/
|
||||||
|
export class FillSelectionCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "填充选区",
|
||||||
|
description: "使用指定颜色填充当前选区",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.color = options.color || "#000000";
|
||||||
|
this.targetLayerId = options.targetLayerId;
|
||||||
|
this.createdObjectIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||||
|
console.error("无法填充选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取选区路径
|
||||||
|
const selectionPath = this.selectionManager.getSelectionObject();
|
||||||
|
if (!selectionPath) {
|
||||||
|
console.error("无法填充选区:当前没有选区");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定目标图层
|
||||||
|
const layerId = this.targetLayerId || this.layerManager.getActiveLayerId();
|
||||||
|
if (!layerId) {
|
||||||
|
console.error("无法填充选区:没有活动图层");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建填充对象
|
||||||
|
const fillObject = new fabric.Path(selectionPath.path, {
|
||||||
|
fill: this.color,
|
||||||
|
stroke: null,
|
||||||
|
opacity: 1,
|
||||||
|
id: `fill_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||||
|
layerId: layerId,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用羽化效果(如果有)
|
||||||
|
const featherAmount = this.selectionManager.getFeatherAmount();
|
||||||
|
if (featherAmount > 0) {
|
||||||
|
fillObject.shadow = new fabric.Shadow({
|
||||||
|
color: this.color,
|
||||||
|
blur: featherAmount,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到图层
|
||||||
|
this.layerManager.addObjectToLayer(layerId, fillObject);
|
||||||
|
this.createdObjectIds.push(fillObject.id);
|
||||||
|
|
||||||
|
// 清空选区
|
||||||
|
this.selectionManager.clearSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.layerManager || this.createdObjectIds.length === 0) return false;
|
||||||
|
|
||||||
|
// 移除创建的填充对象
|
||||||
|
for (const id of this.createdObjectIds) {
|
||||||
|
const layerObj = this._findObjectInLayers(id);
|
||||||
|
if (layerObj) {
|
||||||
|
this.layerManager.removeObjectFromLayer(layerObj.layerId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_findObjectInLayers(objectId) {
|
||||||
|
if (!this.layerManager) return null;
|
||||||
|
|
||||||
|
const layers = this.layerManager.layers.value;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.fabricObjects) {
|
||||||
|
const obj = layer.fabricObjects.find((obj) => obj.id === objectId);
|
||||||
|
if (obj) return { object: obj, layerId: layer.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制选区内容到新图层命令
|
||||||
|
*/
|
||||||
|
export class CopySelectionToNewLayerCommand extends CompositeCommand {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super([], {
|
||||||
|
name: "复制选区到新图层",
|
||||||
|
description: "将选区中的内容复制到新图层",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.sourceLayerId = options.sourceLayerId;
|
||||||
|
this.newLayerName = options.newLayerName || "选区复制";
|
||||||
|
this.newLayerId = null;
|
||||||
|
this.copiedObjectIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||||
|
console.error("无法复制选区:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取选区
|
||||||
|
const selectionObject = this.selectionManager.getSelectionObject();
|
||||||
|
if (!selectionObject) {
|
||||||
|
console.error("无法复制选区:当前没有选区");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定源图层
|
||||||
|
const sourceId =
|
||||||
|
this.sourceLayerId || this.layerManager.getActiveLayerId();
|
||||||
|
const sourceLayer = this.layerManager.getLayerById(sourceId);
|
||||||
|
if (!sourceLayer || !sourceLayer.fabricObjects) {
|
||||||
|
console.error("无法复制选区:源图层无效或为空");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新图层
|
||||||
|
this.newLayerId = await this.layerManager.createLayer(
|
||||||
|
this.newLayerName,
|
||||||
|
LayerType.EMPTY
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取选区内的对象
|
||||||
|
const objectsToCopy = sourceLayer.fabricObjects.filter((obj) => {
|
||||||
|
return this.selectionManager.isObjectInSelection(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (objectsToCopy.length === 0) {
|
||||||
|
console.warn("选区内没有对象可复制");
|
||||||
|
return true; // 仍然返回成功,因为已创建了新图层
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制对象到新图层
|
||||||
|
for (const obj of objectsToCopy) {
|
||||||
|
// 克隆对象
|
||||||
|
const clonedObj = await this._cloneObject(obj);
|
||||||
|
// 设置新的ID和图层ID
|
||||||
|
const newId = `copy_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
|
clonedObj.id = newId;
|
||||||
|
clonedObj.layerId = this.newLayerId;
|
||||||
|
|
||||||
|
// 添加到新图层
|
||||||
|
await this.layerManager.addObjectToLayer(this.newLayerId, clonedObj);
|
||||||
|
this.copiedObjectIds.push(newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新图层为活动图层
|
||||||
|
this.layerManager.setActiveLayer(this.newLayerId);
|
||||||
|
|
||||||
|
// 清空选区
|
||||||
|
this.selectionManager.clearSelection();
|
||||||
|
|
||||||
|
return {
|
||||||
|
newLayerId: this.newLayerId,
|
||||||
|
copiedCount: this.copiedObjectIds.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("复制选区过程中出错:", error);
|
||||||
|
|
||||||
|
// 如果已经创建了新图层,需要进行清理
|
||||||
|
if (this.newLayerId) {
|
||||||
|
try {
|
||||||
|
await this.layerManager.removeLayer(this.newLayerId);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn("清理新图层失败:", cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cloneObject(obj) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!obj) {
|
||||||
|
reject(new Error("对象无效,无法克隆"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
obj.clone((cloned) => {
|
||||||
|
resolve(cloned);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从选区中删除内容命令
|
||||||
|
*/
|
||||||
|
export class ClearSelectionContentCommand extends Command {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super({
|
||||||
|
name: "清除选区内容",
|
||||||
|
description: "删除选区中的内容",
|
||||||
|
saveState: false,
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.selectionManager = options.selectionManager;
|
||||||
|
this.targetLayerId = options.targetLayerId;
|
||||||
|
this.removedObjects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||||
|
console.error("无法清除选区内容:参数无效");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取选区
|
||||||
|
const selectionObject = this.selectionManager.getSelectionObject();
|
||||||
|
if (!selectionObject) {
|
||||||
|
console.error("无法清除选区内容:当前没有选区");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定目标图层
|
||||||
|
const layerId = this.targetLayerId || this.layerManager.getActiveLayerId();
|
||||||
|
const layer = this.layerManager.getLayerById(layerId);
|
||||||
|
if (!layer || !layer.fabricObjects) {
|
||||||
|
console.error("无法清除选区内容:目标图层无效或为空");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到选区内的对象
|
||||||
|
const objectsToRemove = layer.fabricObjects.filter((obj) => {
|
||||||
|
return this.selectionManager.isObjectInSelection(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (objectsToRemove.length === 0) {
|
||||||
|
console.warn("选区内没有对象需要清除");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份被删除的对象
|
||||||
|
this.removedObjects = objectsToRemove.map((obj) => ({
|
||||||
|
object: this._cloneObjectSync(obj),
|
||||||
|
layerId: layerId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 从图层中移除对象
|
||||||
|
for (const obj of objectsToRemove) {
|
||||||
|
this.layerManager.removeObjectFromLayer(layerId, obj.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removedCount: objectsToRemove.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async undo() {
|
||||||
|
if (!this.layerManager || this.removedObjects.length === 0) return false;
|
||||||
|
|
||||||
|
// 恢复被删除的对象
|
||||||
|
for (const item of this.removedObjects) {
|
||||||
|
if (item.object && item.layerId) {
|
||||||
|
const clonedObj = await this._cloneObject(item.object);
|
||||||
|
this.layerManager.addObjectToLayer(item.layerId, clonedObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cloneObjectSync(obj) {
|
||||||
|
// 这是一个简单的深拷贝,不适用于所有场景
|
||||||
|
// 在实际应用中,应该使用fabric.js的clone方法
|
||||||
|
if (!obj) return null;
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cloneObject(obj) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!obj) {
|
||||||
|
reject(new Error("对象无效,无法克隆"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof obj.clone === "function") {
|
||||||
|
obj.clone((cloned) => {
|
||||||
|
resolve(cloned);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果对象没有clone方法(可能是因为它已经是序列化后的对象)
|
||||||
|
// 在实际代码中需要适当处理这种情况
|
||||||
|
resolve(Object.assign({}, obj));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入套索抠图命令
|
||||||
|
export { LassoCutoutCommand } from "./LassoCutoutCommand.js";
|
||||||
134
src/component/Canvas/CanvasEditor/commands/StateCommands.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Command } from "./Command";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象变换命令
|
||||||
|
* 轻量级命令,只记录对象的变换属性变化(位置、缩放、旋转)
|
||||||
|
* 不保存整个对象或画布状态,只关注变换属性
|
||||||
|
*/
|
||||||
|
export class TransformCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: options.name || "对象变换",
|
||||||
|
description: options.description || "移动、缩放或旋转对象",
|
||||||
|
saveState: false, // 自己管理状态,避免递归
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.objectId = options.objectId;
|
||||||
|
this.initialState = options.initialState || null;
|
||||||
|
this.finalState = options.finalState || null;
|
||||||
|
this.objectType = options.objectType || "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令
|
||||||
|
* 如果是首次执行,记录初始和最终状态
|
||||||
|
* 如果是重做,应用最终状态
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
if (!this.finalState) {
|
||||||
|
console.warn("没有最终状态可应用");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找目标对象
|
||||||
|
const targetObject = this._findObject(this.objectId);
|
||||||
|
if (!targetObject) {
|
||||||
|
console.warn(`未找到ID为 ${this.objectId} 的对象`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用最终变换状态
|
||||||
|
this._applyTransform(targetObject, this.finalState);
|
||||||
|
|
||||||
|
// 触发画布更新
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销命令
|
||||||
|
* 应用初始状态
|
||||||
|
*/
|
||||||
|
undo() {
|
||||||
|
if (!this.initialState) {
|
||||||
|
console.warn("没有初始状态可恢复");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找目标对象
|
||||||
|
const targetObject = this._findObject(this.objectId);
|
||||||
|
if (!targetObject) {
|
||||||
|
console.warn(`未找到ID为 ${this.objectId} 的对象`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用初始变换状态
|
||||||
|
this._applyTransform(targetObject, this.initialState);
|
||||||
|
|
||||||
|
// 触发画布更新
|
||||||
|
this.canvas.renderAll();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_findObject(objectId) {
|
||||||
|
if (!this.canvas) return null;
|
||||||
|
return this.canvas.getObjects().find((obj) => obj.id === objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用变换状态到对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_applyTransform(object, transformState) {
|
||||||
|
if (!object || !transformState) return;
|
||||||
|
|
||||||
|
// 应用变换属性,只设置真正变化的值
|
||||||
|
Object.entries(transformState).forEach(([key, value]) => {
|
||||||
|
object.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保对象更新
|
||||||
|
object.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取命令信息
|
||||||
|
*/
|
||||||
|
getInfo() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
objectId: this.objectId,
|
||||||
|
objectType: this.objectType,
|
||||||
|
changedProps: this.finalState ? Object.keys(this.finalState) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 捕获对象的变换状态
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
static captureTransformState(object) {
|
||||||
|
if (!object) return null;
|
||||||
|
|
||||||
|
// 只捕获变换相关的属性
|
||||||
|
return {
|
||||||
|
left: object.left,
|
||||||
|
top: object.top,
|
||||||
|
scaleX: object.scaleX,
|
||||||
|
scaleY: object.scaleY,
|
||||||
|
angle: object.angle,
|
||||||
|
flipX: object.flipX,
|
||||||
|
flipY: object.flipY,
|
||||||
|
skewX: object.skewX,
|
||||||
|
skewY: object.skewY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
304
src/component/Canvas/CanvasEditor/commands/TextCommands.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { Command } from "./Command";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本内容命令
|
||||||
|
* 用于更改文本图层的文本内容
|
||||||
|
*/
|
||||||
|
export class TextContentCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本内容",
|
||||||
|
description: "修改文本图层的文本内容",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newText = options.newText;
|
||||||
|
this.oldText = this.textObject.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("text", this.newText);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("text", this.oldText);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本字体命令
|
||||||
|
* 用于更改文本图层的字体
|
||||||
|
*/
|
||||||
|
export class TextFontCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本字体",
|
||||||
|
description: "修改文本图层的字体",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newFont = options.newFont;
|
||||||
|
this.oldFont = this.textObject.fontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("fontFamily", this.newFont);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("fontFamily", this.oldFont);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本尺寸命令
|
||||||
|
* 用于更改文本图层的字体大小
|
||||||
|
*/
|
||||||
|
export class TextSizeCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本尺寸",
|
||||||
|
description: "修改文本图层的字体大小",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newSize = options.newSize;
|
||||||
|
this.oldSize = this.textObject.fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("fontSize", this.newSize);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("fontSize", this.oldSize);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本颜色命令
|
||||||
|
* 用于更改文本图层的颜色
|
||||||
|
*/
|
||||||
|
export class TextColorCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本颜色",
|
||||||
|
description: "修改文本图层的颜色",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newColor = options.newColor;
|
||||||
|
this.oldColor = this.textObject.fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("fill", this.newColor);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("fill", this.oldColor);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本对齐方式命令
|
||||||
|
* 用于更改文本图层的对齐方式
|
||||||
|
*/
|
||||||
|
export class TextAlignCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本对齐",
|
||||||
|
description: "修改文本图层的对齐方式",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newAlign = options.newAlign;
|
||||||
|
this.oldAlign = this.textObject.textAlign;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("textAlign", this.newAlign);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("textAlign", this.oldAlign);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本样式命令
|
||||||
|
* 用于更改文本图层的样式(粗体、斜体、下划线等)
|
||||||
|
*/
|
||||||
|
export class TextStyleCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本样式",
|
||||||
|
description: "修改文本图层的样式",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.property = options.property; // 'fontWeight', 'fontStyle', 'underline', 'linethrough', 'overline'
|
||||||
|
this.newValue = options.newValue;
|
||||||
|
this.oldValue = this.textObject[this.property];
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set(this.property, this.newValue);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set(this.property, this.oldValue);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本间距命令
|
||||||
|
* 用于更改文本图层的字符间距或行高
|
||||||
|
*/
|
||||||
|
export class TextSpacingCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本间距",
|
||||||
|
description: "修改文本图层的字符间距或行高",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.property = options.property; // 'charSpacing' 或 'lineHeight'
|
||||||
|
this.newValue = options.newValue;
|
||||||
|
this.oldValue = this.textObject[this.property];
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set(this.property, this.newValue);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set(this.property, this.oldValue);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本背景颜色命令
|
||||||
|
* 用于更改文本图层的背景颜色
|
||||||
|
*/
|
||||||
|
export class TextBackgroundCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本背景",
|
||||||
|
description: "修改文本图层的背景颜色",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newColor = options.newColor;
|
||||||
|
this.oldColor = this.textObject.textBackgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("textBackgroundColor", this.newColor);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("textBackgroundColor", this.oldColor);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本透明度命令
|
||||||
|
* 用于更改文本图层的透明度
|
||||||
|
*/
|
||||||
|
export class TextOpacityCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "修改文本透明度",
|
||||||
|
description: "修改文本图层的透明度",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.newOpacity = options.newOpacity;
|
||||||
|
this.oldOpacity = this.textObject.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.textObject.set("opacity", this.newOpacity);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
this.textObject.set("opacity", this.oldOpacity);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组合文本编辑命令
|
||||||
|
* 用于一次性应用多个文本属性更改
|
||||||
|
*/
|
||||||
|
export class CompositeTextCommand extends Command {
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
name: "组合文本编辑",
|
||||||
|
description: "组合多个文本编辑操作",
|
||||||
|
});
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.textObject = options.textObject;
|
||||||
|
this.changes = options.changes; // {property: newValue} 形式的对象
|
||||||
|
this.oldValues = {};
|
||||||
|
|
||||||
|
// 保存所有属性的旧值
|
||||||
|
for (const property in this.changes) {
|
||||||
|
if (this.textObject[property] !== undefined) {
|
||||||
|
this.oldValues[property] = this.textObject[property];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
for (const property in this.changes) {
|
||||||
|
this.textObject.set(property, this.changes[property]);
|
||||||
|
}
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
for (const property in this.oldValues) {
|
||||||
|
this.textObject.set(property, this.oldValues[property]);
|
||||||
|
}
|
||||||
|
this.canvas.renderAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/component/Canvas/CanvasEditor/commands/ToolCommands.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Command } from "./Command";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具切换命令
|
||||||
|
* 用于切换编辑器的工具模式(如绘画、选择、橡皮擦等)
|
||||||
|
*/
|
||||||
|
export class ToolCommand extends Command {
|
||||||
|
/**
|
||||||
|
* 创建一个工具切换命令
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {Object} options.toolManager 工具管理器实例
|
||||||
|
* @param {String} options.tool 要设置的工具名称
|
||||||
|
* @param {String} options.previousTool 先前的工具名称(可选,如果不提供会在执行时记录)
|
||||||
|
* @param {Boolean} options.saveState 是否保存画布状态(默认为false)
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
name: `切换工具: ${options.tool}`,
|
||||||
|
description: `将工具切换为 ${options.tool}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.toolManager = options.toolManager;
|
||||||
|
this.tool = options.tool;
|
||||||
|
this.previousTool = options.previousTool || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行工具切换
|
||||||
|
* @returns {String} 设置的工具名称
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
if (!this.toolManager) return null;
|
||||||
|
|
||||||
|
// 记录当前工具(用于撤销)
|
||||||
|
if (!this.previousTool) {
|
||||||
|
this.previousTool = this.toolManager.getCurrentTool();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换工具
|
||||||
|
return this.toolManager.setTool(this.tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销工具切换
|
||||||
|
* @returns {String} 恢复的工具名称
|
||||||
|
*/
|
||||||
|
undo() {
|
||||||
|
if (!this.toolManager || !this.previousTool) return null;
|
||||||
|
|
||||||
|
// 恢复到先前工具
|
||||||
|
return this.toolManager.setTool(this.previousTool);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="isVisible" class="brush-control-panel">
|
||||||
|
<!-- 笔刷大小控制 -->
|
||||||
|
<VerticalSlider
|
||||||
|
v-model="brushSize"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
:presets="sizePresets"
|
||||||
|
:memorized-values="memorizedSizes"
|
||||||
|
:active-threshold="1"
|
||||||
|
custom-class="size-slider"
|
||||||
|
:step="1"
|
||||||
|
v-model:showTooltip="showSizeTooltip"
|
||||||
|
@slide-start="handleSizeSlideStart"
|
||||||
|
@slide-end="handleSizeSlideEnd"
|
||||||
|
@click="showSizeTooltip = true"
|
||||||
|
>
|
||||||
|
<template #tooltip-content>
|
||||||
|
<div class="tooltip-header">
|
||||||
|
<div class="tooltip-title">Size</div>
|
||||||
|
<div class="tooltip-close-btn" @click.stop="closeSizeTooltip">
|
||||||
|
<SvgIcon name="CClose" size="20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="brush-preview-container">
|
||||||
|
<div
|
||||||
|
class="brush-size-preview"
|
||||||
|
:style="{
|
||||||
|
width: `${Math.min(100, brushSize)}px`,
|
||||||
|
height: `${Math.min(100, brushSize)}px`,
|
||||||
|
backgroundColor: showColorPicker ? brushColor : '#888888',
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-content">
|
||||||
|
<div class="tooltip-text">{{ Math.round(brushSize) }}px</div>
|
||||||
|
<div class="tooltip-controls">
|
||||||
|
<button
|
||||||
|
v-if="!memorizedSizes.includes(brushSize)"
|
||||||
|
class="control-btn add"
|
||||||
|
@click="memorizeSize"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="control-btn remove"
|
||||||
|
@click="removeMemorizedSize"
|
||||||
|
v-if="canRemoveSize"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VerticalSlider>
|
||||||
|
|
||||||
|
<!-- 颜色选择器 - 仅在特定工具下显示 -->
|
||||||
|
<div v-if="showColorPicker" class="color-picker-container">
|
||||||
|
<label for="color-picker" class="current-color-label">
|
||||||
|
<div
|
||||||
|
class="current-color"
|
||||||
|
:style="{ backgroundColor: brushColor }"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color-picker"
|
||||||
|
class="system-color-picker"
|
||||||
|
v-model="customColor"
|
||||||
|
@input="setBrushColor(customColor)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 透明度控制 - 仅在特定工具下显示 -->
|
||||||
|
<VerticalSlider
|
||||||
|
v-if="showOpacitySlider"
|
||||||
|
v-model="brushOpacity"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:presets="opacityPresets"
|
||||||
|
:memorized-values="memorizedOpacities"
|
||||||
|
:is-percentage="true"
|
||||||
|
custom-class="opacity-slider"
|
||||||
|
:active-threshold="0.01"
|
||||||
|
:step="0.01"
|
||||||
|
v-model:showTooltip="showOpacityTooltip"
|
||||||
|
@slide-start="handleOpacitySlideStart"
|
||||||
|
@slide-end="handleOpacitySlideEnd"
|
||||||
|
@click="showOpacityTooltip = true"
|
||||||
|
>
|
||||||
|
<template #tooltip-content>
|
||||||
|
<div class="tooltip-header">
|
||||||
|
<div class="tooltip-title">Opacity</div>
|
||||||
|
<div class="tooltip-close-btn" @click.stop="closeOpacityTooltip">
|
||||||
|
<SvgIcon name="CClose" size="20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="opacity-preview">
|
||||||
|
<div class="opacity-checker"></div>
|
||||||
|
<div
|
||||||
|
class="opacity-color"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: brushColor,
|
||||||
|
opacity: brushOpacity,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-content">
|
||||||
|
<div class="tooltip-text">
|
||||||
|
{{ Math.round(brushOpacity * 100) }}%
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-controls">
|
||||||
|
<button
|
||||||
|
class="control-btn add"
|
||||||
|
v-if="!memorizedOpacities.includes(brushOpacity)"
|
||||||
|
@click="memorizeOpacity"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="control-btn remove"
|
||||||
|
@click="removeMemorizedOpacity"
|
||||||
|
v-if="canRemoveOpacity"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VerticalSlider>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||||
|
import { BrushStore } from "../store/BrushStore";
|
||||||
|
import { OperationType } from "../utils/layerHelper";
|
||||||
|
import { inject } from "vue";
|
||||||
|
import VerticalSlider from "./VerticalSlider.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeTool: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 工具管理器和画布管理器
|
||||||
|
const toolManager = inject("toolManager");
|
||||||
|
const canvasManager = inject("canvasManager");
|
||||||
|
|
||||||
|
// 可见性控制
|
||||||
|
const isVisible = computed(() => {
|
||||||
|
return [
|
||||||
|
OperationType.DRAW,
|
||||||
|
OperationType.ERASER,
|
||||||
|
OperationType.RED_BRUSH,
|
||||||
|
OperationType.GREEN_BRUSH,
|
||||||
|
].includes(props.activeTool);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 控制颜色选择器的显示
|
||||||
|
const showColorPicker = computed(() => {
|
||||||
|
return props.activeTool === OperationType.DRAW;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 控制透明度滑块的显示
|
||||||
|
const showOpacitySlider = computed(() => {
|
||||||
|
return props.activeTool === OperationType.DRAW;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 笔刷大小相关
|
||||||
|
const brushSize = ref(BrushStore.state.size);
|
||||||
|
const showSizeTooltip = ref(false);
|
||||||
|
const sizePresets = ref([5, 10, 20, 50]); // 预设大小,便于吸附
|
||||||
|
const memorizedSizes = ref([]);
|
||||||
|
const canRemoveSize = computed(() => {
|
||||||
|
return memorizedSizes.value.includes(brushSize.value);
|
||||||
|
});
|
||||||
|
const isSizeSliding = ref(false); // 是否正在滑动大小滑块
|
||||||
|
|
||||||
|
// 笔刷颜色相关
|
||||||
|
const brushColor = ref(BrushStore.state.color);
|
||||||
|
const customColor = ref(BrushStore.state.color);
|
||||||
|
|
||||||
|
// 笔刷透明度相关
|
||||||
|
const brushOpacity = ref(BrushStore.state.opacity);
|
||||||
|
const showOpacityTooltip = ref(false);
|
||||||
|
const opacityPresets = ref([0.1, 0.2, 0.5, 0.8]); // 预设透明度,便于吸附
|
||||||
|
const memorizedOpacities = ref([]);
|
||||||
|
const canRemoveOpacity = computed(() => {
|
||||||
|
return memorizedOpacities.value.includes(brushOpacity.value);
|
||||||
|
});
|
||||||
|
const isOpacitySliding = ref(false); // 是否正在滑动透明度滑块
|
||||||
|
|
||||||
|
// 添加计时器变量用于控制外部变化引起的提示框自动隐藏
|
||||||
|
const sizeTooltipTimer = ref(null);
|
||||||
|
const opacityTooltipTimer = ref(null);
|
||||||
|
const TOOLTIP_HIDE_DELAY = 1500; // 与VerticalSlider组件保持一致的延迟时间
|
||||||
|
|
||||||
|
// 处理滑块开始和结束事件
|
||||||
|
function handleSizeSlideStart() {
|
||||||
|
isSizeSliding.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeSlideEnd(event) {
|
||||||
|
isSizeSliding.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpacitySlideStart() {
|
||||||
|
isOpacitySliding.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpacitySlideEnd(event) {
|
||||||
|
isOpacitySliding.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置笔刷大小
|
||||||
|
function setBrushSize(size) {
|
||||||
|
brushSize.value = size;
|
||||||
|
BrushStore.setBrushSize(size);
|
||||||
|
|
||||||
|
// 如果工具管理器存在,立即应用此更改
|
||||||
|
if (toolManager) {
|
||||||
|
toolManager.updateBrushSize(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置笔刷颜色
|
||||||
|
function setBrushColor(color) {
|
||||||
|
brushColor.value = color;
|
||||||
|
customColor.value = color;
|
||||||
|
BrushStore.setBrushColor(color);
|
||||||
|
|
||||||
|
// 如果工具管理器存在,立即应用此更改
|
||||||
|
if (toolManager && props.activeTool === OperationType.DRAW) {
|
||||||
|
toolManager.updateBrushColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置笔刷透明度
|
||||||
|
function setBrushOpacity(opacity) {
|
||||||
|
brushOpacity.value = opacity;
|
||||||
|
BrushStore.setBrushOpacity(opacity);
|
||||||
|
|
||||||
|
// 如果工具管理器存在,立即应用此更改
|
||||||
|
if (toolManager) {
|
||||||
|
toolManager.updateBrushOpacity(opacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加用于自动隐藏大小提示框的方法
|
||||||
|
function startSizeTooltipHideTimer() {
|
||||||
|
// 清除已有的计时器
|
||||||
|
clearSizeTooltipTimer();
|
||||||
|
|
||||||
|
// 创建新计时器
|
||||||
|
sizeTooltipTimer.value = setTimeout(() => {
|
||||||
|
showSizeTooltip.value = false;
|
||||||
|
sizeTooltipTimer.value = null;
|
||||||
|
}, TOOLTIP_HIDE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除大小提示框隐藏计时器
|
||||||
|
function clearSizeTooltipTimer() {
|
||||||
|
if (sizeTooltipTimer.value) {
|
||||||
|
clearTimeout(sizeTooltipTimer.value);
|
||||||
|
sizeTooltipTimer.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加用于自动隐藏透明度提示框的方法
|
||||||
|
function startOpacityTooltipHideTimer() {
|
||||||
|
// 清除已有的计时器
|
||||||
|
clearOpacityTooltipTimer();
|
||||||
|
|
||||||
|
// 创建新计时器
|
||||||
|
opacityTooltipTimer.value = setTimeout(() => {
|
||||||
|
showOpacityTooltip.value = false;
|
||||||
|
opacityTooltipTimer.value = null;
|
||||||
|
}, TOOLTIP_HIDE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除透明度提示框隐藏计时器
|
||||||
|
function clearOpacityTooltipTimer() {
|
||||||
|
if (opacityTooltipTimer.value) {
|
||||||
|
clearTimeout(opacityTooltipTimer.value);
|
||||||
|
opacityTooltipTimer.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主动关闭提示框
|
||||||
|
function closeSizeTooltip() {
|
||||||
|
showSizeTooltip.value = false;
|
||||||
|
clearSizeTooltipTimer(); // 清除任何存在的计时器
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOpacityTooltip() {
|
||||||
|
showOpacityTooltip.value = false;
|
||||||
|
clearOpacityTooltipTimer(); // 清除任何存在的计时器
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记忆当前笔刷大小
|
||||||
|
function memorizeSize() {
|
||||||
|
if (!memorizedSizes.value.includes(brushSize.value)) {
|
||||||
|
memorizedSizes.value.push(brushSize.value);
|
||||||
|
// 记忆值限制为最多5个
|
||||||
|
if (memorizedSizes.value.length > 5) {
|
||||||
|
memorizedSizes.value.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除当前记忆的笔刷大小
|
||||||
|
function removeMemorizedSize() {
|
||||||
|
if (memorizedSizes.value.includes(brushSize.value)) {
|
||||||
|
const index = memorizedSizes.value.indexOf(brushSize.value);
|
||||||
|
memorizedSizes.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记忆当前笔刷透明度
|
||||||
|
function memorizeOpacity() {
|
||||||
|
if (!memorizedOpacities.value.includes(brushOpacity.value)) {
|
||||||
|
memorizedOpacities.value.push(brushOpacity.value);
|
||||||
|
// 记忆值限制为最多5个
|
||||||
|
if (memorizedOpacities.value.length > 5) {
|
||||||
|
memorizedOpacities.value.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除当前记忆的笔刷透明度
|
||||||
|
function removeMemorizedOpacity() {
|
||||||
|
if (memorizedOpacities.value.includes(brushOpacity.value)) {
|
||||||
|
const index = memorizedOpacities.value.indexOf(brushOpacity.value);
|
||||||
|
memorizedOpacities.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听工具的变化
|
||||||
|
watch(
|
||||||
|
() => props.activeTool,
|
||||||
|
(newTool) => {
|
||||||
|
// 当切换到橡皮擦工具时,可以设置特殊的默认值
|
||||||
|
if (newTool === OperationType.ERASER) {
|
||||||
|
// 橡皮擦模式下不需要调整颜色,但可能会调整大小和不透明度
|
||||||
|
} else if (newTool === OperationType.DRAW) {
|
||||||
|
// 恢复到绘制模式时可能有特殊设置
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听brushSize的变化,更新到BrushStore
|
||||||
|
watch(
|
||||||
|
() => brushSize.value,
|
||||||
|
(newSize) => {
|
||||||
|
setBrushSize(newSize);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听brushOpacity的变化,更新到BrushStore
|
||||||
|
watch(
|
||||||
|
() => brushOpacity.value,
|
||||||
|
(newOpacity) => {
|
||||||
|
setBrushOpacity(newOpacity);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听BrushStore中的变化
|
||||||
|
watch(
|
||||||
|
() => BrushStore.state.size,
|
||||||
|
(newSize) => {
|
||||||
|
if (Math.abs(brushSize.value - newSize) > 0.1) {
|
||||||
|
brushSize.value = newSize;
|
||||||
|
// 当外部修改了笔刷大小时,显示提示框
|
||||||
|
showSizeTooltip.value = true;
|
||||||
|
// 启动自动隐藏计时器
|
||||||
|
startSizeTooltipHideTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => BrushStore.state.opacity,
|
||||||
|
(newOpacity) => {
|
||||||
|
if (Math.abs(brushOpacity.value - newOpacity) > 0.01) {
|
||||||
|
brushOpacity.value = newOpacity;
|
||||||
|
// 当外部修改了笔刷透明度时,显示提示框
|
||||||
|
showOpacityTooltip.value = true;
|
||||||
|
// 启动自动隐藏计时器
|
||||||
|
startOpacityTooltipHideTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => BrushStore.state.color,
|
||||||
|
(newColor) => {
|
||||||
|
if (brushColor.value !== newColor) {
|
||||||
|
brushColor.value = newColor;
|
||||||
|
customColor.value = newColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始化时从BrushStore获取当前值
|
||||||
|
brushSize.value = BrushStore.state.size;
|
||||||
|
brushOpacity.value = BrushStore.state.opacity;
|
||||||
|
brushColor.value = BrushStore.state.color;
|
||||||
|
customColor.value = BrushStore.state.color;
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 组件卸载前清除所有计时器
|
||||||
|
clearSizeTooltipTimer();
|
||||||
|
clearOpacityTooltipTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听showSizeTooltip和showOpacityTooltip的变化,防止两个滑块的提示框同时显示
|
||||||
|
watch(
|
||||||
|
() => showSizeTooltip.value,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue && showOpacityTooltip.value) {
|
||||||
|
// 如果大小提示框显示,则隐藏透明度提示框
|
||||||
|
showOpacityTooltip.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => showOpacityTooltip.value,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue && showSizeTooltip.value) {
|
||||||
|
// 如果透明度提示框显示,则隐藏大小提示框
|
||||||
|
showSizeTooltip.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.brush-control-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 15px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px 3px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
z-index: 8;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
color: #333;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔刷大小预览相关样式
|
||||||
|
.brush-preview-container {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
|
||||||
|
10px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brush-size-preview {
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 透明度预览相关样式
|
||||||
|
.opacity-preview {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-checker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
|
||||||
|
10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-color {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具提示内容样式
|
||||||
|
.tooltip-content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.add {
|
||||||
|
color: #4caf50;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.remove {
|
||||||
|
color: #f44336;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 颜色选择器样式
|
||||||
|
.color-picker-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-color-label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-color {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-color-picker {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具提示标题和关闭按钮
|
||||||
|
.tooltip-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
top: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 99;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 淡入淡出动画
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式调整
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.brush-control-panel {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.brush-control-panel {
|
||||||
|
left: 10px;
|
||||||
|
// padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1596
src/component/Canvas/CanvasEditor/components/BrushPanel.vue
Normal file
525
src/component/Canvas/CanvasEditor/components/HeaderMenu.vue
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
inject,
|
||||||
|
ref,
|
||||||
|
provide,
|
||||||
|
onMounted,
|
||||||
|
computed,
|
||||||
|
watch,
|
||||||
|
onUnmounted,
|
||||||
|
} from "vue";
|
||||||
|
import { OperationType } from "../utils/layerHelper";
|
||||||
|
import BrushPanel from "./BrushPanel.vue";
|
||||||
|
import { BrushStore } from "../store/BrushStore";
|
||||||
|
|
||||||
|
// 提供brushStore给子组件
|
||||||
|
provide("brushStore", BrushStore);
|
||||||
|
|
||||||
|
const toolManager = inject("toolManager");
|
||||||
|
const layerManager = inject("layerManager");
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeTool: String,
|
||||||
|
canvasWidth: Number,
|
||||||
|
canvasHeight: Number,
|
||||||
|
canvasColor: String,
|
||||||
|
brushSize: Number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"update:canvasWidth",
|
||||||
|
"update:canvasHeight",
|
||||||
|
"update:canvasColor",
|
||||||
|
"update:brushSize",
|
||||||
|
"canvas-size-change",
|
||||||
|
"canvas-color-change",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 笔刷面板相关状态
|
||||||
|
const showBrushPanel = ref(false);
|
||||||
|
const brushPanelRef = ref(null);
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const shouldShowBrushSettings = computed(() => {
|
||||||
|
return props.activeTool === OperationType.DRAW;
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCanvasSize() {
|
||||||
|
if (!layerManager) {
|
||||||
|
console.warn("LayerManager 未初始化,无法调整背景层尺寸");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查画布上是否有除了背景层的其他元素
|
||||||
|
const hasOtherElements = layerManager.layers.value.some((layer) => {
|
||||||
|
if (layer.isBackground) return false;
|
||||||
|
// 检查普通图层是否有对象
|
||||||
|
if (layer.fabricObjects && layer.fabricObjects.length > 0) return true;
|
||||||
|
// 检查固定图层是否有对象
|
||||||
|
if (layer.isFixed && layer.fabricObjects && layer.fabricObjects.length > 0)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasOtherElements) {
|
||||||
|
// 有其他元素时使用等比缩放命令
|
||||||
|
layerManager.resizeCanvasWithScale(props.canvasWidth, props.canvasHeight);
|
||||||
|
} else {
|
||||||
|
// 只有背景层时使用普通调整命令
|
||||||
|
layerManager.resizeCanvas(props.canvasWidth, props.canvasHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("canvas-size-change");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCanvasColor() {
|
||||||
|
if (!layerManager) {
|
||||||
|
console.warn("LayerManager 未初始化,无法更改背景色");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新背景层颜色而不是画布颜色
|
||||||
|
layerManager.updateBackgroundColor(props.canvasColor);
|
||||||
|
|
||||||
|
emit("canvas-color-change");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换笔刷面板显示状态
|
||||||
|
function toggleBrushPanel() {
|
||||||
|
showBrushPanel.value = !showBrushPanel.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理笔刷大小变化
|
||||||
|
function handleBrushSizeChange(event) {
|
||||||
|
const newSize = parseFloat(event.target.value);
|
||||||
|
emit("update:brushSize", newSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理笔刷设置变化,将BrushStore的数据同步到brushManager
|
||||||
|
function syncBrushStoreToManager() {
|
||||||
|
if (!toolManager?.brushManager) return;
|
||||||
|
|
||||||
|
const brushManager = toolManager.brushManager;
|
||||||
|
|
||||||
|
// 检查画笔是否正在更新中
|
||||||
|
if (brushManager.isUpdatingBrush) {
|
||||||
|
console.warn("画笔正在更新中,请稍候...");
|
||||||
|
// 延迟重试,确保在画笔更新完成后应用最新设置
|
||||||
|
setTimeout(syncBrushStoreToManager, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听BrushStore的变化,更新brushManager
|
||||||
|
const size = BrushStore.state.size;
|
||||||
|
const color = BrushStore.state.color;
|
||||||
|
const type = BrushStore.state.type;
|
||||||
|
const opacity = BrushStore.state.opacity;
|
||||||
|
const textureEnabled = BrushStore.state.textureEnabled;
|
||||||
|
const texturePath = BrushStore.state.texturePath;
|
||||||
|
const textureScale = BrushStore.state.textureScale;
|
||||||
|
|
||||||
|
// 将所有更改一次性应用,减少updateBrush调用次数
|
||||||
|
let needsUpdate = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
brushManager.brushSize &&
|
||||||
|
typeof brushManager.setBrushSize === "function" &&
|
||||||
|
brushManager.getBrushSize() !== size
|
||||||
|
) {
|
||||||
|
brushManager.brushSize.value = size; // 直接设置值,避免触发updateBrush
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
brushManager.brushColor &&
|
||||||
|
typeof brushManager.setBrushColor === "function" &&
|
||||||
|
brushManager.getBrushColor() !== color
|
||||||
|
) {
|
||||||
|
brushManager.brushColor.value = color; // 直接设置值,避免触发updateBrush
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof brushManager.setBrushType === "function" &&
|
||||||
|
brushManager.getCurrentBrushType() !== type
|
||||||
|
) {
|
||||||
|
brushManager.setBrushType(type);
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof brushManager.setBrushOpacity === "function") {
|
||||||
|
brushManager.setBrushOpacity(opacity);
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步材质相关设置
|
||||||
|
if (textureEnabled && texturePath) {
|
||||||
|
if (typeof brushManager.setTexturePath === "function") {
|
||||||
|
brushManager.setTexturePath(texturePath);
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof brushManager.setTextureScale === "function" &&
|
||||||
|
brushManager.getTextureScale() !== textureScale
|
||||||
|
) {
|
||||||
|
brushManager.textureScale.value = textureScale; // 直接设置值,避免触发updateBrush
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在有变化时调用一次updateBrush,减少重绘次数
|
||||||
|
if (needsUpdate && typeof brushManager.updateBrush === "function") {
|
||||||
|
brushManager.updateBrush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部时关闭笔刷面板
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (
|
||||||
|
showBrushPanel.value &&
|
||||||
|
brushPanelRef.value &&
|
||||||
|
!brushPanelRef.value.contains(event.target) &&
|
||||||
|
!event.target.closest(".brush-selector")
|
||||||
|
) {
|
||||||
|
showBrushPanel.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 获取工具管理器和笔刷管理器
|
||||||
|
const brushManager = toolManager?.brushManager;
|
||||||
|
|
||||||
|
// 设置初始的可用笔刷类型
|
||||||
|
if (brushManager) {
|
||||||
|
const availableBrushes = brushManager.getBrushTypes();
|
||||||
|
BrushStore.setAvailableBrushes(availableBrushes);
|
||||||
|
|
||||||
|
// 初始化BrushStore与brushManager的数据同步
|
||||||
|
BrushStore.setBrushSize(brushManager.brushSize?.value || 5);
|
||||||
|
BrushStore.setBrushColor(brushManager.brushColor?.value || "#000000");
|
||||||
|
BrushStore.setBrushType(brushManager.getCurrentBrushType() || "pencil");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加点击外部关闭面板的事件监听
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
|
||||||
|
// 监听BrushStore的变化,同步到brushManager
|
||||||
|
const unwatch = watch(
|
||||||
|
() => [
|
||||||
|
BrushStore.state.size,
|
||||||
|
BrushStore.state.color,
|
||||||
|
BrushStore.state.type,
|
||||||
|
BrushStore.state.opacity,
|
||||||
|
BrushStore.state.textureEnabled,
|
||||||
|
BrushStore.state.texturePath,
|
||||||
|
BrushStore.state.textureScale,
|
||||||
|
],
|
||||||
|
syncBrushStoreToManager,
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件卸载时移除事件监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
unwatch();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="canvas-header">
|
||||||
|
<span class="canvas-title">Canvas</span>
|
||||||
|
|
||||||
|
<!-- 默认设置 -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!activeTool ||
|
||||||
|
activeTool === OperationType.SELECT ||
|
||||||
|
activeTool === OperationType.PAN
|
||||||
|
"
|
||||||
|
class="canvas-settings"
|
||||||
|
>
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Width</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="canvasWidth"
|
||||||
|
class="setting-input"
|
||||||
|
@input="$emit('update:canvasWidth', Number($event.target.value))"
|
||||||
|
@change="updateCanvasSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Height</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="canvasHeight"
|
||||||
|
class="setting-input"
|
||||||
|
@input="$emit('update:canvasHeight', Number($event.target.value))"
|
||||||
|
@change="updateCanvasSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Color</span>
|
||||||
|
<div class="color-picker-wrapper">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="canvasColor"
|
||||||
|
class="color-picker"
|
||||||
|
@input="$emit('update:canvasColor', $event.target.value)"
|
||||||
|
@change="updateCanvasColor"
|
||||||
|
/>
|
||||||
|
<span class="color-dropdown">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 绘图工具设置 -->
|
||||||
|
<div v-if="shouldShowBrushSettings" class="canvas-settings">
|
||||||
|
<!-- 简化的笔刷控制UI -->
|
||||||
|
<!-- <div class="setting-group">
|
||||||
|
<span class="setting-label">大小:</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="BrushStore.state.size"
|
||||||
|
min="0.5"
|
||||||
|
max="100"
|
||||||
|
step="0.5"
|
||||||
|
class="size-slider"
|
||||||
|
@input="handleBrushSizeChange"
|
||||||
|
/>
|
||||||
|
<span class="size-value">{{ BrushStore.state.size }}px</span>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">笔刷:</span>
|
||||||
|
<div class="brush-selector" @click="toggleBrushPanel">
|
||||||
|
<div
|
||||||
|
class="brush-preview"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: BrushStore.state.color,
|
||||||
|
height: BrushStore.state.type === 'marker' ? '4px' : '2px',
|
||||||
|
opacity: BrushStore.state.opacity,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<span class="brush-dropdown">▼</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 笔刷面板 -->
|
||||||
|
<div
|
||||||
|
v-if="showBrushPanel"
|
||||||
|
class="brush-panel-container"
|
||||||
|
ref="brushPanelRef"
|
||||||
|
>
|
||||||
|
<BrushPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">颜色:</span>
|
||||||
|
<div class="color-picker-wrapper">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
:value="BrushStore.state.color"
|
||||||
|
class="color-picker"
|
||||||
|
@input="BrushStore.setBrushColor($event.target.value)"
|
||||||
|
/>
|
||||||
|
<span class="color-dropdown">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本工具设置 -->
|
||||||
|
<div v-if="activeTool === OperationType.TEXT" class="canvas-settings">
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Font:</span>
|
||||||
|
<select class="font-select">
|
||||||
|
<option value="Arial">Arial</option>
|
||||||
|
<option value="Times New Roman">Times New Roman</option>
|
||||||
|
<option value="Courier New">Courier New</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Size:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="setting-input"
|
||||||
|
value="16"
|
||||||
|
min="8"
|
||||||
|
max="72"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Color:</span>
|
||||||
|
<div class="color-picker-wrapper">
|
||||||
|
<input type="color" class="color-picker" value="#000000" />
|
||||||
|
<span class="color-dropdown">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传工具设置 -->
|
||||||
|
<div v-if="activeTool === OperationType.UPLOAD" class="canvas-settings">
|
||||||
|
<div class="setting-group">
|
||||||
|
<span class="setting-label">Upload Type:</span>
|
||||||
|
<select class="setting-select">
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="vector">Vector Graphics</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 导出设置 -->
|
||||||
|
<div class="setting-group export-group">
|
||||||
|
<span class="export-model-select">exportModel.select:</span>
|
||||||
|
<span class="export-model-dropdown">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.canvas-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-title::before {
|
||||||
|
content: "⟳";
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-select {
|
||||||
|
width: 150px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dropdown {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-slider {
|
||||||
|
width: 100px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brush-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: white;
|
||||||
|
width: 80px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brush-preview {
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #000;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brush-dropdown,
|
||||||
|
.export-model-dropdown {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-model-select {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-group {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 笔刷面板 */
|
||||||
|
.brush-panel-container {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 5px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 600px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, inject, onMounted } from "vue";
|
||||||
|
import { Skeleton } from "ant-design-vue";
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const shortcuts = ref([]);
|
||||||
|
const keyboardManager = inject("keyboardManager", null);
|
||||||
|
const platform = ref({});
|
||||||
|
|
||||||
|
// 初始化键盘快捷键信息
|
||||||
|
onMounted(() => {
|
||||||
|
// 添加延迟以显示骨架屏效果
|
||||||
|
setTimeout(() => {
|
||||||
|
if (keyboardManager) {
|
||||||
|
// 使用KeyboardManager的平台检测
|
||||||
|
platform.value = {
|
||||||
|
isMac: keyboardManager.platform === "mac",
|
||||||
|
isIOS: keyboardManager.platform === "ios",
|
||||||
|
isIPad:
|
||||||
|
keyboardManager.platform === "ios" &&
|
||||||
|
/iPad/.test(window.navigator.userAgent),
|
||||||
|
isTouchDevice: keyboardManager.isTouchDevice,
|
||||||
|
isWindows: keyboardManager.platform === "windows",
|
||||||
|
isAndroid: keyboardManager.platform === "android",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用KeyboardManager的API获取所有快捷键
|
||||||
|
const managerShortcuts = keyboardManager.getShortcuts();
|
||||||
|
|
||||||
|
// 转换为组件所需的格式
|
||||||
|
shortcuts.value = convertShortcuts(managerShortcuts);
|
||||||
|
} else {
|
||||||
|
// 如果没有注入keyboardManager,使用默认检测和默认快捷键
|
||||||
|
platform.value = detectPlatform();
|
||||||
|
shortcuts.value = generateDefaultShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 转换KeyboardManager返回的快捷键格式为组件需要的格式
|
||||||
|
function convertShortcuts(managerShortcuts) {
|
||||||
|
// 转换快捷键列表
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
// 基本的Action到显示名称的映射
|
||||||
|
const actionDisplayMap = {
|
||||||
|
undo: "撤销",
|
||||||
|
redo: "重做",
|
||||||
|
delete: "删除选中元素",
|
||||||
|
selectAll: "全选",
|
||||||
|
copy: "复制",
|
||||||
|
paste: "粘贴",
|
||||||
|
cut: "剪切",
|
||||||
|
save: "保存",
|
||||||
|
selectTool: "选择工具",
|
||||||
|
increaseBrushSize: "增加笔触大小",
|
||||||
|
decreaseBrushSize: "减小笔触大小",
|
||||||
|
toggleTempTool: "临时切换工具",
|
||||||
|
newLayer: "新建图层",
|
||||||
|
groupLayers: "组合图层",
|
||||||
|
ungroupLayers: "取消组合",
|
||||||
|
mergeLayers: "合并图层",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 工具ID到显示名称的映射
|
||||||
|
const toolDisplayMap = {
|
||||||
|
select: "选择模式",
|
||||||
|
draw: "绘画模式",
|
||||||
|
eraser: "橡皮擦模式",
|
||||||
|
eyedropper: "吸色工具",
|
||||||
|
pan: "移动画布",
|
||||||
|
lasso: "套索工具",
|
||||||
|
area_custom: "自由选区工具",
|
||||||
|
wave: "波浪工具",
|
||||||
|
liquify: "液化工具",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理每个快捷键
|
||||||
|
for (const shortcut of managerShortcuts) {
|
||||||
|
let actionDisplay = actionDisplayMap[shortcut.action] || shortcut.action;
|
||||||
|
|
||||||
|
// 特殊处理工具选择
|
||||||
|
if (
|
||||||
|
shortcut.action === "selectTool" &&
|
||||||
|
shortcut.param &&
|
||||||
|
toolDisplayMap[shortcut.param]
|
||||||
|
) {
|
||||||
|
actionDisplay = toolDisplayMap[shortcut.param];
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
action: actionDisplay,
|
||||||
|
windows: shortcut.key.replace(/cmdOrCtrl\+/g, "Ctrl+"),
|
||||||
|
mac: shortcut.key.replace(/cmdOrCtrl\+/g, "⌘+"),
|
||||||
|
touch: shortcut.touch || "触控界面点击对应工具",
|
||||||
|
displayKey: shortcut.displayKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加一些组件特定的快捷键
|
||||||
|
result.push({
|
||||||
|
action: "缩放画布",
|
||||||
|
windows: "鼠标滚轮",
|
||||||
|
mac: "鼠标滚轮 或 触控板缩放手势",
|
||||||
|
touch: "双指捏合",
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测平台 - 作为备用
|
||||||
|
function detectPlatform() {
|
||||||
|
const userAgent = window.navigator.userAgent;
|
||||||
|
return {
|
||||||
|
isMac: /Mac/.test(userAgent),
|
||||||
|
isIOS: /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream,
|
||||||
|
isIPad: /iPad/.test(userAgent),
|
||||||
|
isTouchDevice: "ontouchstart" in window || navigator.maxTouchPoints > 0,
|
||||||
|
isWindows: /Win/.test(userAgent),
|
||||||
|
isAndroid: /Android/.test(userAgent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成默认快捷键描述
|
||||||
|
function generateDefaultShortcuts() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
action: "撤销",
|
||||||
|
windows: "Ctrl+Z",
|
||||||
|
mac: "⌘+Z",
|
||||||
|
touch: "双指向右轻扫",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "重做",
|
||||||
|
windows: "Ctrl+Y 或 Ctrl+Shift+Z",
|
||||||
|
mac: "⌘+Shift+Z",
|
||||||
|
touch: "双指向左轻扫",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "删除选中元素",
|
||||||
|
windows: "Delete 或 Backspace",
|
||||||
|
mac: "Delete 或 ⌫",
|
||||||
|
touch: "长按选中元素后点击删除",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "全选",
|
||||||
|
windows: "Ctrl+A",
|
||||||
|
mac: "⌘+A",
|
||||||
|
touch: "无",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "复制",
|
||||||
|
windows: "Ctrl+C",
|
||||||
|
mac: "⌘+C",
|
||||||
|
touch: "无",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "粘贴",
|
||||||
|
windows: "Ctrl+V",
|
||||||
|
mac: "⌘+V",
|
||||||
|
touch: "无",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "剪切",
|
||||||
|
windows: "Ctrl+X",
|
||||||
|
mac: "⌘+X",
|
||||||
|
touch: "无",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "缩放画布",
|
||||||
|
windows: "鼠标滚轮",
|
||||||
|
mac: "鼠标滚轮 或 触控板缩放手势",
|
||||||
|
touch: "双指捏合",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "移动画布",
|
||||||
|
windows: "Alt+拖动 或 鼠标中键拖动",
|
||||||
|
mac: "Option+拖动 或 触控板双指拖动",
|
||||||
|
touch: "双指拖动",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "绘画模式",
|
||||||
|
windows: "B",
|
||||||
|
mac: "B",
|
||||||
|
touch: "点击画笔工具",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "选择模式",
|
||||||
|
windows: "M",
|
||||||
|
mac: "M",
|
||||||
|
touch: "点击选择工具",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "橡皮擦模式",
|
||||||
|
windows: "E",
|
||||||
|
mac: "E",
|
||||||
|
touch: "点击橡皮擦工具",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "吸色工具",
|
||||||
|
windows: "I",
|
||||||
|
mac: "I",
|
||||||
|
touch: "点击吸色工具",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "增加笔触大小",
|
||||||
|
windows: "]",
|
||||||
|
mac: "]",
|
||||||
|
touch: "拖动笔刷大小滑块",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "减小笔触大小",
|
||||||
|
windows: "[",
|
||||||
|
mac: "[",
|
||||||
|
touch: "拖动笔刷大小滑块",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "增加材质图片大小",
|
||||||
|
windows: "Shift+]",
|
||||||
|
mac: "⇧+]",
|
||||||
|
touch: "拖动材质大小滑块",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "减小材质图片大小",
|
||||||
|
windows: "Shift+[",
|
||||||
|
mac: "⇧+[",
|
||||||
|
touch: "拖动材质大小滑块",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "上传图片",
|
||||||
|
windows: "Ctrl+O",
|
||||||
|
mac: "⌘+O",
|
||||||
|
touch: "点击上传按钮",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前平台的快捷键文本
|
||||||
|
function getShortcutForCurrentPlatform(shortcut) {
|
||||||
|
if (platform.value.isTouchDevice) {
|
||||||
|
return shortcut.touch;
|
||||||
|
} else if (platform.value.isMac) {
|
||||||
|
return shortcut.displayKey || shortcut.mac;
|
||||||
|
} else {
|
||||||
|
return shortcut.displayKey || shortcut.windows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按分类获取快捷键
|
||||||
|
function getShortcutsByCategory(category) {
|
||||||
|
const categoryMap = {
|
||||||
|
basic: [
|
||||||
|
"撤销",
|
||||||
|
"重做",
|
||||||
|
"全选",
|
||||||
|
"复制",
|
||||||
|
"粘贴",
|
||||||
|
"剪切",
|
||||||
|
"删除选中元素",
|
||||||
|
"上传图片",
|
||||||
|
],
|
||||||
|
view: ["缩放画布", "移动画布"],
|
||||||
|
tools: [
|
||||||
|
"绘画模式",
|
||||||
|
"选择模式",
|
||||||
|
"橡皮擦模式",
|
||||||
|
"吸色工具",
|
||||||
|
"套索工具",
|
||||||
|
"自由选区工具",
|
||||||
|
"波浪工具",
|
||||||
|
"液化工具",
|
||||||
|
],
|
||||||
|
brush: [
|
||||||
|
"增加笔触大小",
|
||||||
|
"减小笔触大小",
|
||||||
|
"增加材质图片大小",
|
||||||
|
"减小材质图片大小",
|
||||||
|
],
|
||||||
|
layer: ["新建图层", "组合图层", "取消组合", "合并图层"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return shortcuts.value.filter((s) =>
|
||||||
|
categoryMap[category]?.includes(s.action)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="keyboard-shortcut-help">
|
||||||
|
<h2>键盘快捷键 & 操作指南</h2>
|
||||||
|
|
||||||
|
<Skeleton active :loading="loading">
|
||||||
|
<div class="platform-info">
|
||||||
|
检测到的平台:
|
||||||
|
<span v-if="platform.isMac">MacOS</span>
|
||||||
|
<span v-else-if="platform.isWindows">Windows</span>
|
||||||
|
<span v-else-if="platform.isIPad">iPad</span>
|
||||||
|
<span v-else-if="platform.isIOS">iOS</span>
|
||||||
|
<span v-else-if="platform.isAndroid">Android</span>
|
||||||
|
<span v-else>其他</span>
|
||||||
|
<span v-if="platform.isTouchDevice"> (触控设备)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-category">
|
||||||
|
<h3>基本操作</h3>
|
||||||
|
<table class="shortcuts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>操作</th>
|
||||||
|
<th>快捷键/手势</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in getShortcutsByCategory('basic')"
|
||||||
|
:key="item.action"
|
||||||
|
>
|
||||||
|
<td>{{ item.action }}</td>
|
||||||
|
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-category">
|
||||||
|
<h3>视图操作</h3>
|
||||||
|
<table class="shortcuts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>操作</th>
|
||||||
|
<th>快捷键/手势</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in getShortcutsByCategory('view')"
|
||||||
|
:key="item.action"
|
||||||
|
>
|
||||||
|
<td>{{ item.action }}</td>
|
||||||
|
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-category">
|
||||||
|
<h3>工具切换</h3>
|
||||||
|
<table class="shortcuts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>操作</th>
|
||||||
|
<th>快捷键/手势</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in getShortcutsByCategory('tools')"
|
||||||
|
:key="item.action"
|
||||||
|
>
|
||||||
|
<td>{{ item.action }}</td>
|
||||||
|
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-category">
|
||||||
|
<h3>笔刷调整</h3>
|
||||||
|
<table class="shortcuts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>操作</th>
|
||||||
|
<th>快捷键/手势</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in getShortcutsByCategory('brush')"
|
||||||
|
:key="item.action"
|
||||||
|
>
|
||||||
|
<td>{{ item.action }}</td>
|
||||||
|
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-category">
|
||||||
|
<h3>图层操作</h3>
|
||||||
|
<table class="shortcuts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>操作</th>
|
||||||
|
<th>快捷键/手势</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in getShortcutsByCategory('layer')"
|
||||||
|
:key="item.action"
|
||||||
|
>
|
||||||
|
<td>{{ item.action }}</td>
|
||||||
|
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="touch-tips" v-if="platform.isTouchDevice">
|
||||||
|
<h3>触控设备提示</h3>
|
||||||
|
<ul>
|
||||||
|
<li>长按图层面板可访问更多选项</li>
|
||||||
|
<li>双击元素可快速进入编辑模式</li>
|
||||||
|
<li>双指拖动可平移画布</li>
|
||||||
|
<li>双指捏合可缩放画布</li>
|
||||||
|
<li>双指连按可显示元素变换控制点</li>
|
||||||
|
<li>三指左右滑动可进行撤销/重做操作</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Skeleton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.keyboard-shortcut-help {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-category {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-table th,
|
||||||
|
.shortcuts-table td {
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-table th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-tips {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fffbeb;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-tips ul {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-tips li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.keyboard-shortcut-help {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-table th,
|
||||||
|
.shortcuts-table td {
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1062
src/component/Canvas/CanvasEditor/components/LayersPanel.vue
Normal file
1388
src/component/Canvas/CanvasEditor/components/LiquifyPanel.vue
Normal file
146
src/component/Canvas/CanvasEditor/components/MinimapPanel.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
minimapManager: Object,
|
||||||
|
});
|
||||||
|
|
||||||
|
const minimapContainerRef = ref(null);
|
||||||
|
let refreshTimeout = null;
|
||||||
|
|
||||||
|
// 强制重绘小地图,添加防抖处理
|
||||||
|
const forceRefresh = () => {
|
||||||
|
if (refreshTimeout) {
|
||||||
|
clearTimeout(refreshTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimeout = setTimeout(() => {
|
||||||
|
if (props.minimapManager) {
|
||||||
|
props.minimapManager.refresh();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.minimapManager && minimapContainerRef.value) {
|
||||||
|
// 使用新的mount方法挂载小地图
|
||||||
|
props.minimapManager.mount(minimapContainerRef.value);
|
||||||
|
// 初始加载后延迟刷新一次,确保内容正确加载
|
||||||
|
setTimeout(forceRefresh, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.minimapManager,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal && minimapContainerRef.value) {
|
||||||
|
newVal.mount(minimapContainerRef.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加resize observer以适应容器大小变化
|
||||||
|
let resizeObserver = null;
|
||||||
|
onMounted(() => {
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
// 使用防抖处理resize事件,避免过于频繁的刷新
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
forceRefresh();
|
||||||
|
});
|
||||||
|
if (minimapContainerRef.value) {
|
||||||
|
resizeObserver.observe(minimapContainerRef.value.parentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver && minimapContainerRef.value) {
|
||||||
|
resizeObserver.unobserve(minimapContainerRef.value.parentElement);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
if (refreshTimeout) {
|
||||||
|
clearTimeout(refreshTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="minimap-container">
|
||||||
|
<div class="minimap-header">
|
||||||
|
<span>画布小地图</span>
|
||||||
|
<button class="minimap-refresh" @click="forceRefresh" title="刷新小地图">
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="minimap-content" ref="minimapContainerRef">
|
||||||
|
<!-- 不再需要直接提供canvas引用,由MinimapManager内部创建 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.minimap-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: 200px;
|
||||||
|
height: 140px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-header {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-refresh {
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-refresh:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触控设备优化 */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.minimap-container {
|
||||||
|
width: 220px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-header {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimap-refresh {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
814
src/component/Canvas/CanvasEditor/components/SelectionPanel.vue
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="fade">
|
||||||
|
<div class="selection-toolbar" v-if="visible">
|
||||||
|
<!-- 顶部选区类型工具栏 -->
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<div class="toolbar-header">
|
||||||
|
<div class="header-title">选区工具</div>
|
||||||
|
<!-- 移除关闭按钮,完全通过工具切换控制显示隐藏 -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-types">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'tool-btn',
|
||||||
|
{ active: selectionType === OperationType.LASSO },
|
||||||
|
]"
|
||||||
|
@click="setSelectionType(OperationType.LASSO)"
|
||||||
|
>
|
||||||
|
<svg-icon name="CFree" size="26" />
|
||||||
|
<span>{{ $t("手绘") }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'tool-btn',
|
||||||
|
{ active: selectionType === OperationType.LASSO_RECTANGLE },
|
||||||
|
]"
|
||||||
|
@click="setSelectionType(OperationType.LASSO_RECTANGLE)"
|
||||||
|
>
|
||||||
|
<svg-icon name="CRectangle" size="32" />
|
||||||
|
<span>{{ $t("矩形") }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'tool-btn',
|
||||||
|
{ active: selectionType === OperationType.LASSO_ELLIPSE },
|
||||||
|
]"
|
||||||
|
@click="setSelectionType(OperationType.LASSO_ELLIPSE)"
|
||||||
|
>
|
||||||
|
<svg-icon name="CEllipse" size="30" />
|
||||||
|
<span>{{ $t("椭圆") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分割线 -->
|
||||||
|
<div class="toolbar-divider"></div>
|
||||||
|
|
||||||
|
<!-- 底部选区操作工具栏 -->
|
||||||
|
<div class="tool-actions">
|
||||||
|
<div class="action-btn" @click="copySelectionToNewLayer">
|
||||||
|
<svg-icon name="CPaste" />
|
||||||
|
<span class="btn-text">{{ $t("拷贝并粘贴") }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- <button
|
||||||
|
class="action-btn"
|
||||||
|
@click="addSelection"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="添加"
|
||||||
|
>
|
||||||
|
<svg-icon name="plus" />
|
||||||
|
<span class="btn-text">{{ $t("添加") }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="removeSelection"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="移除"
|
||||||
|
>
|
||||||
|
<svg-icon name="minus" />
|
||||||
|
<span class="btn-text">{{ $t("移除") }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="invertSelection"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="反转"
|
||||||
|
>
|
||||||
|
<svg-icon name="flip-horizontal" />
|
||||||
|
<span class="btn-text">{{ $t("反转") }}</span>
|
||||||
|
</button> -->
|
||||||
|
<!-- <button
|
||||||
|
class="action-btn"
|
||||||
|
@click="copySelectionToNewLayer"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="拷贝并粘贴"
|
||||||
|
>
|
||||||
|
<svg-icon name="copy" />
|
||||||
|
<span class="btn-text">{{ $t("拷贝并粘贴") }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="openFeatherDialog"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="羽化"
|
||||||
|
>
|
||||||
|
<svg-icon name="feather" />
|
||||||
|
<span class="btn-text">{{ $t("羽化") }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="fillSelection"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="颜色填充"
|
||||||
|
>
|
||||||
|
<svg-icon name="fill-color" />
|
||||||
|
<span class="btn-text">{{ $t("颜色填充") }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
@click="clearSelection"
|
||||||
|
:disabled="!hasSelection"
|
||||||
|
title="清除"
|
||||||
|
>
|
||||||
|
<svg-icon name="trash" />
|
||||||
|
<span class="btn-text">{{ $t("清除") }}</span>
|
||||||
|
</button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 羽化设置弹窗 -->
|
||||||
|
<div v-if="showFeatherDialog" class="dialog-overlay">
|
||||||
|
<div class="dialog-container">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3>{{ $t("羽化") }}</h3>
|
||||||
|
<button class="close-dialog-btn" @click="cancelFeather">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<div class="feather-control">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
v-model.number="featherAmount"
|
||||||
|
class="slider-control"
|
||||||
|
/>
|
||||||
|
<div class="feather-value">{{ featherAmount }}px</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button class="cancel-btn" @click="cancelFeather">
|
||||||
|
{{ $t("取消") }}
|
||||||
|
</button>
|
||||||
|
<button class="confirm-btn" @click="applyFeather">
|
||||||
|
{{ $t("确认") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 颜色选择器 -->
|
||||||
|
<div v-if="showColorPicker" class="dialog-overlay">
|
||||||
|
<div class="dialog-container">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3>{{ $t("选择填充颜色") }}</h3>
|
||||||
|
<button class="close-dialog-btn" @click="cancelColorPicker">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<input type="color" v-model="fillColor" class="color-picker" />
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button class="cancel-btn" @click="cancelColorPicker">
|
||||||
|
{{ $t("取消") }}
|
||||||
|
</button>
|
||||||
|
<button class="confirm-btn" @click="confirmColorPicker">
|
||||||
|
{{ $t("确认") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from "vue";
|
||||||
|
import {
|
||||||
|
CreateSelectionCommand,
|
||||||
|
InvertSelectionCommand,
|
||||||
|
ClearSelectionCommand,
|
||||||
|
FeatherSelectionCommand,
|
||||||
|
FillSelectionCommand,
|
||||||
|
CopySelectionToNewLayerCommand,
|
||||||
|
ClearSelectionContentCommand,
|
||||||
|
LassoCutoutCommand,
|
||||||
|
} from "../commands/SelectionCommands";
|
||||||
|
import { ToolCommand } from "../commands/ToolCommands";
|
||||||
|
import { OperationType } from "../utils/layerHelper";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
canvas: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
commandManager: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectionManager: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
layerManager: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
toolManager: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
activeTool: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false);
|
||||||
|
const selectionType = ref("rectangle");
|
||||||
|
const featherAmount = ref(0);
|
||||||
|
const fillColor = ref("#000000");
|
||||||
|
const hasSelection = ref(false);
|
||||||
|
const showFeatherDialog = ref(false);
|
||||||
|
const showColorPicker = ref(false);
|
||||||
|
|
||||||
|
// 国际化函数 (简单实现,可根据需要替换为实际的国际化方案)
|
||||||
|
const $t = (key) => key;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 为选区管理器添加监听,以便在选区变化时更新状态
|
||||||
|
if (props.selectionManager) {
|
||||||
|
// 在选区管理器中添加选区变化的监听
|
||||||
|
checkSelectionStatus();
|
||||||
|
|
||||||
|
// 设置选区状态变化的回调
|
||||||
|
props.selectionManager.onSelectionChanged = () => {
|
||||||
|
checkSelectionStatus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 activeTool 变化
|
||||||
|
watch(
|
||||||
|
() => props.activeTool,
|
||||||
|
(newTool) => {
|
||||||
|
// 当工具为LASSO或AREA类型时显示选区面板
|
||||||
|
const selectionTools = [
|
||||||
|
OperationType.LASSO,
|
||||||
|
OperationType.LASSO_RECTANGLE,
|
||||||
|
OperationType.LASSO_ELLIPSE,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (selectionTools.includes(newTool)) {
|
||||||
|
show();
|
||||||
|
// 根据工具类型设置选区类型
|
||||||
|
selectionType.value = newTool;
|
||||||
|
|
||||||
|
// 更新选区管理器的选区类型
|
||||||
|
if (props.selectionManager) {
|
||||||
|
props.selectionManager.setSelectionType(selectionType.value);
|
||||||
|
props.selectionManager.setupSelectionEvents();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示面板
|
||||||
|
*/
|
||||||
|
function show() {
|
||||||
|
visible.value = true;
|
||||||
|
checkSelectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭面板
|
||||||
|
*/
|
||||||
|
function close() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置选区类型
|
||||||
|
*/
|
||||||
|
function setSelectionType(type) {
|
||||||
|
selectionType.value = type;
|
||||||
|
|
||||||
|
// 通过 ToolManager 切换工具,这会自动通知 SelectionManager
|
||||||
|
if (props.toolManager) {
|
||||||
|
props.toolManager.setToolWithCommand(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备用方案:如果没有 toolManager,直接更新 selectionManager
|
||||||
|
else if (props.selectionManager) {
|
||||||
|
props.selectionManager.setSelectionType(type);
|
||||||
|
props.selectionManager.setupSelectionEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查选区状态
|
||||||
|
*/
|
||||||
|
function checkSelectionStatus() {
|
||||||
|
hasSelection.value =
|
||||||
|
props.selectionManager &&
|
||||||
|
props.selectionManager.getSelectionObject() !== null;
|
||||||
|
|
||||||
|
// 同步羽化值
|
||||||
|
if (hasSelection.value) {
|
||||||
|
featherAmount.value = props.selectionManager.getFeatherAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加选区
|
||||||
|
*/
|
||||||
|
function addSelection() {
|
||||||
|
// TODO: 实现添加选区功能
|
||||||
|
console.log("添加选区功能尚未实现");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除选区
|
||||||
|
*/
|
||||||
|
function removeSelection() {
|
||||||
|
// TODO: 实现移除选区功能
|
||||||
|
console.log("移除选区功能尚未实现");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反转选区
|
||||||
|
*/
|
||||||
|
function invertSelection() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
|
||||||
|
props.commandManager.execute(
|
||||||
|
new InvertSelectionCommand({
|
||||||
|
canvas: props.canvas,
|
||||||
|
selectionManager: props.selectionManager,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
checkSelectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除选区
|
||||||
|
*/
|
||||||
|
function clearSelection() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
|
||||||
|
props.commandManager.execute(
|
||||||
|
new ClearSelectionCommand({
|
||||||
|
selectionManager: props.selectionManager,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
checkSelectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用羽化效果
|
||||||
|
*/
|
||||||
|
function applyFeather() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
|
||||||
|
props.commandManager.execute(
|
||||||
|
new FeatherSelectionCommand({
|
||||||
|
selectionManager: props.selectionManager,
|
||||||
|
featherAmount: featherAmount.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填充选区
|
||||||
|
*/
|
||||||
|
function fillSelection() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
showColorPicker.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 套索抠图到新图层
|
||||||
|
*/
|
||||||
|
function copySelectionToNewLayer() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
|
||||||
|
props.commandManager.execute(
|
||||||
|
new LassoCutoutCommand({
|
||||||
|
canvas: props.canvas,
|
||||||
|
layerManager: props.layerManager,
|
||||||
|
selectionManager: props.selectionManager,
|
||||||
|
toolManager: props.toolManager,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
checkSelectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除选区内容
|
||||||
|
*/
|
||||||
|
function clearSelectionContent() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
|
||||||
|
props.commandManager.execute(
|
||||||
|
new ClearSelectionContentCommand({
|
||||||
|
canvas: props.canvas,
|
||||||
|
layerManager: props.layerManager,
|
||||||
|
selectionManager: props.selectionManager,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
checkSelectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开羽化设置弹窗
|
||||||
|
*/
|
||||||
|
function openFeatherDialog() {
|
||||||
|
showFeatherDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消羽化设置
|
||||||
|
*/
|
||||||
|
function cancelFeather() {
|
||||||
|
showFeatherDialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认羽化设置
|
||||||
|
*/
|
||||||
|
function confirmFeather() {
|
||||||
|
applyFeather();
|
||||||
|
showFeatherDialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消颜色选择
|
||||||
|
*/
|
||||||
|
function cancelColorPicker() {
|
||||||
|
showColorPicker.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认颜色选择
|
||||||
|
*/
|
||||||
|
function confirmColorPicker() {
|
||||||
|
if (!hasSelection.value) return;
|
||||||
|
|
||||||
|
props.commandManager.execute(
|
||||||
|
new FillSelectionCommand({
|
||||||
|
canvas: props.canvas,
|
||||||
|
layerManager: props.layerManager,
|
||||||
|
selectionManager: props.selectionManager,
|
||||||
|
color: fillColor.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
checkSelectionStatus();
|
||||||
|
showColorPicker.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.selection-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 22px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
max-width: min(90vw, 640px);
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平板和手机适配 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.selection-toolbar {
|
||||||
|
bottom: 15px;
|
||||||
|
left: 15px;
|
||||||
|
right: 15px;
|
||||||
|
max-width: calc(100vw - 30px);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.selection-toolbar {
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
max-width: calc(100vw - 20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-toolbar.is-active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-header {
|
||||||
|
// display: flex;
|
||||||
|
// justify-content: center;
|
||||||
|
// align-items: center;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.header-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-section {
|
||||||
|
padding: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-types {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平板适配 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.tool-types {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-header {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机适配 */
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.tool-types {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-header {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 5px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn span {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background-color: #007aff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
margin: 0 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平板适配 - 每行4个按钮 */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.tool-actions {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机适配 - 每行3个按钮 */
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.tool-actions {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px 4px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
color: #007aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话框样式 */
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-container {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 280px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-dialog-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feather-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-control {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-control::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #007aff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feather-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn,
|
||||||
|
.confirm-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
background-color: #007aff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1104
src/component/Canvas/CanvasEditor/components/TextEditorPanel.vue
Normal file
411
src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, inject, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import { OperationType } from "../utils/layerHelper";
|
||||||
|
import SvgIcon from "@/component/Canvas/SvgIcon/index.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeTool: String,
|
||||||
|
minimapEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
isRedGreenMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandManager = inject("commandManager");
|
||||||
|
|
||||||
|
// 撤销/重做按钮状态
|
||||||
|
const canUndo = ref(false);
|
||||||
|
const canRedo = ref(false);
|
||||||
|
|
||||||
|
// 监听命令管理器状态变化
|
||||||
|
commandManager.setChangeCallback((info) => {
|
||||||
|
canUndo.value = info.canUndo;
|
||||||
|
canRedo.value = info.canRedo;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 撤销/重做操作
|
||||||
|
const undoFun = () => commandManager.undo();
|
||||||
|
const redoFun = () => commandManager.redo();
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"tool-selected",
|
||||||
|
"trigger-image-upload",
|
||||||
|
"add-text",
|
||||||
|
"undo",
|
||||||
|
"redo",
|
||||||
|
"toggle-minimap",
|
||||||
|
"zoom-in",
|
||||||
|
"zoom-out",
|
||||||
|
"toggle-red-green-mode",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 普通模式工具列表
|
||||||
|
const normalToolsList = ref([
|
||||||
|
{
|
||||||
|
id: "undo",
|
||||||
|
title: "Undo",
|
||||||
|
action: undo,
|
||||||
|
icon: { name: "CUndo", size: "20" },
|
||||||
|
class: "undo-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redo",
|
||||||
|
title: "Redo",
|
||||||
|
action: redo,
|
||||||
|
icon: { name: "CRedo", size: "20" },
|
||||||
|
class: "redo-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.DRAW,
|
||||||
|
title: "Drawing",
|
||||||
|
action: () => selectTool(OperationType.DRAW),
|
||||||
|
icon: { name: "CBrush", size: "24" },
|
||||||
|
class: "draw-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.ERASER,
|
||||||
|
title: "Eraser",
|
||||||
|
action: () => selectTool(OperationType.ERASER),
|
||||||
|
icon: { name: "CEraser", size: "22" },
|
||||||
|
class: "eraser-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.PAN,
|
||||||
|
title: "Pan",
|
||||||
|
action: () => selectTool(OperationType.PAN),
|
||||||
|
icon: { name: "CHand", size: "28" },
|
||||||
|
class: "hand-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.SELECT,
|
||||||
|
title: "Select",
|
||||||
|
action: () => selectTool(OperationType.SELECT),
|
||||||
|
icon: { name: "CSelect", size: "28" },
|
||||||
|
class: "select-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.LIQUIFY,
|
||||||
|
title: "Liquefying",
|
||||||
|
action: () => selectTool(OperationType.LIQUIFY),
|
||||||
|
icon: { name: "CLiquefying", size: "32" },
|
||||||
|
class: "liquify-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.LASSO,
|
||||||
|
title: "Lasso",
|
||||||
|
action: () => selectTool(OperationType.LASSO),
|
||||||
|
icon: { name: "CLasso", size: "28" },
|
||||||
|
class: "lasso-btn",
|
||||||
|
activeList: [
|
||||||
|
OperationType.LASSO,
|
||||||
|
OperationType.LASSO_RECTANGLE,
|
||||||
|
OperationType.AREA_CUSTOM,
|
||||||
|
OperationType.AREA_RECTANGLE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoomIn",
|
||||||
|
title: "Zoom In",
|
||||||
|
action: zoomIn,
|
||||||
|
icon: { name: "CZoomIn", size: "30" },
|
||||||
|
class: "zoom-in-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoomOut",
|
||||||
|
title: "Zoom Out",
|
||||||
|
action: zoomOut,
|
||||||
|
icon: { name: "CZoomOut", size: "26" },
|
||||||
|
class: "zoom-out-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upload",
|
||||||
|
title: "Upload Image",
|
||||||
|
action: triggerImageUpload,
|
||||||
|
icon: { name: "CUpload", size: "26" },
|
||||||
|
class: "upload-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "addText",
|
||||||
|
title: "Add Text",
|
||||||
|
action: () => addText(),
|
||||||
|
icon: { name: "CFont", size: "20" },
|
||||||
|
class: "text-btn",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 红绿图模式工具列表
|
||||||
|
const redGreenToolsList = ref([
|
||||||
|
{
|
||||||
|
id: "undo",
|
||||||
|
title: "Undo",
|
||||||
|
action: undo,
|
||||||
|
icon: { name: "CUndo", size: "20" },
|
||||||
|
class: "undo-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redo",
|
||||||
|
title: "Redo",
|
||||||
|
action: redo,
|
||||||
|
icon: { name: "CRedo", size: "20" },
|
||||||
|
class: "redo-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.RED_BRUSH,
|
||||||
|
title: "Red Brush (R)",
|
||||||
|
action: () => selectTool(OperationType.RED_BRUSH),
|
||||||
|
icon: { name: "CBrush", size: "24" },
|
||||||
|
class: "red-brush-btn",
|
||||||
|
style: { color: "#FF0000" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.GREEN_BRUSH,
|
||||||
|
title: "Green Brush (G)",
|
||||||
|
action: () => selectTool(OperationType.GREEN_BRUSH),
|
||||||
|
icon: { name: "CBrush", size: "24" },
|
||||||
|
class: "green-brush-btn",
|
||||||
|
style: { color: "#00AA00" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OperationType.ERASER,
|
||||||
|
title: "Eraser (E)",
|
||||||
|
action: () => selectTool(OperationType.ERASER),
|
||||||
|
icon: { name: "CEraser", size: "22" },
|
||||||
|
class: "eraser-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoomIn",
|
||||||
|
title: "Zoom In",
|
||||||
|
action: zoomIn,
|
||||||
|
icon: { name: "CZoomIn", size: "30" },
|
||||||
|
class: "zoom-in-btn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zoomOut",
|
||||||
|
title: "Zoom Out",
|
||||||
|
action: zoomOut,
|
||||||
|
icon: { name: "CZoomOut", size: "26" },
|
||||||
|
class: "zoom-out-btn",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 根据模式选择工具列表
|
||||||
|
const toolsList = computed(() => {
|
||||||
|
return props.isRedGreenMode ? redGreenToolsList.value : normalToolsList.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTool(tool) {
|
||||||
|
emit("tool-selected", tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImageUpload() {
|
||||||
|
emit("trigger-image-upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addText() {
|
||||||
|
emit("add-text");
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
if (!canUndo.value) return;
|
||||||
|
undoFun();
|
||||||
|
emit("undo", {
|
||||||
|
canUndo: canUndo.value,
|
||||||
|
canRedo: canRedo.value,
|
||||||
|
commandManager,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
if (!canRedo.value) return;
|
||||||
|
emit("redo", {
|
||||||
|
canUndo: canUndo.value,
|
||||||
|
canRedo: canRedo.value,
|
||||||
|
commandManager,
|
||||||
|
});
|
||||||
|
redoFun();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMinimap() {
|
||||||
|
emit("toggle-minimap", !props.minimapEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
emit("zoom-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
emit("zoom-out");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRedGreenMode() {
|
||||||
|
emit("toggle-red-green-mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘快捷键处理
|
||||||
|
function handleKeyDown(event) {
|
||||||
|
// 在红绿图模式下处理特定快捷键
|
||||||
|
if (props.isRedGreenMode) {
|
||||||
|
const key = event.key.toUpperCase();
|
||||||
|
|
||||||
|
// 当处于输入状态时不触发快捷键
|
||||||
|
if (
|
||||||
|
event.target.tagName === "INPUT" ||
|
||||||
|
event.target.tagName === "TEXTAREA"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "R":
|
||||||
|
selectTool(OperationType.RED_BRUSH);
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "G":
|
||||||
|
selectTool(OperationType.GREEN_BRUSH);
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "E":
|
||||||
|
selectTool(OperationType.ERASER);
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 添加键盘事件监听
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 移除键盘事件监听
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tools-sidebar">
|
||||||
|
<div
|
||||||
|
v-for="tool in toolsList"
|
||||||
|
:key="tool.id"
|
||||||
|
:class="[
|
||||||
|
'tool-btn',
|
||||||
|
tool.class,
|
||||||
|
{
|
||||||
|
active:
|
||||||
|
tool.id === activeTool ||
|
||||||
|
tool.id === activeTool.toLowerCase() ||
|
||||||
|
tool?.activeList?.includes(activeTool),
|
||||||
|
disabled:
|
||||||
|
(tool.id === 'undo' && !canUndo) ||
|
||||||
|
(tool.id === 'redo' && !canRedo),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="tool.style"
|
||||||
|
@click="tool.action"
|
||||||
|
>
|
||||||
|
<SvgIcon :name="tool.icon.name" :size="tool.icon.size"></SvgIcon>
|
||||||
|
<div class="tool-tooltip">{{ tool.title }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tools-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 15px 10px;
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover .tool-tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.active {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-tooltip:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 100%;
|
||||||
|
margin-top: -5px;
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent rgba(0, 0, 0, 0.7) transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-green-mode {
|
||||||
|
background-color: #fff4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-indicator {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffcccc;
|
||||||
|
color: #a33;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
688
src/component/Canvas/CanvasEditor/components/VerticalSlider.vue
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vertical-slider-container" :class="customClass">
|
||||||
|
<div
|
||||||
|
class="slider-track"
|
||||||
|
ref="sliderTrack"
|
||||||
|
@mousedown="startSliding"
|
||||||
|
@touchstart.prevent="startTouchSliding"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="slider-fill"
|
||||||
|
:style="{ height: `${displayPercentage}%` }"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="slider-thumb"
|
||||||
|
:style="{
|
||||||
|
bottom: `${displayPercentage}%`,
|
||||||
|
transform: `translateX(0) translateY(8px) scale(${thumbScale})`,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-for="(preset, index) in presets"
|
||||||
|
:key="`preset-${index}`"
|
||||||
|
class="slider-notch"
|
||||||
|
:class="{ active: isActivePreset(preset) }"
|
||||||
|
:style="{ bottom: `${calculatePresetPosition(preset)}%` }"
|
||||||
|
@click.stop="setValue(preset)"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(preset, index) in memorizedValues"
|
||||||
|
:key="`preset-${index}`"
|
||||||
|
class="slider-notch"
|
||||||
|
:class="{ active: isActivePreset(preset) }"
|
||||||
|
:style="{ bottom: `${calculatePresetPosition(preset)}%` }"
|
||||||
|
@click.stop="setValue(preset)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示框 -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div class="slider-tooltip" v-if="tooltipVisible" ref="tooltip">
|
||||||
|
<slot name="tooltip-content" :value="modelValue"></slot>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: Number,
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
presets: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
memorizedValues: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
snapThreshold: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
// 用于判断当前值与预设值是否匹配的阈值
|
||||||
|
activeThreshold: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.05,
|
||||||
|
},
|
||||||
|
// 是否将预设值位置按百分比计算
|
||||||
|
isPercentage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
// 添加步进属性,控制数值的步长
|
||||||
|
step: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
// 增加外部控制tooltip显示的属性
|
||||||
|
showTooltip: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// 是否启用点击外部关闭tooltip
|
||||||
|
closeOnOutsideClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"update:modelValue",
|
||||||
|
"slide-start",
|
||||||
|
"slide-end",
|
||||||
|
"click",
|
||||||
|
"update:showTooltip", // 新增emit事件用于双向绑定tooltip状态
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sliderTrack = ref(null);
|
||||||
|
const tooltip = ref(null);
|
||||||
|
const isSliding = ref(false);
|
||||||
|
const internalShowTooltip = ref(false);
|
||||||
|
const slideMoved = ref(false); // 用于跟踪是否发生了滑动移动
|
||||||
|
const hideTooltipTimer = ref(null); // 用于存储隐藏tooltip的计时器
|
||||||
|
|
||||||
|
// 在script setup顶部添加新的ref
|
||||||
|
const isSnapping = ref(false);
|
||||||
|
const snapLockValue = ref(null);
|
||||||
|
const snapLockTimeout = ref(null);
|
||||||
|
const thumbScale = ref(1); // 新增:用于控制滑块的缩放效果
|
||||||
|
|
||||||
|
// 定义一个统一的延迟时间常量
|
||||||
|
const TOOLTIP_HIDE_DELAY = 1500; // 1.5秒,可根据需要调整
|
||||||
|
|
||||||
|
// 开始新的隐藏tooltip计时器
|
||||||
|
function startHideTooltipTimer() {
|
||||||
|
// 先清除已有的计时器
|
||||||
|
clearHideTooltipTimer();
|
||||||
|
|
||||||
|
// 创建新计时器
|
||||||
|
hideTooltipTimer.value = setTimeout(() => {
|
||||||
|
// 只有在用户不在滑动时才隐藏tooltip
|
||||||
|
if (!isSliding.value) {
|
||||||
|
updateTooltipVisibility(false);
|
||||||
|
}
|
||||||
|
hideTooltipTimer.value = null;
|
||||||
|
}, TOOLTIP_HIDE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除隐藏tooltip的计时器
|
||||||
|
function clearHideTooltipTimer() {
|
||||||
|
if (hideTooltipTimer.value) {
|
||||||
|
clearTimeout(hideTooltipTimer.value);
|
||||||
|
hideTooltipTimer.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算tooltip的可见性,优先使用props中的showTooltip,否则使用内部状态
|
||||||
|
const tooltipVisible = computed(() => {
|
||||||
|
return props.showTooltip !== undefined
|
||||||
|
? props.showTooltip
|
||||||
|
: internalShowTooltip.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新tooltip状态的方法
|
||||||
|
function updateTooltipVisibility(visible) {
|
||||||
|
if (props.showTooltip !== undefined) {
|
||||||
|
// 如果父组件提供了showTooltip属性,通过emit更新
|
||||||
|
emit("update:showTooltip", visible);
|
||||||
|
} else {
|
||||||
|
// 否则更新内部状态
|
||||||
|
internalShowTooltip.value = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算显示百分比
|
||||||
|
const displayPercentage = computed(() => {
|
||||||
|
const range = props.max - props.min;
|
||||||
|
return ((props.modelValue - props.min) / range) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断是否是活动预设值
|
||||||
|
function isActivePreset(preset) {
|
||||||
|
return Math.abs(props.modelValue - preset) < props.activeThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算预设值的位置
|
||||||
|
function calculatePresetPosition(preset) {
|
||||||
|
if (props.isPercentage) {
|
||||||
|
return preset * 100;
|
||||||
|
} else {
|
||||||
|
const range = props.max - props.min;
|
||||||
|
return ((preset - props.min) / range) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将值舍入到最接近的步进值
|
||||||
|
function roundToStep(value) {
|
||||||
|
if (!props.step || props.step <= 0) return value;
|
||||||
|
|
||||||
|
const numSteps = Math.round((value - props.min) / props.step);
|
||||||
|
return props.min + numSteps * props.step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标滑动处理
|
||||||
|
function startSliding(event) {
|
||||||
|
isSliding.value = true;
|
||||||
|
slideMoved.value = false; // 重置滑动状态
|
||||||
|
updateTooltipVisibility(true);
|
||||||
|
clearHideTooltipTimer(); // 清除任何现有的隐藏计时器
|
||||||
|
updateValueFromMousePosition(event);
|
||||||
|
|
||||||
|
emit("slide-start");
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", stopSliding);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(event) {
|
||||||
|
if (isSliding.value) {
|
||||||
|
slideMoved.value = true; // 标记已经发生了滑动
|
||||||
|
updateValueFromMousePosition(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValueFromMousePosition(event) {
|
||||||
|
const rect = sliderTrack.value.getBoundingClientRect();
|
||||||
|
const trackHeight = rect.height;
|
||||||
|
const posY = event.clientY - rect.top;
|
||||||
|
|
||||||
|
// 计算相对位置并转换为min-max范围的值
|
||||||
|
let newValue = props.max - (posY / trackHeight) * (props.max - props.min);
|
||||||
|
|
||||||
|
// 边界检查
|
||||||
|
newValue = Math.max(props.min, Math.min(props.max, newValue));
|
||||||
|
|
||||||
|
// 应用步进
|
||||||
|
if (props.step > 0) {
|
||||||
|
newValue = roundToStep(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前处于吸附锁定状态,并且移动不超过锁定阈值,则保持当前值不变
|
||||||
|
if (isSnapping.value && snapLockValue.value !== null) {
|
||||||
|
// 增加吸附力度:扩大保持吸附的阈值范围
|
||||||
|
if (
|
||||||
|
Math.abs(newValue - snapLockValue.value) <
|
||||||
|
(props.snapThreshold / trackHeight) * (props.max - props.min) * 1.2
|
||||||
|
) {
|
||||||
|
newValue = snapLockValue.value;
|
||||||
|
} else {
|
||||||
|
// 移动距离超过阈值,解除吸附锁定
|
||||||
|
clearSnapLock();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 检查是否可以进入吸附状态
|
||||||
|
const snapPercentage =
|
||||||
|
(props.snapThreshold / trackHeight) * (props.max - props.min);
|
||||||
|
|
||||||
|
// 检查是否接近预设值,增加吸附判定范围
|
||||||
|
for (const preset of props.presets) {
|
||||||
|
if (Math.abs(newValue - preset) < snapPercentage * 0.4) {
|
||||||
|
// 增加判定范围
|
||||||
|
// 进入吸附状态
|
||||||
|
setSnapLock(preset);
|
||||||
|
newValue = preset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否接近记忆值
|
||||||
|
if (!isSnapping.value) {
|
||||||
|
for (const memValue of props.memorizedValues) {
|
||||||
|
if (Math.abs(newValue - memValue) < snapPercentage * 0.4) {
|
||||||
|
// 增加判定范围
|
||||||
|
// 进入吸附状态
|
||||||
|
setSnapLock(memValue);
|
||||||
|
newValue = memValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置吸附锁定
|
||||||
|
function setSnapLock(value) {
|
||||||
|
if (!isSnapping.value) {
|
||||||
|
isSnapping.value = true;
|
||||||
|
snapLockValue.value = value;
|
||||||
|
|
||||||
|
// 添加视觉反馈:放大滑块
|
||||||
|
thumbScale.value = 1.3;
|
||||||
|
|
||||||
|
// 触感反馈:在支持的设备上触发振动
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(15); // 短暂振动15ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延长锁定时间,增强吸附感
|
||||||
|
clearTimeout(snapLockTimeout.value);
|
||||||
|
snapLockTimeout.value = setTimeout(() => {
|
||||||
|
// 恢复滑块大小
|
||||||
|
thumbScale.value = 1;
|
||||||
|
clearSnapLock();
|
||||||
|
}, 500); // 500ms的吸附感觉,比原来的300ms更强
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除吸附锁定
|
||||||
|
function clearSnapLock() {
|
||||||
|
isSnapping.value = false;
|
||||||
|
snapLockValue.value = null;
|
||||||
|
clearTimeout(snapLockTimeout.value);
|
||||||
|
snapLockTimeout.value = null;
|
||||||
|
thumbScale.value = 1; // 确保滑块恢复正常大小
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(value) {
|
||||||
|
// 应用步进,确保即使通过点击预设值也会遵循步进
|
||||||
|
if (props.step > 0) {
|
||||||
|
value = roundToStep(value);
|
||||||
|
}
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSliding() {
|
||||||
|
// 清除吸附锁定
|
||||||
|
clearSnapLock();
|
||||||
|
|
||||||
|
if (isSliding.value) {
|
||||||
|
// 如果确实进行了滑动,则发送滑动结束事件
|
||||||
|
if (slideMoved.value) {
|
||||||
|
emit("slide-end", { isSlide: true });
|
||||||
|
|
||||||
|
// 只有在滑动操作后才启动隐藏计时器
|
||||||
|
startHideTooltipTimer();
|
||||||
|
} else {
|
||||||
|
// 只是点击,不是滑动,不自动隐藏tooltip
|
||||||
|
emit("slide-end", { isSlide: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSliding.value = false;
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", stopSliding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理点击事件
|
||||||
|
function handleClick(event) {
|
||||||
|
// 对于纯点击操作,发出click事件
|
||||||
|
if (!slideMoved.value) {
|
||||||
|
clearHideTooltipTimer(); // 清除任何现有的隐藏计时器
|
||||||
|
updateTooltipVisibility(true); // 确保tooltip在点击时显示
|
||||||
|
emit("click");
|
||||||
|
// 点击不启动隐藏计时器
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触摸事件处理
|
||||||
|
function startTouchSliding(event) {
|
||||||
|
isSliding.value = true;
|
||||||
|
slideMoved.value = false; // 重置滑动状态
|
||||||
|
updateTooltipVisibility(true);
|
||||||
|
updateValueFromTouchPosition(event);
|
||||||
|
|
||||||
|
// 滑动开始的触感反馈
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(10); // 更轻微的振动反馈
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("slide-start");
|
||||||
|
|
||||||
|
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
|
window.addEventListener("touchend", stopTouchSliding);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event) {
|
||||||
|
if (isSliding.value) {
|
||||||
|
slideMoved.value = true; // 标记已经发生了滑动
|
||||||
|
event.preventDefault(); // 阻止页面滚动
|
||||||
|
updateValueFromTouchPosition(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValueFromTouchPosition(event) {
|
||||||
|
if (!event.touches || event.touches.length === 0) return;
|
||||||
|
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const rect = sliderTrack.value.getBoundingClientRect();
|
||||||
|
const trackHeight = rect.height;
|
||||||
|
const posY = touch.clientY - rect.top;
|
||||||
|
|
||||||
|
// 计算相对位置并转换为min-max范围的值
|
||||||
|
let newValue = props.max - (posY / trackHeight) * (props.max - props.min);
|
||||||
|
|
||||||
|
// 边界检查
|
||||||
|
newValue = Math.max(props.min, Math.min(props.max, newValue));
|
||||||
|
|
||||||
|
// 应用步进
|
||||||
|
if (props.step > 0) {
|
||||||
|
newValue = roundToStep(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前处于吸附锁定状态,并且移动不超过锁定阈值,则保持当前值不变
|
||||||
|
if (isSnapping.value && snapLockValue.value !== null) {
|
||||||
|
// 增加吸附力度:扩大保持吸附的阈值范围
|
||||||
|
if (
|
||||||
|
Math.abs(newValue - snapLockValue.value) <
|
||||||
|
(props.snapThreshold / trackHeight) * (props.max - props.min) * 1.5
|
||||||
|
) {
|
||||||
|
newValue = snapLockValue.value;
|
||||||
|
} else {
|
||||||
|
// 移动距离超过阈值,解除吸附锁定,并提供反馈
|
||||||
|
clearSnapLock();
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(8); // 解除吸附的轻微振动
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 检查是否可以进入吸附状态
|
||||||
|
const snapPercentage =
|
||||||
|
(props.snapThreshold / trackHeight) * (props.max - props.min);
|
||||||
|
|
||||||
|
// 检查是否接近预设值,增加吸附判定范围
|
||||||
|
for (const preset of props.presets) {
|
||||||
|
if (Math.abs(newValue - preset) < snapPercentage * 0.4) {
|
||||||
|
// 增加判定范围
|
||||||
|
// 进入吸附状态
|
||||||
|
setSnapLock(preset);
|
||||||
|
newValue = preset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否接近记忆值
|
||||||
|
if (!isSnapping.value) {
|
||||||
|
for (const memValue of props.memorizedValues) {
|
||||||
|
if (Math.abs(newValue - memValue) < snapPercentage * 0.4) {
|
||||||
|
// 增加判定范围
|
||||||
|
// 进入吸附状态
|
||||||
|
setSnapLock(memValue);
|
||||||
|
newValue = memValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTouchSliding() {
|
||||||
|
// 滑动结束时的反馈
|
||||||
|
if (slideMoved.value && navigator.vibrate) {
|
||||||
|
navigator.vibrate(12); // 滑动结束的振动反馈
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除吸附锁定
|
||||||
|
clearSnapLock();
|
||||||
|
|
||||||
|
if (isSliding.value) {
|
||||||
|
// 如果确实进行了滑动,则发送滑动结束事件
|
||||||
|
if (slideMoved.value) {
|
||||||
|
emit("slide-end", { isSlide: true });
|
||||||
|
|
||||||
|
// 只有在滑动操作后才启动隐藏计时器
|
||||||
|
startHideTooltipTimer();
|
||||||
|
} else {
|
||||||
|
// 只是点击,不是滑动,不自动隐藏tooltip
|
||||||
|
emit("slide-end", { isSlide: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSliding.value = false;
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
window.removeEventListener("touchend", stopTouchSliding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加点击外部关闭tooltip的处理函数
|
||||||
|
function handleOutsideClick(event) {
|
||||||
|
if (!props.closeOnOutsideClick || !tooltipVisible.value) return;
|
||||||
|
|
||||||
|
// 如果正在滑动,不处理外部点击事件
|
||||||
|
if (isSliding.value) return;
|
||||||
|
|
||||||
|
// 检查点击是否在slider组件外部
|
||||||
|
const containerEl = sliderTrack.value?.parentElement;
|
||||||
|
const tooltipEl = tooltip.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
containerEl &&
|
||||||
|
tooltipEl &&
|
||||||
|
!containerEl.contains(event.target) &&
|
||||||
|
!tooltipEl.contains(event.target)
|
||||||
|
) {
|
||||||
|
updateTooltipVisibility(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 添加全局点击事件监听
|
||||||
|
if (props.closeOnOutsideClick) {
|
||||||
|
// 使用 setTimeout 确保点击事件在其他处理程序之后执行
|
||||||
|
window.addEventListener("click", (e) =>
|
||||||
|
setTimeout(() => handleOutsideClick(e), 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 清理事件监听器
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", stopSliding);
|
||||||
|
window.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
window.removeEventListener("touchend", stopTouchSliding);
|
||||||
|
window.removeEventListener("click", handleOutsideClick);
|
||||||
|
|
||||||
|
// 清除任何剩余的计时器
|
||||||
|
clearHideTooltipTimer();
|
||||||
|
clearSnapLock();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.vertical-slider-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
|
height: 150px;
|
||||||
|
position: relative;
|
||||||
|
// margin-top: 8px;
|
||||||
|
// margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-fill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
// background: linear-gradient(to top, #2196f3, #64b5f6);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
// border: 1px solid #2196f3;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: 0 0px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275); // 更平滑的动画效果
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加iPad和移动设备的专有样式
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.slider-thumb {
|
||||||
|
height: 20px; // 在触摸设备上增加滑块尺寸,更容易点击
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0px 6px rgba(0, 0, 0, 0.5); // 更明显的阴影
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
width: 40px; // 在触摸设备上增加宽度
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-notch {
|
||||||
|
width: 60%; // 增加刻度线宽度
|
||||||
|
height: 3px; // 增加刻度线高度
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当设备支持悬停时的效果 (通常是桌面设备)
|
||||||
|
@media (hover: hover) {
|
||||||
|
.slider-thumb:hover {
|
||||||
|
box-shadow: 0 0 5px rgba(33, 150, 243, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-notch {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 2px;
|
||||||
|
background: #999;
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: translateY(1px) translateX(50%);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 15px);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
min-width: 120px;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-width: 8px 8px 8px 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent rgba(255, 255, 255, 0.95) transparent transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义滑块颜色
|
||||||
|
// .size-slider {
|
||||||
|
// .slider-fill {
|
||||||
|
// // background: linear-gradient(to top, #2196f3, #64b5f6);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .slider-thumb {
|
||||||
|
// border-color: #2196f3;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .slider-notch.active {
|
||||||
|
// background: #2196f3;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .opacity-slider {
|
||||||
|
// .slider-fill {
|
||||||
|
// // background: linear-gradient(to top, #ff9800, #ffb74d);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .slider-thumb {
|
||||||
|
// border-color: #ff9800;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .slider-notch.active {
|
||||||
|
// background: #ff9800;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 淡入淡出动画
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式调整
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.vertical-slider-container {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.slider-tooltip {
|
||||||
|
left: calc(100% + 10px);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/component/Canvas/CanvasEditor/config/canvasConfig.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { OperationType } from "../utils/layerHelper";
|
||||||
|
|
||||||
|
// 画布默认配置
|
||||||
|
export const canvasConfig = {
|
||||||
|
width: 1024, // 画布宽度
|
||||||
|
height: 1024, // 画布高度
|
||||||
|
backgroundColor: "#ffffff", // 背景颜色
|
||||||
|
objectCaching: true, // 是否启用对象缓存
|
||||||
|
enableRetinaScaling: true, // 是否启用视网膜缩放
|
||||||
|
brushWidth: 5, // 画笔宽度
|
||||||
|
brushOpacity: 1, // 画笔透明度
|
||||||
|
brushColor: "#000000", // 画笔颜色
|
||||||
|
brushType: "pencil", // 画笔类型
|
||||||
|
brushTypeList: [
|
||||||
|
{ name: "pencil", text: "铅笔" },
|
||||||
|
// { name: "eraser", text: "橡皮擦" },
|
||||||
|
{ name: "brush", text: "画笔" },
|
||||||
|
{ name: "spray", text: "喷枪" },
|
||||||
|
{ name: "rectangle", text: "矩形" },
|
||||||
|
{ name: "circle", text: "圆形" },
|
||||||
|
], // 画笔类型列表
|
||||||
|
layerWidth: 250, // 图层宽度
|
||||||
|
|
||||||
|
// History settings
|
||||||
|
maxHistorySteps: 50, // 最大历史记录步数
|
||||||
|
onlyActiveLayerEditable: false, // 是否只有当前活动图层可编辑
|
||||||
|
// 默认工具模式
|
||||||
|
defaultTool: OperationType.DRAW || OperationType.SELECT, // 默认工具 TODO: 默认的地方可以陆续改成这个
|
||||||
|
isCropBackground: true, // 是否要裁剪背景层以外的内容
|
||||||
|
};
|
||||||
|
|
||||||
|
export default canvasConfig;
|
||||||
1021
src/component/Canvas/CanvasEditor/index.vue
Normal file
1186
src/component/Canvas/CanvasEditor/managers/CanvasManager.js
Normal file
355
src/component/Canvas/CanvasEditor/managers/ExportManager.js
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* 图片导出管理器
|
||||||
|
* 负责处理画布的图片导出功能,支持多种导出选项和图层过滤
|
||||||
|
*/
|
||||||
|
export class ExportManager {
|
||||||
|
constructor(canvasManager, layerManager) {
|
||||||
|
this.canvasManager = canvasManager;
|
||||||
|
this.layerManager = layerManager;
|
||||||
|
this.canvas = canvasManager.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出图片
|
||||||
|
* @param {Object} options 导出选项
|
||||||
|
* @param {Boolean} options.isContainBg 是否包含背景图层
|
||||||
|
* @param {Boolean} options.isContainFixed 是否包含固定图层
|
||||||
|
* @param {String} options.layerId 导出具体图层ID
|
||||||
|
* @param {Array} options.layerIdArray 导出多个图层ID数组
|
||||||
|
* @param {String} options.expPicType 导出图片类型 (png/jpg/svg)
|
||||||
|
* @returns {String} 导出的图片数据URL
|
||||||
|
*/
|
||||||
|
exportImage(options = {}) {
|
||||||
|
const {
|
||||||
|
isContainBg = false,
|
||||||
|
isContainFixed = false,
|
||||||
|
layerId = "",
|
||||||
|
layerIdArray = [],
|
||||||
|
expPicType = "png"
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果指定了具体图层ID,导出指定图层
|
||||||
|
if (layerId) {
|
||||||
|
return this._exportSpecificLayer(layerId, expPicType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了多个图层ID,导出多个图层
|
||||||
|
if (layerIdArray && layerIdArray.length > 0) {
|
||||||
|
return this._exportMultipleLayers(layerIdArray, expPicType, isContainBg, isContainFixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认导出所有可见图层
|
||||||
|
return this._exportAllLayers(expPicType, isContainBg, isContainFixed);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("导出图片失败:", error);
|
||||||
|
throw new Error(`图片导出失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出指定单个图层
|
||||||
|
* @param {String} layerId 图层ID
|
||||||
|
* @param {String} expPicType 导出类型
|
||||||
|
* @returns {String} 图片数据URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_exportSpecificLayer(layerId, expPicType) {
|
||||||
|
if (!this.layerManager) {
|
||||||
|
throw new Error("图层管理器未初始化");
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = this._getLayerById(layerId);
|
||||||
|
if (!layer) {
|
||||||
|
throw new Error(`未找到ID为 ${layerId} 的图层`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layer.visible) {
|
||||||
|
console.warn(`图层 ${layer.name} 不可见,将导出空白图片`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建临时画布
|
||||||
|
const tempCanvas = this._createExportCanvas();
|
||||||
|
const tempFabricCanvas = this._createTempFabricCanvas(tempCanvas);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 只添加指定图层的对象
|
||||||
|
this._addLayerObjectsToCanvas(tempFabricCanvas, layer);
|
||||||
|
|
||||||
|
// 渲染并导出
|
||||||
|
tempFabricCanvas.renderAll();
|
||||||
|
return this._generateDataURL(tempCanvas, expPicType);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this._cleanupTempCanvas(tempFabricCanvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出多个指定图层
|
||||||
|
* @param {Array} layerIdArray 图层ID数组
|
||||||
|
* @param {String} expPicType 导出类型
|
||||||
|
* @param {Boolean} isContainBg 是否包含背景图层
|
||||||
|
* @param {Boolean} isContainFixed 是否包含固定图层
|
||||||
|
* @returns {String} 图片数据URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_exportMultipleLayers(layerIdArray, expPicType, isContainBg, isContainFixed) {
|
||||||
|
if (!this.layerManager) {
|
||||||
|
throw new Error("图层管理器未初始化");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建临时画布
|
||||||
|
const tempCanvas = this._createExportCanvas();
|
||||||
|
const tempFabricCanvas = this._createTempFabricCanvas(tempCanvas);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 按照图层顺序添加指定的图层
|
||||||
|
const allLayers = this._getAllLayers();
|
||||||
|
|
||||||
|
allLayers.forEach(layer => {
|
||||||
|
if (!layerIdArray.includes(layer.id)) return;
|
||||||
|
|
||||||
|
// 检查图层类型过滤条件
|
||||||
|
if (!this._shouldIncludeLayer(layer, isContainBg, isContainFixed)) return;
|
||||||
|
|
||||||
|
if (layer.visible) {
|
||||||
|
this._addLayerObjectsToCanvas(tempFabricCanvas, layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染并导出
|
||||||
|
tempFabricCanvas.renderAll();
|
||||||
|
return this._generateDataURL(tempCanvas, expPicType);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this._cleanupTempCanvas(tempFabricCanvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出所有图层
|
||||||
|
* @param {String} expPicType 导出类型
|
||||||
|
* @param {Boolean} isContainBg 是否包含背景图层
|
||||||
|
* @param {Boolean} isContainFixed 是否包含固定图层
|
||||||
|
* @returns {String} 图片数据URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_exportAllLayers(expPicType, isContainBg, isContainFixed) {
|
||||||
|
// 创建临时画布
|
||||||
|
const tempCanvas = this._createExportCanvas();
|
||||||
|
const tempFabricCanvas = this._createTempFabricCanvas(tempCanvas);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取所有图层并按顺序添加
|
||||||
|
const allLayers = this._getAllLayers();
|
||||||
|
|
||||||
|
allLayers.forEach(layer => {
|
||||||
|
// 检查图层类型过滤条件
|
||||||
|
if (!this._shouldIncludeLayer(layer, isContainBg, isContainFixed)) return;
|
||||||
|
|
||||||
|
if (layer.visible) {
|
||||||
|
this._addLayerObjectsToCanvas(tempFabricCanvas, layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染并导出
|
||||||
|
tempFabricCanvas.renderAll();
|
||||||
|
return this._generateDataURL(tempCanvas, expPicType);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this._cleanupTempCanvas(tempFabricCanvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否应该包含该图层
|
||||||
|
* @param {Object} layer 图层对象
|
||||||
|
* @param {Boolean} isContainBg 是否包含背景图层
|
||||||
|
* @param {Boolean} isContainFixed 是否包含固定图层
|
||||||
|
* @returns {Boolean} 是否包含
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_shouldIncludeLayer(layer, isContainBg, isContainFixed) {
|
||||||
|
// 背景图层处理
|
||||||
|
if (layer.type === 'background' || layer.isBackground) {
|
||||||
|
return isContainBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定图层处理
|
||||||
|
if (layer.type === 'fixed' || layer.isFixed || layer.locked) {
|
||||||
|
return isContainFixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他图层默认包含
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建导出用的临时画布
|
||||||
|
* @returns {HTMLCanvasElement} 临时画布
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createExportCanvas() {
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
tempCanvas.width = this.canvas.width || 800;
|
||||||
|
tempCanvas.height = this.canvas.height || 600;
|
||||||
|
return tempCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时Fabric画布
|
||||||
|
* @param {HTMLCanvasElement} tempCanvas 临时画布元素
|
||||||
|
* @returns {fabric.StaticCanvas} 临时Fabric画布
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createTempFabricCanvas(tempCanvas) {
|
||||||
|
const { fabric } = window;
|
||||||
|
|
||||||
|
const tempFabricCanvas = new fabric.StaticCanvas(tempCanvas, {
|
||||||
|
width: this.canvas.width || 800,
|
||||||
|
height: this.canvas.height || 600,
|
||||||
|
backgroundColor: this.canvas.backgroundColor || 'transparent'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置高质量渲染选项
|
||||||
|
tempFabricCanvas.enableRetinaScaling = true;
|
||||||
|
tempFabricCanvas.imageSmoothingEnabled = true;
|
||||||
|
|
||||||
|
return tempFabricCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将图层对象添加到临时画布
|
||||||
|
* @param {fabric.StaticCanvas} tempCanvas 临时画布
|
||||||
|
* @param {Object} layer 图层对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_addLayerObjectsToCanvas(tempCanvas, layer) {
|
||||||
|
if (!layer) return;
|
||||||
|
|
||||||
|
// 处理背景图层
|
||||||
|
if (layer.type === 'background' && layer.fabricObject) {
|
||||||
|
this._cloneAndAddObject(tempCanvas, layer.fabricObject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理普通图层的对象
|
||||||
|
if (layer.fabricObjects && Array.isArray(layer.fabricObjects)) {
|
||||||
|
layer.fabricObjects.forEach(obj => {
|
||||||
|
if (obj && obj.visible !== false) {
|
||||||
|
this._cloneAndAddObject(tempCanvas, obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理单个fabricObject的情况
|
||||||
|
if (layer.fabricObject && !layer.fabricObjects) {
|
||||||
|
this._cloneAndAddObject(tempCanvas, layer.fabricObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分组图层的子图层
|
||||||
|
if (layer.children && Array.isArray(layer.children)) {
|
||||||
|
layer.children.forEach(childLayerId => {
|
||||||
|
const childLayer = this._getLayerById(childLayerId);
|
||||||
|
if (childLayer && childLayer.visible) {
|
||||||
|
this._addLayerObjectsToCanvas(tempCanvas, childLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆并添加对象到临时画布
|
||||||
|
* @param {fabric.StaticCanvas} tempCanvas 临时画布
|
||||||
|
* @param {fabric.Object} obj Fabric对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_cloneAndAddObject(tempCanvas, obj) {
|
||||||
|
if (!obj) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
obj.clone((cloned) => {
|
||||||
|
if (cloned) {
|
||||||
|
// 确保克隆对象的属性正确
|
||||||
|
cloned.set({
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
visible: true
|
||||||
|
});
|
||||||
|
tempCanvas.add(cloned);
|
||||||
|
}
|
||||||
|
}, ['id', 'layerId', 'name']); // 保留自定义属性
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("克隆对象失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成数据URL
|
||||||
|
* @param {HTMLCanvasElement} canvas 画布元素
|
||||||
|
* @param {String} expPicType 导出类型
|
||||||
|
* @returns {String} 数据URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generateDataURL(canvas, expPicType) {
|
||||||
|
const format = expPicType.toLowerCase();
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.9);
|
||||||
|
case 'svg':
|
||||||
|
// SVG导出需要特殊处理,这里先返回PNG
|
||||||
|
console.warn("SVG导出暂未实现,返回PNG格式");
|
||||||
|
return canvas.toDataURL('image/png', 1.0);
|
||||||
|
case 'png':
|
||||||
|
default:
|
||||||
|
return canvas.toDataURL('image/png', 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理临时画布资源
|
||||||
|
* @param {fabric.StaticCanvas} tempFabricCanvas 临时Fabric画布
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_cleanupTempCanvas(tempFabricCanvas) {
|
||||||
|
if (tempFabricCanvas) {
|
||||||
|
try {
|
||||||
|
tempFabricCanvas.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("清理临时画布失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有图层
|
||||||
|
* @returns {Array} 图层数组
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getAllLayers() {
|
||||||
|
if (this.layerManager && this.layerManager.layers) {
|
||||||
|
return this.layerManager.layers.value || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取图层
|
||||||
|
* @param {String} layerId 图层ID
|
||||||
|
* @returns {Object|null} 图层对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getLayerById(layerId) {
|
||||||
|
if (this.layerManager && this.layerManager.getLayerById) {
|
||||||
|
return this.layerManager.getLayerById(layerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备用方法:直接从图层数组中查找
|
||||||
|
const allLayers = this._getAllLayers();
|
||||||
|
return allLayers.find(layer => layer.id === layerId) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
2198
src/component/Canvas/CanvasEditor/managers/LayerManager.js
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
//import { fabric } from "fabric-with-all";
|
||||||
|
import { createLayer, LayerType, OperationType } from "../utils/layerHelper.js";
|
||||||
|
import { BatchInitializeRedGreenModeCommand } from "../commands/RedGreenCommands.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 红绿图模式管理器
|
||||||
|
* 利用已有图层结构,不清除现有图层
|
||||||
|
*/
|
||||||
|
export class RedGreenModeManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.layerManager = options.layerManager;
|
||||||
|
this.toolManager = options.toolManager;
|
||||||
|
this.canvasManager = options.canvasManager;
|
||||||
|
this.commandManager = options.commandManager;
|
||||||
|
|
||||||
|
// 红绿图模式状态
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.normalLayerOpacity = 0.4; // 默认40%透明度 (0-1)
|
||||||
|
|
||||||
|
// 图片URL
|
||||||
|
this.clothingImageUrl = null;
|
||||||
|
this.redGreenImageUrl = null;
|
||||||
|
|
||||||
|
// 回调函数
|
||||||
|
this.onImageGenerated = null;
|
||||||
|
|
||||||
|
console.log("RedGreenModeManager 已创建");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化红绿图模式
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {String} options.clothingImageUrl 衣服底图URL
|
||||||
|
* @param {String} options.redGreenImageUrl 红绿图URL
|
||||||
|
* @param {Number} options.normalLayerOpacity 普通图层透明度 (0-1)
|
||||||
|
* @param {Function} options.onImageGenerated 图片生成回调
|
||||||
|
* @param {Boolean} options.useBatchMode 是否使用批量模式 (默认true,减少闪烁)
|
||||||
|
* @returns {Promise<boolean>} 是否初始化成功
|
||||||
|
*/
|
||||||
|
async initialize(options = {}) {
|
||||||
|
try {
|
||||||
|
// 如果已经初始化,先清理状态
|
||||||
|
if (this.isInitialized) {
|
||||||
|
console.log("红绿图模式已初始化,重新初始化...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
this.clothingImageUrl = options.clothingImageUrl;
|
||||||
|
this.redGreenImageUrl = options.redGreenImageUrl;
|
||||||
|
this.onImageGenerated = options.onImageGenerated;
|
||||||
|
|
||||||
|
// 设置透明度 (支持0-100的百分比值或0-1的小数值)
|
||||||
|
if (typeof options.normalLayerOpacity === "number") {
|
||||||
|
if (options.normalLayerOpacity > 1) {
|
||||||
|
// 如果大于1,认为是百分比值(0-100)
|
||||||
|
this.normalLayerOpacity =
|
||||||
|
Math.max(0, Math.min(100, options.normalLayerOpacity)) / 100;
|
||||||
|
} else {
|
||||||
|
// 如果小于等于1,认为是小数值(0-1)
|
||||||
|
this.normalLayerOpacity = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, options.normalLayerOpacity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必需参数
|
||||||
|
if (!this.clothingImageUrl || !this.redGreenImageUrl) {
|
||||||
|
throw new Error("缺少必需的图片URL参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用批量模式或传统模式
|
||||||
|
const useBatchMode = options.useBatchMode !== false; // 默认为true
|
||||||
|
|
||||||
|
let initCommand;
|
||||||
|
|
||||||
|
// 使用新的批量初始化命令,减少页面闪烁
|
||||||
|
initCommand = new BatchInitializeRedGreenModeCommand({
|
||||||
|
canvas: this.canvas,
|
||||||
|
layerManager: this.layerManager,
|
||||||
|
toolManager: this.toolManager,
|
||||||
|
clothingImageUrl: this.clothingImageUrl,
|
||||||
|
redGreenImageUrl: this.redGreenImageUrl,
|
||||||
|
normalLayerOpacity: this.normalLayerOpacity,
|
||||||
|
onImageGenerated: this.onImageGenerated,
|
||||||
|
});
|
||||||
|
initCommand.undoable = false; // 不可撤销
|
||||||
|
|
||||||
|
// 执行命令
|
||||||
|
if (this.commandManager) {
|
||||||
|
await this.commandManager.execute(initCommand);
|
||||||
|
} else {
|
||||||
|
await initCommand.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registerRedGreenMouseUpEvent();
|
||||||
|
// 标记为已初始化
|
||||||
|
this.isInitialized = true;
|
||||||
|
|
||||||
|
// 启用图层管理器的红绿图模式
|
||||||
|
if (
|
||||||
|
this.layerManager &&
|
||||||
|
typeof this.layerManager.enableRedGreenMode === "function"
|
||||||
|
) {
|
||||||
|
this.layerManager.enableRedGreenMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置工具管理器状态
|
||||||
|
// 默认红色笔刷
|
||||||
|
if (this.toolManager) {
|
||||||
|
this.toolManager.isRedGreenMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("红绿图模式初始化成功", {
|
||||||
|
衣服底图: this.clothingImageUrl,
|
||||||
|
红绿图: this.redGreenImageUrl,
|
||||||
|
普通图层透明度: `${Math.round(this.normalLayerOpacity * 100)}%`,
|
||||||
|
批量模式: useBatchMode ? "已启用" : "已禁用",
|
||||||
|
画布背景: "白色",
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("红绿图模式初始化失败:", error);
|
||||||
|
this.isInitialized = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册鼠标抬起事件
|
||||||
|
registerRedGreenMouseUpEvent() {
|
||||||
|
this.canvas.on("mouse:up", (event) => {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
console.warn("红绿图模式未初始化,无法处理鼠标事件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可以在这里添加更多逻辑,比如生成图片或更新状态
|
||||||
|
if (this.onImageGenerated) {
|
||||||
|
const imageData = this.canvasManager.exportImage();
|
||||||
|
console.log("生成红绿图图片数据:", imageData);
|
||||||
|
this.onImageGenerated(imageData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已初始化
|
||||||
|
* @returns {boolean} 是否已初始化
|
||||||
|
*/
|
||||||
|
isReady() {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新普通图层透明度
|
||||||
|
* @param {Number} opacity 透明度值 (0-100的百分比值或0-1的小数值)
|
||||||
|
* @returns {boolean} 是否更新成功
|
||||||
|
*/
|
||||||
|
updateNormalLayerOpacity(opacity) {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
console.warn("红绿图模式未初始化,无法更新透明度");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 处理透明度值
|
||||||
|
let normalizedOpacity;
|
||||||
|
if (opacity > 1) {
|
||||||
|
// 如果大于1,认为是百分比值(0-100)
|
||||||
|
normalizedOpacity = Math.max(0, Math.min(100, opacity)) / 100;
|
||||||
|
} else {
|
||||||
|
// 如果小于等于1,认为是小数值(0-1)
|
||||||
|
normalizedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建透明度更新命令
|
||||||
|
const opacityCommand = new UpdateNormalLayerOpacityCommand({
|
||||||
|
canvas: this.canvas,
|
||||||
|
layerManager: this.layerManager,
|
||||||
|
opacity: normalizedOpacity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行命令
|
||||||
|
if (this.commandManager) {
|
||||||
|
this.commandManager.execute(opacityCommand);
|
||||||
|
} else {
|
||||||
|
opacityCommand.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新内部状态
|
||||||
|
this.normalLayerOpacity = normalizedOpacity;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`普通图层透明度已更新为: ${Math.round(normalizedOpacity * 100)}%`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新普通图层透明度失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前普通图层透明度
|
||||||
|
* @param {boolean} asPercentage 是否返回百分比值(0-100),默认返回小数值(0-1)
|
||||||
|
* @returns {Number} 透明度值
|
||||||
|
*/
|
||||||
|
getNormalLayerOpacity(asPercentage = false) {
|
||||||
|
if (asPercentage) {
|
||||||
|
return Math.round(this.normalLayerOpacity * 100);
|
||||||
|
}
|
||||||
|
return this.normalLayerOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新加载图片
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {String} options.clothingImageUrl 新的衣服底图URL (可选)
|
||||||
|
* @param {String} options.redGreenImageUrl 新的红绿图URL (可选)
|
||||||
|
* @returns {Promise<boolean>} 是否重新加载成功
|
||||||
|
*/
|
||||||
|
async reloadImages(options = {}) {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
console.warn("红绿图模式未初始化,无法重新加载图片");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新图片URL
|
||||||
|
if (options.clothingImageUrl) {
|
||||||
|
this.clothingImageUrl = options.clothingImageUrl;
|
||||||
|
}
|
||||||
|
if (options.redGreenImageUrl) {
|
||||||
|
this.redGreenImageUrl = options.redGreenImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新初始化
|
||||||
|
await this.initialize({
|
||||||
|
clothingImageUrl: this.clothingImageUrl,
|
||||||
|
redGreenImageUrl: this.redGreenImageUrl,
|
||||||
|
normalLayerOpacity: this.normalLayerOpacity,
|
||||||
|
onImageGenerated: this.onImageGenerated,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("图片重新加载成功");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("重新加载图片失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前状态信息
|
||||||
|
* @returns {Object} 状态信息
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
isInitialized: this.isInitialized,
|
||||||
|
clothingImageUrl: this.clothingImageUrl,
|
||||||
|
redGreenImageUrl: this.redGreenImageUrl,
|
||||||
|
normalLayerOpacity: this.normalLayerOpacity,
|
||||||
|
normalLayerOpacityPercentage: Math.round(this.normalLayerOpacity * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层信息
|
||||||
|
* @returns {Object} 图层信息
|
||||||
|
*/
|
||||||
|
getLayerInfo() {
|
||||||
|
if (!this.layerManager || !this.layerManager.layers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = this.layerManager.layers.value || [];
|
||||||
|
const backgroundLayer = layers.find((layer) => layer.isBackground);
|
||||||
|
const fixedLayer = layers.find((layer) => layer.isFixed);
|
||||||
|
const normalLayers = layers.filter(
|
||||||
|
(layer) => !layer.isBackground && !layer.isFixed
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundLayer:
|
||||||
|
backgroundLayer &&
|
||||||
|
Object.assign(
|
||||||
|
{
|
||||||
|
hasObject: !!backgroundLayer.fabricObject,
|
||||||
|
},
|
||||||
|
backgroundLayer
|
||||||
|
),
|
||||||
|
fixedLayer:
|
||||||
|
fixedLayer &&
|
||||||
|
Object.assign(
|
||||||
|
{
|
||||||
|
hasObject: !!fixedLayer.fabricObject,
|
||||||
|
},
|
||||||
|
fixedLayer
|
||||||
|
),
|
||||||
|
normalLayers: normalLayers.map((layer) => ({
|
||||||
|
id: layer.id,
|
||||||
|
name: layer.name,
|
||||||
|
visible: layer.visible,
|
||||||
|
opacity: layer.opacity,
|
||||||
|
objectCount: layer.fabricObjects ? layer.fabricObjects.length : 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理红绿图模式
|
||||||
|
* 注意:这不会删除图层,只是清理红绿图模式的特定内容
|
||||||
|
*/
|
||||||
|
cleanup() {
|
||||||
|
try {
|
||||||
|
// 禁用图层管理器的红绿图模式
|
||||||
|
if (
|
||||||
|
this.layerManager &&
|
||||||
|
typeof this.layerManager.disableRedGreenMode === "function"
|
||||||
|
) {
|
||||||
|
this.layerManager.disableRedGreenMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置工具管理器
|
||||||
|
if (this.toolManager && this.toolManager.isRedGreenMode) {
|
||||||
|
this.toolManager.isRedGreenMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.clothingImageUrl = null;
|
||||||
|
this.redGreenImageUrl = null;
|
||||||
|
this.onImageGenerated = null;
|
||||||
|
|
||||||
|
console.log("红绿图模式已清理");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("清理红绿图模式失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁管理器
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
// 清除引用
|
||||||
|
this.canvas = null;
|
||||||
|
this.layerManager = null;
|
||||||
|
this.toolManager = null;
|
||||||
|
this.commandManager = null;
|
||||||
|
|
||||||
|
console.log("RedGreenModeManager 已销毁");
|
||||||
|
}
|
||||||
|
}
|
||||||
370
src/component/Canvas/CanvasEditor/managers/ThumbnailManager.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* 缩略图管理器 - 负责生成和缓存图层和元素的预览缩略图
|
||||||
|
*/
|
||||||
|
export class ThumbnailManager {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.layers = options.layers || []; // 图层管理器
|
||||||
|
this.layerThumbSize = options.layerThumbSize || { width: 48, height: 48 };
|
||||||
|
this.elementThumbSize = options.elementThumbSize || {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
};
|
||||||
|
this.layerThumbnails = new Map(); // 图层缩略图缓存
|
||||||
|
this.elementThumbnails = new Map(); // 元素缩略图缓存
|
||||||
|
this.defaultThumbnail =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; // 1x1 透明图
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图层缩略图
|
||||||
|
* @param {Object} layer 图层对象ID
|
||||||
|
*/
|
||||||
|
generateLayerThumbnail(layerId) {
|
||||||
|
// const layer = this?.layers.value?.find((layer) => layer.id === layerId);
|
||||||
|
// if (!layer) return;
|
||||||
|
// // 延迟执行,避免阻塞UI
|
||||||
|
// requestAnimationFrame(() => {
|
||||||
|
// this._generateLayerThumbnailNow(layer);
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即生成图层缩略图
|
||||||
|
* @param {Object} layer 图层对象
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generateLayerThumbnailNow(layer) {
|
||||||
|
if (
|
||||||
|
!layer ||
|
||||||
|
!this.canvas ||
|
||||||
|
!layer.fabricObjects ||
|
||||||
|
!layer.fabricObjects?.length
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let thumbnail = null;
|
||||||
|
|
||||||
|
if (!layer.children?.length) {
|
||||||
|
// 如果是元素图层,直接生成元素缩略图
|
||||||
|
thumbnail = this._generateThumbnailFromObjects(
|
||||||
|
layer.fabricObjects,
|
||||||
|
this.layerThumbSize.width,
|
||||||
|
this.layerThumbSize.height
|
||||||
|
);
|
||||||
|
} else if (layer.type === "group" || layer.children?.length) {
|
||||||
|
const fabricObjects = layer.children.reduce((pre, next) => {
|
||||||
|
if (next.fabricObjects.length) {
|
||||||
|
pre.push(...next.fabricObjects);
|
||||||
|
}
|
||||||
|
return pre;
|
||||||
|
}, []);
|
||||||
|
// 如果是分组图层,合并所有子对象的缩略图
|
||||||
|
thumbnail = this._generateThumbnailFromObjects(
|
||||||
|
fabricObjects,
|
||||||
|
this.layerThumbSize.width,
|
||||||
|
this.layerThumbSize.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到缩略图缓存
|
||||||
|
if (thumbnail) {
|
||||||
|
this.layerThumbnails.set(layer.id, thumbnail);
|
||||||
|
} else {
|
||||||
|
// 如果无法生成缩略图,使用默认缩略图
|
||||||
|
this.layerThumbnails.set(layer.id, this.defaultThumbnail);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("生成图层缩略图出错:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成元素缩略图
|
||||||
|
* @param {Object} element 元素对象
|
||||||
|
* @param {Object} fabricObject fabric对象
|
||||||
|
*/
|
||||||
|
generateElementThumbnail(element, fabricObject) {
|
||||||
|
if (!element || !element.id || !fabricObject) return;
|
||||||
|
|
||||||
|
// 延迟执行,避免阻塞UI
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
const thumbnail = this._generateThumbnailFromObject(
|
||||||
|
fabricObject,
|
||||||
|
this.elementThumbSize.width,
|
||||||
|
this.elementThumbSize.height
|
||||||
|
);
|
||||||
|
|
||||||
|
if (thumbnail) {
|
||||||
|
this.elementThumbnails.set(element.id, thumbnail);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("生成元素缩略图出错:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从fabric对象生成缩略图
|
||||||
|
* @param {Object} obj fabric对象
|
||||||
|
* @param {Number} width 缩略图宽度
|
||||||
|
* @param {Number} height 缩略图高度
|
||||||
|
* @returns {String} 缩略图数据URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generateThumbnailFromObject(obj, width, height) {
|
||||||
|
if (!obj || !this.canvas) return null;
|
||||||
|
|
||||||
|
// 保存对象状态
|
||||||
|
const originalState = {
|
||||||
|
active: obj.active,
|
||||||
|
visible: obj.visible,
|
||||||
|
left: obj.left,
|
||||||
|
top: obj.top,
|
||||||
|
scaleX: obj.scaleX,
|
||||||
|
scaleY: obj.scaleY,
|
||||||
|
opacity: obj.opacity,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 临时修改对象状态
|
||||||
|
obj.set({
|
||||||
|
active: false,
|
||||||
|
visible: true,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建临时画布
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
tempCanvas.width = width;
|
||||||
|
tempCanvas.height = height;
|
||||||
|
const tempCtx = tempCanvas.getContext("2d");
|
||||||
|
|
||||||
|
// 获取对象边界
|
||||||
|
const bounds = obj.getBoundingRect();
|
||||||
|
|
||||||
|
// 绘制缩略图
|
||||||
|
try {
|
||||||
|
// 清空画布
|
||||||
|
tempCtx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 计算缩放比例
|
||||||
|
const scaleFactorX = width / bounds.width;
|
||||||
|
const scaleFactorY = height / bounds.height;
|
||||||
|
const scaleFactor = Math.min(scaleFactorX, scaleFactorY) * 0.8; // 保留一些边距
|
||||||
|
|
||||||
|
// 居中绘制
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
|
||||||
|
tempCtx.save();
|
||||||
|
tempCtx.translate(centerX, centerY);
|
||||||
|
tempCtx.scale(scaleFactor, scaleFactor);
|
||||||
|
tempCtx.translate(
|
||||||
|
-bounds.left - bounds.width / 2,
|
||||||
|
-bounds.top - bounds.height / 2
|
||||||
|
);
|
||||||
|
|
||||||
|
// 绘制对象
|
||||||
|
obj.render(tempCtx);
|
||||||
|
|
||||||
|
tempCtx.restore();
|
||||||
|
|
||||||
|
// 转换为数据URL
|
||||||
|
const dataUrl = tempCanvas.toDataURL("image/png");
|
||||||
|
|
||||||
|
// 恢复对象状态
|
||||||
|
obj.set(originalState);
|
||||||
|
|
||||||
|
return dataUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("绘制对象缩略图出错:", error);
|
||||||
|
|
||||||
|
// 恢复对象状态
|
||||||
|
obj.set(originalState);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从多个fabric对象生成组合缩略图
|
||||||
|
* @param {Array} objects fabric对象数组
|
||||||
|
* @param {Number} width 缩略图宽度
|
||||||
|
* @param {Number} height 缩略图高度
|
||||||
|
* @returns {String} 缩略图数据URL
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generateThumbnailFromObjects(objects, width, height) {
|
||||||
|
if (!objects || !objects.length || !this.canvas) return null;
|
||||||
|
|
||||||
|
// 创建临时画布
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
tempCanvas.width = width;
|
||||||
|
tempCanvas.height = height;
|
||||||
|
const tempCtx = tempCanvas.getContext("2d");
|
||||||
|
|
||||||
|
// 计算所有对象的总边界
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
|
||||||
|
objects.forEach((obj) => {
|
||||||
|
if (!obj.visible) return;
|
||||||
|
|
||||||
|
const bounds = obj.getBoundingRect();
|
||||||
|
minX = Math.min(minX, bounds.left);
|
||||||
|
minY = Math.min(minY, bounds.top);
|
||||||
|
maxX = Math.max(maxX, bounds.left + bounds.width);
|
||||||
|
maxY = Math.max(maxY, bounds.top + bounds.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupWidth = maxX - minX;
|
||||||
|
const groupHeight = maxY - minY;
|
||||||
|
|
||||||
|
// 如果没有有效对象,返回null
|
||||||
|
if (groupWidth <= 0 || groupHeight <= 0) return null;
|
||||||
|
|
||||||
|
// 保存对象状态
|
||||||
|
const originalStates = objects.map((obj) => ({
|
||||||
|
obj,
|
||||||
|
state: {
|
||||||
|
active: obj.active,
|
||||||
|
visible: obj.visible,
|
||||||
|
opacity: obj.opacity,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 临时修改对象状态
|
||||||
|
originalStates.forEach((item) => {
|
||||||
|
item.obj.set({
|
||||||
|
active: false,
|
||||||
|
visible: true,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绘制缩略图
|
||||||
|
try {
|
||||||
|
// 清空画布
|
||||||
|
tempCtx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 计算缩放比例
|
||||||
|
const scaleFactorX = width / groupWidth;
|
||||||
|
const scaleFactorY = height / groupHeight;
|
||||||
|
const scaleFactor = Math.min(scaleFactorX, scaleFactorY) * 0.8; // 保留一些边距
|
||||||
|
|
||||||
|
// 居中绘制
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
|
||||||
|
tempCtx.save();
|
||||||
|
tempCtx.translate(centerX, centerY);
|
||||||
|
tempCtx.scale(scaleFactor, scaleFactor);
|
||||||
|
tempCtx.translate(-(minX + groupWidth / 2), -(minY + groupHeight / 2));
|
||||||
|
|
||||||
|
// 按顺序绘制所有对象
|
||||||
|
objects.forEach((obj) => {
|
||||||
|
if (obj.visible) {
|
||||||
|
obj.render(tempCtx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tempCtx.restore();
|
||||||
|
|
||||||
|
// 转换为数据URL
|
||||||
|
const dataUrl = tempCanvas.toDataURL("image/png");
|
||||||
|
|
||||||
|
// 恢复对象状态
|
||||||
|
originalStates.forEach((item) => {
|
||||||
|
item.obj.set(item.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
return dataUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("绘制组合缩略图出错:", error);
|
||||||
|
|
||||||
|
// 恢复对象状态
|
||||||
|
originalStates.forEach((item) => {
|
||||||
|
item.obj.set(item.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量生成图层缩略图
|
||||||
|
* @param {Array} layers 图层数组
|
||||||
|
*/
|
||||||
|
generateAllLayerThumbnails(layers) {
|
||||||
|
if (!layers || !Array.isArray(layers)) return;
|
||||||
|
|
||||||
|
// 使用requestAnimationFrame批量生成,避免阻塞主线程
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
layers.forEach((layer) => {
|
||||||
|
if (layer && layer.id) {
|
||||||
|
this._generateLayerThumbnailNow(layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层缩略图
|
||||||
|
* @param {String} layerId 图层ID
|
||||||
|
* @returns {String|null} 缩略图URL或null
|
||||||
|
*/
|
||||||
|
getLayerThumbnail(layerId) {
|
||||||
|
if (!layerId) return null;
|
||||||
|
return this.layerThumbnails.get(layerId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取元素缩略图
|
||||||
|
* @param {String} elementId 元素ID
|
||||||
|
* @returns {String|null} 缩略图URL或null
|
||||||
|
*/
|
||||||
|
getElementThumbnail(elementId) {
|
||||||
|
if (!elementId) return null;
|
||||||
|
return this.elementThumbnails.get(elementId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除图层缩略图
|
||||||
|
* @param {String} layerId 图层ID
|
||||||
|
*/
|
||||||
|
clearLayerThumbnail(layerId) {
|
||||||
|
if (layerId && this.layerThumbnails.has(layerId)) {
|
||||||
|
this.layerThumbnails.delete(layerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除元素缩略图
|
||||||
|
* @param {String} elementId 元素ID
|
||||||
|
*/
|
||||||
|
clearElementThumbnail(elementId) {
|
||||||
|
if (elementId && this.elementThumbnails.has(elementId)) {
|
||||||
|
this.elementThumbnails.delete(elementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有缩略图
|
||||||
|
*/
|
||||||
|
clearAllThumbnails() {
|
||||||
|
this.layerThumbnails.clear();
|
||||||
|
this.elementThumbnails.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
this.clearAllThumbnails();
|
||||||
|
this.canvas = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1192
src/component/Canvas/CanvasEditor/managers/ToolManager.js
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
import { gsap } from "gsap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布动画管理器
|
||||||
|
* 负责处理画布平移、缩放等动画效果
|
||||||
|
*/
|
||||||
|
export class AnimationManager {
|
||||||
|
/**
|
||||||
|
* 创建动画管理器
|
||||||
|
* @param {fabric.Canvas} canvas fabric.js画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.currentZoom = options.currentZoom || { value: 100 };
|
||||||
|
|
||||||
|
// 动画相关属性
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
this._panAnimation = null;
|
||||||
|
this._lastWheelTime = 0;
|
||||||
|
this._lastWheelProcessTime = 0; // 上次处理wheel事件的时间
|
||||||
|
this._wheelEvents = [];
|
||||||
|
|
||||||
|
// 检测设备类型,Mac设备使用更短的节流时间确保响应性
|
||||||
|
this._isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
this._wheelThrottleTime = this._isMac
|
||||||
|
? options.wheelThrottleTime || 8 // Mac设备使用更短的节流时间
|
||||||
|
: options.wheelThrottleTime || 30;
|
||||||
|
|
||||||
|
this._accumulatedWheelDelta = 0; // 累积滚轮增量
|
||||||
|
this._wheelAccumulationTimeout = null; // 滚轮累积超时
|
||||||
|
|
||||||
|
// Mac设备使用更短的累积时间窗口,确保及时响应
|
||||||
|
this._wheelAccumulationTime = this._isMac ? 60 : 120; // 滚轮累积时间窗口(毫秒)
|
||||||
|
|
||||||
|
// 添加新的状态跟踪变量
|
||||||
|
this._wasPanning = false; // 是否有平移动画正在进行
|
||||||
|
this._wasZooming = false; // 是否有缩放动画正在进行
|
||||||
|
this._combinedAnimation = null; // 组合动画引用
|
||||||
|
|
||||||
|
// Mac特有的动画优化变量 - 使用最小防抖机制
|
||||||
|
if (this._isMac) {
|
||||||
|
this._lastMacAnimationTime = 0; // 上次Mac动画时间
|
||||||
|
this._macAnimationCooldown = 2; // 最小的动画冷却时间,确保最大响应性
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化GSAP默认配置
|
||||||
|
gsap.defaults({
|
||||||
|
ease: options.defaultEase || (this._isMac ? "power2.out" : "power2.out"), // Mac使用简单高效的缓动
|
||||||
|
duration: options.defaultDuration || (this._isMac ? 0.3 : 0.3), // Mac使用标准持续时间
|
||||||
|
overwrite: "auto", // 自动覆盖同一对象上的动画
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 GSAP 实现平滑缩放动画
|
||||||
|
* @param {Object} point 缩放中心点 {x, y}
|
||||||
|
* @param {Number} targetZoom 目标缩放值
|
||||||
|
* @param {Object} options 动画选项
|
||||||
|
*/
|
||||||
|
animateZoom(point, targetZoom, options = {}) {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
// 限制缩放范围
|
||||||
|
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
|
||||||
|
|
||||||
|
// 当前缩放值
|
||||||
|
const currentZoom = this.canvas.getZoom();
|
||||||
|
|
||||||
|
// 如果变化太小,直接应用缩放
|
||||||
|
if (Math.abs(targetZoom - currentZoom) < 0.01) {
|
||||||
|
this._applyZoom(point, targetZoom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止任何进行中的缩放动画
|
||||||
|
if (this._zoomAnimation) {
|
||||||
|
// 不是直接 kill,而是获取当前进度值作为新的起点
|
||||||
|
const currentProgress = this._zoomAnimation.progress();
|
||||||
|
const currentZoomValue = this._zoomAnimation.targets()[0].value;
|
||||||
|
this._zoomAnimation.kill();
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
|
||||||
|
// 从当前过渡中的值开始新动画,而不是从最初的值
|
||||||
|
const zoomObj = { value: currentZoomValue };
|
||||||
|
const currentVpt = [...this.canvas.viewportTransform];
|
||||||
|
|
||||||
|
// 计算过渡动画持续时间 - 根据当前值到目标值的距离比例
|
||||||
|
const progressRatio =
|
||||||
|
Math.abs(targetZoom - currentZoomValue) /
|
||||||
|
Math.abs(targetZoom - currentZoom);
|
||||||
|
const duration = options.duration || 0.3 * progressRatio;
|
||||||
|
|
||||||
|
// 计算缩放后目标位置需要的修正,保持缩放点不变
|
||||||
|
const animOptions = {
|
||||||
|
value: targetZoom,
|
||||||
|
duration: duration,
|
||||||
|
ease: options.ease || "power2.out",
|
||||||
|
onUpdate: () => {
|
||||||
|
// 更新缩放值显示
|
||||||
|
this.currentZoom.value = Math.round(zoomObj.value * 100);
|
||||||
|
|
||||||
|
// 计算过渡中的变换矩阵
|
||||||
|
const zoom = zoomObj.value;
|
||||||
|
const scale = zoom / currentZoomValue;
|
||||||
|
const currentScaleFactor = scale;
|
||||||
|
|
||||||
|
// 应用变换
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
vpt[0] = currentVpt[0] * scale;
|
||||||
|
vpt[3] = currentVpt[3] * scale;
|
||||||
|
|
||||||
|
// 应用平移修正以保持缩放点
|
||||||
|
const adjustX = (1 - currentScaleFactor) * point.x;
|
||||||
|
const adjustY = (1 - currentScaleFactor) * point.y;
|
||||||
|
vpt[4] = currentVpt[4] * scale + adjustX;
|
||||||
|
vpt[5] = currentVpt[5] * scale + adjustY;
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
|
||||||
|
// 确保最终状态准确
|
||||||
|
this._applyZoom(point, targetZoom, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动 GSAP 动画
|
||||||
|
this._zoomAnimation = gsap.to(zoomObj, animOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有正在进行的动画,创建新的缩放动画
|
||||||
|
const zoomObj = { value: currentZoom };
|
||||||
|
const currentVpt = [...this.canvas.viewportTransform];
|
||||||
|
|
||||||
|
// 计算缩放后目标位置需要的修正,保持缩放点不变
|
||||||
|
const scaleFactor = targetZoom / currentZoom;
|
||||||
|
const invertedScaleFactor = 1 / scaleFactor;
|
||||||
|
|
||||||
|
// 这个数学公式确保缩放点在屏幕上的位置保持不变
|
||||||
|
const dx = point.x - point.x * invertedScaleFactor;
|
||||||
|
const dy = point.y - point.y * invertedScaleFactor;
|
||||||
|
|
||||||
|
// 创建动画配置
|
||||||
|
const animOptions = {
|
||||||
|
value: targetZoom,
|
||||||
|
duration: options.duration || 0.3,
|
||||||
|
ease: options.ease || (this._isMac ? "expo.out" : "power2.out"), // Mac使用更平滑的缓动
|
||||||
|
onUpdate: () => {
|
||||||
|
// 更新缩放值显示
|
||||||
|
this.currentZoom.value = Math.round(zoomObj.value * 100);
|
||||||
|
|
||||||
|
// 计算过渡中的变换矩阵
|
||||||
|
const zoom = zoomObj.value;
|
||||||
|
const scale = zoom / currentZoom;
|
||||||
|
const currentScaleFactor = scale;
|
||||||
|
|
||||||
|
// 应用变换
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
vpt[0] = currentVpt[0] * scale;
|
||||||
|
vpt[3] = currentVpt[3] * scale;
|
||||||
|
|
||||||
|
// 应用平移修正以保持缩放点
|
||||||
|
const adjustX = (1 - currentScaleFactor) * point.x;
|
||||||
|
const adjustY = (1 - currentScaleFactor) * point.y;
|
||||||
|
vpt[4] = currentVpt[4] * scale + adjustX;
|
||||||
|
vpt[5] = currentVpt[5] * scale + adjustY;
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
|
||||||
|
// 确保最终状态准确
|
||||||
|
this._applyZoom(point, targetZoom, true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动 GSAP 动画
|
||||||
|
this._zoomAnimation = gsap.to(zoomObj, animOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用缩放(内部使用)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_applyZoom(point, zoom, skipUpdate = false) {
|
||||||
|
if (!skipUpdate) {
|
||||||
|
this.currentZoom.value = Math.round(zoom * 100);
|
||||||
|
}
|
||||||
|
this.canvas.zoomToPoint(point, zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 GSAP 实现平滑平移动画
|
||||||
|
* @param {Object} targetPosition 目标位置 {x, y}
|
||||||
|
* @param {Object} options 动画选项
|
||||||
|
*/
|
||||||
|
animatePan(targetPosition, options = {}) {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
// 停止任何进行中的平移动画
|
||||||
|
if (this._panAnimation) {
|
||||||
|
this._panAnimation.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVpt = [...this.canvas.viewportTransform];
|
||||||
|
const position = {
|
||||||
|
x: -currentVpt[4],
|
||||||
|
y: -currentVpt[5],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算平移距离
|
||||||
|
const dx = targetPosition.x - position.x;
|
||||||
|
const dy = targetPosition.y - position.y;
|
||||||
|
|
||||||
|
// 如果距离太小,直接应用平移
|
||||||
|
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
|
||||||
|
this._applyPan(targetPosition.x, targetPosition.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建动画配置
|
||||||
|
const animOptions = {
|
||||||
|
x: targetPosition.x,
|
||||||
|
y: targetPosition.y,
|
||||||
|
duration: options.duration || 0.3,
|
||||||
|
ease: options.ease || (this._isMac ? "circ.out" : "power2.out"), // Mac使用更柔和的缓动
|
||||||
|
onUpdate: () => {
|
||||||
|
this._applyPan(position.x, position.y);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
this._panAnimation = null;
|
||||||
|
// 确保最终位置准确
|
||||||
|
this._applyPan(targetPosition.x, targetPosition.y);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动 GSAP 动画
|
||||||
|
this._panAnimation = gsap.to(position, animOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用平移(内部使用)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_applyPan(x, y) {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
vpt[4] = -x;
|
||||||
|
vpt[5] = -y;
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用动画平移到指定元素
|
||||||
|
* @param {Object} elementId 元素ID
|
||||||
|
*/
|
||||||
|
panToElement(elementId) {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
const obj = this.canvas.getObjects().find((obj) => obj.id === elementId);
|
||||||
|
if (!obj) return;
|
||||||
|
|
||||||
|
const zoom = this.canvas.getZoom();
|
||||||
|
const center = obj.getCenterPoint();
|
||||||
|
|
||||||
|
// 计算目标中心位置
|
||||||
|
const targetX = center.x * zoom - this.canvas.width / 2;
|
||||||
|
const targetY = center.y * zoom - this.canvas.height / 2;
|
||||||
|
|
||||||
|
// 动画平移
|
||||||
|
this.animatePan(
|
||||||
|
{ x: targetX, y: targetY },
|
||||||
|
{
|
||||||
|
duration: 0.6,
|
||||||
|
ease: this._isMac ? "back.out(0.3)" : "power3.out", // Mac使用轻微回弹效果
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置缩放(带平滑动画)
|
||||||
|
* @param {Boolean} animated 是否使用动画
|
||||||
|
*/
|
||||||
|
async resetZoom(animated = true) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (animated) {
|
||||||
|
// 停止任何进行中的动画
|
||||||
|
if (this._zoomAnimation) {
|
||||||
|
this._zoomAnimation.kill();
|
||||||
|
}
|
||||||
|
if (this._panAnimation) {
|
||||||
|
this._panAnimation.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = {
|
||||||
|
x: this.canvas.width / 2,
|
||||||
|
y: this.canvas.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前变换矩阵
|
||||||
|
const currentVpt = [...this.canvas.viewportTransform];
|
||||||
|
const currentZoom = this.canvas.getZoom();
|
||||||
|
|
||||||
|
// 创建一个对象来动画整个视图变换
|
||||||
|
const viewTransform = {
|
||||||
|
zoom: currentZoom,
|
||||||
|
panX: currentVpt[4],
|
||||||
|
panY: currentVpt[5],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用GSAP同时动画缩放和平移
|
||||||
|
gsap.to(viewTransform, {
|
||||||
|
zoom: 1,
|
||||||
|
panX: 0,
|
||||||
|
panY: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: this._isMac ? "back.out(0.2)" : "power3.out", // Mac使用轻微回弹效果
|
||||||
|
onUpdate: () => {
|
||||||
|
// 更新缩放显示值
|
||||||
|
this.currentZoom.value = Math.round(viewTransform.zoom * 100);
|
||||||
|
|
||||||
|
// 应用新的变换
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
vpt[0] = viewTransform.zoom;
|
||||||
|
vpt[3] = viewTransform.zoom;
|
||||||
|
vpt[4] = viewTransform.panX;
|
||||||
|
vpt[5] = viewTransform.panY;
|
||||||
|
|
||||||
|
this.canvas.renderAll();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
// 确保最终状态准确
|
||||||
|
this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||||
|
this.currentZoom.value = 100;
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
this._panAnimation = null;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||||
|
this.currentZoom.value = 100;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理鼠标滚轮缩放
|
||||||
|
* @param {Object} opt 事件对象
|
||||||
|
*/
|
||||||
|
handleMouseWheel(opt) {
|
||||||
|
const now = Date.now();
|
||||||
|
let delta = opt.e.deltaY;
|
||||||
|
|
||||||
|
// 记录事件用于计算速度和惯性
|
||||||
|
this._wheelEvents.push({
|
||||||
|
delta: delta,
|
||||||
|
point: { x: opt.e.offsetX, y: opt.e.offsetY },
|
||||||
|
time: now,
|
||||||
|
hasPanAnimation: this._wasPanning,
|
||||||
|
hasZoomAnimation: this._wasZooming,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保留最近的事件记录
|
||||||
|
if (this._wheelEvents.length > 10) {
|
||||||
|
this._wheelEvents.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是第一个事件或者距离上次处理已经过了足够时间
|
||||||
|
const isFirstEvent = !this._wheelAccumulationTimeout;
|
||||||
|
const timeSinceLastProcess = now - (this._lastWheelProcessTime || 0);
|
||||||
|
|
||||||
|
if (isFirstEvent || timeSinceLastProcess > this._wheelAccumulationTime) {
|
||||||
|
// 立即处理第一个事件或长时间没有处理的事件,确保响应性
|
||||||
|
this._processAccumulatedWheel(opt);
|
||||||
|
this._lastWheelProcessTime = now;
|
||||||
|
|
||||||
|
// 清理之前的累积
|
||||||
|
this._accumulatedWheelDelta = 0;
|
||||||
|
|
||||||
|
// 如果有pending的timeout,清除它
|
||||||
|
if (this._wheelAccumulationTimeout) {
|
||||||
|
clearTimeout(this._wheelAccumulationTimeout);
|
||||||
|
this._wheelAccumulationTimeout = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 累积后续事件
|
||||||
|
this._accumulatedWheelDelta += delta;
|
||||||
|
|
||||||
|
// 如果正在累积中,清除之前的定时器
|
||||||
|
if (this._wheelAccumulationTimeout) {
|
||||||
|
clearTimeout(this._wheelAccumulationTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新的定时器,处理累积的事件
|
||||||
|
this._wheelAccumulationTimeout = setTimeout(() => {
|
||||||
|
this._processAccumulatedWheel(opt);
|
||||||
|
this._lastWheelProcessTime = Date.now();
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
this._accumulatedWheelDelta = 0;
|
||||||
|
this._wheelAccumulationTimeout = null;
|
||||||
|
}, this._wheelThrottleTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
opt.e.preventDefault();
|
||||||
|
opt.e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理累积的滚轮事件并应用缩放
|
||||||
|
* @private
|
||||||
|
* @param {Object} lastOpt 最后一个滚轮事件
|
||||||
|
*/
|
||||||
|
_processAccumulatedWheel(lastOpt) {
|
||||||
|
if (!this._wheelEvents.length) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Mac设备的轻量防抖检查 - 进一步减少冷却时间,确保响应性
|
||||||
|
if (
|
||||||
|
this._isMac &&
|
||||||
|
now - this._lastMacAnimationTime < this._macAnimationCooldown
|
||||||
|
) {
|
||||||
|
// 如果距离上次动画时间太短,只延迟很短时间,不阻塞太久
|
||||||
|
if (this._wheelAccumulationTimeout) {
|
||||||
|
clearTimeout(this._wheelAccumulationTimeout);
|
||||||
|
}
|
||||||
|
this._wheelAccumulationTimeout = setTimeout(() => {
|
||||||
|
this._processAccumulatedWheel(lastOpt);
|
||||||
|
}, Math.min(this._macAnimationCooldown, 3)); // 最多延迟3ms
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentZoom = this.canvas.getZoom();
|
||||||
|
|
||||||
|
// 分析滚轮事件模式,计算平均增量、速度和加速度
|
||||||
|
let sumDelta = 0;
|
||||||
|
let count = 0;
|
||||||
|
let earliestTime = now;
|
||||||
|
let latestTime = 0;
|
||||||
|
let point = {
|
||||||
|
x: lastOpt.e.offsetX,
|
||||||
|
y: lastOpt.e.offsetY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 判断是否在事件收集期间有平移或缩放动画
|
||||||
|
let hadPanAnimation = false;
|
||||||
|
let hadZoomAnimation = false;
|
||||||
|
|
||||||
|
// 计算平均增量和速度
|
||||||
|
this._wheelEvents.forEach((event) => {
|
||||||
|
sumDelta += event.delta;
|
||||||
|
count++;
|
||||||
|
earliestTime = Math.min(earliestTime, event.time);
|
||||||
|
latestTime = Math.max(latestTime, event.time);
|
||||||
|
|
||||||
|
// 使用最后记录的点作为缩放中心
|
||||||
|
if (event.time > latestTime) {
|
||||||
|
point = event.point;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有动画状态
|
||||||
|
if (event.hasPanAnimation) hadPanAnimation = true;
|
||||||
|
if (event.hasZoomAnimation) hadZoomAnimation = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算平均增量
|
||||||
|
const avgDelta = sumDelta / count;
|
||||||
|
|
||||||
|
// 计算滚动速度 - 基于事件频率和时间跨度
|
||||||
|
const timeSpan = latestTime - earliestTime + 1; // 避免除以零
|
||||||
|
const eventsPerSecond = (count / timeSpan) * 1000;
|
||||||
|
|
||||||
|
// 速度系数: 速度越快,缩放越敏感
|
||||||
|
let speedFactor = Math.min(3, Math.max(0.5, eventsPerSecond / 10));
|
||||||
|
|
||||||
|
// 计算缩放因子,应用速度系数
|
||||||
|
// 针对Mac设备优化:Mac触控板的deltaY值通常较小,需要适度增加敏感度
|
||||||
|
let zoomFactorBase = 0.999;
|
||||||
|
if (this._isMac) {
|
||||||
|
// Mac设备的触控板需要适度的敏感度,避免过度反应
|
||||||
|
zoomFactorBase = 0.995; // 适度降低基数,增加缩放敏感度
|
||||||
|
|
||||||
|
// 检测是否为触控板滚动(小幅度、高频次的特征)
|
||||||
|
const avgAbsDelta = Math.abs(avgDelta);
|
||||||
|
if (avgAbsDelta < 50 && count > 2) {
|
||||||
|
// 触控板滚动,适度增加敏感度
|
||||||
|
speedFactor *= 1.6; // 适度增加敏感度倍数
|
||||||
|
zoomFactorBase = 0.993; // 进一步调整基数
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomFactor = zoomFactorBase ** (avgDelta * speedFactor);
|
||||||
|
let targetZoom = currentZoom * zoomFactor;
|
||||||
|
|
||||||
|
// 限制缩放范围
|
||||||
|
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
|
||||||
|
|
||||||
|
// 根据滚动速度和缩放幅度计算动画持续时间
|
||||||
|
// 速度快时缩短动画时间,缩放幅度大时延长动画时间
|
||||||
|
const zoomRatio = Math.abs(targetZoom - currentZoom) / currentZoom;
|
||||||
|
|
||||||
|
let duration;
|
||||||
|
if (this._isMac) {
|
||||||
|
// Mac设备使用平衡的动画时间控制
|
||||||
|
if (speedFactor > 2) {
|
||||||
|
// 快速操作:快速但平滑
|
||||||
|
duration = Math.min(
|
||||||
|
0.18,
|
||||||
|
Math.max(0.08, (zoomRatio * 0.3) / Math.sqrt(speedFactor))
|
||||||
|
);
|
||||||
|
} else if (speedFactor > 1.2) {
|
||||||
|
// 中等速度:标准响应
|
||||||
|
duration = Math.min(
|
||||||
|
0.25,
|
||||||
|
Math.max(0.1, (zoomRatio * 0.4) / Math.sqrt(speedFactor))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 慢速精确操作:确保平滑
|
||||||
|
duration = Math.min(
|
||||||
|
0.3,
|
||||||
|
Math.max(0.12, (zoomRatio * 0.5) / Math.sqrt(speedFactor))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
duration = Math.min(
|
||||||
|
0.5,
|
||||||
|
Math.max(0.15, (zoomRatio * 0.8) / Math.sqrt(speedFactor))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据滚动速度选择不同的缓动效果
|
||||||
|
let easeType;
|
||||||
|
if (this._isMac) {
|
||||||
|
// Mac设备使用更简单、性能更好的缓动函数
|
||||||
|
// 避免复杂的指数和回弹效果,减少计算量
|
||||||
|
if (speedFactor > 2) {
|
||||||
|
// 快速滚动:使用简单的缓出效果
|
||||||
|
easeType = "power2.out";
|
||||||
|
} else if (speedFactor > 1.2) {
|
||||||
|
// 中等速度:使用平滑的缓出
|
||||||
|
easeType = "power1.out";
|
||||||
|
} else {
|
||||||
|
// 慢速精确操作:使用线性过渡
|
||||||
|
easeType = "power1.out";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非Mac设备保持原有的缓动
|
||||||
|
easeType = speedFactor > 1.5 ? "power1.out" : "power2.out";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据是否有其他动画正在进行,选择合适的动画方法
|
||||||
|
if (hadPanAnimation || this._wasPanning) {
|
||||||
|
// 如果有平移动画,使用组合动画以保持平滑过渡
|
||||||
|
this.animateCombinedTransform(point, targetZoom, {
|
||||||
|
duration: duration,
|
||||||
|
ease: easeType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果没有其他动画,使用标准缩放动画
|
||||||
|
this.animateZoom(point, targetZoom, {
|
||||||
|
duration: duration,
|
||||||
|
ease: easeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Mac设备的最后动画时间
|
||||||
|
if (this._isMac) {
|
||||||
|
this._lastMacAnimationTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理事件记录
|
||||||
|
this._wheelEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算并应用拖动结束后的惯性效果
|
||||||
|
* @param {Array} positions 拖动过程中记录的位置数组
|
||||||
|
* @param {Boolean} isTouchDevice 是否是触摸设备
|
||||||
|
*/
|
||||||
|
applyInertiaEffect(positions, isTouchDevice) {
|
||||||
|
if (!positions || positions.length <= 1) return;
|
||||||
|
|
||||||
|
const lastPos = positions[positions.length - 1];
|
||||||
|
const firstPos = positions[0];
|
||||||
|
const deltaTime = lastPos.time - firstPos.time;
|
||||||
|
|
||||||
|
if (deltaTime <= 0) return;
|
||||||
|
|
||||||
|
// 计算速度向量 (像素/毫秒)
|
||||||
|
const velocityX = (lastPos.x - firstPos.x) / deltaTime;
|
||||||
|
const velocityY = (lastPos.y - firstPos.y) / deltaTime;
|
||||||
|
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
|
||||||
|
|
||||||
|
// 仅当速度足够大时应用惯性效果
|
||||||
|
if (speed > 0.2) {
|
||||||
|
// 计算惯性距离,基于速度和衰减因子
|
||||||
|
const decayFactor = 300; // 调整此值以改变惯性效果的强度
|
||||||
|
const inertiaDistanceX = velocityX * decayFactor;
|
||||||
|
const inertiaDistanceY = velocityY * decayFactor;
|
||||||
|
|
||||||
|
// 计算目标位置
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
const currentPos = {
|
||||||
|
x: -vpt[4],
|
||||||
|
y: -vpt[5],
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPos = {
|
||||||
|
x: currentPos.x - inertiaDistanceX,
|
||||||
|
y: currentPos.y - inertiaDistanceY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 应用惯性动画,速度越大,动画时间越长
|
||||||
|
const animationDuration = Math.min(1.2, Math.max(0.6, speed * 2));
|
||||||
|
|
||||||
|
// 应用惯性动画
|
||||||
|
this.animatePan(targetPos, {
|
||||||
|
duration: animationDuration, // 动态计算持续时间
|
||||||
|
ease: this._isMac ? "quart.out" : "power3.out", // Mac使用更自然的减速效果
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平滑过渡停止所有动画
|
||||||
|
* 用于在需要中断当前动画时提供更自然的过渡,而不是硬性中断
|
||||||
|
* @param {Object} options 过渡选项
|
||||||
|
*/
|
||||||
|
smoothStopAnimations(options = {}) {
|
||||||
|
const duration = options.duration || 0.15; // 默认短暂过渡时间
|
||||||
|
|
||||||
|
// 处理缩放动画
|
||||||
|
if (this._zoomAnimation) {
|
||||||
|
const zoomObj = this._zoomAnimation.targets()[0];
|
||||||
|
const currentZoom = this.canvas.getZoom();
|
||||||
|
|
||||||
|
// 创建短暂的过渡动画到当前值
|
||||||
|
gsap.to(zoomObj, {
|
||||||
|
value: currentZoom,
|
||||||
|
duration: duration,
|
||||||
|
ease: this._isMac ? "circ.out" : "power1.out", // Mac使用更平滑的缓动
|
||||||
|
onUpdate: () => {
|
||||||
|
this.currentZoom.value = Math.round(zoomObj.value * 100);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
if (this._zoomAnimation) {
|
||||||
|
this._zoomAnimation.kill();
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理平移动画
|
||||||
|
if (this._panAnimation) {
|
||||||
|
const panObj = this._panAnimation.targets()[0];
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
const currentPos = { x: -vpt[4], y: -vpt[5] };
|
||||||
|
|
||||||
|
// 创建短暂的过渡动画到当前位置
|
||||||
|
gsap.to(panObj, {
|
||||||
|
x: currentPos.x,
|
||||||
|
y: currentPos.y,
|
||||||
|
duration: duration,
|
||||||
|
ease: this._isMac ? "circ.out" : "power1.out", // Mac使用更平滑的缓动
|
||||||
|
onUpdate: () => {
|
||||||
|
this._applyPan(panObj.x, panObj.y);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
if (this._panAnimation) {
|
||||||
|
this._panAnimation.kill();
|
||||||
|
this._panAnimation = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置画布交互动画
|
||||||
|
* 为对象交互添加流畅的动画效果
|
||||||
|
*/
|
||||||
|
setupInteractionAnimations() {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
// 启用对象旋转的流畅动画
|
||||||
|
this._setupRotationAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置旋转动画
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setupRotationAnimation() {
|
||||||
|
if (!fabric) return;
|
||||||
|
|
||||||
|
// 保存原始旋转方法
|
||||||
|
const originalRotate = fabric.Object.prototype.rotate;
|
||||||
|
const isMac = this._isMac; // 保存Mac检测结果
|
||||||
|
|
||||||
|
// 覆盖旋转方法以添加动画
|
||||||
|
fabric.Object.prototype.rotate = function (angle) {
|
||||||
|
const currentAngle = this.angle || 0;
|
||||||
|
|
||||||
|
if (Math.abs(angle - currentAngle) > 0.1) {
|
||||||
|
gsap.to(this, {
|
||||||
|
angle: angle,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: isMac ? "back.out(0.3)" : "power2.out", // Mac使用轻微回弹
|
||||||
|
onUpdate: () => {
|
||||||
|
this.canvas && this.canvas.renderAll();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果角度差异很小,使用原始方法
|
||||||
|
return originalRotate.call(this, angle);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理滚轮缩放,同时兼容正在进行的平移动画
|
||||||
|
* @param {Object} point 缩放中心点
|
||||||
|
* @param {Number} targetZoom 目标缩放值
|
||||||
|
* @param {Object} options 动画选项
|
||||||
|
*/
|
||||||
|
animateCombinedTransform(point, targetZoom, options = {}) {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
// 限制缩放范围
|
||||||
|
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
|
||||||
|
|
||||||
|
// 当前状态
|
||||||
|
const currentZoom = this.canvas.getZoom();
|
||||||
|
const currentVpt = [...this.canvas.viewportTransform];
|
||||||
|
const currentPos = { x: -currentVpt[4], y: -currentVpt[5] };
|
||||||
|
|
||||||
|
// 如果有正在进行的动画,先停止它们
|
||||||
|
if (this._combinedAnimation) {
|
||||||
|
this._combinedAnimation.kill();
|
||||||
|
this._combinedAnimation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._zoomAnimation) {
|
||||||
|
this._zoomAnimation.kill();
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._panAnimation) {
|
||||||
|
this._panAnimation.kill();
|
||||||
|
this._panAnimation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个统一的变换对象来动画
|
||||||
|
const transform = {
|
||||||
|
zoom: currentZoom,
|
||||||
|
panX: currentVpt[4],
|
||||||
|
panY: currentVpt[5],
|
||||||
|
progress: 0, // 用于动画进度跟踪
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取平移目标位置(如果有的话)
|
||||||
|
let panTarget = { x: currentPos.x, y: currentPos.y };
|
||||||
|
if (this._wasPanning) {
|
||||||
|
// 如果之前有平移动画,尝试获取平移的目标位置
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
panTarget = {
|
||||||
|
x: currentPos.x,
|
||||||
|
y: currentPos.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算新的变换矩阵,同时考虑平移和缩放
|
||||||
|
const scaleFactor = targetZoom / currentZoom;
|
||||||
|
|
||||||
|
// 创建动画
|
||||||
|
this._combinedAnimation = gsap.to(transform, {
|
||||||
|
zoom: targetZoom,
|
||||||
|
progress: 1,
|
||||||
|
duration: options.duration || 0.3,
|
||||||
|
ease: options.ease || (this._isMac ? "expo.out" : "power2.out"), // Mac使用更平滑的缓动
|
||||||
|
onUpdate: () => {
|
||||||
|
// 计算当前动画阶段的混合变换
|
||||||
|
const currentScaleFactor = transform.zoom / currentZoom;
|
||||||
|
|
||||||
|
// 应用缩放
|
||||||
|
const vpt = this.canvas.viewportTransform;
|
||||||
|
vpt[0] = currentVpt[0] * (transform.zoom / currentZoom);
|
||||||
|
vpt[3] = currentVpt[3] * (transform.zoom / currentZoom);
|
||||||
|
|
||||||
|
// 平滑混合平移和缩放调整
|
||||||
|
const adjustX = (1 - currentScaleFactor) * point.x;
|
||||||
|
const adjustY = (1 - currentScaleFactor) * point.y;
|
||||||
|
|
||||||
|
// 如果存在平移目标,进行插值
|
||||||
|
if (this._wasPanning) {
|
||||||
|
const t = transform.progress;
|
||||||
|
const interpolatedX = currentPos.x * (1 - t) + panTarget.x * t;
|
||||||
|
const interpolatedY = currentPos.y * (1 - t) + panTarget.y * t;
|
||||||
|
|
||||||
|
// 结合缩放和平移的调整
|
||||||
|
vpt[4] = -interpolatedX * currentScaleFactor + adjustX;
|
||||||
|
vpt[5] = -interpolatedY * currentScaleFactor + adjustY;
|
||||||
|
} else {
|
||||||
|
// 只有缩放,保持中心点
|
||||||
|
vpt[4] = currentVpt[4] * currentScaleFactor + adjustX;
|
||||||
|
vpt[5] = currentVpt[5] * currentScaleFactor + adjustY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缩放值显示
|
||||||
|
this.currentZoom.value = Math.round(transform.zoom * 100);
|
||||||
|
this.canvas.renderAll();
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
this._combinedAnimation = null;
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
this._panAnimation = null;
|
||||||
|
this._wasPanning = false;
|
||||||
|
this._wasZooming = false;
|
||||||
|
|
||||||
|
// 确保最终状态准确
|
||||||
|
this._applyZoom(point, targetZoom, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理资源
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
if (this._zoomAnimation) {
|
||||||
|
this._zoomAnimation.kill();
|
||||||
|
this._zoomAnimation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._panAnimation) {
|
||||||
|
this._panAnimation.kill();
|
||||||
|
this._panAnimation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._wheelEvents = [];
|
||||||
|
this.canvas = null;
|
||||||
|
this.currentZoom = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/component/Canvas/CanvasEditor/managers/brushes/BaseBrush.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* 笔刷基类
|
||||||
|
* 所有笔刷类型应继承此基类并实现必要的方法
|
||||||
|
*/
|
||||||
|
export class BaseBrush {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} canvas fabric画布实例
|
||||||
|
* @param {Object} options 笔刷配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.options = options;
|
||||||
|
|
||||||
|
// 基本属性
|
||||||
|
this.id = options.id || this.constructor.name;
|
||||||
|
this.name = options.name || "未命名笔刷";
|
||||||
|
this.description = options.description || "";
|
||||||
|
this.icon = options.icon || null;
|
||||||
|
this.category = options.category || "默认";
|
||||||
|
|
||||||
|
// 笔刷实例
|
||||||
|
this.brush = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建笔刷实例(必须由子类实现)
|
||||||
|
* @returns {Object} fabric笔刷实例
|
||||||
|
*/
|
||||||
|
create() {
|
||||||
|
throw new Error("必须由子类实现create方法");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置笔刷(必须由子类实现)
|
||||||
|
* @param {Object} brush fabric笔刷实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
configure(brush, options) {
|
||||||
|
throw new Error("必须由子类实现configure方法");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷的元数据
|
||||||
|
* @returns {Object} 笔刷元数据
|
||||||
|
*/
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
icon: this.icon,
|
||||||
|
category: this.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷预览
|
||||||
|
* @returns {String|null} 预览图URL或null
|
||||||
|
*/
|
||||||
|
getPreview() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷可配置属性
|
||||||
|
* 这个方法返回一个对象数组,每个对象描述一个可配置属性
|
||||||
|
* 每个属性对象包含:
|
||||||
|
* - id: 属性标识符
|
||||||
|
* - name: 属性显示名称
|
||||||
|
* - type: 属性类型(例如:'slider', 'color', 'checkbox', 'select')
|
||||||
|
* - defaultValue: 默认值
|
||||||
|
* - min/max/step: 对于slider类型的限制值
|
||||||
|
* - options: 对于select类型的选项
|
||||||
|
* - description: 属性描述
|
||||||
|
* - category: 属性分类
|
||||||
|
* - order: 显示顺序(越小越靠前)
|
||||||
|
* - visibleWhen: 函数或对象,定义何时显示该属性
|
||||||
|
* - dynamicOptions: 函数,返回动态的选项列表
|
||||||
|
* @returns {Array} 可配置属性描述数组
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
// 返回基础属性,所有笔刷都有这些属性
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "size",
|
||||||
|
name: "笔刷大小",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: 5,
|
||||||
|
min: 0.5,
|
||||||
|
max: 100,
|
||||||
|
step: 0.5,
|
||||||
|
description: "笔刷的大小(像素)",
|
||||||
|
category: "基本",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "color",
|
||||||
|
name: "笔刷颜色",
|
||||||
|
type: "color",
|
||||||
|
defaultValue: "#000000",
|
||||||
|
description: "笔刷的颜色",
|
||||||
|
category: "基本",
|
||||||
|
order: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opacity",
|
||||||
|
name: "不透明度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: 1,
|
||||||
|
min: 0.05,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
description: "笔刷的不透明度",
|
||||||
|
category: "基本",
|
||||||
|
order: 30,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并特有属性与基本属性
|
||||||
|
* 子类应该调用此方法来合并自身特有属性与基类提供的基本属性
|
||||||
|
* @param {Array} specificProperties 特有属性数组
|
||||||
|
* @returns {Array} 合并后的属性数组
|
||||||
|
*/
|
||||||
|
mergeWithBaseProperties(specificProperties) {
|
||||||
|
const baseProperties = super.getConfigurableProperties();
|
||||||
|
|
||||||
|
// 过滤掉同名属性(子类优先)
|
||||||
|
const basePropsFiltered = baseProperties.filter(
|
||||||
|
(baseProp) =>
|
||||||
|
!specificProperties.some(
|
||||||
|
(specificProp) => specificProp.id === baseProp.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...basePropsFiltered, ...specificProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
// 基础实现,可以被子类覆盖以处理特殊属性
|
||||||
|
if (propId === "size") {
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush.width = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (propId === "color") {
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush.color = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (propId === "opacity") {
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush.opacity = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查属性是否可见
|
||||||
|
* @param {Object} property 属性对象
|
||||||
|
* @param {Object} currentValues 当前所有属性的值
|
||||||
|
* @returns {Boolean} 是否可见
|
||||||
|
*/
|
||||||
|
isPropertyVisible(property, currentValues) {
|
||||||
|
// 如果没有visibleWhen条件,则始终显示
|
||||||
|
if (!property.visibleWhen) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果visibleWhen是函数,则调用函数判断
|
||||||
|
if (typeof property.visibleWhen === "function") {
|
||||||
|
return property.visibleWhen(currentValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果visibleWhen是对象,检查条件是否满足
|
||||||
|
if (typeof property.visibleWhen === "object") {
|
||||||
|
for (const [key, value] of Object.entries(property.visibleWhen)) {
|
||||||
|
if (currentValues[key] !== value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取动态选项
|
||||||
|
* @param {Object} property 属性对象
|
||||||
|
* @param {Object} currentValues 当前所有属性的值
|
||||||
|
* @returns {Array} 选项数组
|
||||||
|
*/
|
||||||
|
getDynamicOptions(property, currentValues) {
|
||||||
|
if (
|
||||||
|
property.dynamicOptions &&
|
||||||
|
typeof property.dynamicOptions === "function"
|
||||||
|
) {
|
||||||
|
return property.dynamicOptions(currentValues);
|
||||||
|
}
|
||||||
|
return property.options || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生命周期方法:笔刷被选中
|
||||||
|
*/
|
||||||
|
onSelected() {
|
||||||
|
// 可由子类覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生命周期方法:笔刷被取消选中
|
||||||
|
*/
|
||||||
|
onDeselected() {
|
||||||
|
// 可由子类覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁笔刷实例并清理资源
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.brush = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 笔刷注册表
|
||||||
|
* 用于注册、获取和管理所有笔刷
|
||||||
|
*/
|
||||||
|
export class BrushRegistry {
|
||||||
|
constructor() {
|
||||||
|
// 存储所有注册的笔刷类
|
||||||
|
this.brushes = new Map();
|
||||||
|
|
||||||
|
// 按类别组织的笔刷
|
||||||
|
this.brushesByCategory = new Map();
|
||||||
|
|
||||||
|
// 事件监听器
|
||||||
|
this.listeners = {
|
||||||
|
register: [],
|
||||||
|
unregister: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册一个笔刷
|
||||||
|
* @param {String} id 笔刷唯一标识
|
||||||
|
* @param {Class} brushClass 笔刷类(需要继承BaseBrush)
|
||||||
|
* @param {Object} metadata 笔刷元数据(可选)
|
||||||
|
* @returns {Boolean} 是否注册成功
|
||||||
|
*/
|
||||||
|
register(id, brushClass, metadata = {}) {
|
||||||
|
if (this.brushes.has(id)) {
|
||||||
|
console.warn(`笔刷 ${id} 已存在,请使用其他ID`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储笔刷信息
|
||||||
|
const brushInfo = {
|
||||||
|
id,
|
||||||
|
class: brushClass,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.brushes.set(id, brushInfo);
|
||||||
|
|
||||||
|
// 添加到分类
|
||||||
|
const category = metadata.category || "默认";
|
||||||
|
if (!this.brushesByCategory.has(category)) {
|
||||||
|
this.brushesByCategory.set(category, []);
|
||||||
|
}
|
||||||
|
this.brushesByCategory.get(category).push(brushInfo);
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
this._triggerEvent("register", brushInfo);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消注册笔刷
|
||||||
|
* @param {String} id 笔刷ID
|
||||||
|
* @returns {Boolean} 是否成功
|
||||||
|
*/
|
||||||
|
unregister(id) {
|
||||||
|
if (!this.brushes.has(id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brushInfo = this.brushes.get(id);
|
||||||
|
this.brushes.delete(id);
|
||||||
|
|
||||||
|
// 从分类中移除
|
||||||
|
const category = brushInfo.metadata.category || "默认";
|
||||||
|
if (this.brushesByCategory.has(category)) {
|
||||||
|
const brushes = this.brushesByCategory.get(category);
|
||||||
|
const index = brushes.findIndex((b) => b.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
brushes.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果分类为空,删除该分类
|
||||||
|
if (brushes.length === 0) {
|
||||||
|
this.brushesByCategory.delete(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
this._triggerEvent("unregister", brushInfo);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷信息
|
||||||
|
* @param {String} id 笔刷ID
|
||||||
|
* @returns {Object|null} 笔刷信息或null
|
||||||
|
*/
|
||||||
|
getBrush(id) {
|
||||||
|
return this.brushes.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有笔刷
|
||||||
|
* @returns {Array} 笔刷信息数组
|
||||||
|
*/
|
||||||
|
getAllBrushes() {
|
||||||
|
return Array.from(this.brushes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定分类的笔刷
|
||||||
|
* @param {String} category 分类名称
|
||||||
|
* @returns {Array} 笔刷信息数组
|
||||||
|
*/
|
||||||
|
getBrushesByCategory(category) {
|
||||||
|
return this.brushesByCategory.get(category) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有分类
|
||||||
|
* @returns {Array} 分类名称数组
|
||||||
|
*/
|
||||||
|
getCategories() {
|
||||||
|
return Array.from(this.brushesByCategory.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个笔刷实例
|
||||||
|
* @param {String} id 笔刷ID
|
||||||
|
* @param {Object} canvas 画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @returns {Object|null} 笔刷实例或null
|
||||||
|
*/
|
||||||
|
createBrushInstance(id, canvas, options = {}) {
|
||||||
|
const brushInfo = this.getBrush(id);
|
||||||
|
if (!brushInfo) {
|
||||||
|
console.error(`笔刷 ${id} 不存在`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建笔刷实例
|
||||||
|
return new brushInfo.class(canvas, {
|
||||||
|
...options,
|
||||||
|
id: brushInfo.id,
|
||||||
|
...brushInfo.metadata,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`创建笔刷 ${id} 失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加事件监听器
|
||||||
|
* @param {String} event 事件名称 ('register'|'unregister')
|
||||||
|
* @param {Function} callback 回调函数
|
||||||
|
*/
|
||||||
|
addEventListener(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除事件监听器
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {Function} callback 回调函数
|
||||||
|
*/
|
||||||
|
removeEventListener(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
const index = this.listeners[event].indexOf(callback);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.listeners[event].splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发事件
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {*} data 事件数据
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_triggerEvent(event, data) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`执行 ${event} 事件监听器出错:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const brushRegistry = new BrushRegistry();
|
||||||
|
|
||||||
|
// 默认导出单例
|
||||||
|
export default brushRegistry;
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
/**
|
||||||
|
* 材质预设管理器
|
||||||
|
* 负责管理所有材质预设,包括内置预设和用户自定义预设
|
||||||
|
*/
|
||||||
|
export class TexturePresetManager {
|
||||||
|
constructor() {
|
||||||
|
// 内置材质预设
|
||||||
|
this.builtInTextures = [];
|
||||||
|
|
||||||
|
// 用户自定义材质
|
||||||
|
this.customTextures = [];
|
||||||
|
|
||||||
|
// 材质分类
|
||||||
|
this.categories = new Map();
|
||||||
|
|
||||||
|
// 材质缓存
|
||||||
|
this.textureCache = new Map();
|
||||||
|
|
||||||
|
// 事件监听器
|
||||||
|
this.listeners = {
|
||||||
|
textureAdded: [],
|
||||||
|
textureRemoved: [],
|
||||||
|
textureUpdated: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化内置材质
|
||||||
|
this._initBuiltInTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化内置材质预设
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_initBuiltInTextures() {
|
||||||
|
// 基于项目中的texture文件夹内容创建预设
|
||||||
|
const textureList = [
|
||||||
|
// 基础纹理
|
||||||
|
{
|
||||||
|
id: "texture0",
|
||||||
|
name: "纸质纹理",
|
||||||
|
category: "基础纹理",
|
||||||
|
path: "/src/assets/texture/texture0.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture1",
|
||||||
|
name: "粗糙表面",
|
||||||
|
category: "基础纹理",
|
||||||
|
path: "/src/assets/texture/texture1.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture2",
|
||||||
|
name: "细腻纹理",
|
||||||
|
category: "基础纹理",
|
||||||
|
path: "/src/assets/texture/texture2.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture3",
|
||||||
|
name: "颗粒质感",
|
||||||
|
category: "基础纹理",
|
||||||
|
path: "/src/assets/texture/texture3.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture4",
|
||||||
|
name: "布料纹理",
|
||||||
|
category: "基础纹理",
|
||||||
|
path: "/src/assets/texture/texture4.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture5",
|
||||||
|
name: "木质纹理",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture5.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture6",
|
||||||
|
name: "石材纹理",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture6.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture7",
|
||||||
|
name: "金属质感",
|
||||||
|
category: "金属纹理",
|
||||||
|
path: "/src/assets/texture/texture7.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture8",
|
||||||
|
name: "皮革纹理",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture8.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture9",
|
||||||
|
name: "水彩纸质",
|
||||||
|
category: "艺术纹理",
|
||||||
|
path: "/src/assets/texture/texture9.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture10",
|
||||||
|
name: "画布纹理",
|
||||||
|
category: "艺术纹理",
|
||||||
|
path: "/src/assets/texture/texture10.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture11",
|
||||||
|
name: "沙砾质感",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture11.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture12",
|
||||||
|
name: "水波纹理",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture12.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture13",
|
||||||
|
name: "云朵纹理",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture13.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture14",
|
||||||
|
name: "火焰纹理",
|
||||||
|
category: "特效纹理",
|
||||||
|
path: "/src/assets/texture/texture14.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture15",
|
||||||
|
name: "烟雾效果",
|
||||||
|
category: "特效纹理",
|
||||||
|
path: "/src/assets/texture/texture15.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture16",
|
||||||
|
name: "星空纹理",
|
||||||
|
category: "特效纹理",
|
||||||
|
path: "/src/assets/texture/texture16.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture17",
|
||||||
|
name: "大理石纹",
|
||||||
|
category: "石材纹理",
|
||||||
|
path: "/src/assets/texture/texture17.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture18",
|
||||||
|
name: "花岗岩纹",
|
||||||
|
category: "石材纹理",
|
||||||
|
path: "/src/assets/texture/texture18.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture19",
|
||||||
|
name: "竹纹理",
|
||||||
|
category: "自然纹理",
|
||||||
|
path: "/src/assets/texture/texture19.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture20",
|
||||||
|
name: "抽象图案",
|
||||||
|
category: "艺术纹理",
|
||||||
|
path: "/src/assets/texture/texture20.webp",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 添加内置材质
|
||||||
|
textureList.forEach((texture) => {
|
||||||
|
this.builtInTextures.push({
|
||||||
|
id: texture.id,
|
||||||
|
name: texture.name,
|
||||||
|
category: texture.category,
|
||||||
|
path: texture.path,
|
||||||
|
type: "builtin",
|
||||||
|
preview: texture.path, // 使用原图作为预览
|
||||||
|
description: `内置${texture.category} - ${texture.name}`,
|
||||||
|
tags: [texture.category.replace("纹理", ""), "内置"],
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
// 默认属性
|
||||||
|
defaultSettings: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
repeat: "repeat",
|
||||||
|
angle: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加到分类
|
||||||
|
if (!this.categories.has(texture.category)) {
|
||||||
|
this.categories.set(texture.category, []);
|
||||||
|
}
|
||||||
|
this.categories.get(texture.category).push(texture.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有材质(内置 + 自定义)
|
||||||
|
* @returns {Array} 材质数组
|
||||||
|
*/
|
||||||
|
getAllTextures() {
|
||||||
|
return [...this.builtInTextures, ...this.customTextures];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取材质
|
||||||
|
* @param {String} textureId 材质ID
|
||||||
|
* @returns {Object|null} 材质对象
|
||||||
|
*/
|
||||||
|
getTextureById(textureId) {
|
||||||
|
return (
|
||||||
|
this.getAllTextures().find((texture) => texture.id === textureId) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据分类获取材质
|
||||||
|
* @param {String} category 分类名称
|
||||||
|
* @returns {Array} 材质数组
|
||||||
|
*/
|
||||||
|
getTexturesByCategory(category) {
|
||||||
|
return this.getAllTextures().filter(
|
||||||
|
(texture) => texture.category === category
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有分类
|
||||||
|
* @returns {Array} 分类名称数组
|
||||||
|
*/
|
||||||
|
getCategories() {
|
||||||
|
const categories = new Set();
|
||||||
|
this.getAllTextures().forEach((texture) => {
|
||||||
|
categories.add(texture.category);
|
||||||
|
});
|
||||||
|
return Array.from(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加自定义材质
|
||||||
|
* @param {Object} textureData 材质数据
|
||||||
|
* @returns {String} 材质ID
|
||||||
|
*/
|
||||||
|
addCustomTexture(textureData) {
|
||||||
|
const textureId =
|
||||||
|
textureData.id ||
|
||||||
|
`custom_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const texture = {
|
||||||
|
id: textureId,
|
||||||
|
name: textureData.name || "自定义材质",
|
||||||
|
category: textureData.category || "自定义材质",
|
||||||
|
path: textureData.path || textureData.dataUrl,
|
||||||
|
type: "custom",
|
||||||
|
preview: textureData.preview || textureData.path || textureData.dataUrl,
|
||||||
|
description: textureData.description || "用户自定义材质",
|
||||||
|
tags: textureData.tags || ["自定义"],
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
defaultSettings: {
|
||||||
|
scale: textureData.scale || 1,
|
||||||
|
opacity: textureData.opacity || 1,
|
||||||
|
repeat: textureData.repeat || "repeat",
|
||||||
|
angle: textureData.angle || 0,
|
||||||
|
...textureData.defaultSettings,
|
||||||
|
},
|
||||||
|
// 保存原始文件信息
|
||||||
|
file: textureData.file || null,
|
||||||
|
dataUrl: textureData.dataUrl || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.customTextures.push(texture);
|
||||||
|
|
||||||
|
// 添加到分类
|
||||||
|
if (!this.categories.has(texture.category)) {
|
||||||
|
this.categories.set(texture.category, []);
|
||||||
|
}
|
||||||
|
this.categories.get(texture.category).push(textureId);
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
this._triggerEvent("textureAdded", texture);
|
||||||
|
|
||||||
|
return textureId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除自定义材质
|
||||||
|
* @param {String} textureId 材质ID
|
||||||
|
* @returns {Boolean} 是否删除成功
|
||||||
|
*/
|
||||||
|
removeCustomTexture(textureId) {
|
||||||
|
const index = this.customTextures.findIndex(
|
||||||
|
(texture) => texture.id === textureId
|
||||||
|
);
|
||||||
|
if (index === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = this.customTextures[index];
|
||||||
|
|
||||||
|
// 只能删除自定义材质
|
||||||
|
if (texture.type !== "custom") {
|
||||||
|
console.warn("不能删除内置材质");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.customTextures.splice(index, 1);
|
||||||
|
|
||||||
|
// 从分类中移除
|
||||||
|
if (this.categories.has(texture.category)) {
|
||||||
|
const categoryTextures = this.categories.get(texture.category);
|
||||||
|
const categoryIndex = categoryTextures.indexOf(textureId);
|
||||||
|
if (categoryIndex !== -1) {
|
||||||
|
categoryTextures.splice(categoryIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果分类为空且不是内置分类,删除分类
|
||||||
|
if (categoryTextures.length === 0 && texture.category === "自定义材质") {
|
||||||
|
this.categories.delete(texture.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
this.textureCache.delete(textureId);
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
this._triggerEvent("textureRemoved", texture);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新材质信息
|
||||||
|
* @param {String} textureId 材质ID
|
||||||
|
* @param {Object} updates 更新数据
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
*/
|
||||||
|
updateTexture(textureId, updates) {
|
||||||
|
const texture = this.getTextureById(textureId);
|
||||||
|
if (!texture || texture.type === "builtin") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新材质属性
|
||||||
|
Object.assign(texture, updates);
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
this._triggerEvent("textureUpdated", texture);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取材质预览URL
|
||||||
|
* @param {Object} texture 材质对象
|
||||||
|
* @returns {String} 预览URL
|
||||||
|
*/
|
||||||
|
getTexturePreviewUrl(texture) {
|
||||||
|
if (!texture) return null;
|
||||||
|
|
||||||
|
// 如果有预览图,使用预览图
|
||||||
|
if (texture.preview) {
|
||||||
|
return texture.preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用原图
|
||||||
|
return texture.path || texture.dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载材质图像
|
||||||
|
* @param {String} textureId 材质ID
|
||||||
|
* @returns {Promise<HTMLImageElement>} 图像对象
|
||||||
|
*/
|
||||||
|
loadTextureImage(textureId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 检查缓存
|
||||||
|
if (this.textureCache.has(textureId)) {
|
||||||
|
resolve(this.textureCache.get(textureId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = this.getTextureById(textureId);
|
||||||
|
if (!texture) {
|
||||||
|
reject(new Error(`材质 ${textureId} 不存在`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// 缓存图像
|
||||||
|
this.textureCache.set(textureId, img);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error(`材质 ${textureId} 加载失败`));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = texture.path || texture.dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索材质
|
||||||
|
* @param {String} query 搜索关键词
|
||||||
|
* @returns {Array} 匹配的材质数组
|
||||||
|
*/
|
||||||
|
searchTextures(query) {
|
||||||
|
if (!query) return this.getAllTextures();
|
||||||
|
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
return this.getAllTextures().filter((texture) => {
|
||||||
|
return (
|
||||||
|
texture.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
texture.category.toLowerCase().includes(searchTerm) ||
|
||||||
|
texture.description.toLowerCase().includes(searchTerm) ||
|
||||||
|
texture.tags.some((tag) => tag.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存自定义材质到本地存储
|
||||||
|
*/
|
||||||
|
saveCustomTexturesToStorage() {
|
||||||
|
try {
|
||||||
|
const customTexturesData = this.customTextures.map((texture) => ({
|
||||||
|
...texture,
|
||||||
|
// 不保存file对象到localStorage
|
||||||
|
file: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
"canvasEditor_customTextures",
|
||||||
|
JSON.stringify(customTexturesData)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存自定义材质失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地存储加载自定义材质
|
||||||
|
*/
|
||||||
|
loadCustomTexturesFromStorage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("canvasEditor_customTextures");
|
||||||
|
if (stored) {
|
||||||
|
const customTexturesData = JSON.parse(stored);
|
||||||
|
this.customTextures = customTexturesData;
|
||||||
|
|
||||||
|
// 重建分类索引
|
||||||
|
this.customTextures.forEach((texture) => {
|
||||||
|
if (!this.categories.has(texture.category)) {
|
||||||
|
this.categories.set(texture.category, []);
|
||||||
|
}
|
||||||
|
if (!this.categories.get(texture.category).includes(texture.id)) {
|
||||||
|
this.categories.get(texture.category).push(texture.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载自定义材质失败:", error);
|
||||||
|
this.customTextures = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建材质预设
|
||||||
|
* @param {String} name 预设名称
|
||||||
|
* @param {Object} settings 材质设置
|
||||||
|
* @returns {String} 预设ID
|
||||||
|
*/
|
||||||
|
createTexturePreset(name, settings) {
|
||||||
|
const presetId = `preset_${Date.now()}_${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.substr(2, 9)}`;
|
||||||
|
|
||||||
|
const preset = {
|
||||||
|
id: presetId,
|
||||||
|
name: name,
|
||||||
|
type: "preset",
|
||||||
|
category: "材质预设",
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
settings: {
|
||||||
|
textureId: settings.textureId,
|
||||||
|
scale: settings.scale || 1,
|
||||||
|
opacity: settings.opacity || 1,
|
||||||
|
repeat: settings.repeat || "repeat",
|
||||||
|
angle: settings.angle || 0,
|
||||||
|
brushSize: settings.brushSize || 5,
|
||||||
|
brushOpacity: settings.brushOpacity || 1,
|
||||||
|
brushColor: settings.brushColor || "#000000",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.customTextures.push(preset);
|
||||||
|
this._triggerEvent("textureAdded", preset);
|
||||||
|
|
||||||
|
return presetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用材质预设
|
||||||
|
* @param {String} presetId 预设ID
|
||||||
|
* @returns {Object|null} 预设设置
|
||||||
|
*/
|
||||||
|
applyTexturePreset(presetId) {
|
||||||
|
const preset = this.getTextureById(presetId);
|
||||||
|
if (!preset || preset.type !== "preset") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加事件监听器
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {Function} callback 回调函数
|
||||||
|
*/
|
||||||
|
addEventListener(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除事件监听器
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {Function} callback 回调函数
|
||||||
|
*/
|
||||||
|
removeEventListener(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
const index = this.listeners[event].indexOf(callback);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.listeners[event].splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发事件
|
||||||
|
* @param {String} event 事件名称
|
||||||
|
* @param {*} data 事件数据
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_triggerEvent(event, data) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`执行 ${event} 事件监听器出错:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有缓存
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.textureCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统计信息
|
||||||
|
* @returns {Object} 统计信息
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
builtInCount: this.builtInTextures.length,
|
||||||
|
customCount: this.customTextures.length,
|
||||||
|
totalCount: this.getAllTextures().length,
|
||||||
|
categoriesCount: this.getCategories().length,
|
||||||
|
cacheSize: this.textureCache.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例实例
|
||||||
|
const texturePresetManager = new TexturePresetManager();
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export default texturePresetManager;
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
//import { fabric } from "fabric-with-all";
|
||||||
|
import { BrushStore } from "../../store/BrushStore";
|
||||||
|
import { brushRegistry } from "./BrushRegistry";
|
||||||
|
|
||||||
|
// 导入基础笔刷类型
|
||||||
|
import { PencilBrush } from "./types/PencilBrush";
|
||||||
|
import { TextureBrush } from "./types/TextureBrush";
|
||||||
|
|
||||||
|
// 导入集成的笔刷类型
|
||||||
|
import { CrayonBrush } from "./types/CrayonBrush";
|
||||||
|
import { FurBrush } from "./types/FurBrush";
|
||||||
|
import { InkBrush } from "./types/InkBrush";
|
||||||
|
import { LongfurBrush } from "./types/LongfurBrush";
|
||||||
|
import { WritingBrush } from "./types/WritingBrush";
|
||||||
|
import { MarkerBrush } from "./types/MarkerBrush";
|
||||||
|
import { CustomPenBrush } from "./types/CustomPenBrush";
|
||||||
|
import { RibbonBrush } from "./types/RibbonBrush";
|
||||||
|
import { ShadedBrush } from "./types/ShadedBrush";
|
||||||
|
import { SketchyBrush } from "./types/SketchyBrush";
|
||||||
|
import { SpraypaintBrush } from "./types/SpraypaintBrush";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔刷管理器
|
||||||
|
* 负责管理和切换不同的笔刷类型
|
||||||
|
*/
|
||||||
|
export class BrushManager {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
* @param {Object} options.canvas fabric.js画布实例
|
||||||
|
* @param {Object} options.brushStore 笔刷数据存储(可选)
|
||||||
|
* @param {Object} options.layerManager 图层管理器实例(可选)
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.canvas = options.canvas;
|
||||||
|
this.brushStore = options.brushStore || BrushStore;
|
||||||
|
this.layerManager = options.layerManager; // 添加图层管理器引用
|
||||||
|
|
||||||
|
// 当前活动笔刷
|
||||||
|
this.activeBrush = null;
|
||||||
|
this.activeBrushId = null;
|
||||||
|
|
||||||
|
// 初始化笔刷注册
|
||||||
|
this._registerDefaultBrushes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册默认笔刷
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_registerDefaultBrushes() {
|
||||||
|
// 注册铅笔笔刷
|
||||||
|
brushRegistry.register("pencil", PencilBrush, {
|
||||||
|
name: "铅笔",
|
||||||
|
description: "基础铅笔工具,适合精细线条绘制",
|
||||||
|
category: "基础笔刷",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册材质笔刷
|
||||||
|
brushRegistry.register("texture", TextureBrush);
|
||||||
|
|
||||||
|
// 注册集成的笔刷类型
|
||||||
|
brushRegistry.register("crayon", CrayonBrush);
|
||||||
|
brushRegistry.register("fur", FurBrush);
|
||||||
|
brushRegistry.register("ink", InkBrush);
|
||||||
|
brushRegistry.register("longfur", LongfurBrush);
|
||||||
|
brushRegistry.register("writing", WritingBrush);
|
||||||
|
brushRegistry.register("marker", MarkerBrush);
|
||||||
|
brushRegistry.register("pen", CustomPenBrush);
|
||||||
|
brushRegistry.register("ribbon", RibbonBrush);
|
||||||
|
brushRegistry.register("shaded", ShadedBrush);
|
||||||
|
brushRegistry.register("sketchy", SketchyBrush);
|
||||||
|
brushRegistry.register("spraypaint", SpraypaintBrush);
|
||||||
|
|
||||||
|
// 注册喷枪笔刷
|
||||||
|
brushRegistry.register(
|
||||||
|
"spray",
|
||||||
|
class SprayBrush extends PencilBrush {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "spray",
|
||||||
|
name: "喷枪",
|
||||||
|
description: "模拟喷枪效果,创建散点效果",
|
||||||
|
category: "基础笔刷",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.brush = new fabric.SprayBrush(this.canvas);
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
super.configure(brush, options);
|
||||||
|
|
||||||
|
if (options.density !== undefined) {
|
||||||
|
brush.density = options.density;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.randomOpacity !== undefined) {
|
||||||
|
brush.randomOpacity = options.randomOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dotWidth !== undefined) {
|
||||||
|
brush.dotWidth = options.dotWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// 注册橡皮擦笔刷
|
||||||
|
brushRegistry.register(
|
||||||
|
"eraser",
|
||||||
|
class EraserBrush extends PencilBrush {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "eraser",
|
||||||
|
name: "橡皮擦",
|
||||||
|
description: "擦除已绘制的内容",
|
||||||
|
category: "工具",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
// 直接使用 fabric-with-erasing 库提供的 EraserBrush
|
||||||
|
this.brush = new fabric.EraserBrush(this.canvas);
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
// 配置橡皮擦特有属性
|
||||||
|
this.brush.inverted = this.options.inverted || false; // 是否反向擦除(恢复擦除)
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
super.configure(brush, options);
|
||||||
|
|
||||||
|
// 橡皮擦特有配置
|
||||||
|
if (options.inverted !== undefined) {
|
||||||
|
brush.inverted = options.inverted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置反向擦除模式
|
||||||
|
* @param {Boolean} inverted 是否启用反向擦除(撤销擦除效果)
|
||||||
|
*/
|
||||||
|
setInverted(inverted) {
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush.inverted = inverted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取橡皮擦配置属性
|
||||||
|
* @returns {Array} 可配置属性数组
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
return [
|
||||||
|
...super.getConfigurableProperties(),
|
||||||
|
{
|
||||||
|
id: "inverted",
|
||||||
|
name: "反向擦除",
|
||||||
|
type: "boolean",
|
||||||
|
description: "启用时可以恢复已擦除的内容",
|
||||||
|
defaultValue: false,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新橡皮擦属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
if (propId === "inverted") {
|
||||||
|
this.setInverted(value);
|
||||||
|
} else {
|
||||||
|
super.updateProperty(propId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 注册水彩笔刷
|
||||||
|
brushRegistry.register(
|
||||||
|
"watercolor",
|
||||||
|
class WatercolorBrush extends PencilBrush {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "watercolor",
|
||||||
|
name: "水彩",
|
||||||
|
description: "模拟水彩效果,带有流动感和透明感",
|
||||||
|
category: "特效笔刷",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
// 创建一个自定义的PencilBrush来模拟水彩效果
|
||||||
|
this.brush = new fabric.PencilBrush(this.canvas);
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
// 水彩效果特有的属性
|
||||||
|
this.brush.globalCompositeOperation = "multiply";
|
||||||
|
this.brush.shadow = new fabric.Shadow({
|
||||||
|
color: this.options.color || "#000",
|
||||||
|
blur: 5,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
super.configure(brush, options);
|
||||||
|
|
||||||
|
// 水彩笔刷特有的配置
|
||||||
|
brush.opacity = Math.min(0.5, options.opacity || 0.3); // 默认透明度30%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 注册粉笔笔刷
|
||||||
|
brushRegistry.register(
|
||||||
|
"chalk",
|
||||||
|
class ChalkBrush extends PencilBrush {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "chalk",
|
||||||
|
name: "粉笔",
|
||||||
|
description: "模拟粉笔效果,有颗粒感和不连续性",
|
||||||
|
category: "特效笔刷",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.brush = new fabric.PencilBrush(this.canvas);
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
// 自定义绘画方法来模拟粉笔效果
|
||||||
|
const originalOnMouseMove = this.brush.onMouseMove;
|
||||||
|
this.brush.onMouseMove = function (pointer, options) {
|
||||||
|
// 随机调整坐标位置,增加粉笔质感
|
||||||
|
const jitter = 2;
|
||||||
|
pointer.x += (Math.random() - 0.5) * jitter;
|
||||||
|
pointer.y += (Math.random() - 0.5) * jitter;
|
||||||
|
|
||||||
|
// 调用原始方法
|
||||||
|
originalOnMouseMove.call(this, pointer, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
super.configure(brush, options);
|
||||||
|
|
||||||
|
// 粉笔特有的设置
|
||||||
|
brush.strokeDashArray = [5, 5]; // 虚线效果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可用笔刷类型
|
||||||
|
* @returns {Array} 笔刷类型数组,包含id、name和description
|
||||||
|
*/
|
||||||
|
getBrushTypes() {
|
||||||
|
// 从注册表获取所有笔刷信息
|
||||||
|
const brushes = brushRegistry.getAllBrushes();
|
||||||
|
|
||||||
|
// 将笔刷信息转换为期望的格式
|
||||||
|
return brushes.map((brushInfo) => ({
|
||||||
|
id: brushInfo.id,
|
||||||
|
name: brushInfo.metadata.name || brushInfo.id,
|
||||||
|
description: brushInfo.metadata.description || "",
|
||||||
|
category: brushInfo.metadata.category || "默认",
|
||||||
|
icon: brushInfo.metadata.icon || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化笔刷列表并更新BrushStore
|
||||||
|
*/
|
||||||
|
initializeBrushes() {
|
||||||
|
// 获取所有笔刷
|
||||||
|
const allBrushes = this.getBrushTypes();
|
||||||
|
|
||||||
|
// 更新BrushStore中的可用笔刷列表
|
||||||
|
this.brushStore.setAvailableBrushes(allBrushes);
|
||||||
|
|
||||||
|
// 设置默认笔刷
|
||||||
|
if (!this.activeBrushId && allBrushes.length > 0) {
|
||||||
|
this.setBrushType(allBrushes[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置笔刷类型
|
||||||
|
* @param {String} brushId 笔刷ID
|
||||||
|
* @returns {Object|null} 设置的笔刷实例
|
||||||
|
*/
|
||||||
|
setBrushType(brushId) {
|
||||||
|
// 如果相同笔刷,不做处理
|
||||||
|
if (this.activeBrushId === brushId) {
|
||||||
|
return this.activeBrush;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁当前笔刷
|
||||||
|
if (this.activeBrush) {
|
||||||
|
// 调用生命周期方法
|
||||||
|
if (this.activeBrush.onDeselected) {
|
||||||
|
this.activeBrush.onDeselected();
|
||||||
|
}
|
||||||
|
this.activeBrush.destroy();
|
||||||
|
}
|
||||||
|
// 创建新笔刷实例
|
||||||
|
try {
|
||||||
|
const brushInstance = brushRegistry.createBrushInstance(
|
||||||
|
brushId,
|
||||||
|
this.canvas,
|
||||||
|
{
|
||||||
|
color: brushId === "eraser" ? this.brushStore.state.color : undefined,
|
||||||
|
width: this.brushStore.state.size,
|
||||||
|
opacity: this.brushStore.state.opacity,
|
||||||
|
|
||||||
|
// 材质笔刷特有配置
|
||||||
|
textureEnabled: this.brushStore.state.textureEnabled,
|
||||||
|
texturePath: this.brushStore.state.texturePath,
|
||||||
|
textureScale: this.brushStore.state.textureScale,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (brushInstance) {
|
||||||
|
// 创建笔刷
|
||||||
|
const fabricBrush = brushInstance.create();
|
||||||
|
|
||||||
|
// 更新画布的当前笔刷
|
||||||
|
if (fabricBrush) {
|
||||||
|
this.canvas.freeDrawingBrush = fabricBrush;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前笔刷引用
|
||||||
|
this.activeBrush = brushInstance;
|
||||||
|
this.activeBrushId = brushId;
|
||||||
|
|
||||||
|
// 调用生命周期方法
|
||||||
|
if (this.activeBrush.onSelected) {
|
||||||
|
this.activeBrush.onSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Store的笔刷类型
|
||||||
|
this.brushStore.setBrushType(brushId);
|
||||||
|
|
||||||
|
// 更新Store的当前笔刷实例,用于动态属性系统
|
||||||
|
this.brushStore.setCurrentBrushInstance(brushInstance);
|
||||||
|
|
||||||
|
return brushInstance;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`创建笔刷 ${brushId} 失败:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置笔刷颜色
|
||||||
|
* @param {String} color 十六进制颜色值
|
||||||
|
*/
|
||||||
|
setBrushColor(color) {
|
||||||
|
if (!this.canvas.freeDrawingBrush) return;
|
||||||
|
|
||||||
|
// 更新笔刷颜色
|
||||||
|
this.canvas.freeDrawingBrush.color = color;
|
||||||
|
|
||||||
|
// 更新活动笔刷
|
||||||
|
if (this.activeBrush) {
|
||||||
|
this.activeBrush.configure(this.canvas.freeDrawingBrush, { color });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Store
|
||||||
|
this.brushStore.setBrushColor(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置笔刷大小
|
||||||
|
* @param {Number} size 笔刷大小
|
||||||
|
*/
|
||||||
|
setBrushSize(size) {
|
||||||
|
if (!this.canvas.freeDrawingBrush) return;
|
||||||
|
|
||||||
|
// 限制大小范围
|
||||||
|
const brushSize = Math.max(0.1, Math.min(100, size));
|
||||||
|
|
||||||
|
// 更新笔刷大小
|
||||||
|
this.canvas.freeDrawingBrush.width = brushSize;
|
||||||
|
|
||||||
|
// 更新活动笔刷
|
||||||
|
if (this.activeBrush) {
|
||||||
|
this.activeBrush.configure(this.canvas.freeDrawingBrush, {
|
||||||
|
width: brushSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Store
|
||||||
|
this.brushStore.setBrushSize(brushSize);
|
||||||
|
|
||||||
|
return brushSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加笔刷大小
|
||||||
|
* @param {Number} amount 增加量
|
||||||
|
* @returns {Number} 新的笔刷大小
|
||||||
|
*/
|
||||||
|
increaseBrushSize(amount = 1) {
|
||||||
|
const currentSize = this.brushStore.state.size;
|
||||||
|
return this.setBrushSize(currentSize + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减少笔刷大小
|
||||||
|
* @param {Number} amount 减少量
|
||||||
|
* @returns {Number} 新的笔刷大小
|
||||||
|
*/
|
||||||
|
decreaseBrushSize(amount = 1) {
|
||||||
|
const currentSize = this.brushStore.state.size;
|
||||||
|
return this.setBrushSize(currentSize - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加笔刷透明度
|
||||||
|
* @param {Number} amount 增加量
|
||||||
|
* @returns {Number} 新的笔刷大小
|
||||||
|
*/
|
||||||
|
increaseBrushOpacity(amount = 0.01) {
|
||||||
|
const currentSize = this.brushStore.state.opacity;
|
||||||
|
return this.setBrushOpacity(currentSize + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减少笔刷大小
|
||||||
|
* @param {Number} amount 减少量
|
||||||
|
* @returns {Number} 新的笔刷大小
|
||||||
|
*/
|
||||||
|
decreaseBrushOpacity(amount = 0.01) {
|
||||||
|
const currentSize = this.brushStore.state.opacity;
|
||||||
|
return this.setBrushOpacity(currentSize - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置笔刷透明度
|
||||||
|
* @param {Number} opacity 透明度 (0-1)
|
||||||
|
*/
|
||||||
|
setBrushOpacity(opacity) {
|
||||||
|
if (!this.canvas.freeDrawingBrush) return;
|
||||||
|
|
||||||
|
// 限制透明度范围
|
||||||
|
const brushOpacity = Math.max(0.05, Math.min(1, opacity));
|
||||||
|
|
||||||
|
// 更新笔刷透明度
|
||||||
|
this.canvas.freeDrawingBrush.opacity = brushOpacity;
|
||||||
|
|
||||||
|
// 更新活动笔刷
|
||||||
|
if (this.activeBrush) {
|
||||||
|
this.activeBrush.configure(this.canvas.freeDrawingBrush, {
|
||||||
|
opacity: brushOpacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Store
|
||||||
|
this.brushStore.setBrushOpacity(brushOpacity);
|
||||||
|
|
||||||
|
return brushOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置材质缩放
|
||||||
|
* @param {Number} scale 缩放比例
|
||||||
|
*/
|
||||||
|
setTextureScale(scale) {
|
||||||
|
// 限制缩放范围
|
||||||
|
const textureScale = Math.max(0.1, Math.min(10, scale));
|
||||||
|
|
||||||
|
// 更新活动笔刷
|
||||||
|
if (this.activeBrush && this.activeBrush.setTextureScale) {
|
||||||
|
this.activeBrush.setTextureScale(textureScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Store
|
||||||
|
this.brushStore.setTextureScale(textureScale);
|
||||||
|
|
||||||
|
return textureScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加材质缩放
|
||||||
|
* @param {Number} amount 增加量
|
||||||
|
*/
|
||||||
|
increaseTextureScale(amount = 0.1) {
|
||||||
|
const currentScale = this.brushStore.state.textureScale;
|
||||||
|
return this.setTextureScale(currentScale + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减少材质缩放
|
||||||
|
* @param {Number} amount 减少量
|
||||||
|
*/
|
||||||
|
decreaseTextureScale(amount = 0.1) {
|
||||||
|
const currentScale = this.brushStore.state.textureScale;
|
||||||
|
return this.setTextureScale(currentScale - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置材质路径
|
||||||
|
* @param {String} path 材质图片路径
|
||||||
|
*/
|
||||||
|
setTexturePath(path) {
|
||||||
|
// 更新活动笔刷
|
||||||
|
if (this.activeBrush && this.activeBrush.setTexturePath) {
|
||||||
|
this.activeBrush.setTexturePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Store
|
||||||
|
this.brushStore.setTexturePath(path);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用/禁用材质
|
||||||
|
* @param {Boolean} enabled 是否启用
|
||||||
|
*/
|
||||||
|
setTextureEnabled(enabled) {
|
||||||
|
// 更新Store
|
||||||
|
this.brushStore.setTextureEnabled(enabled);
|
||||||
|
|
||||||
|
// 如果启用材质,且当前不是材质笔刷,需要切换
|
||||||
|
if (enabled && this.activeBrushId !== "texture") {
|
||||||
|
this.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册新笔刷
|
||||||
|
* @param {String} id 笔刷ID
|
||||||
|
* @param {Class} brushClass 笔刷类
|
||||||
|
* @param {Object} metadata 笔刷元数据
|
||||||
|
* @returns {Boolean} 是否注册成功
|
||||||
|
*/
|
||||||
|
registerBrush(id, brushClass, metadata = {}) {
|
||||||
|
const success = brushRegistry.register(id, brushClass, metadata);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 更新可用笔刷列表
|
||||||
|
this.initializeBrushes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前笔刷类型
|
||||||
|
* @returns {String} 当前笔刷类型ID
|
||||||
|
*/
|
||||||
|
getCurrentBrushType() {
|
||||||
|
return this.activeBrushId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前笔刷大小
|
||||||
|
* @returns {Number} 当前笔刷大小
|
||||||
|
*/
|
||||||
|
getBrushSize() {
|
||||||
|
return this.brushStore.state.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前笔刷颜色
|
||||||
|
* @returns {String} 当前笔刷颜色
|
||||||
|
*/
|
||||||
|
getBrushColor() {
|
||||||
|
return this.brushStore.state.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前笔刷透明度
|
||||||
|
* @returns {Number} 当前笔刷透明度
|
||||||
|
*/
|
||||||
|
getBrushOpacity() {
|
||||||
|
return this.brushStore.state.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取材质缩放
|
||||||
|
* @returns {Number} 材质缩放比例
|
||||||
|
*/
|
||||||
|
getTextureScale() {
|
||||||
|
return this.brushStore.state.textureScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建材质笔刷
|
||||||
|
* 这个方法保留用于向下兼容
|
||||||
|
* @deprecated 请使用setBrushType('texture')代替
|
||||||
|
* @returns {Object} 材质笔刷实例
|
||||||
|
*/
|
||||||
|
createTextureBrush() {
|
||||||
|
console.warn('createTextureBrush方法已废弃,请使用setBrushType("texture")');
|
||||||
|
return this.setBrushType("texture");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷
|
||||||
|
* 根据当前设置应用笔刷属性到画布
|
||||||
|
*/
|
||||||
|
updateBrush() {
|
||||||
|
if (!this.canvas) return;
|
||||||
|
|
||||||
|
// 如果有活动的笔刷实例,重新配置它
|
||||||
|
if (this.activeBrush && this.canvas.freeDrawingBrush) {
|
||||||
|
this.activeBrush.configure(this.canvas.freeDrawingBrush, {
|
||||||
|
color: this.brushStore.state.color,
|
||||||
|
width: this.brushStore.state.size,
|
||||||
|
opacity: this.brushStore.state.opacity,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果没有活动笔刷,创建一个默认的
|
||||||
|
this.setBrushType(this.activeBrushId || "pencil");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新画布状态
|
||||||
|
this?.canvas?.renderAll?.();
|
||||||
|
|
||||||
|
return this.canvas.freeDrawingBrush;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建橡皮擦
|
||||||
|
* @returns {Object} 橡皮擦笔刷
|
||||||
|
*/
|
||||||
|
createEraser() {
|
||||||
|
return this.setBrushType("eraser");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建吸色工具
|
||||||
|
* @param {Function} callback 选择颜色后的回调函数
|
||||||
|
*/
|
||||||
|
createEyedropper(callback) {
|
||||||
|
// 保存当前状态
|
||||||
|
const previousBrushId = this.activeBrushId;
|
||||||
|
|
||||||
|
// 一次性事件处理程序
|
||||||
|
const handleMouseDown = (event) => {
|
||||||
|
const pointer = this.canvas.getPointer(event.e);
|
||||||
|
const ctx = this.canvas.getContext();
|
||||||
|
|
||||||
|
// 获取点击位置的像素
|
||||||
|
const imageData = ctx.getImageData(pointer.x, pointer.y, 1, 1).data;
|
||||||
|
|
||||||
|
// 将RGB转换为十六进制颜色
|
||||||
|
const color = `#${(
|
||||||
|
(1 << 24) +
|
||||||
|
(imageData[0] << 16) +
|
||||||
|
(imageData[1] << 8) +
|
||||||
|
imageData[2]
|
||||||
|
)
|
||||||
|
.toString(16)
|
||||||
|
.slice(1)}`;
|
||||||
|
|
||||||
|
// 调用回调函数
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
callback(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复之前的笔刷
|
||||||
|
this.setBrushType(previousBrushId);
|
||||||
|
|
||||||
|
// 移除事件监听器
|
||||||
|
this.canvas.off("mouse:down", handleMouseDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加事件监听器
|
||||||
|
this.canvas.on("mouse:down", handleMouseDown);
|
||||||
|
|
||||||
|
// 设置吸色光标
|
||||||
|
this.canvas.defaultCursor = "crosshair";
|
||||||
|
|
||||||
|
console.log("吸色工具已激活,点击画布选择颜色");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁资源
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
// 销毁当前笔刷
|
||||||
|
if (this.activeBrush) {
|
||||||
|
this.activeBrush.destroy();
|
||||||
|
this.activeBrush = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export default BrushManager;
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { BaseBrush } from "../BaseBrush";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 蜡笔笔刷
|
||||||
|
* 模拟蜡笔效果,具有颗粒感和纹理
|
||||||
|
*/
|
||||||
|
export class CrayonBrush extends BaseBrush {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} canvas fabric画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "crayon",
|
||||||
|
name: "蜡笔",
|
||||||
|
description: "模拟蜡笔效果,具有颗粒感和纹理",
|
||||||
|
category: "特效笔刷",
|
||||||
|
icon: "crayon",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 蜡笔笔刷特有属性
|
||||||
|
this._baseWidth = options._baseWidth || 15;
|
||||||
|
this._size = options._size || 0;
|
||||||
|
this._sep = options._sep || options._sep === 0 ? options._sep : 3;
|
||||||
|
this._inkAmount = options._inkAmount || 10;
|
||||||
|
this.randomness = options.randomness || 0.5; // 随机性
|
||||||
|
this.texture = options.texture || "default"; // 纹理类型
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建笔刷实例
|
||||||
|
* @returns {Object} fabric笔刷实例
|
||||||
|
*/
|
||||||
|
create() {
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error("画布实例不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建fabric原生蜡笔笔刷
|
||||||
|
this.brush = new fabric.CrayonBrush(this.canvas);
|
||||||
|
|
||||||
|
// 配置笔刷
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置笔刷
|
||||||
|
* @param {Object} brush fabric笔刷实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
if (!brush) return;
|
||||||
|
|
||||||
|
// 基础属性配置
|
||||||
|
if (options.width !== undefined) {
|
||||||
|
brush.width = options.width;
|
||||||
|
// 更新笔刷相关属性
|
||||||
|
this._baseWidth = options.width / 2;
|
||||||
|
this._size = options.width / 2 + this._baseWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.color !== undefined) {
|
||||||
|
brush.color = options.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.opacity !== undefined) {
|
||||||
|
brush.opacity = options.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 蜡笔笔刷特有属性
|
||||||
|
if (options._baseWidth !== undefined) {
|
||||||
|
brush._baseWidth = options._baseWidth;
|
||||||
|
this._baseWidth = options._baseWidth;
|
||||||
|
this._size = this.width / 2 + this._baseWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options._sep !== undefined) {
|
||||||
|
brush._sep = options._sep;
|
||||||
|
this._sep = options._sep;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options._inkAmount !== undefined) {
|
||||||
|
brush._inkAmount = options._inkAmount;
|
||||||
|
this._inkAmount = options._inkAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置颗粒分离度
|
||||||
|
* @param {Number} sep 分离度值
|
||||||
|
*/
|
||||||
|
setSeparation(sep) {
|
||||||
|
this._sep = Math.max(0.5, Math.min(10, sep));
|
||||||
|
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush._sep = this._sep;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._sep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置墨量
|
||||||
|
* @param {Number} amount 墨量值
|
||||||
|
*/
|
||||||
|
setInkAmount(amount) {
|
||||||
|
this._inkAmount = Math.max(1, Math.min(50, amount));
|
||||||
|
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush._inkAmount = this._inkAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._inkAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置随机性
|
||||||
|
* @param {Number} value 随机性值(0-1)
|
||||||
|
*/
|
||||||
|
setRandomness(value) {
|
||||||
|
this.randomness = Math.max(0, Math.min(1, value));
|
||||||
|
return this.randomness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置纹理类型
|
||||||
|
* @param {String} type 纹理类型
|
||||||
|
*/
|
||||||
|
setTexture(type) {
|
||||||
|
this.texture = type;
|
||||||
|
// 实际应用可能需要更多的实现逻辑
|
||||||
|
return this.texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷可配置属性
|
||||||
|
* @returns {Array} 可配置属性描述数组
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
// 获取基础属性
|
||||||
|
const baseProperties = super.getConfigurableProperties();
|
||||||
|
|
||||||
|
// 定义蜡笔笔刷特有属性
|
||||||
|
const crayonProperties = [
|
||||||
|
{
|
||||||
|
id: "separation",
|
||||||
|
name: "颗粒分离度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this._sep,
|
||||||
|
min: 0.5,
|
||||||
|
max: 10,
|
||||||
|
step: 0.5,
|
||||||
|
description: "控制蜡笔颗粒的分离程度",
|
||||||
|
category: "蜡笔设置",
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inkAmount",
|
||||||
|
name: "墨量",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this._inkAmount,
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
step: 1,
|
||||||
|
description: "控制蜡笔的颜料量",
|
||||||
|
category: "蜡笔设置",
|
||||||
|
order: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "randomness",
|
||||||
|
name: "随机性",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.randomness,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制蜡笔纹理的随机程度",
|
||||||
|
category: "蜡笔设置",
|
||||||
|
order: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "texture",
|
||||||
|
name: "纹理类型",
|
||||||
|
type: "select",
|
||||||
|
defaultValue: this.texture,
|
||||||
|
options: [
|
||||||
|
{ value: "default", label: "默认" },
|
||||||
|
{ value: "rough", label: "粗糙" },
|
||||||
|
{ value: "smooth", label: "平滑" },
|
||||||
|
],
|
||||||
|
description: "设置蜡笔的纹理类型",
|
||||||
|
category: "蜡笔设置",
|
||||||
|
order: 130,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合并并返回所有属性
|
||||||
|
return [...baseProperties, ...crayonProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
// 先检查基类能否处理此属性
|
||||||
|
if (super.updateProperty(propId, value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理蜡笔笔刷特有属性
|
||||||
|
if (propId === "separation") {
|
||||||
|
this.setSeparation(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "inkAmount") {
|
||||||
|
this.setInkAmount(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "randomness") {
|
||||||
|
this.setRandomness(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "texture") {
|
||||||
|
this.setTexture(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览图
|
||||||
|
* @returns {String} 预览图URL
|
||||||
|
*/
|
||||||
|
getPreview() {
|
||||||
|
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSI4MCIgaGVpZ2h0PSI4MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiLz48cmVjdCB4PSIyMCIgeT0iMjAiIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiLz48cmVjdCB4PSIzMCIgeT0iMzAiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4=";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { BaseBrush } from "../BaseBrush";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钢笔笔刷
|
||||||
|
* 模拟钢笔效果,具有变化的透明度
|
||||||
|
*/
|
||||||
|
export class CustomPenBrush extends BaseBrush {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} canvas fabric画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "pen",
|
||||||
|
name: "钢笔",
|
||||||
|
description: "模拟钢笔效果,具有变化的透明度",
|
||||||
|
category: "基础笔刷",
|
||||||
|
icon: "pen",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 钢笔笔刷特有属性
|
||||||
|
this._baseWidth = options._baseWidth || 15;
|
||||||
|
this._lineWidth = options._lineWidth || 2;
|
||||||
|
this.inkOpacityMin = options.inkOpacityMin || 0.2;
|
||||||
|
this.inkOpacityMax = options.inkOpacityMax || 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建笔刷实例
|
||||||
|
* @returns {Object} fabric笔刷实例
|
||||||
|
*/
|
||||||
|
create() {
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error("画布实例不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建fabric原生钢笔笔刷
|
||||||
|
this.brush = new fabric.PenBrush(this.canvas);
|
||||||
|
|
||||||
|
// 配置笔刷
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置笔刷
|
||||||
|
* @param {Object} brush fabric笔刷实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
if (!brush) return;
|
||||||
|
|
||||||
|
// 基础属性配置
|
||||||
|
if (options.width !== undefined) {
|
||||||
|
brush.width = options.width;
|
||||||
|
// 更新笔刷相关属性
|
||||||
|
this._baseWidth = options.width / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.color !== undefined) {
|
||||||
|
brush.color = options.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.opacity !== undefined) {
|
||||||
|
brush.opacity = options.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钢笔笔刷特有属性
|
||||||
|
if (options._baseWidth !== undefined) {
|
||||||
|
brush._baseWidth = options._baseWidth;
|
||||||
|
this._baseWidth = options._baseWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options._lineWidth !== undefined) {
|
||||||
|
brush._lineWidth = options._lineWidth;
|
||||||
|
this._lineWidth = options._lineWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保线条连接设置正确
|
||||||
|
brush.canvas.contextTop.lineJoin = "round";
|
||||||
|
brush.canvas.contextTop.lineCap = "round";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置最小墨水透明度
|
||||||
|
* @param {Number} opacity 透明度值(0-1)
|
||||||
|
*/
|
||||||
|
setInkOpacityMin(opacity) {
|
||||||
|
this.inkOpacityMin = Math.max(0.1, Math.min(0.5, opacity));
|
||||||
|
return this.inkOpacityMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置最大墨水透明度
|
||||||
|
* @param {Number} opacity 透明度值(0-1)
|
||||||
|
*/
|
||||||
|
setInkOpacityMax(opacity) {
|
||||||
|
this.inkOpacityMax = Math.max(0.3, Math.min(1, opacity));
|
||||||
|
return this.inkOpacityMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷可配置属性
|
||||||
|
* @returns {Array} 可配置属性描述数组
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
// 获取基础属性
|
||||||
|
const baseProperties = super.getConfigurableProperties();
|
||||||
|
|
||||||
|
// 定义钢笔笔刷特有属性
|
||||||
|
const penProperties = [
|
||||||
|
{
|
||||||
|
id: "lineWidth",
|
||||||
|
name: "线条宽度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this._lineWidth,
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
step: 0.5,
|
||||||
|
description: "控制钢笔线条的宽度",
|
||||||
|
category: "钢笔设置",
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inkOpacityMin",
|
||||||
|
name: "最小墨水透明度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.inkOpacityMin,
|
||||||
|
min: 0.1,
|
||||||
|
max: 0.5,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制钢笔墨水最小透明度",
|
||||||
|
category: "钢笔设置",
|
||||||
|
order: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inkOpacityMax",
|
||||||
|
name: "最大墨水透明度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.inkOpacityMax,
|
||||||
|
min: 0.3,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制钢笔墨水最大透明度",
|
||||||
|
category: "钢笔设置",
|
||||||
|
order: 120,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合并并返回所有属性
|
||||||
|
return [...baseProperties, ...penProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
// 先检查基类能否处理此属性
|
||||||
|
if (super.updateProperty(propId, value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理钢笔笔刷特有属性
|
||||||
|
if (propId === "lineWidth") {
|
||||||
|
this._lineWidth = value;
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush._lineWidth = value;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else if (propId === "inkOpacityMin") {
|
||||||
|
this.setInkOpacityMin(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "inkOpacityMax") {
|
||||||
|
this.setInkOpacityMax(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览图
|
||||||
|
* @returns {String} 预览图URL
|
||||||
|
*/
|
||||||
|
getPreview() {
|
||||||
|
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjAgMjBMODAgODBNMjAgODBMODAgMjAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { BaseBrush } from "../BaseBrush";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 毛发笔刷
|
||||||
|
* 创建类似于毛发或草的效果
|
||||||
|
*/
|
||||||
|
export class FurBrush extends BaseBrush {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} canvas fabric画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "fur",
|
||||||
|
name: "毛发笔刷",
|
||||||
|
description: "创建类似于毛发或草的纹理效果",
|
||||||
|
category: "特效笔刷",
|
||||||
|
icon: "fur",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 毛发笔刷特有属性
|
||||||
|
this.furLength = options.furLength || 10;
|
||||||
|
this.furDensity = options.furDensity || 0.7;
|
||||||
|
this.furRandomness = options.furRandomness || 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建笔刷实例
|
||||||
|
* @returns {Object} fabric笔刷实例
|
||||||
|
*/
|
||||||
|
create() {
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error("画布实例不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建fabric原生毛发笔刷
|
||||||
|
this.brush = new fabric.FurBrush(this.canvas);
|
||||||
|
|
||||||
|
// 配置笔刷
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置笔刷
|
||||||
|
* @param {Object} brush fabric笔刷实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
if (!brush) return;
|
||||||
|
|
||||||
|
// 基础属性配置
|
||||||
|
if (options.width !== undefined) {
|
||||||
|
brush.width = options.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.color !== undefined) {
|
||||||
|
brush.color = options.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.opacity !== undefined) {
|
||||||
|
brush.opacity = options.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里可以添加对毛发笔刷特有属性的配置
|
||||||
|
// 由于fabric.FurBrush的原始实现可能没有直接暴露这些属性,
|
||||||
|
// 我们可能需要在onMouseMove等事件中动态调整行为
|
||||||
|
|
||||||
|
// 存储特有属性,供后续使用
|
||||||
|
if (options.furLength !== undefined) {
|
||||||
|
this.furLength = options.furLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.furDensity !== undefined) {
|
||||||
|
this.furDensity = options.furDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.furRandomness !== undefined) {
|
||||||
|
this.furRandomness = options.furRandomness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发长度
|
||||||
|
* @param {Number} length 长度值
|
||||||
|
*/
|
||||||
|
setFurLength(length) {
|
||||||
|
this.furLength = Math.max(1, Math.min(50, length));
|
||||||
|
return this.furLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发密度
|
||||||
|
* @param {Number} density 密度值(0-1)
|
||||||
|
*/
|
||||||
|
setFurDensity(density) {
|
||||||
|
this.furDensity = Math.max(0.1, Math.min(1, density));
|
||||||
|
return this.furDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发随机性
|
||||||
|
* @param {Number} randomness 随机性值(0-1)
|
||||||
|
*/
|
||||||
|
setFurRandomness(randomness) {
|
||||||
|
this.furRandomness = Math.max(0, Math.min(1, randomness));
|
||||||
|
return this.furRandomness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷可配置属性
|
||||||
|
* @returns {Array} 可配置属性描述数组
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
// 获取基础属性
|
||||||
|
const baseProperties = super.getConfigurableProperties();
|
||||||
|
|
||||||
|
// 定义毛发笔刷特有属性
|
||||||
|
const furProperties = [
|
||||||
|
{
|
||||||
|
id: "furLength",
|
||||||
|
name: "毛发长度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furLength,
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
step: 1,
|
||||||
|
description: "控制毛发的长度",
|
||||||
|
category: "毛发设置",
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "furDensity",
|
||||||
|
name: "毛发密度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furDensity,
|
||||||
|
min: 0.1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制毛发的密度",
|
||||||
|
category: "毛发设置",
|
||||||
|
order: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "furRandomness",
|
||||||
|
name: "随机性",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furRandomness,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制毛发的随机分布程度",
|
||||||
|
category: "毛发设置",
|
||||||
|
order: 120,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合并并返回所有属性
|
||||||
|
return [...baseProperties, ...furProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
// 先检查基类能否处理此属性
|
||||||
|
if (super.updateProperty(propId, value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理毛发笔刷特有属性
|
||||||
|
if (propId === "furLength") {
|
||||||
|
this.setFurLength(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "furDensity") {
|
||||||
|
this.setFurDensity(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "furRandomness") {
|
||||||
|
this.setFurRandomness(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览图
|
||||||
|
* @returns {String} 预览图URL
|
||||||
|
*/
|
||||||
|
getPreview() {
|
||||||
|
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMTAgODBMNTAgMjBNMjAgODBMNjAgMjBNMzAgODBMNzAgMjBNNDAgODBMODAgMjBNNTAgODBMOTAgMjAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIi8+PC9zdmc+";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { BaseBrush } from "../BaseBrush";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 水墨笔刷
|
||||||
|
* 模拟中国传统水墨画效果
|
||||||
|
*/
|
||||||
|
export class InkBrush extends BaseBrush {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} canvas fabric画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "ink",
|
||||||
|
name: "水墨笔刷",
|
||||||
|
description: "模拟中国传统水墨画效果,墨色深浅不一",
|
||||||
|
category: "特效笔刷",
|
||||||
|
icon: "ink",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 水墨笔刷特有属性
|
||||||
|
this._baseWidth = options._baseWidth || 15;
|
||||||
|
this._inkAmount = options._inkAmount || 7;
|
||||||
|
this._range = options._range || 10;
|
||||||
|
this.splashEnabled =
|
||||||
|
options.splashEnabled !== undefined ? options.splashEnabled : true;
|
||||||
|
this.splashSize = options.splashSize || 5;
|
||||||
|
this.splashDistance = options.splashDistance || 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建笔刷实例
|
||||||
|
* @returns {Object} fabric笔刷实例
|
||||||
|
*/
|
||||||
|
create() {
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error("画布实例不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建fabric原生水墨笔刷
|
||||||
|
this.brush = new fabric.InkBrush(this.canvas);
|
||||||
|
|
||||||
|
// 配置笔刷
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置笔刷
|
||||||
|
* @param {Object} brush fabric笔刷实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
if (!brush) return;
|
||||||
|
|
||||||
|
// 基础属性配置
|
||||||
|
if (options.width !== undefined) {
|
||||||
|
brush.width = options.width;
|
||||||
|
this._baseWidth = options.width / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.color !== undefined) {
|
||||||
|
brush.color = options.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.opacity !== undefined) {
|
||||||
|
brush.opacity = options.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 水墨笔刷特有属性
|
||||||
|
if (options._inkAmount !== undefined) {
|
||||||
|
brush._inkAmount = options._inkAmount;
|
||||||
|
this._inkAmount = options._inkAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options._range !== undefined) {
|
||||||
|
brush._range = options._range;
|
||||||
|
this._range = options._range;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新溅墨相关配置(这需要修改原始InkBrush的drawSplash方法)
|
||||||
|
if (this.splashEnabled !== undefined) {
|
||||||
|
// 由于原始InkBrush没有直接暴露这个配置,我们可能需要覆盖方法
|
||||||
|
// 这里仅保存配置,实际逻辑需要在brush创建后处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置墨量
|
||||||
|
* @param {Number} amount 墨量值
|
||||||
|
*/
|
||||||
|
setInkAmount(amount) {
|
||||||
|
this._inkAmount = Math.max(1, Math.min(20, amount));
|
||||||
|
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush._inkAmount = this._inkAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._inkAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置笔触范围
|
||||||
|
* @param {Number} range 范围值
|
||||||
|
*/
|
||||||
|
setRange(range) {
|
||||||
|
this._range = Math.max(5, Math.min(50, range));
|
||||||
|
|
||||||
|
if (this.brush) {
|
||||||
|
this.brush._range = this._range;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._range;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用/禁用溅墨效果
|
||||||
|
* @param {Boolean} enabled 是否启用
|
||||||
|
*/
|
||||||
|
setSplashEnabled(enabled) {
|
||||||
|
this.splashEnabled = enabled;
|
||||||
|
|
||||||
|
// 实际应用需要更多的逻辑来支持这个功能
|
||||||
|
// 由于需要修改fabric.InkBrush的内部行为
|
||||||
|
|
||||||
|
return this.splashEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置溅墨大小
|
||||||
|
* @param {Number} size 大小值
|
||||||
|
*/
|
||||||
|
setSplashSize(size) {
|
||||||
|
this.splashSize = Math.max(1, Math.min(20, size));
|
||||||
|
return this.splashSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置溅墨距离
|
||||||
|
* @param {Number} distance 距离值
|
||||||
|
*/
|
||||||
|
setSplashDistance(distance) {
|
||||||
|
this.splashDistance = Math.max(10, Math.min(100, distance));
|
||||||
|
return this.splashDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷可配置属性
|
||||||
|
* @returns {Array} 可配置属性描述数组
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
// 获取基础属性
|
||||||
|
const baseProperties = super.getConfigurableProperties();
|
||||||
|
|
||||||
|
// 定义水墨笔刷特有属性
|
||||||
|
const inkProperties = [
|
||||||
|
{
|
||||||
|
id: "inkAmount",
|
||||||
|
name: "墨量",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this._inkAmount,
|
||||||
|
min: 1,
|
||||||
|
max: 20,
|
||||||
|
step: 1,
|
||||||
|
description: "控制水墨的浓度",
|
||||||
|
category: "水墨设置",
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "range",
|
||||||
|
name: "笔触范围",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this._range,
|
||||||
|
min: 5,
|
||||||
|
max: 50,
|
||||||
|
step: 1,
|
||||||
|
description: "控制水墨扩散的范围",
|
||||||
|
category: "水墨设置",
|
||||||
|
order: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "splashEnabled",
|
||||||
|
name: "溅墨效果",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: this.splashEnabled,
|
||||||
|
description: "是否启用溅墨效果",
|
||||||
|
category: "水墨设置",
|
||||||
|
order: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "splashSize",
|
||||||
|
name: "溅墨大小",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.splashSize,
|
||||||
|
min: 1,
|
||||||
|
max: 20,
|
||||||
|
step: 1,
|
||||||
|
description: "溅墨点的大小",
|
||||||
|
category: "水墨设置",
|
||||||
|
order: 130,
|
||||||
|
visibleWhen: { splashEnabled: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "splashDistance",
|
||||||
|
name: "溅墨距离",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.splashDistance,
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
description: "溅墨可扩散的最大距离",
|
||||||
|
category: "水墨设置",
|
||||||
|
order: 140,
|
||||||
|
visibleWhen: { splashEnabled: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合并并返回所有属性
|
||||||
|
return [...baseProperties, ...inkProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
// 先检查基类能否处理此属性
|
||||||
|
if (super.updateProperty(propId, value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理水墨笔刷特有属性
|
||||||
|
if (propId === "inkAmount") {
|
||||||
|
this.setInkAmount(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "range") {
|
||||||
|
this.setRange(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "splashEnabled") {
|
||||||
|
this.setSplashEnabled(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "splashSize") {
|
||||||
|
this.setSplashSize(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "splashDistance") {
|
||||||
|
this.setSplashDistance(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览图
|
||||||
|
* @returns {String} 预览图URL
|
||||||
|
*/
|
||||||
|
getPreview() {
|
||||||
|
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjAgODBDNDAgNjAgNjAgNDAgODAgMjAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIGZpbGw9Im5vbmUiLz48Y2lyY2xlIGN4PSI3MCIgY3k9IjMwIiByPSI1IiBmaWxsPSIjMDAwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iMyIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjMwIiBjeT0iNzAiIHI9IjYiIGZpbGw9IiMwMDAiLz48L3N2Zz4=";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { BaseBrush } from "../BaseBrush";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 长毛发笔刷
|
||||||
|
* 创建类似于长毛、毛皮、草或头发的效果
|
||||||
|
*/
|
||||||
|
export class LongfurBrush extends BaseBrush {
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param {Object} canvas fabric画布实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
super(canvas, {
|
||||||
|
id: "longfur",
|
||||||
|
name: "长毛发",
|
||||||
|
description: "创建流动的长毛发效果,适合绘制动物毛皮、草或头发",
|
||||||
|
category: "特效笔刷",
|
||||||
|
icon: "longfur",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 长毛发笔刷特有属性
|
||||||
|
this.furLength = options.furLength || 20;
|
||||||
|
this.furDensity = options.furDensity || 0.7;
|
||||||
|
this.furFlowFactor = options.furFlowFactor || 0.5;
|
||||||
|
this.furCurvature = options.furCurvature || 0.3;
|
||||||
|
this.randomizeDirection =
|
||||||
|
options.randomizeDirection !== undefined
|
||||||
|
? options.randomizeDirection
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建笔刷实例
|
||||||
|
* @returns {Object} fabric笔刷实例
|
||||||
|
*/
|
||||||
|
create() {
|
||||||
|
if (!this.canvas) {
|
||||||
|
throw new Error("画布实例不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建fabric原生长毛发笔刷
|
||||||
|
this.brush = new fabric.LongfurBrush(this.canvas);
|
||||||
|
|
||||||
|
// 配置笔刷
|
||||||
|
this.configure(this.brush, this.options);
|
||||||
|
|
||||||
|
return this.brush;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置笔刷
|
||||||
|
* @param {Object} brush fabric笔刷实例
|
||||||
|
* @param {Object} options 配置选项
|
||||||
|
*/
|
||||||
|
configure(brush, options = {}) {
|
||||||
|
if (!brush) return;
|
||||||
|
|
||||||
|
// 基础属性配置
|
||||||
|
if (options.width !== undefined) {
|
||||||
|
brush.width = options.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.color !== undefined) {
|
||||||
|
brush.color = options.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.opacity !== undefined) {
|
||||||
|
brush.opacity = options.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长毛发笔刷特有属性
|
||||||
|
if (options.furLength !== undefined) {
|
||||||
|
this.furLength = options.furLength;
|
||||||
|
|
||||||
|
// 如果原生笔刷支持此属性,则设置
|
||||||
|
if (brush.furLength !== undefined) {
|
||||||
|
brush.furLength = this.furLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.furDensity !== undefined) {
|
||||||
|
this.furDensity = options.furDensity;
|
||||||
|
|
||||||
|
// 如果原生笔刷支持此属性,则设置
|
||||||
|
if (brush.furDensity !== undefined) {
|
||||||
|
brush.furDensity = this.furDensity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.furFlowFactor !== undefined) {
|
||||||
|
this.furFlowFactor = options.furFlowFactor;
|
||||||
|
|
||||||
|
// 如果原生笔刷支持此属性,则设置
|
||||||
|
if (brush.furFlowFactor !== undefined) {
|
||||||
|
brush.furFlowFactor = this.furFlowFactor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.furCurvature !== undefined) {
|
||||||
|
this.furCurvature = options.furCurvature;
|
||||||
|
|
||||||
|
// 如果原生笔刷支持此属性,则设置
|
||||||
|
if (brush.furCurvature !== undefined) {
|
||||||
|
brush.furCurvature = this.furCurvature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.randomizeDirection !== undefined) {
|
||||||
|
this.randomizeDirection = options.randomizeDirection;
|
||||||
|
|
||||||
|
// 如果原生笔刷支持此属性,则设置
|
||||||
|
if (brush.randomizeDirection !== undefined) {
|
||||||
|
brush.randomizeDirection = this.randomizeDirection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发长度
|
||||||
|
* @param {Number} length 长度值
|
||||||
|
*/
|
||||||
|
setFurLength(length) {
|
||||||
|
this.furLength = Math.max(5, Math.min(100, length));
|
||||||
|
|
||||||
|
if (this.brush && this.brush.furLength !== undefined) {
|
||||||
|
this.brush.furLength = this.furLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.furLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发密度
|
||||||
|
* @param {Number} density 密度值(0-1)
|
||||||
|
*/
|
||||||
|
setFurDensity(density) {
|
||||||
|
this.furDensity = Math.max(0.1, Math.min(1, density));
|
||||||
|
|
||||||
|
if (this.brush && this.brush.furDensity !== undefined) {
|
||||||
|
this.brush.furDensity = this.furDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.furDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发流动系数
|
||||||
|
* @param {Number} factor 流动系数(0-1)
|
||||||
|
*/
|
||||||
|
setFurFlowFactor(factor) {
|
||||||
|
this.furFlowFactor = Math.max(0, Math.min(1, factor));
|
||||||
|
|
||||||
|
if (this.brush && this.brush.furFlowFactor !== undefined) {
|
||||||
|
this.brush.furFlowFactor = this.furFlowFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.furFlowFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置毛发弯曲度
|
||||||
|
* @param {Number} curvature 弯曲度(0-1)
|
||||||
|
*/
|
||||||
|
setFurCurvature(curvature) {
|
||||||
|
this.furCurvature = Math.max(0, Math.min(1, curvature));
|
||||||
|
|
||||||
|
if (this.brush && this.brush.furCurvature !== undefined) {
|
||||||
|
this.brush.furCurvature = this.furCurvature;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.furCurvature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置是否随机化方向
|
||||||
|
* @param {Boolean} randomize 是否随机化
|
||||||
|
*/
|
||||||
|
setRandomizeDirection(randomize) {
|
||||||
|
this.randomizeDirection = randomize;
|
||||||
|
|
||||||
|
if (this.brush && this.brush.randomizeDirection !== undefined) {
|
||||||
|
this.brush.randomizeDirection = this.randomizeDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.randomizeDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取笔刷可配置属性
|
||||||
|
* @returns {Array} 可配置属性描述数组
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
getConfigurableProperties() {
|
||||||
|
// 获取基础属性
|
||||||
|
const baseProperties = super.getConfigurableProperties();
|
||||||
|
|
||||||
|
// 定义长毛发笔刷特有属性
|
||||||
|
const longfurProperties = [
|
||||||
|
{
|
||||||
|
id: "furLength",
|
||||||
|
name: "毛发长度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furLength,
|
||||||
|
min: 5,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
description: "控制毛发的长度",
|
||||||
|
category: "长毛发设置",
|
||||||
|
order: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "furDensity",
|
||||||
|
name: "毛发密度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furDensity,
|
||||||
|
min: 0.1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制毛发的密度",
|
||||||
|
category: "长毛发设置",
|
||||||
|
order: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "furFlowFactor",
|
||||||
|
name: "流动系数",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furFlowFactor,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制毛发的流动感",
|
||||||
|
category: "长毛发设置",
|
||||||
|
order: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "furCurvature",
|
||||||
|
name: "弯曲度",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: this.furCurvature,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
description: "控制毛发的弯曲程度",
|
||||||
|
category: "长毛发设置",
|
||||||
|
order: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "randomizeDirection",
|
||||||
|
name: "随机方向",
|
||||||
|
type: "checkbox",
|
||||||
|
defaultValue: this.randomizeDirection,
|
||||||
|
description: "是否随机化毛发方向",
|
||||||
|
category: "长毛发设置",
|
||||||
|
order: 140,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合并并返回所有属性
|
||||||
|
return [...baseProperties, ...longfurProperties];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔刷属性
|
||||||
|
* @param {String} propId 属性ID
|
||||||
|
* @param {any} value 属性值
|
||||||
|
* @returns {Boolean} 是否更新成功
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
updateProperty(propId, value) {
|
||||||
|
// 先检查基类能否处理此属性
|
||||||
|
if (super.updateProperty(propId, value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理长毛发笔刷特有属性
|
||||||
|
if (propId === "furLength") {
|
||||||
|
this.setFurLength(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "furDensity") {
|
||||||
|
this.setFurDensity(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "furFlowFactor") {
|
||||||
|
this.setFurFlowFactor(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "furCurvature") {
|
||||||
|
this.setFurCurvature(value);
|
||||||
|
return true;
|
||||||
|
} else if (propId === "randomizeDirection") {
|
||||||
|
this.setRandomizeDirection(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预览图
|
||||||
|
* @returns {String} 预览图URL
|
||||||
|
*/
|
||||||
|
getPreview() {
|
||||||
|
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjUgNTBDMjUgNTAgNTAgMTAgNTAgNTBDNTAgNTAgNTAgOTAgNzUgNTAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIi8+PGxpbmUgeDE9IjMwIiB5MT0iNDUiIHgyPSIzMCIgeTI9IjEwIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMSIvPjxsaW5lIHgxPSI0MCIgeTE9IjQwIiB4Mj0iNDAiIHkyPSI1IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMSIvPjxsaW5lIHgxPSI1MCIgeTE9IjQwIiB4Mj0iNTAiIHkyPSIxMCIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEiLz48bGluZSB4MT0iNjAiIHkxPSI0MCIgeDI9IjYwIiB5Mj0iNSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEiLz48bGluZSB4MT0iNzAiIHkxPSI0NSIgeDI9IjcwIiB5Mj0iMTAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxIi8+PC9zdmc+";
|
||||||
|
}
|
||||||
|
}
|
||||||