Browse Source

feat: 添加节点调整、对齐操作等

jiaxing.liao 14 hours ago
parent
commit
2bf450131d

+ 2 - 1
package.json

@@ -42,7 +42,8 @@
     "vue-i18n": "^11.1.12",
     "vue-icons-plus": "^0.1.8",
     "vue-router": "^4.6.3",
-    "vue3-colorpicker": "^2.3.0"
+    "vue3-colorpicker": "^2.3.0",
+    "vue3-moveable": "^0.28.0"
   },
   "devDependencies": {
     "@electron-toolkit/eslint-config-prettier": "3.0.0",

+ 218 - 0
pnpm-lock.yaml

@@ -74,6 +74,9 @@ importers:
       vue3-colorpicker:
         specifier: ^2.3.0
         version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@14.0.0(vue@3.5.22(typescript@5.9.3)))(gradient-parser@1.1.1)(lodash-es@4.17.21)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))
+      vue3-moveable:
+        specifier: ^0.28.0
+        version: 0.28.0
     devDependencies:
       '@electron-toolkit/eslint-config-prettier':
         specifier: 3.0.0
@@ -316,14 +319,32 @@ packages:
     resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
     engines: {node: '>=6.9.0'}
 
+  '@cfcs/core@0.0.6':
+    resolution: {integrity: sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==}
+
   '@ctrl/tinycolor@3.6.1':
     resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
     engines: {node: '>=10'}
 
+  '@daybrush/utils@1.13.0':
+    resolution: {integrity: sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==}
+
   '@develar/schema-utils@2.6.5':
     resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
     engines: {node: '>= 8.9.0'}
 
+  '@egjs/agent@2.4.4':
+    resolution: {integrity: sha512-cvAPSlUILhBBOakn2krdPnOGv5hAZq92f1YHxYcfu0p7uarix2C6Ia3AVizpS1SGRZGiEkIS5E+IVTLg1I2Iog==}
+
+  '@egjs/children-differ@1.0.1':
+    resolution: {integrity: sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==}
+
+  '@egjs/component@3.0.5':
+    resolution: {integrity: sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==}
+
+  '@egjs/list-differ@1.0.1':
+    resolution: {integrity: sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==}
+
   '@electron-toolkit/eslint-config-prettier@3.0.0':
     resolution: {integrity: sha512-YapmIOVkbYdHLuTa+ad1SAVtcqYL9A/SJsc7cxQokmhcwAwonGevNom37jBf9slXegcZ/Slh01I/JARG1yhNFw==}
     peerDependencies:
@@ -842,6 +863,15 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@scena/dragscroll@1.4.0':
+    resolution: {integrity: sha512-3O8daaZD9VXA9CP3dra6xcgt/qrm0mg0xJCwiX6druCteQ9FFsXffkF8PrqxY4Z4VJ58fFKEa0RlKqbsi/XnRA==}
+
+  '@scena/event-emitter@1.0.5':
+    resolution: {integrity: sha512-AzY4OTb0+7ynefmWFQ6hxDdk0CySAq/D4efljfhtRHCOP7MBF9zUfhKG3TJiroVjASqVgkRJFdenS8ArZo6Olg==}
+
+  '@scena/matrix@1.1.1':
+    resolution: {integrity: sha512-JVKBhN0tm2Srl+Yt+Ywqu0oLgLcdemDQlD1OxmN9jaCTwaFPZ7tY8n6dhVgMEaR9qcR7r+kAlMXnSfNyYdE+Vg==}
+
   '@sindresorhus/is@4.6.0':
     resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
     engines: {node: '>=10'}
@@ -1576,10 +1606,27 @@ packages:
   crc@3.8.0:
     resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
 
+  croact-css-styled@1.1.9:
+    resolution: {integrity: sha512-G7yvRiVJ3Eoj0ov2h2xR4312hpOzATay2dGS9clK8yJQothjH1sBXIyvOeRP5wBKD9mPcKcoUXPCPsl0tQog4w==}
+
+  croact-moveable@0.9.0:
+    resolution: {integrity: sha512-fc3bieV6CdqqZFtzsSLi9KmvUMFW3oakUfhPCls1BxKjOfUfn8rktteGED2341A/Qghy8tI3Hm6SdocIc68IKg==}
+    peerDependencies:
+      croact: ^1.0.4
+
+  croact@1.0.4:
+    resolution: {integrity: sha512-9GhvyzTY/IVUrMQ2iz/mzgZ8+NcjczmIo/t4FkC1CU0CEcau6v6VsEih4jkTa4ZmRgYTF0qXEZLObCzdDFplpw==}
+
   cross-spawn@7.0.6:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  css-styled@1.0.8:
+    resolution: {integrity: sha512-tCpP7kLRI8dI95rCh3Syl7I+v7PP+2JYOzWkl0bUEoSbJM+u8ITbutjlQVf0NC2/g4ULROJPi16sfwDIO8/84g==}
+
+  css-to-mat@1.1.1:
+    resolution: {integrity: sha512-kvpxFYZb27jRd2vium35G7q5XZ2WJ9rWjDUMNT36M3Hc41qCrLXFM5iEKMGXcrPsKfXEN+8l/riB4QzwwwiEyQ==}
+
   css-tree@3.1.0:
     resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
     engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -1974,6 +2021,9 @@ packages:
     resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
     engines: {node: '>= 6'}
 
+  framework-utils@1.1.0:
+    resolution: {integrity: sha512-KAfqli5PwpFJ8o3psRNs8svpMGyCSAe8nmGcjQ0zZBWN2H6dZDnq+ABp3N3hdUmFeMrLtjOCTXD4yplUJIWceg==}
+
   fs-constants@1.0.0:
     resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
 
@@ -2017,6 +2067,9 @@ packages:
     resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
     engines: {node: '>=6.9.0'}
 
+  gesto@1.19.4:
+    resolution: {integrity: sha512-hfr/0dWwh0Bnbb88s3QVJd1ZRJeOWcgHPPwmiH6NnafDYvhTsxg+SLYu+q/oPNh9JS3V+nlr6fNs8kvPAtcRDQ==}
+
   get-browser-rtc@1.1.0:
     resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==}
 
@@ -2344,6 +2397,12 @@ packages:
     resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==}
     hasBin: true
 
+  keycode@2.2.1:
+    resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==}
+
+  keycon@1.4.0:
+    resolution: {integrity: sha512-p1NAIxiRMH3jYfTeXRs2uWbVJ1WpEjpi8ktzUyBJsX7/wn2qu2VRXktneBLNtKNxJmlUYxRi9gOJt1DuthXR7A==}
+
   keyv@4.5.4:
     resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
 
@@ -2671,6 +2730,9 @@ packages:
   monaco-editor@0.54.0:
     resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==}
 
+  moveable@0.53.0:
+    resolution: {integrity: sha512-71jS9zIoQzMhnNvduhg4tUEdm23+fO/40FN7muVMbZvVwbTku2MIxxLhnU4qFvxI4oVxn75l79SbtgjuA+s7Pw==}
+
   mri@1.2.0:
     resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
     engines: {node: '>=4'}
@@ -2779,6 +2841,9 @@ packages:
     resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
     engines: {node: '>=10'}
 
+  overlap-area@1.1.0:
+    resolution: {integrity: sha512-3dlJgJCaVeXH0/eZjYVJvQiLVVrPO4U1ZGqlATtx6QGO3b5eNM6+JgUKa7oStBTdYuGTk7gVoABCW6Tp+dhRdw==}
+
   p-cancelable@2.1.1:
     resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
     engines: {node: '>=8'}
@@ -2955,6 +3020,15 @@ packages:
   randombytes@2.1.0:
     resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
 
+  react-css-styled@1.1.9:
+    resolution: {integrity: sha512-M7fJZ3IWFaIHcZEkoFOnkjdiUFmwd8d+gTh2bpqMOcnxy/0Gsykw4dsL4QBiKsxcGow6tETUa4NAUcmJF+/nfw==}
+
+  react-moveable@0.56.0:
+    resolution: {integrity: sha512-FmJNmIOsOA36mdxbrc/huiE4wuXSRlmon/o+/OrfNhSiYYYL0AV5oObtPluEhb2Yr/7EfYWBHTxF5aWAvjg1SA==}
+
+  react-selecto@1.26.3:
+    resolution: {integrity: sha512-Ubik7kWSnZyQEBNro+1k38hZaI1tJarE+5aD/qsqCOA1uUBSjgKVBy3EWRzGIbdmVex7DcxznFZLec/6KZNvwQ==}
+
   read-binary-file-arch@1.0.6:
     resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
     hasBin: true
@@ -3062,6 +3136,9 @@ packages:
   scule@1.3.0:
     resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
 
+  selecto@1.26.3:
+    resolution: {integrity: sha512-gZHgqMy5uyB6/2YDjv3Qqaf7bd2hTDOpPdxXlrez4R3/L0GiEWDCFaUfrflomgqdb3SxHF2IXY0Jw0EamZi7cw==}
+
   semver-compare@1.0.0:
     resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
 
@@ -3506,6 +3583,9 @@ packages:
       vue: ^3.2.6
       vue-types: ^4.1.0
 
+  vue3-moveable@0.28.0:
+    resolution: {integrity: sha512-vplQO0XkxVEtXMDh2/lZE+c5kMycGXAfYFMvbwFKi8UVYzVk8MTgVHr4fxO9Z+4i4Rb+U/IEIgkhHRMAbx8FJg==}
+
   vue@3.5.22:
     resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==}
     peerDependencies:
@@ -3886,13 +3966,29 @@ snapshots:
       '@babel/helper-string-parser': 7.27.1
       '@babel/helper-validator-identifier': 7.28.5
 
+  '@cfcs/core@0.0.6':
+    dependencies:
+      '@egjs/component': 3.0.5
+
   '@ctrl/tinycolor@3.6.1': {}
 
+  '@daybrush/utils@1.13.0': {}
+
   '@develar/schema-utils@2.6.5':
     dependencies:
       ajv: 6.12.6
       ajv-keywords: 3.5.2(ajv@6.12.6)
 
+  '@egjs/agent@2.4.4': {}
+
+  '@egjs/children-differ@1.0.1':
+    dependencies:
+      '@egjs/list-differ': 1.0.1
+
+  '@egjs/component@3.0.5': {}
+
+  '@egjs/list-differ@1.0.1': {}
+
   '@electron-toolkit/eslint-config-prettier@3.0.0(eslint@9.37.0(jiti@2.6.1))(prettier@3.6.2)':
     dependencies:
       eslint: 9.37.0(jiti@2.6.1)
@@ -4352,6 +4448,19 @@ snapshots:
   '@rollup/rollup-win32-x64-msvc@4.52.4':
     optional: true
 
+  '@scena/dragscroll@1.4.0':
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@scena/event-emitter': 1.0.5
+
+  '@scena/event-emitter@1.0.5':
+    dependencies:
+      '@daybrush/utils': 1.13.0
+
+  '@scena/matrix@1.1.1':
+    dependencies:
+      '@daybrush/utils': 1.13.0
+
   '@sindresorhus/is@4.6.0': {}
 
   '@svgdotjs/svg.js@3.2.0': {}
@@ -5327,12 +5436,50 @@ snapshots:
       buffer: 5.7.1
     optional: true
 
+  croact-css-styled@1.1.9:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      css-styled: 1.0.8
+      framework-utils: 1.1.0
+
+  croact-moveable@0.9.0(croact@1.0.4):
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@egjs/agent': 2.4.4
+      '@egjs/children-differ': 1.0.1
+      '@egjs/list-differ': 1.0.1
+      '@scena/dragscroll': 1.4.0
+      '@scena/event-emitter': 1.0.5
+      '@scena/matrix': 1.1.1
+      croact: 1.0.4
+      croact-css-styled: 1.1.9
+      css-to-mat: 1.1.1
+      framework-utils: 1.1.0
+      gesto: 1.19.4
+      overlap-area: 1.1.0
+      react-css-styled: 1.1.9
+      react-moveable: 0.56.0
+
+  croact@1.0.4:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@egjs/list-differ': 1.0.1
+
   cross-spawn@7.0.6:
     dependencies:
       path-key: 3.1.1
       shebang-command: 2.0.0
       which: 2.0.2
 
+  css-styled@1.0.8:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+
+  css-to-mat@1.1.1:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@scena/matrix': 1.1.1
+
   css-tree@3.1.0:
     dependencies:
       mdn-data: 2.12.2
@@ -5810,6 +5957,8 @@ snapshots:
       hasown: 2.0.2
       mime-types: 2.1.35
 
+  framework-utils@1.1.0: {}
+
   fs-constants@1.0.0: {}
 
   fs-extra@10.1.0:
@@ -5861,6 +6010,11 @@ snapshots:
 
   gensync@1.0.0-beta.2: {}
 
+  gesto@1.19.4:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@scena/event-emitter': 1.0.5
+
   get-browser-rtc@1.1.0: {}
 
   get-caller-file@2.0.5: {}
@@ -6179,6 +6333,15 @@ snapshots:
     dependencies:
       commander: 8.3.0
 
+  keycode@2.2.1: {}
+
+  keycon@1.4.0:
+    dependencies:
+      '@cfcs/core': 0.0.6
+      '@daybrush/utils': 1.13.0
+      '@scena/event-emitter': 1.0.5
+      keycode: 2.2.1
+
   keyv@4.5.4:
     dependencies:
       json-buffer: 3.0.1
@@ -6580,6 +6743,14 @@ snapshots:
       dompurify: 3.1.7
       marked: 14.0.0
 
+  moveable@0.53.0:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@scena/event-emitter': 1.0.5
+      croact: 1.0.4
+      croact-moveable: 0.9.0(croact@1.0.4)
+      react-moveable: 0.56.0
+
   mri@1.2.0: {}
 
   mrmime@2.0.1: {}
@@ -6697,6 +6868,10 @@ snapshots:
       strip-ansi: 6.0.1
       wcwidth: 1.0.1
 
+  overlap-area@1.1.0:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+
   p-cancelable@2.1.1: {}
 
   p-limit@3.1.0:
@@ -6849,6 +7024,31 @@ snapshots:
     dependencies:
       safe-buffer: 5.2.1
 
+  react-css-styled@1.1.9:
+    dependencies:
+      css-styled: 1.0.8
+      framework-utils: 1.1.0
+
+  react-moveable@0.56.0:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@egjs/agent': 2.4.4
+      '@egjs/children-differ': 1.0.1
+      '@egjs/list-differ': 1.0.1
+      '@scena/dragscroll': 1.4.0
+      '@scena/event-emitter': 1.0.5
+      '@scena/matrix': 1.1.1
+      css-to-mat: 1.1.1
+      framework-utils: 1.1.0
+      gesto: 1.19.4
+      overlap-area: 1.1.0
+      react-css-styled: 1.1.9
+      react-selecto: 1.26.3
+
+  react-selecto@1.26.3:
+    dependencies:
+      selecto: 1.26.3
+
   read-binary-file-arch@1.0.6:
     dependencies:
       debug: 4.4.3
@@ -6991,6 +7191,19 @@ snapshots:
 
   scule@1.3.0: {}
 
+  selecto@1.26.3:
+    dependencies:
+      '@daybrush/utils': 1.13.0
+      '@egjs/children-differ': 1.0.1
+      '@scena/dragscroll': 1.4.0
+      '@scena/event-emitter': 1.0.5
+      css-styled: 1.0.8
+      css-to-mat: 1.1.1
+      framework-utils: 1.1.0
+      gesto: 1.19.4
+      keycon: 1.4.0
+      overlap-area: 1.1.0
+
   semver-compare@1.0.0:
     optional: true
 
@@ -7471,6 +7684,11 @@ snapshots:
       vue: 3.5.22(typescript@5.9.3)
       vue-types: 4.2.1(vue@3.5.22(typescript@5.9.3))
 
+  vue3-moveable@0.28.0:
+    dependencies:
+      framework-utils: 1.1.0
+      moveable: 0.53.0
+
   vue@3.5.22(typescript@5.9.3):
     dependencies:
       '@vue/compiler-dom': 3.5.22

+ 2 - 1
src/renderer/src/locales/en_US.json

@@ -84,5 +84,6 @@
   "basic": "Basic",
   "container": "Container",
   "page": "page",
-  "createTime": "Create Time"
+  "createTime": "Create Time",
+  "imgButton": "Image Button"
 }

+ 2 - 1
src/renderer/src/locales/zh_CN.json

@@ -84,5 +84,6 @@
   "basic": "基础",
   "container": "容器",
   "page": "页面",
-  "createTime": "创建时间"
+  "createTime": "创建时间",
+  "imgButton": "图片按钮"
 }

+ 57 - 0
src/renderer/src/lvgl-widgets/image-button/ImageButton.vue

@@ -0,0 +1,57 @@
+<template>
+  <div v-bind="getProps">{{ props.text }}</div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+const props = defineProps<{
+  width: number
+  height: number
+  text?: string
+  styles: any
+  state?: string
+}>()
+
+const getProps = computed(() => {
+  const styles = props.styles
+  const stateStyles = styles.find((item) => item.state === props.state)
+
+  return {
+    class: 'button',
+    style: {
+      width: `${props.width}px`,
+      height: `${props.height}px`,
+
+      backgroundColor: stateStyles?.background.color,
+
+      fontSize: `${stateStyles?.text.size}px`,
+      color: stateStyles?.text?.color,
+      display: 'flex',
+      justifyContent: stateStyles?.text?.align || 'center',
+      alignItems: 'center',
+
+      borderRadius: `${stateStyles?.border.radius}px`,
+      borderColor: 'transparent',
+      borderWidth: `${stateStyles?.border.width}px`,
+      borderTopColor: ['all', 'top'].includes(stateStyles?.border?.side)
+        ? stateStyles?.border?.color
+        : 'transparent',
+      borderRightColor: ['all', 'right'].includes(stateStyles?.border?.side)
+        ? stateStyles?.border?.color
+        : 'transparent',
+      borderBottomColor: ['all', 'bottom'].includes(stateStyles?.border?.side)
+        ? stateStyles?.border?.color
+        : 'transparent',
+      borderLeftColor: ['all', 'left'].includes(stateStyles?.border?.side)
+        ? stateStyles?.border?.color
+        : 'transparent',
+      /* x 偏移量 | y 偏移量 | 阴影模糊半径 | 阴影扩散半径 | 阴影颜色 */
+      boxShodow: stateStyles?.boxShadow
+        ? `${stateStyles?.boxShadow?.offsetX}px ${stateStyles?.boxShadow?.offsetY}px ${stateStyles?.boxShadow?.width}px ${stateStyles?.boxShadow?.spread}px ${stateStyles?.boxShadow?.color}`
+        : 'none'
+    }
+  }
+})
+</script>
+
+<style scoped></style>

+ 195 - 0
src/renderer/src/lvgl-widgets/image-button/index.ts

@@ -0,0 +1,195 @@
+import LvImageButton from './ImageButton.vue'
+import icon from '../assets/icon/icon_2img_btn.svg'
+import { flagOptions } from '@/constants'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+
+export default {
+  label: i18n.global.t('imgButton'),
+  icon,
+  component: LvImageButton,
+  key: 'lv_img_button',
+  group: i18n.global.t('basic'),
+  sort: 1,
+  parts: [
+    {
+      name: 'main',
+      stateList: ['default', 'focused', 'pressed', 'checked', 'disabled']
+    }
+  ],
+  defaultSchema: {
+    props: {
+      name: 'button',
+      x: 0,
+      y: 0,
+      width: 90,
+      height: 45,
+      text: 'Button',
+      mode: 'Wrap'
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#2195f6',
+          alpha: 255,
+          image: {
+            imgId: '',
+            color: ''
+          }
+        },
+        text: {
+          color: '#ffffff',
+          family: '',
+          size: 16,
+          weight: 400,
+          align: 'center'
+        },
+        border: {
+          color: '#000000ff',
+          width: 1,
+          radius: 5,
+          side: 'all'
+        },
+        shadow: {
+          color: '#2092f5ff',
+          x: 0,
+          y: 0,
+          spread: 0,
+          width: 0
+        }
+      }
+    ]
+  },
+  config: {
+    // 组件属性
+    props: [
+      {
+        label: '基本属性',
+        valueType: 'group',
+        children: [
+          {
+            label: '名称',
+            field: 'name',
+            valueType: 'text',
+            componentProps: {
+              placeholder: '请输入名称'
+            }
+          },
+          {
+            label: '类型',
+            field: 'type',
+            valueType: 'text',
+            componentProps: {
+              readOnly: true
+            }
+          }
+        ]
+      },
+      {
+        label: '位置/大小',
+        valueType: 'group',
+        children: [
+          {
+            label: 'X',
+            field: 'x',
+            valueType: 'number',
+            componentProps: {
+              span: 12
+            }
+          },
+          {
+            label: 'Y',
+            field: 'y',
+            valueType: 'number',
+            componentProps: {
+              span: 12
+            }
+          },
+          {
+            label: '宽度',
+            field: 'width',
+            valueType: 'number',
+            componentProps: {
+              span: 12
+            }
+          },
+          {
+            label: '高度',
+            field: 'height',
+            valueType: 'number',
+            componentProps: {
+              span: 12
+            }
+          }
+        ]
+      },
+      {
+        label: '标识',
+        field: 'flags',
+        valueType: 'select',
+        componentProps: {
+          options: flagOptions,
+          multiple: true,
+          clearable: true
+        }
+      },
+      {
+        label: '可见',
+        field: 'visible',
+        valueType: 'checkbox'
+      },
+      {
+        label: '启/禁用',
+        field: 'enabled',
+        valueType: 'checkbox'
+      },
+      {
+        label: '文本',
+        field: 'text',
+        valueType: 'text'
+      }
+    ],
+    // 组件样式
+    styles: [
+      {
+        label: '模块状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        label: '背景',
+        field: 'background',
+        valueType: 'background'
+      },
+      {
+        label: '边框',
+        field: 'border',
+        valueType: 'border'
+      },
+      {
+        label: '内边距',
+        field: 'padding',
+        valueType: 'padding'
+      },
+      {
+        label: '外边距',
+        field: 'margin',
+        valueType: 'margin'
+      },
+      {
+        label: '阴影',
+        field: 'boxShadow',
+        valueType: 'boxShadow'
+      },
+      {
+        label: '对齐',
+        field: 'align',
+        valueType: 'align'
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 3 - 1
src/renderer/src/lvgl-widgets/index.ts

@@ -1,8 +1,10 @@
 import Button from './button'
+import ImageButton from './image-button'
 import Container from './container'
+import Page from './page'
 import { IComponentModelConfig } from './type'
 
-export const ComponentArray = [Button, Container]
+export const ComponentArray = [Button, ImageButton, Container, Page]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {
   acc[cur.key] = cur

+ 6 - 0
src/renderer/src/lvgl-widgets/page/Page.vue

@@ -0,0 +1,6 @@
+<template>
+  <div></div>
+</template>
+
+<script setup lang="ts"></script>
+<

+ 2 - 0
src/renderer/src/lvgl-widgets/page/index.ts

@@ -8,6 +8,8 @@ export default {
   component: Page,
   key: 'page',
   group: i18n.global.t('container'),
+  hideLibary: true,
+  hasChildren: true,
   parts: [
     {
       name: 'main',

+ 155 - 0
src/renderer/src/store/modules/action.ts

@@ -0,0 +1,155 @@
+import { defineStore } from 'pinia'
+import { useProjectStore } from '@/store/modules/project'
+
+type AlignType =
+  | 'left'
+  | 'right'
+  | 'top'
+  | 'bottom'
+  | 'vcenter'
+  | 'hcenter'
+  | 'vspace'
+  | 'hcenter'
+  | 'vspace'
+  | 'hspace'
+export const useActionStore = defineStore('action', () => {
+  const projectStore = useProjectStore()
+
+  /**
+   * 对齐组件
+   * @param type 对齐类型
+   */
+  const onAlign = (type: AlignType) => {
+    const widgets = projectStore.activeWidgets
+    switch (type) {
+      case 'left': {
+        const minX = Math.min(...widgets.map((widget) => widget.props.x))
+        widgets.forEach((widget) => {
+          widget.props.x = minX
+        })
+        break
+      }
+
+      case 'right': {
+        const maxX = Math.max(...widgets.map((widget) => widget.props.x + widget.props.width))
+        widgets.forEach((widget) => {
+          widget.props.x = maxX - widget.props.width
+        })
+        break
+      }
+
+      case 'top': {
+        const minY = Math.min(...widgets.map((widget) => widget.props.y))
+        widgets.forEach((widget) => {
+          widget.props.y = minY
+        })
+        break
+      }
+
+      case 'bottom': {
+        const maxY = Math.max(...widgets.map((widget) => widget.props.y + widget.props.height))
+        widgets.forEach((widget) => {
+          widget.props.y = maxY - widget.props.height
+        })
+        break
+      }
+
+      case 'vcenter': {
+        const maxY = Math.max(...widgets.map((widget) => widget.props.y + widget.props.height))
+        const minY = Math.min(...widgets.map((widget) => widget.props.y))
+        const centerY = minY + (maxY + minY) / 2
+        widgets.forEach((widget) => {
+          widget.props.y = centerY - widget.props.height / 2
+        })
+        break
+      }
+
+      case 'hcenter': {
+        const maxX = Math.max(...widgets.map((widget) => widget.props.x + widget.props.width))
+        const minX = Math.min(...widgets.map((widget) => widget.props.x))
+        const centerX = minX + (maxX + minX) / 2
+        widgets.forEach((widget) => {
+          widget.props.x = centerX - widget.props.width / 2
+        })
+        break
+      }
+      // 水平平均分布
+      case 'hspace': {
+        const maxX = Math.max(...widgets.map((widget) => widget.props.x + widget.props.width))
+        const minX = Math.min(...widgets.map((widget) => widget.props.x))
+
+        // 排序
+        const list = widgets.sort((a, b) => a.props.x - b.props.x)
+        // 计算gap
+        const gap = (maxX - minX - list.reduce((a, b) => a + b.props.width, 0)) / (list.length - 1)
+        // flag
+        let start = minX
+
+        list.forEach((widget) => {
+          widget.props.x = start
+          start += widget.props.width + gap
+        })
+        break
+      }
+
+      // 垂直平均分布
+      case 'vspace': {
+        const maxY = Math.max(...widgets.map((widget) => widget.props.y + widget.props.height))
+        const minY = Math.min(...widgets.map((widget) => widget.props.y))
+
+        // 排序
+        const list = widgets.sort((a, b) => a.props.y - b.props.y)
+        // 计算gap
+        const gap = (maxY - minY - list.reduce((a, b) => a + b.props.height, 0)) / (list.length - 1)
+        // flag
+        let start = minY
+
+        list.forEach((widget) => {
+          widget.props.y = start
+          start += widget.props.height + gap
+        })
+        break
+      }
+    }
+  }
+
+  /**
+   * 匹配宽高
+   * @param type 宽高匹配类型
+   */
+  const onMatchSize = (type: 'width' | 'height' | 'both') => {
+    const widgets = projectStore.activeWidgets
+    switch (type) {
+      case 'width': {
+        const width = Math.max(...widgets.map((widget) => widget.props.width))
+        widgets.forEach((widget) => {
+          widget.props.width = width
+        })
+        break
+      }
+
+      case 'height': {
+        const height = Math.max(...widgets.map((widget) => widget.props.height))
+        widgets.forEach((widget) => {
+          widget.props.height = height
+        })
+        break
+      }
+
+      case 'both': {
+        const width = Math.max(...widgets.map((widget) => widget.props.width))
+        const height = Math.max(...widgets.map((widget) => widget.props.height))
+        widgets.forEach((widget) => {
+          widget.props.width = width
+          widget.props.height = height
+        })
+        break
+      }
+    }
+  }
+
+  return {
+    onAlign,
+    onMatchSize
+  }
+})

+ 20 - 8
src/renderer/src/store/modules/project.ts

@@ -56,22 +56,26 @@ export const useProjectStore = defineStore('project', () => {
 
   // 项目路径
   const projectPath = ref<string>()
-  // 活动页面key
-  const activePageId = ref<string>()
   // 图片压缩格式
   const imageCompressFormat = ref<string[]>([])
 
-  // 活动页面
+  // 激活页面ID
+  const activePageId = ref<string>()
+  // 当前激活的页面
   const activePage = computed(() => {
     const pages = project.value?.screens.map((screen) => screen.pages)
     return pages?.flat().find((page) => page.id === activePageId.value)
   })
+  // 打开页面 用以记录每个屏幕打开的页面
+  const openPages = ref<Page[]>([])
 
-  // 当前选中元素
+  // 当前选中元素列表
   const activeWidgets = ref<any[]>([])
 
-  // 打开页面
-  const openPages = ref<Page[]>([])
+  // 当前激活的元素
+  const activeWidget = computed(() => {
+    return activeWidgets.value?.at(-1) ?? activePage.value
+  })
 
   /**
    * 创建应用
@@ -109,7 +113,6 @@ export const useProjectStore = defineStore('project', () => {
       openPages.value.push(newScreen.pages[0])
     })
     activePageId.value = project.value.screens[0].pages[0].id
-    activeWidgets.value = [project.value.screens[0].pages[0]]
     // 3、创建BIN
     if (meta.resourcePackaging === 'c_bin' && meta.binNum > 0) {
       for (let i = 0; i < meta.binNum; i++) {
@@ -175,7 +178,6 @@ export const useProjectStore = defineStore('project', () => {
     projectPath.value = path
     openPages.value = newProject.screens.map((screen) => screen.pages?.[0]).filter((item) => item)
     activePageId.value = newProject.screens[0].pages?.[0]?.id
-    activeWidgets.value = [newProject.screens[0].pages?.[0]]
     imageCompressFormat.value = newProject.meta.imageCompress
     recentProjectStore.addProject({
       id: v4(),
@@ -262,11 +264,20 @@ export const useProjectStore = defineStore('project', () => {
     }
   }
 
+  /**
+   * 选中元素
+   * @param widgets 选中的元素
+   */
+  const setSelectWidgets = (widgets: BaseWidget[]) => {
+    activeWidgets.value = widgets
+  }
+
   return {
     createApp,
     project,
     activePageId,
     activePage,
+    activeWidget,
     deletePage,
     deleteWidget,
     projectPath,
@@ -276,6 +287,7 @@ export const useProjectStore = defineStore('project', () => {
     saveProject,
     activeWidgets,
     openPages,
+    setSelectWidgets,
 
     // 历史记录
     history,

+ 2 - 1
src/renderer/src/types/page.d.ts

@@ -1,3 +1,4 @@
+import { BaseWidget } from './baseWidget'
 import { Variable } from './variables'
 
 export type ReferenceLine = {
@@ -52,5 +53,5 @@ export type Page = {
   // 页面变量
   variables: Variable[]
   // 子组件
-  children: any[]
+  children: BaseWidget[]
 }

+ 0 - 1
src/renderer/src/views/designer/config/index.vue

@@ -13,7 +13,6 @@
       <el-tab-pane label="多语言">
         <languages-config v-model:languages="data.languages" />
       </el-tab-pane>
-      <el-tab-pane label="历史"></el-tab-pane>
     </el-tabs>
   </div>
 </template>

+ 1 - 1
src/renderer/src/views/designer/sidebar/Hierarchy.vue

@@ -9,7 +9,7 @@
         node-key="id"
         highlight-current
         check-on-click-node
-        :default-checked-keys="projectStore.activePageId ? [projectStore.activePageId] : []"
+        :current-node-key="projectStore.activePageId"
         :data="projectStore.project?.screens"
         :props="{ label: 'name', children: 'pages' }"
         @node-click="handleNodeClick"

+ 22 - 4
src/renderer/src/views/designer/sidebar/Libary.vue

@@ -6,8 +6,13 @@
       class="m-8px"
       style="width: calc(100% - 16px)"
     />
-    <el-collapse>
-      <el-collapse-item v-for="group in getGroups" :key="group.label" :title="group.label">
+    <el-collapse v-model="activeCollapse">
+      <el-collapse-item
+        v-for="group in getGroups"
+        :key="group.label"
+        :title="group.label"
+        :name="group.label"
+      >
         <div class="px-2 pb-2 pt-1 grid grid-cols-[auto_auto] gap-2">
           <LibaryItem v-for="item in group.items" :key="item.key" :comp="item" />
         </div>
@@ -29,6 +34,7 @@ import LibaryItem from './components/LibaryItem.vue'
 import type { IComponentModelConfig } from '@/lvgl-widgets/type'
 
 const search = ref('')
+const activeCollapse = ref<string[]>([])
 
 const groupMap = ref<{
   [key: string]: {
@@ -36,6 +42,7 @@ const groupMap = ref<{
     items: IComponentModelConfig[]
   }
 }>({})
+
 ComponentArray.filter((item) => !item.hideLibary).forEach((item) => {
   if (!groupMap.value[item.group]) {
     groupMap.value[item.group] = {
@@ -43,13 +50,24 @@ ComponentArray.filter((item) => !item.hideLibary).forEach((item) => {
       items: []
     }
   }
-  groupMap.value[item.group].items.push(item)
+  if (!item?.hideLibary) {
+    groupMap.value[item.group].items.push(item)
+  }
+
   return item.group
 })
 
 const getGroups = computed(() => {
-  return Object.values(groupMap.value).filter((item) =>
+  const list = Object.values(groupMap.value).filter((item) =>
     item.items.some((item) => item.label.includes(search.value))
   )
+
+  if (search.value) {
+    activeCollapse.value = list.map((item) => item.label)
+  } else {
+    activeCollapse.value = list.length ? [list[0].label] : []
+  }
+
+  return list
 })
 </script>

+ 3 - 3
src/renderer/src/views/designer/sidebar/components/LibaryItem.vue

@@ -1,11 +1,11 @@
 <template>
   <div
     ref="libaryItemRef"
-    :key="comp.componentName"
+    :key="comp.key"
     draggable="true"
-    class="w-70px h-70px border border-solid border-border rounded-4px flex flex-col items-center text-text-secondary justify-center cursor-move hover:bg-bg-secondary hover:text-text-active"
+    class="w-60px h-60px border border-solid border-border rounded-4px flex flex-col items-center text-text-secondary justify-center cursor-move hover:bg-bg-secondary hover:text-text-active"
   >
-    <div class="w-40px h-40px flex items-center justify-center">
+    <div class="w-32px h-32px flex items-center justify-center">
       <img width="32px" :src="comp.icon" draggable="false" />
     </div>
     <span class="text-xs">{{ comp.label }}</span>

+ 48 - 10
src/renderer/src/views/designer/tools/Operate.vue

@@ -21,16 +21,22 @@ import {
   LuLayoutGrid,
   LuArrowUp,
   LuArrowUpToLine,
-  LuArrowDownToLine
+  LuArrowDownToLine,
+  LuAlignHorizontalSpaceAround,
+  LuAlignVerticalSpaceAround
 } from 'vue-icons-plus/lu'
 import MenuItem from './components/MenuItem.vue'
 import { useProjectStore } from '@/store/modules/project'
+import { useActionStore } from '@/store/modules/action'
 
 import type { MenuItemType } from './components/MenuItem.vue'
 
 const projectStore = useProjectStore()
+const actionStore = useActionStore()
 
 const projectMenu = computed((): MenuItemType[] => {
+  const disabledAlign = projectStore.activeWidgets.length < 2
+  const disabledAvg = projectStore.activeWidgets.length < 3
   return [
     {
       key: 'undo',
@@ -56,32 +62,58 @@ const projectMenu = computed((): MenuItemType[] => {
     {
       key: 'alignLeft',
       label: '左对齐',
-      img: LuAlignStartVertical
+      img: LuAlignStartVertical,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onAlign('left')
     },
     {
       key: 'alignHorizontal',
       label: '水平对齐',
-      img: LuAlignCenterVertical
+      img: LuAlignCenterVertical,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onAlign('hcenter')
     },
     {
       key: 'alignRight',
       label: '右对齐',
-      img: LuAlignEndVertical
+      img: LuAlignEndVertical,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onAlign('right')
     },
     {
       key: 'alignTop',
       label: '顶对齐',
-      img: LuAlignStartHorizontal
+      img: LuAlignStartHorizontal,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onAlign('top')
     },
     {
       key: 'alignVertical',
       label: '垂直居中',
-      img: LuAlignCenterHorizontal
+      img: LuAlignCenterHorizontal,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onAlign('vcenter')
     },
     {
       key: 'alignBottom',
       label: '底对齐',
-      img: LuAlignEndHorizontal
+      img: LuAlignEndHorizontal,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onAlign('bottom')
+    },
+    {
+      key: 'alignHorizontal',
+      label: '水平平均',
+      img: LuAlignHorizontalSpaceAround,
+      disabled: disabledAvg,
+      onClick: () => actionStore.onAlign('hspace')
+    },
+    {
+      key: 'alignVertical',
+      label: '垂直平均',
+      img: LuAlignVerticalSpaceAround,
+      disabled: disabledAvg,
+      onClick: () => actionStore.onAlign('vspace')
     },
     {
       type: 'divider'
@@ -89,17 +121,23 @@ const projectMenu = computed((): MenuItemType[] => {
     {
       key: 'alignWidth',
       label: '宽度匹配',
-      img: LuAlignHorizontalSpaceBetween
+      img: LuAlignHorizontalSpaceBetween,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onMatchSize('width')
     },
     {
       key: 'alignHeight',
       label: '高度匹配',
-      img: LuAlignVerticalSpaceBetween
+      img: LuAlignVerticalSpaceBetween,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onMatchSize('height')
     },
     {
       key: 'alignAll',
       label: '宽高匹配',
-      img: LuLayoutGrid
+      img: LuLayoutGrid,
+      disabled: disabledAlign,
+      onClick: () => actionStore.onMatchSize('both')
     },
     {
       type: 'divider'

+ 4 - 4
src/renderer/src/views/designer/workspace/composite/eventEdit/index.vue

@@ -31,9 +31,9 @@ useResizeObserver(containerRef, () => {
 const mindMapData = ref<NodeItemType>()
 
 watch(
-  () => projectStore.activeWidgets,
-  async (list) => {
-    const widget = klona(list?.at(-1))
+  () => projectStore.activeWidget,
+  async () => {
+    const widget = klona(projectStore.activeWidget)
     // 当前选择的最后一个元素且不是当前元素 切换
     if (widget && widget.id !== mindMapData.value?.id) {
       const data: NodeItemType = {
@@ -60,7 +60,7 @@ watch(
   () => mindMapData.value,
   () => {
     const data = klona(mindMapData.value)
-    const item = projectStore.activeWidgets?.at(-1)
+    const item = projectStore.activeWidget
     if (data && item?.id === data.id) {
       // 移除添加按钮
       bfsWalk(data, (child) => {

+ 0 - 250
src/renderer/src/views/designer/workspace/stage/ComponentWrapper.vue

@@ -1,250 +0,0 @@
-<template>
-  <div class="component-wrapper" ref="componentWrapperRef" :style="warpperStyle">
-    <div class="group-box" v-if="componentData.componentType === 'group'">
-      <ComponentWrapper
-        v-for="item in componentData.children"
-        v-show="item.visible"
-        :component-data="item"
-        :key="item.key"
-        :style="{ zIndex: item.zIndex }"
-        :state="state"
-      />
-    </div>
-    <Container v-bind="componentData.container" v-else>
-      <component
-        :is="component"
-        v-bind="componentData.props"
-        :width="getComponentWidth"
-        :height="getComponentHeight"
-      />
-    </Container>
-    <div v-if="showEditBox" class="edit-box" :style="editWapperStyle">
-      <span class="name-tip">{{ getTip }}</span>
-      <UseDraggable
-        v-for="item in dragPointList"
-        :key="item"
-        @move="(_, e) => handleDragPoint(item, e)"
-        @start="handleDragStart"
-        @end="handleDragEnd"
-      >
-        <span v-if="!componentData.locked" class="edit-box-point" :class="item"></span>
-      </UseDraggable>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { defineProps, defineAsyncComponent, computed, ref } from 'vue'
-import { useDraggable } from '@vueuse/core'
-import { UseDraggable } from '@vueuse/components'
-import { useAdsorb } from '@/hooks/useAdsorb'
-import { StageState } from './type'
-
-const { componentData, state } = defineProps<{
-  componentData: any
-  state: StageState
-}>()
-// 动态引入组件
-const component = ref()
-
-const componentWrapperRef = ref<HTMLElement | null>(null)
-
-const { getMoveAdsorbInfo } = useAdsorb()
-const editWapperStyle = computed(() => {
-  const { width = 400, height = 260 } = componentData.container.props || {}
-
-  return {
-    transform: `scale(${1 / state.scale})`,
-    transformOrigin: '50% 50%',
-    width: `${width * state.scale}px`,
-    height: `${height * state.scale}px`,
-    border: '1px solid #1890ff',
-    left: (width / 2) * (1 - state.scale) + 'px',
-    top: (height / 2) * (1 - state.scale) + 'px'
-  }
-})
-
-// 组件宽--根据边距计算
-const getComponentWidth = computed(() => {
-  const { width = 400 } = componentData.container.props || {}
-  const { paddingLeft = 0, paddingRight = 0 } = componentData.container.props || {}
-  return width - paddingLeft - paddingRight
-})
-
-// 组件高--根据边距计算
-const getComponentHeight = computed(() => {
-  const { height = 260 } = componentData.container.props || {}
-  const { paddingTop = 0, paddingBottom = 0 } = componentData.container.props || {}
-  return height - paddingTop - paddingBottom
-})
-
-const warpperStyle = computed(() => {
-  const { width = 400, height = 260, x, y } = componentData.container.props || {}
-  // const style = transformStyle(componentData.container?.style || {});
-
-  return {
-    width: `${width}px`,
-    height: `${height}px`,
-    left: x + 'px',
-    top: y + 'px'
-  }
-})
-// 是否显示编辑框
-const showEditBox = computed(() => {
-  return (
-    // projectStore.mode === 'edit' && projectStore.selectedElementKeys.includes(componentData.key)
-    true
-  )
-})
-// 获取提示信息
-const getTip = computed(() => {
-  const { x, y } = componentData.container.props || {}
-  return showNameTip.value ? componentData.name : `x: ${Math.round(x)} y: ${Math.round(y)}`
-})
-
-let isPointDragFlag = false
-const showNameTip = ref(true)
-let moveLeft: number
-// 拖拽移动组件
-useDraggable(componentWrapperRef, {
-  onMove: (position) => {
-    if (isPointDragFlag) return
-
-    const originPosition = componentWrapperRef.value!.getBoundingClientRect()
-    // 计算移动的距离
-    const xMoveLength = position.x - originPosition.left
-    const yMoveLentgh = position.y - originPosition.top
-
-    const { newMoveX, newMoveY } = getMoveAdsorbInfo({
-      moveX: xMoveLength,
-      moveY: yMoveLentgh
-    })
-
-    moveLeft = Math.max(Math.abs(newMoveX), Math.abs(newMoveY))
-    // todo 对每个选中的组件进行移动
-  },
-  onStart: () => {
-    showNameTip.value = false
-    moveLeft = 0
-  },
-  onEnd: () => {
-    showNameTip.value = true
-  }
-})
-
-/* ===============================缩放组件==================================== */
-const dragPointList = [
-  'top-left',
-  'top-center',
-  'top-right',
-  'left-center',
-  'right-center',
-  'bottom-left',
-  'bottom-center',
-  'bottom-right'
-]
-
-const startPoint = {
-  x: 0,
-  y: 0
-}
-// 拖拽点移动 => 缩放组件
-const handleDragPoint = (type: string, e: PointerEvent) => {
-  const moveX = (e.x - startPoint.x) / state.scale
-  const moveY = (e.y - startPoint.y) / state.scale
-
-  startPoint.x = e.x
-  startPoint.y = e.y
-
-  // todo 对选中的组件进行缩放
-}
-// 拖拽点开始
-const handleDragStart = (_: any, e: PointerEvent) => {
-  startPoint.x = e.x
-  startPoint.y = e.y
-  isPointDragFlag = true
-  showNameTip.value = false
-}
-// 拖拽点结束
-const handleDragEnd = () => {
-  isPointDragFlag = false
-  showNameTip.value = true
-}
-</script>
-
-<script lang="ts">
-export default {
-  name: 'ComponentWrapper'
-}
-</script>
-
-<style lang="less" scoped>
-.component-wrapper {
-  position: absolute;
-}
-.edit-box {
-  position: absolute;
-  &-point {
-    position: absolute;
-    width: 8px;
-    height: 8px;
-    background: #fff;
-    border-radius: 50%;
-    border: solid 1px @primary-color;
-  }
-  .name-tip {
-    position: absolute;
-    top: -20px;
-    left: 4px;
-    font-size: 12px;
-    color: #fff;
-    background: @primary-color;
-    padding: 2px 4px;
-  }
-  .top-left {
-    top: -4px;
-    left: -4px;
-    cursor: nw-resize;
-  }
-  .top-center {
-    top: -4px;
-    left: 50%;
-    transform: translateX(-50%);
-    transform-origin: center;
-    cursor: n-resize;
-  }
-  .top-right {
-    top: -4px;
-    right: -4px;
-    cursor: ne-resize;
-  }
-  .left-center {
-    top: 50%;
-    left: -4px;
-    transform: translateY(-50%);
-    cursor: w-resize;
-  }
-  .right-center {
-    top: 50%;
-    right: -4px;
-    transform: translateY(-50%);
-    cursor: e-resize;
-  }
-  .bottom-left {
-    bottom: -4px;
-    left: -4px;
-    cursor: sw-resize;
-  }
-  .bottom-center {
-    bottom: -4px;
-    left: 50%;
-    transform: translateX(-50%);
-    cursor: s-resize;
-  }
-  .bottom-right {
-    bottom: -4px;
-    right: -4px;
-    cursor: se-resize;
-  }
-}
-</style>

+ 2 - 1
src/renderer/src/views/designer/workspace/stage/DesignerCanvas.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="stage-wrapper" ref="stageWrapperRef" :style="getWrapperStyle">
-    <div class="stage" :style="getStyles.stageStyle">
+    <div class="stage" ref="stageRef" :style="getStyles.stageStyle">
       <div ref="tipRef" class="tip-txt" :style="getStyles.tipStyle">{{ page?.name }}</div>
       <!-- 格子背景 -->
       <div class="absolute transparent-bg" :style="getStyles.transpartBg"></div>
@@ -18,6 +18,7 @@
         <!-- 内容节点 -->
         <Nodes
           :schema="page!"
+          :root-container="canvasRef!"
           :style="{
             ...getStyles.canvasStyle,
             transform: '',

+ 112 - 14
src/renderer/src/views/designer/workspace/stage/Node.vue

@@ -1,11 +1,55 @@
 <template>
-  <div :style="getStyle">
+  <div
+    :style="getStyle"
+    ref="nodeRef"
+    :class="schema.type === 'page' ? '' : 'ignore-click widget-wrapper'"
+    @click.stop="handleSelect"
+  >
+    <!-- 控件 -->
     <component :is="widget" v-bind="schema.props" :styles="schema.style" />
-    <div v-if="schema.children" class="w-full h-full" ref="nodeRef">
-      <template v-for="child in schema.children" :key="child.id">
-        <NodeItem :schema="child" />
-      </template>
-    </div>
+    <!-- 子节点 -->
+    <NodeItem
+      v-if="schema.children"
+      v-for="child in schema.children"
+      :key="child.id"
+      :schema="child"
+      :rootContainer="nodeRef!"
+    />
+  </div>
+  <!-- 拖拽、缩放、吸附 -->
+  <div v-show="schema.type !== 'page' && selected">
+    <Moveable
+      :target="nodeRef"
+      :draggable="selected"
+      :resizable="selected"
+      :padding="2"
+      :container="rootContainer"
+      :snappable="true"
+      :useMutationObserver="true"
+      :useResizeObserver="true"
+      :snapDirections="{
+        top: true,
+        left: true,
+        bottom: true,
+        right: true,
+        center: true,
+        middle: true
+      }"
+      :elementSnapDirections="{
+        top: true,
+        left: true,
+        bottom: true,
+        right: true,
+        center: true,
+        middle: true
+      }"
+      :maxSnapElementGuidelineDistance="50"
+      :elementGuidelines="elementGridelines"
+      :controlPadding="4"
+      @render="onRender"
+      @drag="onDrag"
+      @resize="onResize"
+    />
   </div>
 </template>
 
@@ -17,33 +61,51 @@ import { computed, type CSSProperties, ref } from 'vue'
 import { useDrop } from 'vue-hooks-plus'
 import { createWidget } from '@/model'
 import LvglWidgets from '@/lvgl-widgets'
+import { useProjectStore } from '@/store/modules/project'
+
+import Moveable from 'vue3-moveable'
 
 defineOptions({
   name: 'NodeItem'
 })
 
 const props = defineProps<{
+  // 父级容器 拖拽缩放设置
+  rootContainer: HTMLElement
+  // 传入节点模型数据
   schema: BaseWidget | Page
+  // 传入样式 如页面样式
   style?: CSSProperties
 }>()
 
+const projectStore = useProjectStore()
+// 获取lvgl vue控件
 const widget = computed(() => LvglWidgets[props.schema.type]?.component)
+// 节点容器放置ref
 const nodeRef = ref<HTMLDivElement>()
-const hovering = ref(false)
+// 判断当前节点是否选中
+const selected = computed(() =>
+  projectStore.activeWidgets.map((item) => item.id).includes(props.schema.id)
+)
 
 // 组件样式
 const getStyle = computed((): CSSProperties => {
   const { style = {}, schema } = props
+
   return {
     position: 'absolute',
-    left: schema.props.x + 'px',
-    top: schema.props.y + 'px',
-    width: schema.props.w + 'px',
-    height: schema.props.h + 'px',
+    left: 0,
+    top: 0,
+    transform: `translate(${schema.props.x}px, ${schema.props.y}px)`,
+    width: schema.props.width + 'px',
+    height: schema.props.height + 'px',
     ...style
   }
 })
 
+// 吸附辅助线
+const elementGridelines = ref([])
+
 // 拖拽放置事件处理
 useDrop(nodeRef, {
   onDom: (content, event) => {
@@ -53,8 +115,44 @@ useDrop(nodeRef, {
     newWidget.props.x = offsetX
     newWidget.props.y = offsetY
     props.schema.children?.push(newWidget)
-  },
-  onDragEnter: () => (hovering.value = true),
-  onDragLeave: () => (hovering.value = false)
+    projectStore.setSelectWidgets([newWidget])
+  }
 })
+
+// 选择节点
+const handleSelect = (e) => {
+  if (props.schema.type !== 'page') {
+    // 判断当前是否按住ctrl
+    if (e.ctrlKey) {
+      projectStore.activeWidgets.push(props.schema as BaseWidget)
+    } else {
+      projectStore.setSelectWidgets([props.schema as BaseWidget])
+    }
+  }
+}
+
+// 渲染节点拖拽
+const onDrag = (e) => {
+  // 当前选中节点整体移动
+  projectStore.activeWidgets.forEach((item) => {
+    item.props.x += e.beforeDelta[0]
+    item.props.y += e.beforeDelta[1]
+  })
+}
+
+// 渲染节点缩放
+const onResize = (e) => {
+  props.schema.props.width = e.width
+  props.schema.props.height = e.height
+  if (e.drag?.beforeTranslate) {
+    props.schema.props.x = e.drag.beforeTranslate[0]
+    props.schema.props.y = e.drag.beforeTranslate[1]
+  }
+  e.target.style.cssText += e.cssText
+}
+
+// 渲染节点事件
+const onRender = (e) => {
+  e.target.style.cssText += e.cssText
+}
 </script>

+ 25 - 2
src/renderer/src/views/designer/workspace/stage/index.vue

@@ -2,6 +2,7 @@
   <div
     ref="boxRef"
     class="w-full h-full relative"
+    @click="handleClick"
     @mousedown="handleMouseDown"
     @mousemove="handleMouseMove"
     @mouseup="handleMouseUp"
@@ -105,6 +106,7 @@ const props = defineProps<{
 const projectStore = useProjectStore()
 const appStore = useAppStore()
 const canvasRef = ref<InstanceType<typeof DesignerCanvas>>()
+// 画布状态
 const state = reactive<StageState>({
   scale: 1,
   width: 1280,
@@ -125,6 +127,7 @@ const state = reactive<StageState>({
   showReferenceLine: true
 })
 
+// 监听屏幕参数变化
 watch(
   () => props.screen,
   async (val) => {
@@ -134,6 +137,9 @@ watch(
       await nextTick()
       handleCenter()
     }
+  },
+  {
+    immediate: true
   }
 )
 
@@ -150,6 +156,12 @@ const handleCenter = () => {
   canvasRef.value?.initStagePosition()
 }
 
+const handleClick = () => {
+  // 点击画布空白出
+  projectStore.activePageId = props.page?.id
+  projectStore.setSelectWidgets([])
+}
+
 /* ====================处理框选多个组件======================== */
 const selectBoxRef = ref<HTMLElement | null>(null)
 const boxRef = ref<HTMLElement | null>(null)
@@ -170,7 +182,8 @@ const handleMouseDown = (e: MouseEvent) => {
     target?.closest('.scaleplate-vertical') ||
     target?.closest('.refer-line-img') ||
     target?.closest('.workspace-bottom') ||
-    target?.closest('.refer-line')
+    target?.closest('.refer-line') ||
+    target?.closest('.ignore-click')
   ) {
     return
   }
@@ -226,7 +239,17 @@ const handleMouseUp = (e: MouseEvent) => {
 }
 /* 处理框选的组件 */
 const handleSelectComponent = (startX: number, startY: number, endX: number, endY: number) => {
-  // todo 框选组件
+  const widgets = props.page?.children || []
+  const select = widgets.filter((item) => {
+    const { x, y, width, height } = item.props
+    const x1 = Math.min(startX, endX)
+    const x2 = Math.max(startX, endX)
+    const y1 = Math.min(startY, endY)
+    const y2 = Math.max(startY, endY)
+    // 返回判断完全包裹组件
+    return x >= x1 && y >= y1 && x + width <= x2 && y + height <= y2
+  })
+  projectStore.setSelectWidgets(select)
 }
 /* ====================处理框选多个组件======================== */
 </script>