Veronique il y a 1 an
commit
60ecd83dbb
100 fichiers modifiés avec 29654 ajouts et 0 suppressions
  1. 74 0
      .eslintrc-auto-import.json
  2. 46 0
      .eslintrc.js
  3. 27 0
      .gitignore
  4. 5 0
      .npmrc
  5. 5 0
      .prettierignore
  6. 37 0
      .stylelintrc.js
  7. 21 0
      LICENSE
  8. 87 0
      build/optimize.ts
  9. 117 0
      build/plugins.ts
  10. 44 0
      components.d.ts
  11. 71 0
      index.html
  12. 15284 0
      package-lock.json
  13. 106 0
      package.json
  14. 9607 0
      pnpm-lock.yaml
  15. BIN
      public/favicon.ico
  16. BIN
      public/img/icons/android-chrome-192x192.png
  17. BIN
      public/img/icons/android-chrome-512x512.png
  18. BIN
      public/img/icons/android-chrome-maskable-192x192.png
  19. BIN
      public/img/icons/android-chrome-maskable-512x512.png
  20. BIN
      public/img/icons/apple-touch-icon-120x120.png
  21. BIN
      public/img/icons/apple-touch-icon-152x152.png
  22. BIN
      public/img/icons/apple-touch-icon-180x180.png
  23. BIN
      public/img/icons/apple-touch-icon-60x60.png
  24. BIN
      public/img/icons/apple-touch-icon-76x76.png
  25. BIN
      public/img/icons/apple-touch-icon.png
  26. BIN
      public/img/icons/favicon-16x16.png
  27. BIN
      public/img/icons/favicon-32x32.png
  28. BIN
      public/img/icons/msapplication-icon-144x144.png
  29. BIN
      public/img/icons/mstile-150x150.png
  30. 3 0
      public/img/icons/safari-pinned-tab.svg
  31. BIN
      public/img/icons/yft-design-120x120.png
  32. BIN
      public/img/icons/yft-design-150x150.png
  33. BIN
      public/img/icons/yft-design-152x152.png
  34. BIN
      public/img/icons/yft-design-16x16 .png
  35. BIN
      public/img/icons/yft-design-180x180.png
  36. BIN
      public/img/icons/yft-design-192x192.png
  37. BIN
      public/img/icons/yft-design-32x32 .png
  38. BIN
      public/img/icons/yft-design-512x512.png
  39. BIN
      public/img/icons/yft-design-60x60 .png
  40. BIN
      public/img/icons/yft-design-76x76 .png
  41. 1 0
      public/nYtBCuVtfK.txt
  42. 1 0
      public/resource/color/shading.json
  43. 2 0
      public/robots.txt
  44. 39 0
      src/App.vue
  45. 8 0
      src/api/color/index.ts
  46. 36 0
      src/api/file/index.ts
  47. 45 0
      src/api/file/types.ts
  48. 37 0
      src/api/matting/index.ts
  49. 13 0
      src/api/matting/types.ts
  50. 68 0
      src/api/oauth/index.ts
  51. 98 0
      src/api/oauth/types.ts
  52. 11 0
      src/api/static/font.ts
  53. 35 0
      src/api/static/image.ts
  54. 75 0
      src/api/static/types.ts
  55. 27 0
      src/api/template/index.ts
  56. 59 0
      src/api/template/types.ts
  57. 0 0
      src/app/attribute/collect.ts
  58. 124 0
      src/app/attribute/toRef.ts
  59. 92 0
      src/app/fabricCanvas.ts
  60. 55 0
      src/app/fabricCommand.ts
  61. 492 0
      src/app/fabricControls.ts
  62. 413 0
      src/app/fabricGuide.ts
  63. 43 0
      src/app/fabricHistory.ts
  64. 172 0
      src/app/fabricHover.ts
  65. 600 0
      src/app/fabricRuler.ts
  66. 231 0
      src/app/fabricTool.ts
  67. 140 0
      src/app/fabricTouch.ts
  68. 174 0
      src/app/hoverBorders.ts
  69. 24 0
      src/app/instantiation/descriptors.ts
  70. 52 0
      src/app/instantiation/extensions.ts
  71. 108 0
      src/app/instantiation/graph.ts
  72. 106 0
      src/app/instantiation/instantiation.ts
  73. 471 0
      src/app/instantiation/instantiationService.ts
  74. 34 0
      src/app/instantiation/serviceCollection.ts
  75. 63 0
      src/app/keybinding.ts
  76. 155 0
      src/app/wheelScroll.ts
  77. BIN
      src/assets/fonts/xuminY.TTF
  78. BIN
      src/assets/fonts/仓耳小丸子.ttf
  79. BIN
      src/assets/fonts/优设标题黑.ttf
  80. BIN
      src/assets/fonts/字制区喜脉体.ttf
  81. BIN
      src/assets/fonts/峰广明锐体.ttf
  82. BIN
      src/assets/fonts/得意黑.ttf
  83. BIN
      src/assets/fonts/摄图摩登小方体.ttf
  84. BIN
      src/assets/fonts/站酷快乐体.ttf
  85. BIN
      src/assets/fonts/素材集市康康体.ttf
  86. BIN
      src/assets/fonts/素材集市酷方体.ttf
  87. BIN
      src/assets/fonts/途牛类圆体.ttf
  88. BIN
      src/assets/fonts/锐字真言体.ttf
  89. BIN
      src/assets/images/escheresque.png
  90. BIN
      src/assets/images/greyfloral.png
  91. BIN
      src/assets/images/honey_im_subtle.png
  92. BIN
      src/assets/images/index.png
  93. BIN
      src/assets/images/loading.gif
  94. BIN
      src/assets/images/nasty_fabric.png
  95. BIN
      src/assets/images/retina_wood.png
  96. BIN
      src/assets/images/rotate.png
  97. BIN
      src/assets/logo.png
  98. 13 0
      src/assets/logo.svg
  99. 3 0
      src/assets/style/element-plus.scss
  100. 0 0
      src/assets/style/font.scss

+ 74 - 0
.eslintrc-auto-import.json

@@ -0,0 +1,74 @@
+{
+  "globals": {
+    "Component": true,
+    "ComponentPublicInstance": true,
+    "ComputedRef": true,
+    "EffectScope": true,
+    "ExtractDefaultPropTypes": true,
+    "ExtractPropTypes": true,
+    "ExtractPublicPropTypes": true,
+    "InjectionKey": true,
+    "PropType": true,
+    "Ref": true,
+    "VNode": true,
+    "WritableComputedRef": true,
+    "computed": true,
+    "createApp": true,
+    "customRef": true,
+    "defineAsyncComponent": true,
+    "defineComponent": true,
+    "effectScope": true,
+    "getCurrentInstance": true,
+    "getCurrentScope": true,
+    "h": true,
+    "inject": true,
+    "isProxy": true,
+    "isReactive": true,
+    "isReadonly": true,
+    "isRef": true,
+    "markRaw": true,
+    "nextTick": true,
+    "onActivated": true,
+    "onBeforeMount": true,
+    "onBeforeUnmount": true,
+    "onBeforeUpdate": true,
+    "onDeactivated": true,
+    "onErrorCaptured": true,
+    "onMounted": true,
+    "onRenderTracked": true,
+    "onRenderTriggered": true,
+    "onScopeDispose": true,
+    "onServerPrefetch": true,
+    "onUnmounted": true,
+    "onUpdated": true,
+    "provide": true,
+    "reactive": true,
+    "readonly": true,
+    "ref": true,
+    "resolveComponent": true,
+    "shallowReactive": true,
+    "shallowReadonly": true,
+    "shallowRef": true,
+    "toRaw": true,
+    "toRef": true,
+    "toRefs": true,
+    "toValue": true,
+    "triggerRef": true,
+    "unref": true,
+    "useAttrs": true,
+    "useCssModule": true,
+    "useCssVars": true,
+    "useSlots": true,
+    "watch": true,
+    "watchEffect": true,
+    "watchPostEffect": true,
+    "watchSyncEffect": true,
+    "DirectiveBinding": true,
+    "MaybeRef": true,
+    "MaybeRefOrGetter": true,
+    "onWatcherCleanup": true,
+    "useId": true,
+    "useModel": true,
+    "useTemplateRef": true
+  }
+}

+ 46 - 0
.eslintrc.js

@@ -0,0 +1,46 @@
+module.exports = {
+  root: true,
+  env: {
+    node: true,
+    'vue/setup-compiler-macros': true
+  },
+  extends: [
+    "plugin:vue/vue3-essential",
+    "eslint:recommended",
+    "@vue/typescript/recommended",
+    // "plugin:prettier/recommended",
+    "./.eslintrc-auto-import.json"
+  ],
+  parserOptions: {
+    ecmaVersion: 2020,
+  },
+  rules: {
+    "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
+    "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
+    "@typescript-eslint/no-explicit-any": ["off"],
+    // "vue/multi-word-component-names": ["error", {ignores: ["index"]},],
+    'vue/multi-word-component-names': 'off',
+    "@typescript-eslint/ban-ts-comment": "off",
+    '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/ban-types': ['error', {
+      'extendDefaults': true,
+      'types': {
+        '{}': false,
+      },
+    }],
+    '@typescript-eslint/no-unused-vars': 'off',
+    '@typescript-eslint/no-non-null-assertion': 'off',
+    'vue/comment-directive': 'off'
+  },
+  overrides: [
+    {
+      files: [
+        '**/__tests__/*.{j,t}s?(x)',
+        '**/tests/unit/**/*.spec.{j,t}s?(x)'
+      ],
+      env: {
+        jest: true,
+      },
+    },
+  ],
+};

+ 27 - 0
.gitignore

@@ -0,0 +1,27 @@
+.DS_Store
+node_modules
+dist
+
+stats.html
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+dist/*
+dev-dist/*
+types/auto-imports.d.ts
+types/components.d.ts

+ 5 - 0
.npmrc

@@ -0,0 +1,5 @@
+registry=https://registry.npmmirror.com
+
+disturl=https://registry.npmmirror.com/-/binary/node/
+
+canvas_binary_host_mirror=https://registry.npmmirror.com/-/binary/canvas

+ 5 - 0
.prettierignore

@@ -0,0 +1,5 @@
+dist
+node_modules
+src/types/auto-imports.d.ts
+src/types/components.d.ts
+.hbuilderx

+ 37 - 0
.stylelintrc.js

@@ -0,0 +1,37 @@
+// https://stylelint.io/user-guide/rules/list
+
+module.exports = {
+  extends: 'stylelint-config-standard',
+  rules: {
+    'indentation': 2,
+    'max-nesting-depth': 5,
+    'max-empty-lines': null,
+    'no-eol-whitespace': true,
+    'no-missing-end-of-source-newline': null,
+    'no-descending-specificity': null,
+    'at-rule-empty-line-before': null,
+    'at-rule-no-unknown': null,
+    'rule-empty-line-before': null,
+    'number-leading-zero': null,
+    'string-no-newline': true,
+    'string-quotes': 'single',
+    'color-hex-case': 'lower',
+    'color-hex-length': 'short',
+    'color-no-invalid-hex': true,
+    'font-weight-notation': 'numeric',
+    'font-family-no-missing-generic-family-keyword': null,
+    'function-calc-no-unspaced-operator': true,
+    'function-url-quotes': 'never',
+    'block-no-empty': true,
+    'block-opening-brace-newline-after': 'always',
+    'block-opening-brace-space-before': 'always',
+    'declaration-block-no-duplicate-properties': [true, {
+      ignoreProperties: ['overflow'],
+    }],
+    'declaration-block-semicolon-newline-after': 'always',
+    'declaration-block-trailing-semicolon': 'always',
+    'selector-pseudo-element-colon-notation': 'double',
+    'selector-pseudo-element-no-unknown': null,
+    'selector-list-comma-newline-after': null,
+  },
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Nevermore
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 87 - 0
build/optimize.ts

@@ -0,0 +1,87 @@
+const include = [
+  'vue',
+  'axios',
+  'vue-router',
+  'vue-i18n',
+  'lodash-es',
+  'element-plus',
+  'element-plus/es',
+  'element-plus/es/locale/lang/zh-cn',
+  'element-plus/es/locale/lang/en',
+  'element-plus/es/components/backtop/style/css',
+  'element-plus/es/components/form/style/css',
+  'element-plus/es/components/radio-group/style/css',
+  'element-plus/es/components/radio/style/css',
+  'element-plus/es/components/checkbox/style/css',
+  'element-plus/es/components/checkbox-group/style/css',
+  'element-plus/es/components/switch/style/css',
+  'element-plus/es/components/time-picker/style/css',
+  'element-plus/es/components/date-picker/style/css',
+  'element-plus/es/components/descriptions/style/css',
+  'element-plus/es/components/descriptions-item/style/css',
+  'element-plus/es/components/link/style/css',
+  'element-plus/es/components/tooltip/style/css',
+  'element-plus/es/components/drawer/style/css',
+  'element-plus/es/components/dialog/style/css',
+  'element-plus/es/components/checkbox-button/style/css',
+  'element-plus/es/components/option-group/style/css',
+  'element-plus/es/components/radio-button/style/css',
+  'element-plus/es/components/cascader/style/css',
+  'element-plus/es/components/color-picker/style/css',
+  'element-plus/es/components/input-number/style/css',
+  'element-plus/es/components/rate/style/css',
+  'element-plus/es/components/select-v2/style/css',
+  'element-plus/es/components/tree-select/style/css',
+  'element-plus/es/components/slider/style/css',
+  'element-plus/es/components/time-select/style/css',
+  'element-plus/es/components/autocomplete/style/css',
+  'element-plus/es/components/image-viewer/style/css',
+  'element-plus/es/components/upload/style/css',
+  'element-plus/es/components/col/style/css',
+  'element-plus/es/components/form-item/style/css',
+  'element-plus/es/components/alert/style/css',
+  'element-plus/es/components/breadcrumb/style/css',
+  'element-plus/es/components/select/style/css',
+  'element-plus/es/components/input/style/css',
+  'element-plus/es/components/breadcrumb-item/style/css',
+  'element-plus/es/components/tag/style/css',
+  'element-plus/es/components/pagination/style/css',
+  'element-plus/es/components/table/style/css',
+  'element-plus/es/components/table-v2/style/css',
+  'element-plus/es/components/table-column/style/css',
+  'element-plus/es/components/card/style/css',
+  'element-plus/es/components/row/style/css',
+  'element-plus/es/components/button/style/css',
+  'element-plus/es/components/menu/style/css',
+  'element-plus/es/components/sub-menu/style/css',
+  'element-plus/es/components/menu-item/style/css',
+  'element-plus/es/components/option/style/css',
+  'element-plus/es/components/dropdown/style/css',
+  'element-plus/es/components/dropdown-menu/style/css',
+  'element-plus/es/components/dropdown-item/style/css',
+  'element-plus/es/components/skeleton/style/css',
+  'element-plus/es/components/skeleton/style/css',
+  'element-plus/es/components/backtop/style/css',
+  'element-plus/es/components/menu/style/css',
+  'element-plus/es/components/sub-menu/style/css',
+  'element-plus/es/components/menu-item/style/css',
+  'element-plus/es/components/dropdown/style/css',
+  'element-plus/es/components/tree/style/css',
+  'element-plus/es/components/dropdown-menu/style/css',
+  'element-plus/es/components/dropdown-item/style/css',
+  'element-plus/es/components/badge/style/css',
+  'element-plus/es/components/breadcrumb/style/css',
+  'element-plus/es/components/breadcrumb-item/style/css',
+  'element-plus/es/components/image/style/css',
+  'element-plus/es/components/collapse-transition/style/css',
+  'element-plus/es/components/timeline/style/css',
+  'element-plus/es/components/timeline-item/style/css',
+  'element-plus/es/components/collapse/style/css',
+  'element-plus/es/components/collapse-item/style/css',
+  'element-plus/es/components/button-group/style/css',
+  'element-plus/es/components/text/style/css'
+]
+
+const exclude = []
+
+export { include, exclude }

+ 117 - 0
build/plugins.ts

@@ -0,0 +1,117 @@
+import { PluginOption } from 'vite'
+import { VitePWA } from "vite-plugin-pwa";
+import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
+import { createHtmlPlugin } from "vite-plugin-html";
+import { visualizer } from "rollup-plugin-visualizer";
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
+import AutoImport from 'unplugin-auto-import/vite';
+import Components from 'unplugin-vue-components/vite';
+import viteCompression from 'vite-plugin-compression';
+import vue from "@vitejs/plugin-vue";
+import path from "path";
+
+export const createVitePlugins = (
+  mode: string,
+): (PluginOption | PluginOption[])[] => {
+  return [
+    vue(),
+    visualizer({ open: true }),
+    viteCompression({
+      verbose: true,
+      disable: false,
+      threshold: 10240,
+      algorithm: 'gzip',
+      ext: '.gz',
+    }),
+    AutoImport({
+      imports: ['vue'],
+      dts: path.resolve(__dirname, "../src/types/auto-imports.d.ts"),
+      eslintrc: {
+        enabled: true
+      },
+      resolvers: [ElementPlusResolver()],
+    }),
+    Components({
+      resolvers: [ElementPlusResolver()],
+      dts: path.resolve(__dirname, "../src/types/components.d.ts"),
+    }),
+    VitePWA({
+      registerType: "autoUpdate",
+      workbox: {
+        cacheId: "sd-designer-cache",
+        runtimeCaching: [
+          {
+            urlPattern: /.*/i,
+            handler: "NetworkFirst", // 接口网络优先
+            options: {
+              cacheName: "interface-cache",
+            },
+          },
+          {
+            urlPattern: /(.*?)\.(js|css|ts)/, // js /css /ts静态资源缓存
+            handler: "CacheFirst",
+            options: {
+              cacheName: "js-css-cache",
+            },
+          },
+          {
+            urlPattern: /(.*?)\.(png|jpe?g|svg|gif|bmp|psd|tiff|tga|eps)/, // 图片缓存
+            handler: "CacheFirst",
+            options: {
+              cacheName: "image-cache",
+            },
+          },
+        ],
+      },
+      manifest: {
+        name: "sd-designer",
+        short_name: "sd-designer",
+        theme_color: "#d14424",
+        icons: [
+          {
+            src: "/img/icons/sd-designer-192x192.png",
+            sizes: "192x192",
+            type: "image/png",
+          },
+          {
+            src: "/img/icons/sd-designer-512x512.png",
+            sizes: "512x512",
+            type: "image/png",
+          },
+          {
+            src: "/img/icons/sd-designer-192x192.png",
+            sizes: "192x192",
+            type: "image/png",
+            purpose: "maskable",
+          },
+          {
+            src: "/img/icons/sd-designer-512x512.png",
+            sizes: "512x512",
+            type: "image/png",
+            purpose: "maskable",
+          },
+        ],
+        start_url: "./index.html",
+        display: "standalone",
+        background_color: "#000000",
+      },
+      devOptions: {
+        enabled: false,
+      }
+    }),
+    createSvgIconsPlugin({
+      iconDirs: [path.resolve(process.cwd(), "src/icons/svg")], // icon存放的目录
+      symbolId: "icon-[name]", // symbol的id
+      inject: "body-last", // 插入的位置
+      customDomId: "__svg__icons__dom__", // svg的id
+    }),
+    createHtmlPlugin({
+      minify: true,
+      inject: {
+        data: {
+          title: 'sd-designer'
+        }
+      }
+    })
+  ]
+}

+ 44 - 0
components.d.ts

@@ -0,0 +1,44 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+declare module 'vue' {
+  export interface GlobalComponents {
+    Alpha: typeof import('./src/components/ColorPicker/Alpha.vue')['default']
+    Checkboard: typeof import('./src/components/ColorPicker/Checkboard.vue')['default']
+    ColorButton: typeof import('./src/components/ColorButton.vue')['default']
+    ColorPicker: typeof import('./src/components/ColorPicker/index.vue')['default']
+    Contextmenu: typeof import('./src/components/Contextmenu/index.vue')['default']
+    EditableInput: typeof import('./src/components/ColorPicker/EditableInput.vue')['default']
+    ExportImage: typeof import('./src/components/FileExport/ExportImage.vue')['default']
+    ExportJSON: typeof import('./src/components/FileExport/ExportJSON.vue')['default']
+    ExportPDF: typeof import('./src/components/FileExport/ExportPDF.vue')['default']
+    ExportPSD: typeof import('./src/components/FileExport/ExportPSD.vue')['default']
+    ExportSVG: typeof import('./src/components/FileExport/ExportSVG.vue')['default']
+    Extraction: typeof import('./src/components/ImageMatting/extraction.vue')['default']
+    FileExport: typeof import('./src/components/FileExport/index.vue')['default']
+    FileInput: typeof import('./src/components/FileInput.vue')['default']
+    FileUpload: typeof import('./src/components/FileUpload/index.vue')['default']
+    FullscreenSpin: typeof import('./src/components/FullscreenSpin.vue')['default']
+    HomePopover: typeof import('./src/components/HomePopover.vue')['default']
+    Hue: typeof import('./src/components/ColorPicker/Hue.vue')['default']
+    ImageFillColor: typeof import('./src/components/ImageFillColor.vue')['default']
+    ImageMatting: typeof import('./src/components/ImageMatting/index.vue')['default']
+    Lang: typeof import('./src/components/Lang/index.vue')['default']
+    LinePointMarker: typeof import('./src/components/LinePointMarker.vue')['default']
+    LoginDialog: typeof import('./src/components/LoginDialog/index.vue')['default']
+    Matting: typeof import('./src/components/ImageMatting/matting.vue')['default']
+    MenuContent: typeof import('./src/components/Contextmenu/MenuContent.vue')['default']
+    OpenGpt: typeof import('./src/components/GPTModal/OpenGpt.vue')['default']
+    ReferencePopover: typeof import('./src/components/ReferencePopover.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    Saturation: typeof import('./src/components/ColorPicker/Saturation.vue')['default']
+    SvgIcon: typeof import('./src/components/SvgIcon/index.vue')['default']
+    SwipeInput: typeof import('./src/components/SwipeInput.vue')['default']
+    TextColorButton: typeof import('./src/components/TextColorButton.vue')['default']
+  }
+}

+ 71 - 0
index.html

@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title><%- title %></title>
+    <style>
+      .first-screen-loading {
+        width: 200px;
+        height: 200px;
+        position: fixed;
+        top: 50%;
+        left: 50%;
+        margin-top: -100px;
+        margin-left: -100px;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        align-items: center;
+      }
+      .first-screen-loading-spinner {
+        width: 36px;
+        height: 36px;
+        border: 3px solid #409eff;
+        border-top-color: transparent;
+        border-radius: 50%;
+        animation: spinner 0.8s linear infinite;
+      }
+      .first-screen-loading-text {
+        margin-top: 20px;
+        color: #409eff;
+      }
+      @keyframes spinner {
+        0% {
+          transform: rotate(0deg);
+        }
+        100% {
+          transform: rotate(360deg);
+        }
+      }
+    </style>
+  </head>
+  <body>
+    <noscript>
+      <strong
+        >We're sorry but <%- title %> doesn't work properly without
+        JavaScript enabled. Please enable it to continue.</strong
+      >
+    </noscript>
+    <div id="app">
+      <div class="first-screen-loading">
+        <div class="first-screen-loading-spinner"></div>
+        <div class="first-screen-loading-text">正在加载中,请稍等 ...</div>
+      </div>
+      <script>
+        document.oncontextmenu = (e) => e.preventDefault();
+      </script>
+      <script type="module" src="./src/main.ts"></script>
+    </div>
+  </body>
+  <script>
+    var _hmt = _hmt || [];
+    (function() {
+      var hm = document.createElement("script");
+      hm.src = "https://hm.baidu.com/hm.js?f4f61fa05a26e4d1d0314cec127ce9bf";
+      var s = document.getElementsByTagName("script")[0]; 
+      s.parentNode.insertBefore(hm, s);
+    })();
+  </script>
+</html>

Fichier diff supprimé car celui-ci est trop grand
+ 15284 - 0
package-lock.json


+ 106 - 0
package.json

@@ -0,0 +1,106 @@
+{
+  "name": "sd-designer",
+  "version": "0.1.0",
+  "private": true,
+  "keywords": [
+    "Vue3",
+    "fabric.js",
+    "Typescript",
+    "Element-plus"
+  ],
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "deploy": "bash deploy.sh"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.1.0",
+    "@icon-park/vue-next": "^1.4.2",
+    "@pixi/webworker": "^7.3.3",
+    "@vueuse/core": "^10.11.0",
+    "@vueuse/shared": "^10.11.0",
+    "axios": "^1.7.2",
+    "beautify-qrcode": "^1.0.3",
+    "canvas": "^2.11.2",
+    "changedpi": "^1.0.4",
+    "chroma-js": "^2.4.2",
+    "clipboard": "^2.0.11",
+    "clipper-lib": "^6.4.2",
+    "core-js": "^3.37.1",
+    "crypto-js": "^4.2.0",
+    "delaunator": "^5.0.1",
+    "dexie": "^3.2.7",
+    "element-plus": "^2.7.6",
+    "fabric": "^6.4.1",
+    "file-saver": "^2.0.5",
+    "gifler": "^0.1.0",
+    "html-to-image": "^1.11.11",
+    "jsbarcode": "^3.11.6",
+    "lodash-es": "^4.17.21",
+    "mousetrap": "^1.6.5",
+    "nanoid": "^5.0.3",
+    "number-precision": "^1.6.0",
+    "opentype.js": "^1.3.4",
+    "pako": "^2.1.0",
+    "perfect-freehand": "^1.2.0",
+    "pinia": "^2.1.7",
+    "pixi-filters": "^5.2.1",
+    "pixi.js": "^7.3.3",
+    "raphael": "^2.3.0",
+    "register-service-worker": "^1.7.2",
+    "tinycolor2": "^1.6.0",
+    "vue": "^3.4.30",
+    "vue-i18n": "^9.13.1",
+    "vue-router": "^4.4.0",
+    "vuedraggable": "^4.1.0"
+  },
+  "devDependencies": {
+    "@types/chroma-js": "^2.4.3",
+    "@types/clipboard": "^2.0.7",
+    "@types/crypto-js": "^4.2.1",
+    "@types/delaunator": "^5.0.2",
+    "@types/file-saver": "^2.0.7",
+    "@types/hammerjs": "^2.0.45",
+    "@types/lodash-es": "^4.17.12",
+    "@types/mousetrap": "^1.6.15",
+    "@types/opentype.js": "^1.3.8",
+    "@types/pako": "^2.0.3",
+    "@types/pdfkit": "^0.13.3",
+    "@types/raphael": "^2.3.9",
+    "@types/tinycolor2": "^1.4.6",
+    "@typescript-eslint/eslint-plugin": "^5.62.0",
+    "@typescript-eslint/parser": "^5.62.0",
+    "@vitejs/plugin-vue": "^4.5.0",
+    "@vue/eslint-config-typescript": "^11.0.3",
+    "autoprefixer": "^10.4.19",
+    "eslint": "^7.32.0",
+    "eslint-config-prettier": "^8.10.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.18.1",
+    "less": "^4.2.0",
+    "less-loader": "^11.1.4",
+    "postcss": "^8.4.38",
+    "postcss-loader": "^8.1.1",
+    "prettier": "^2.8.8",
+    "rollup-plugin-visualizer": "^5.12.0",
+    "sass": "^1.77.6",
+    "sass-loader": "^16.0.1",
+    "stylelint-config-standard": "^29.0.0",
+    "stylelint-webpack-plugin": "^3.3.0",
+    "svg-sprite-loader": "^6.0.11",
+    "tailwindcss": "^3.4.4",
+    "typescript": "^4.9.5",
+    "unplugin-auto-import": "^0.16.7",
+    "unplugin-vue-components": "^0.25.2",
+    "vite": "^5.4.3",
+    "vite-plugin-compression": "^0.5.1",
+    "vite-plugin-html": "^3.2.2",
+    "vite-plugin-pwa": "^0.15.2",
+    "vite-plugin-svg-icons": "^2.0.1",
+    "vue-tsc": "^1.8.22",
+    "workbox-window": "^7.0.0"
+  },
+  "volta": {
+    "node": "18.19.0"
+  }
+}

Fichier diff supprimé car celui-ci est trop grand
+ 9607 - 0
pnpm-lock.yaml


BIN
public/favicon.ico


BIN
public/img/icons/android-chrome-192x192.png


BIN
public/img/icons/android-chrome-512x512.png


BIN
public/img/icons/android-chrome-maskable-192x192.png


BIN
public/img/icons/android-chrome-maskable-512x512.png


BIN
public/img/icons/apple-touch-icon-120x120.png


BIN
public/img/icons/apple-touch-icon-152x152.png


BIN
public/img/icons/apple-touch-icon-180x180.png


BIN
public/img/icons/apple-touch-icon-60x60.png


BIN
public/img/icons/apple-touch-icon-76x76.png


BIN
public/img/icons/apple-touch-icon.png


BIN
public/img/icons/favicon-16x16.png


BIN
public/img/icons/favicon-32x32.png


BIN
public/img/icons/msapplication-icon-144x144.png


BIN
public/img/icons/mstile-150x150.png


+ 3 - 0
public/img/icons/safari-pinned-tab.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
+</svg>

BIN
public/img/icons/yft-design-120x120.png


BIN
public/img/icons/yft-design-150x150.png


BIN
public/img/icons/yft-design-152x152.png


BIN
public/img/icons/yft-design-16x16 .png


BIN
public/img/icons/yft-design-180x180.png


BIN
public/img/icons/yft-design-192x192.png


BIN
public/img/icons/yft-design-32x32 .png


BIN
public/img/icons/yft-design-512x512.png


BIN
public/img/icons/yft-design-60x60 .png


BIN
public/img/icons/yft-design-76x76 .png


+ 1 - 0
public/nYtBCuVtfK.txt

@@ -0,0 +1 @@
+65c72e7981788f7c732ed70fbd3d78c0

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
public/resource/color/shading.json


+ 2 - 0
public/robots.txt

@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:

+ 39 - 0
src/App.vue

@@ -0,0 +1,39 @@
+<!--
+ * @Author: June 1601745371@qq.com
+ * @Date: 2024-03-08 09:06:56
+ * @LastEditors: June 1601745371@qq.com
+ * @LastEditTime: 2024-03-11 12:00:19
+ * @FilePath: \github\sd-designer\src\App.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <el-config-provider :locale="locale.el">
+    <router-view />
+  </el-config-provider>
+</template>
+
+<script lang="ts" setup>
+import useI18n from '@/hooks/useI18n'
+
+const { messages }= useI18n()
+const locale = computed(() => messages.value)
+
+// 在主入口监听PWA注册事件
+window.addEventListener('beforeinstallprompt', (e) => {
+  e.preventDefault();
+  window.deferredPrompt = e;
+})
+
+
+</script>
+
+<style lang="scss">
+#app {
+  height: 100%;
+}
+</style>
+<style scoped>
+:deep(#app .el-divider .el-divider--horizontal) {
+  margin: 12px 0;
+}
+</style>

+ 8 - 0
src/api/color/index.ts

@@ -0,0 +1,8 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+export function getColorShading(): any {
+  return request({
+    url: 'resource/color/shading.json',
+    method: 'get',
+  });
+}

+ 36 - 0
src/api/file/index.ts

@@ -0,0 +1,36 @@
+import request from '@/utils/request'
+import { AxiosPromise } from 'axios'
+import { UploadResult, ExportData, ExportResult } from './types'
+
+/**
+ * 上传文件
+ *
+ * @param file
+ */
+export function uploadFile(file: File, type: string): AxiosPromise<UploadResult> {
+  const formData = new FormData()
+  formData.append('file', file)
+  formData.append('type', type)
+  return request({
+    url: '/api/design/parse/file',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data',
+    },
+  })
+}
+
+/**
+ * 导出文件
+ *
+ * @param file
+ */
+export function exportFile(data: ExportData): AxiosPromise<ExportResult> {
+
+  return request({
+    url: '/api/design/export/file',
+    method: 'post',
+    data,
+  })
+}

+ 45 - 0
src/api/file/types.ts

@@ -0,0 +1,45 @@
+import { Template } from "@/types/canvas"
+
+export interface SVGInfo {
+  width: number
+  height: number
+  index: number
+  svg: string
+}
+
+export interface UploadResult {
+  code: number
+  msg: string
+  file: string
+  svg: SVGInfo[]
+  data: Template | Template[]
+}
+
+export interface ExportContent {
+  data: string
+  width: number
+  height: number
+}
+
+export interface ExportData {
+  data: string[]
+  width: number
+  height: number
+}
+
+export interface ExportResult {
+  code: number
+  msg: string
+  link: string
+}
+
+/**
+ * 文件API类型声明
+ */
+export interface ImgData {
+  id: number
+  name: string
+  dateTime: string
+  imgPath: string
+}
+

+ 37 - 0
src/api/matting/index.ts

@@ -0,0 +1,37 @@
+import request from '@/utils/request'
+import { AxiosPromise } from 'axios'
+import { UploadResult } from './types'
+
+
+/**
+ * 上传文件
+ *
+ * @param image
+ */
+ export function uploadImage(image: File, type?: string): AxiosPromise<UploadResult> {
+  const formData = new FormData()
+  formData.append('image', image)
+  return request({
+    url: '/api/design/matting/file',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data',
+    },
+  })
+}
+
+/**
+ * 上传URL
+ *
+ * @param image
+ */
+export function uploadURL(image: string): AxiosPromise<UploadResult> {
+  // const formData = new FormData()
+  // formData.append('image', image)
+  return request({
+    url: '/api/matting/url',
+    method: 'post',
+    data: {image},
+  })
+}

+ 13 - 0
src/api/matting/types.ts

@@ -0,0 +1,13 @@
+export interface ImageSize {
+  width: number
+  height: number
+}
+
+export interface UploadResult {
+  code: number
+  msg: string
+  resultImage: string
+  maskImage: string
+  size: ImageSize
+  time: string
+}

+ 68 - 0
src/api/oauth/index.ts

@@ -0,0 +1,68 @@
+import request from '@/utils/request'
+import { AxiosPromise } from 'axios'
+import { 
+  OauthWechatResult, 
+  ImageCaptchaResult, 
+  EmailCaptchaResult,
+  OauthVerifyData, 
+  OauthCheckData,
+  OauthGithubResult, 
+  CodeParams, 
+  EmailData,
+  OauthUserResult,
+  OauthRegisterResult
+} from './types'
+
+
+export function imageCaptcha(): AxiosPromise<ImageCaptchaResult> {
+  return request({
+    url: '/api/oauth/captcha/image',
+    method: 'get',
+  })
+}
+
+export function emailCaptcha(data: EmailData): AxiosPromise<EmailCaptchaResult> {
+  return request({
+    url: '/api/oauth/captcha/email',
+    method: 'post',
+    data
+  })
+}
+
+export function oauthRegister(data: OauthVerifyData): AxiosPromise<OauthRegisterResult> {
+  return request({
+    url: '/api/oauth/register',
+    method: 'post',
+    data
+  })
+}
+
+export function oauthLogin(data: OauthVerifyData): AxiosPromise<OauthUserResult> {
+  return request({
+    url: '/api/oauth/login',
+    method: 'post',
+    data
+  })
+}
+
+export function oauthWechat(): AxiosPromise<OauthWechatResult> {
+  return request({
+    url: '/api/oauth/token/wechat',
+    method: 'post',
+  })
+}
+
+export function oauthTokenGithub(): AxiosPromise<OauthGithubResult> {
+  return request({
+    url: '/api/oauth/github/token',
+    method: 'get',
+  })
+}
+
+export function oauthCallbackGithub(params: CodeParams): AxiosPromise<OauthUserResult> {
+  return request({
+    url: '/api/oauth/github/callback',
+    method: 'get',
+    params,
+  })
+}

+ 98 - 0
src/api/oauth/types.ts

@@ -0,0 +1,98 @@
+export interface YFTUser {
+  id: number
+  uuid: string
+  username: string
+  nickname: string
+  phone?: string
+  avatar?: string
+  dept_id?: number
+  email: string
+  is_multi_login: boolean
+  is_staff: boolean
+  is_superuser: boolean
+  join_time: string
+  last_login_time: string
+}
+
+export interface CaptchaResult {
+  image: string
+  image_type: string
+}
+
+export interface ImageCaptchaResult {
+  code: number
+  data: CaptchaResult
+  msg: string
+}
+
+export interface EmailResult {
+  msg: string
+}
+
+export interface EmailCaptchaResult {
+  code: number
+  data: EmailResult
+  msg: string
+}
+
+export interface OauthVerifyData {
+  email: string
+  password: string
+  captcha: string
+}
+
+export interface OauthCheckData {
+  email: string
+  password: string
+}
+
+
+export interface RegisterResult {
+  code: string
+}
+
+export interface OauthRegisterResult {
+  code: number
+  data: RegisterResult
+  msg: string
+}
+
+export interface OauthWechatData {
+  img: string
+}
+
+export interface OauthWechatResult {
+  code: number
+  data: OauthWechatData
+  msg: string
+}
+
+export interface OauthGithubResult {
+  code: number
+  data: string
+  msg: string
+}
+
+export interface EmailData {
+  email: string
+}
+
+export interface CodeParams {
+  code: string
+}
+
+export interface UserResult {
+  access_token: string
+  access_token_expire_time: string
+  access_token_type: string
+  refresh_token: string
+  refresh_token_expire_time: string
+  refresh_token_type: string
+  user: YFTUser
+}
+
+export interface OauthUserResult {
+  code: number
+  data: UserResult
+  msg: string
+}

+ 11 - 0
src/api/static/font.ts

@@ -0,0 +1,11 @@
+import request from '@/utils/request'
+import { QueryFont, FontInfoResult } from './types'
+import { AxiosPromise } from 'axios'
+
+export const getFontInfo = (params?: QueryFont): AxiosPromise<FontInfoResult> => {
+  return request({
+    url: 'api/design/font/info',
+    method: 'get',
+    params,
+  });
+}

+ 35 - 0
src/api/static/image.ts

@@ -0,0 +1,35 @@
+import request from '@/utils/request'
+import { QueryPgaes, QueryCategory, ImagePageResult, ImageCategoryResult } from './types'
+import { AxiosPromise } from 'axios'
+
+export function getImagePages(params?: QueryPgaes): AxiosPromise<ImagePageResult> {
+  return request({
+    url: 'api/design/image/page',
+    method: 'get',
+    params,
+  });
+}
+
+export function getImageCategory(params?: QueryCategory): AxiosPromise<ImageCategoryResult> {
+  return request({
+    url: 'api/design/image/category',
+    method: 'get',
+    params
+  });
+}
+
+export function getIllustrationPages(params?: QueryPgaes): AxiosPromise<ImagePageResult> {
+  return request({
+    url: 'api/design/illustration/page',
+    method: 'get',
+    params,
+  });
+}
+
+export function getIllustrationCategory(params?: QueryCategory): AxiosPromise<ImageCategoryResult> {
+  return request({
+    url: 'api/design/illustration/category',
+    method: 'get',
+    params
+  });
+}

+ 75 - 0
src/api/static/types.ts

@@ -0,0 +1,75 @@
+export interface QueryFont {
+  size?: number
+  page?: number
+}
+
+export interface FontInfo {
+  id: number
+  url: string
+  fontname: string
+}
+
+export interface FontInfoResult {
+  code: number
+  msg: string
+  data: FontInfo[]
+}
+
+
+export interface QueryPgaes {
+  t: string
+  page: number
+}
+
+export interface QueryCategory {
+  t: string
+}
+
+export enum ImageType {
+  photo,
+  illustration,
+  vector,
+}
+
+export interface ImageHit {
+  id: number
+  pageURL: string
+  type: ImageType
+  tags: string
+  previewURL: string
+  previewWidth: number
+  previewHeight: number
+  webformatURL: string
+  webformatWidth: number
+  webformatHeight: number
+  largeImageURL: string
+  imageWidth: number
+  imageHeight: number
+  imageSize: number
+  views: number
+  downloads: number
+  collections: number
+  likes: number
+  comments: number
+  user_id: number
+  user: string
+  userImageURL: string
+}
+
+export interface ImagePage {
+  total: number
+  totalHits: number
+  hits: ImageHit[]
+}
+
+export interface ImagePageResult {
+  code: number
+  msg: string
+  data: ImageHit[]
+}
+
+export interface ImageCategoryResult {
+  code: number
+  msg: string
+  data: ImageHit[]
+}

+ 27 - 0
src/api/template/index.ts

@@ -0,0 +1,27 @@
+import { AxiosPromise } from 'axios'
+import request from '@/utils/request'
+import { PageParams, infoParams, TemplateResult, TemplateInfo } from "./types"
+
+
+export const getTemplateInfoPages = (params: PageParams): AxiosPromise<TemplateResult> => {
+  return request({
+    url: '/api/design/template/info/pages',
+    method: 'get',
+    params,
+  })
+}
+
+export const getTemplateDetailPages = (params: PageParams): AxiosPromise<TemplateResult> => {
+  return request({
+    url: '/api/design/template/detail/pages',
+    method: 'get',
+    params,
+  })
+}
+
+export const getTemplateData = (pk: number): AxiosPromise<TemplateInfo> => {
+  return request({
+    url: `/api/design/template/data/${pk}`,
+    method: 'get',
+  })
+}

+ 59 - 0
src/api/template/types.ts

@@ -0,0 +1,59 @@
+export interface ItemInfo {
+  id: number
+  title: string
+  text: string
+  /** 图片路径 */
+  photo: string
+  /** 图片的宽度,前端获取图片信息之后设置 */
+  width?: number
+  /** 图片的高度,前端获取图片信息之后设置 */
+  height?: number
+  /** 
+   * 当前节点的所在列的高度
+   * - 非列的总高度,只是调试用
+   */
+  currentColumnHeight?: number
+}
+
+export type ItemList = Array<ItemInfo>;
+
+export interface PageParams {
+  page: number
+  size: number
+}
+
+export interface infoParams {
+  pk: number
+}
+
+export interface TemplateItem {
+  id: number
+  preview: string
+  width: number
+  height: number
+  data: string
+  title: string
+  text: string
+  images?: string
+}
+
+export interface PageResult {
+  total: number
+  page: number
+  total_pages: number
+  size: number
+  pages: number
+  items: TemplateItem[]
+}
+
+export interface TemplateResult {
+  code: number
+  data: PageResult
+  msg: string
+}
+
+export interface TemplateInfo {
+  code: number
+  data: TemplateItem
+  msg: string
+}

+ 0 - 0
src/app/attribute/collect.ts


+ 124 - 0
src/app/attribute/toRef.ts

@@ -0,0 +1,124 @@
+import { reactive } from 'vue'
+import { Object as FabricObject, ObjectRef } from 'fabric'
+
+
+/**
+ * 元素添加相应式属性
+ */
+const toRef = (object: FabricObject) => {
+  if (object.ref) return object
+
+  const keyArr: (keyof ObjectRef)[] = [
+    'id',
+    'name',
+    'hideOnLayer',
+    'originX',
+    'originY',
+    'top',
+    'left',
+    'width',
+    'height',
+    'scaleX',
+    'scaleY',
+    'flipX',
+    'flipY',
+    'opacity',
+    'angle',
+    'skewX',
+    'skewY',
+    'hoverCursor',
+    'moveCursor',
+    'padding',
+    'borderColor',
+    'borderDashArray',
+    'cornerColor',
+    'cornerStrokeColor',
+    'cornerStyle',
+    'cornerDashArray',
+    'centeredScaling',
+    'centeredRotation',
+    'fill',
+    'fillRule',
+    'globalCompositeOperation',
+    'backgroundColor',
+    'selectionBackgroundColor',
+    'stroke',
+    'strokeWidth',
+    'strokeDashArray',
+    'strokeDashOffset',
+    'strokeLineCap',
+    'strokeLineJoin',
+    'strokeMiterLimit',
+    'shadow',
+    'borderScaleFactor',
+    'minScaleLimit',
+    'selectable',
+    'evented',
+    'visible',
+    'hasControls',
+    'hasBorders',
+    'perPixelTargetFind',
+    'includeDefaultValues',
+    'lockMovementX',
+    'lockMovementY',
+    'lockRotation',
+    'lockScalingX',
+    'lockScalingY',
+    'lockSkewingX',
+    'lockSkewingY',
+    'lockScalingFlip',
+    'excludeFromExport',
+    'objectCaching',
+    'noScaleCache',
+    'strokeUniform',
+    'dirty',
+    'paintFirst',
+    'activeOn',
+    'colorProperties',
+    'inverted',
+    'absolutePositioned',
+  ]
+
+  if (object.isType('Rect')) {
+    keyArr.push('rx', 'ry')
+  }
+
+  if (object.isType('Text', 'Textbox')) {
+    keyArr.push(
+      'text',
+      'charSpacing',
+      'lineHeight',
+      'fontSize',
+      'fontWeight',
+      'fontFamily',
+      'fontStyle',
+      'pathSide',
+      'pathAlign',
+      'underline',
+      'overline',
+      'linethrough',
+      'textAlign',
+      'direction',
+    )
+  }
+
+  object.ref = reactive({}) as ObjectRef
+
+  keyArr.forEach(<K extends keyof ObjectRef>(key: K) => {
+    object.ref[key] = object[key]
+
+    Object.defineProperty(object, key, {
+      get() {
+        return this.ref[key]
+      },
+      set(value) {
+        if (this.ref[key] === value) return
+        this.ref[key] = value
+      },
+    })
+  })
+
+  return object
+}
+
+export { toRef }

+ 92 - 0
src/app/fabricCanvas.ts

@@ -0,0 +1,92 @@
+import { useMainStore, useTemplatesStore } from '@/store'
+import { Canvas, FabricObject, FabricImage, Point, TMat2D } from 'fabric'
+import { shallowRef } from 'vue'
+import { toRef } from './attribute/toRef'
+import { check } from '@/utils/check'
+import { nonid } from '@/utils/common'
+import { FabricRuler } from './fabricRuler'
+
+export class FabricCanvas extends Canvas {
+  public ruler?: FabricRuler
+  public loading?: FabricImage 
+  public activeObject = shallowRef<FabricObject>()
+
+  constructor(el: string | HTMLCanvasElement, options?: any) {
+    super(el, options)
+  }
+
+  // @ts-ignore
+  public get _activeObject() {
+    return this.activeObject ? this.activeObject.value : undefined
+  }
+
+  public set _activeObject(value) {
+    const mainStore = useMainStore()
+    mainStore.setCanvasObject(value as FabricObject)
+    this.activeObject.value = value
+  }
+
+  override add(...objects: FabricObject[]): number {
+    return super.add(
+      ...objects.map((obj) => {
+        this.setDefaultAttr(obj)
+        return toRef(obj)
+      }),
+    )
+  }
+
+  override insertAt(index: number, ...objects: FabricObject[]): number {
+    return super.insertAt(
+      index,
+      ...objects.map((obj) => {
+        this.setDefaultAttr(obj)
+        return toRef(obj)
+      }),
+    )
+  }
+
+  private setDefaultAttr(target: FabricObject) {
+    // 添加name
+    if (!target.name) {
+      target.set({name: target.type})
+    }
+    // 添加id
+    if (!target.id) {
+      target.set({id: nonid(8)})
+    }
+    if (check.isTextObject(target)) {
+      target.set({color: target.fill})
+    }
+    if (check.isCollection(target)) {
+      target._objects.forEach((obj) => {
+        this.setDefaultAttr(obj)
+      })
+    }
+  }
+
+  override absolutePan(point: Point, skipSetCoords?: boolean) {
+    const vpt: TMat2D = [...this.viewportTransform]
+    vpt[4] = -point.x
+    vpt[5] = -point.y
+    // 执行 setCoords 导致卡顿,添加一个跳过属性
+    if (skipSetCoords) {
+      this.viewportTransform = vpt
+      // this.getObjects()?.forEach((board) => {
+      //   FabricObject.prototype.setCoords.call(board)
+      // })
+      this.requestRenderAll()
+      return
+    }
+    this.setViewportTransform(vpt)
+  }
+
+  override relativePan(point: Point, skipSetCoords?: boolean) {
+    return this.absolutePan(
+      new Point(
+        -point.x - this.viewportTransform[4],
+        -point.y - this.viewportTransform[5]
+      ),
+      skipSetCoords
+    );
+  }
+}

+ 55 - 0
src/app/fabricCommand.ts

@@ -0,0 +1,55 @@
+
+import { Object as FabricObject } from "fabric";
+
+class FabricCommand {
+  public receiver: FabricObject
+  public state: any
+  public prevState: any
+  public stateProperties: any
+  constructor(receiver: FabricObject) {
+    this.receiver = receiver;
+
+    this._initStateProperties();
+    this.state = {};
+    this.prevState = {};
+
+    this._saveState();
+    this._savePrevState();
+  }
+  execute() {
+    this._restoreState();
+    this.receiver.setCoords();
+  }
+  undo() {
+    this._restorePrevState();
+    this.receiver.setCoords();
+  }
+  _initStateProperties() {
+    this.stateProperties = Object.keys(this.receiver._stateProperties);
+  }
+  _restoreState() {
+    this._restore(this.state);
+  }
+  _restorePrevState() {
+    this._restore(this.prevState);
+  }
+  _restore(state: any) {
+    this.stateProperties.forEach(prop => {
+      this.receiver.set(prop, state[prop]);
+    });
+  }
+  _saveState() {
+    this.stateProperties.forEach(prop => {
+      this.state[prop] = this.receiver.get(prop);
+    });
+  }
+  _savePrevState() {
+    if (this.receiver._stateProperties) {
+      this.stateProperties.forEach(prop => {
+        this.prevState[prop] = this.receiver._stateProperties[prop];
+      });
+    }
+  }
+}
+
+export default FabricCommand;

Fichier diff supprimé car celui-ci est trop grand
+ 492 - 0
src/app/fabricControls.ts


+ 413 - 0
src/app/fabricGuide.ts

@@ -0,0 +1,413 @@
+
+import { StaticCanvas, Canvas, ActiveSelection, Object as FabricObject, Group, Point, util } from 'fabric'
+import { Disposable } from '@/utils/lifecycle'
+import { check } from '@/utils/check'
+
+type VerticalLineCoords = {
+  x: number
+  y1: number
+  y2: number
+}
+
+type HorizontalLineCoords = {
+  y: number
+  x1: number
+  x2: number
+}
+
+type IgnoreObjTypes<T = keyof FabricObject> = {
+  key: T
+  value: any
+}[]
+
+type ACoordsAppendCenter = NonNullable<FabricObject['aCoords']> & {
+  c: Point
+}
+
+const Keys = <T extends object>(obj: T): (keyof T)[] => {
+  return Object.keys(obj) as (keyof T)[]
+}
+
+export class FabricGuide extends Disposable {
+  private canvasEvents
+
+  private aligningLineMargin = 10
+  private aligningLineWidth = 1
+  private aligningLineColor = '#F68066'
+
+  private verticalLines: VerticalLineCoords[] = []
+  private horizontalLines: HorizontalLineCoords[] = []
+  private activeObj: FabricObject | undefined
+  private ignoreObjTypes: IgnoreObjTypes = []
+  private pickObjTypes: IgnoreObjTypes = []
+  private dirty = false
+
+  constructor(private readonly canvas: Canvas) {
+    super()
+
+    const mouseUp = () => {
+      if (this.horizontalLines.length || this.verticalLines.length) {
+        this.clearGuideline()
+        this.clearStretLine()
+      }
+    }
+
+    this.canvasEvents = {
+      'before:render': this.clearGuideline.bind(this),
+      'after:render': this.drawGuideLines.bind(this),
+      'object:moving': this.objectMoving.bind(this),
+      'mouse:up': mouseUp,
+    }
+
+    canvas.on(this.canvasEvents as any)
+  }
+
+  private objectMoving({ target }: any) {
+    this.clearStretLine()
+    if (check.isCircle(target)) {
+      return false
+    }
+    const transform = this.canvas._currentTransform
+    if (!transform) return
+
+    this.activeObj = target
+
+    const activeObjects = this.canvas.getActiveObjects()
+
+    const canvasObjects: FabricObject[] = []
+    const add = (group: Group | Canvas | StaticCanvas | ActiveSelection) => {
+      const objects = group.getObjects().filter((obj) => {
+        if (this.ignoreObjTypes.length) {
+          return !this.ignoreObjTypes.some((item) => obj.get(item.key) === item.value)
+        }
+        if (this.pickObjTypes.length) {
+          return this.pickObjTypes.some((item) => obj.get(item.key) === item.value)
+        }
+        // 排除 自己 和 激活选区内的元素
+        if (activeObjects.includes(obj)) {
+          return false
+        }
+        if (!obj.visible) {
+          return false
+        }
+        // 元素为组,把组内元素加入,同时排除组本身
+        if (check.isActiveSelection(obj)) {
+          add(obj)
+          return false
+        }
+        // 元素为组,把组内元素加入,同时排除组本身
+        if (check.isCollection(obj) && target.group && obj === target.group) {
+          add(obj)
+          return false
+        }
+        return true
+      })
+      canvasObjects.push(...objects as FabricObject[])
+    }
+
+    if (check.isActiveSelection(target)) {
+      const needAddParent = new Set<Group | Canvas | StaticCanvas>()
+      target.forEachObject((obj) => {
+        const parent = obj.group ? obj.group : this.canvas
+        if (parent) needAddParent.add(parent as Group)
+      })
+      needAddParent.forEach((parent) => {
+        if (check.isNativeGroup(parent)) {
+          canvasObjects.push(parent)
+        }
+        add(parent)
+      })
+    } else {
+      const parent = target.group ? target.group : this.canvas
+      if (check.isNativeGroup(parent)) {
+        canvasObjects.push(parent)
+      }
+      add(parent)
+    }
+
+    this.traversAllObjects(target, canvasObjects)
+  }
+
+  private clearStretLine() {
+    this.verticalLines.length = this.horizontalLines.length = 0
+  }
+
+  private getObjDraggingObjCoords(activeObject: FabricObject): ACoordsAppendCenter {
+    const coords = this.getCoords(activeObject)
+    const centerPoint = this.calcCenterPointByACoords(coords).subtract(activeObject.getCenterPoint())
+    const newCoords = Keys(coords).map((key) => coords[key].subtract(centerPoint))
+    return {
+      tl: newCoords[0],
+      tr: newCoords[1],
+      br: newCoords[2],
+      bl: newCoords[3],
+      c: activeObject.getCenterPoint(),
+    }
+  }
+
+  private getObjMaxWidthHeightByCoords(coords: ACoordsAppendCenter) {
+    const { c, tl, tr } = coords
+    const objHeight = Math.max(Math.abs(c.y - tl.y), Math.abs(c.y - tr.y)) * 2
+    const objWidth = Math.max(Math.abs(c.x - tl.x), Math.abs(c.x - tr.x)) * 2
+    return { objHeight, objWidth }
+  }
+
+  // 当对象被旋转时,需要忽略一些坐标,例如水平辅助线只取最上、下边的坐标(参考 figma)
+  private omitCoords(objCoords: ACoordsAppendCenter, type: 'vertical' | 'horizontal') {
+    const newCoords = objCoords
+    const axis = type === 'vertical' ? 'x' : 'y'
+    Keys(objCoords).forEach((key) => {
+      if (objCoords[key][axis] < newCoords.tl[axis]) {
+        newCoords[key] = objCoords[key]
+      }
+      if (objCoords[key][axis] > newCoords.tl[axis]) {
+        newCoords[key] = objCoords[key]
+      }
+    })
+    return newCoords
+  }
+
+  /**
+   * 检查 value1 和 value2 是否在指定的范围内,用于对齐线的计算
+   */
+  private isInRange(value1: number, value2: number) {
+    return (
+      Math.abs(Math.round(value1) - Math.round(value2)) <=
+      this.aligningLineMargin / this.canvas.getZoom()
+    )
+  }
+
+  private getCoords(obj: FabricObject) {
+    const [tl, tr, br, bl] = obj.getCoords()
+    return { tl, tr, br, bl }
+  }
+
+  /**
+   * fabric.Object.getCenterPoint will return the center point of the object calc by mouse moving & dragging distance.
+   * calcCenterPointByACoords will return real center point of the object position.
+   */
+  private calcCenterPointByACoords(coords: NonNullable<FabricObject['aCoords']>): Point {
+    return new Point((coords.tl.x + coords.br.x) / 2, (coords.tl.y + coords.br.y) / 2)
+  }
+
+  private traversAllObjects(activeObject: FabricObject, canvasObjects: FabricObject[]) {
+    const objCoordsByMovingDistance = this.getObjDraggingObjCoords(activeObject)
+    const snapXPoints: Set<number> = new Set()
+    const snapYPoints: Set<number> = new Set()
+
+    for (let i = canvasObjects.length; i--;) {
+      const objCoords = {
+        ...this.getCoords(canvasObjects[i]),
+        c: canvasObjects[i].getCenterPoint(),
+      } as ACoordsAppendCenter
+      const { objHeight, objWidth } = this.getObjMaxWidthHeightByCoords(objCoords)
+      Keys(objCoordsByMovingDistance).forEach((activeObjPoint) => {
+        const newCoords =
+          canvasObjects[i].angle !== 0 ? this.omitCoords(objCoords, 'horizontal') : objCoords
+
+        function calcHorizontalLineCoords(
+          objPoint: keyof ACoordsAppendCenter,
+          activeObjCoords: ACoordsAppendCenter,
+        ) {
+          let x1: number, x2: number
+          if (objPoint === 'c') {
+            x1 = Math.min(objCoords.c.x - objWidth / 2, activeObjCoords[activeObjPoint].x)
+            x2 = Math.max(objCoords.c.x + objWidth / 2, activeObjCoords[activeObjPoint].x)
+          } else {
+            x1 = Math.min(objCoords[objPoint].x, activeObjCoords[activeObjPoint].x)
+            x2 = Math.max(objCoords[objPoint].x, activeObjCoords[activeObjPoint].x)
+          }
+          return { x1, x2 }
+        }
+
+        Keys(newCoords).forEach((objPoint) => {
+          if (this.isInRange(objCoordsByMovingDistance[activeObjPoint].y, objCoords[objPoint].y)) {
+            const y = objCoords[objPoint].y
+
+            const offset = objCoordsByMovingDistance[activeObjPoint].y - y
+            snapYPoints.add(objCoordsByMovingDistance.c.y - offset)
+
+            const aCoords = this.getCoords(activeObject)
+            const { x1, x2 } = calcHorizontalLineCoords(objPoint, {
+              ...aCoords,
+              c: this.calcCenterPointByACoords(aCoords),
+            } as ACoordsAppendCenter)
+            this.horizontalLines.push({ y, x1, x2 })
+          }
+        })
+      })
+
+      Keys(objCoordsByMovingDistance).forEach((activeObjPoint) => {
+        const newCoords =
+          canvasObjects[i].angle !== 0 ? this.omitCoords(objCoords, 'vertical') : objCoords
+
+        function calcVerticalLineCoords(
+          objPoint: keyof ACoordsAppendCenter,
+          activeObjCoords: ACoordsAppendCenter,
+        ) {
+          let y1: number, y2: number
+          if (objPoint === 'c') {
+            y1 = Math.min(newCoords.c.y - objHeight / 2, activeObjCoords[activeObjPoint].y)
+            y2 = Math.max(newCoords.c.y + objHeight / 2, activeObjCoords[activeObjPoint].y)
+          } else {
+            y1 = Math.min(objCoords[objPoint].y, activeObjCoords[activeObjPoint].y)
+            y2 = Math.max(objCoords[objPoint].y, activeObjCoords[activeObjPoint].y)
+          }
+          return { y1, y2 }
+        }
+
+        Keys(newCoords).forEach((objPoint) => {
+          if (this.isInRange(objCoordsByMovingDistance[activeObjPoint].x, objCoords[objPoint].x)) {
+            const x = objCoords[objPoint].x
+
+            const offset = objCoordsByMovingDistance[activeObjPoint].x - x
+            snapXPoints.add(objCoordsByMovingDistance.c.x - offset)
+
+            const aCoords = this.getCoords(activeObject)
+            const { y1, y2 } = calcVerticalLineCoords(objPoint, {
+              ...aCoords,
+              c: this.calcCenterPointByACoords(aCoords),
+            } as ACoordsAppendCenter)
+            this.verticalLines.push({ x, y1, y2 })
+          }
+        })
+      })
+    }
+
+    this.snap({
+      activeObject,
+      draggingObjCoords: objCoordsByMovingDistance,
+      snapXPoints,
+      snapYPoints,
+    })
+  }
+
+  /**
+   * 自动吸附对象
+   */
+  private snap({
+    activeObject,
+    draggingObjCoords,
+    snapXPoints,
+    snapYPoints,
+  }: {
+    /** 当前活动对象 */
+    activeObject: FabricObject
+    /** 活动对象的坐标 */
+    draggingObjCoords: ACoordsAppendCenter
+    /** 横向吸附点列表 */
+    snapXPoints: Set<number>
+    /** 纵向吸附点列表 */
+    snapYPoints: Set<number>
+  }) {
+    if (snapXPoints.size === 0 && snapYPoints.size === 0) return
+
+    // 获得最近的吸附点
+    const sortPoints = (list: Set<number>, originPoint: number): number => {
+      if (list.size === 0) {
+        return originPoint
+      }
+
+      const sortedList = [...list].sort(
+        (a, b) => Math.abs(originPoint - a) - Math.abs(originPoint - b),
+      )
+
+      return sortedList[0]
+    }
+
+    // auto snap nearest object, record all the snap points, and then find the nearest one
+    activeObject.setXY(
+      new Point(
+        sortPoints(snapXPoints, draggingObjCoords.c.x),
+        sortPoints(snapYPoints, draggingObjCoords.c.y),
+      ),
+      'center',
+      'center',
+    )
+  }
+
+  private drawSign(x: number, y: number) {
+    const ctx = this.canvas.getTopContext()
+
+    ctx.strokeStyle = this.aligningLineColor
+    ctx.beginPath()
+
+    const size = 3
+    ctx.moveTo(x - size, y - size)
+    ctx.lineTo(x + size, y + size)
+    ctx.moveTo(x + size, y - size)
+    ctx.lineTo(x - size, y + size)
+    ctx.stroke()
+  }
+
+  private drawLine(x1: number, y1: number, x2: number, y2: number) {
+    const ctx = this.canvas.getTopContext()
+    const point1 = util.transformPoint(new Point(x1, y1), this.canvas.viewportTransform)
+    const point2 = util.transformPoint(new Point(x2, y2), this.canvas.viewportTransform)
+
+    // use origin canvas api to draw guideline
+    ctx.save()
+    ctx.lineWidth = this.aligningLineWidth
+    ctx.strokeStyle = this.aligningLineColor
+    ctx.beginPath()
+
+    ctx.moveTo(point1.x, point1.y)
+    ctx.lineTo(point2.x, point2.y)
+
+    ctx.stroke()
+
+    this.drawSign(point1.x, point1.y)
+    this.drawSign(point2.x, point2.y)
+
+    ctx.restore()
+
+    this.dirty = true
+  }
+
+  private drawVerticalLine(coords: VerticalLineCoords, movingCoords: ACoordsAppendCenter) {
+    if (!Object.values(movingCoords).some((coord) => Math.abs(coord.x - coords.x) < 0.0001)) return
+    this.drawLine(
+      coords.x,
+      Math.min(coords.y1, coords.y2),
+      coords.x,
+      Math.max(coords.y1, coords.y2),
+    )
+  }
+
+  private drawHorizontalLine(coords: HorizontalLineCoords, movingCoords: ACoordsAppendCenter) {
+    if (!Object.values(movingCoords).some((coord) => Math.abs(coord.y - coords.y) < 0.0001)) return
+    this.drawLine(
+      Math.min(coords.x1, coords.x2),
+      coords.y,
+      Math.max(coords.x1, coords.x2),
+      coords.y,
+    )
+  }
+
+  private drawGuideLines(e: any) {
+    if (!e.ctx || (!this.verticalLines.length && !this.horizontalLines.length) || !this.activeObj) {
+      return
+    }
+
+    const movingCoords = this.getObjDraggingObjCoords(this.activeObj)
+
+    for (let i = this.verticalLines.length; i--;) {
+      this.drawVerticalLine(this.verticalLines[i], movingCoords)
+    }
+    for (let i = this.horizontalLines.length; i--;) {
+      this.drawHorizontalLine(this.horizontalLines[i], movingCoords)
+    }
+    // this.canvas.calcOffset()
+  }
+
+  private clearGuideline() {
+    if (!this.dirty) return
+    this.dirty = false
+    this.canvas.clearContext(this.canvas.getTopContext())
+  }
+
+  public dispose(): void {
+    super.dispose()
+    this.canvas.off(this.canvasEvents)
+  }
+}

+ 43 - 0
src/app/fabricHistory.ts

@@ -0,0 +1,43 @@
+
+import FabricCommand  from './fabricCommand'
+
+class FabricHistory {
+  public index
+  public commands: FabricCommand[]
+  constructor() {
+    this.commands = [];
+    this.index = 0;
+  }
+  getIndex() {
+    return this.index;
+  }
+  back() {
+    if (this.index > 0) {
+      const command = this.commands[--this.index];
+      command.undo();
+    }
+    return this;
+  }
+  forward() {
+    if (this.index < this.commands.length) {
+      const command = this.commands[this.index++];
+      command.execute();
+    }
+    return this;
+  }
+  add(command: FabricCommand) {
+    if (this.commands.length) {
+      this.commands.splice(this.index, this.commands.length - this.index);
+    }
+    this.commands.push(command);
+    this.index++;
+    return this;
+  }
+  clear() {
+    this.commands.length = 0;
+    this.index = 0;
+    return this;
+  }
+}
+
+export default FabricHistory;

+ 172 - 0
src/app/fabricHover.ts

@@ -0,0 +1,172 @@
+
+import { Object as FabricObject, CanvasEvents, Canvas, Rect, Textbox, IText } from 'fabric'
+import { clone } from 'lodash-es'
+import { check } from '@/utils/check'
+import { Disposable } from '@/utils/lifecycle'
+import { addDisposableListener } from '@/utils/dom'
+import { useMainStore } from '@/store'
+import { storeToRefs } from 'pinia'
+import { computed, watch } from 'vue'
+
+/**
+ * 对象获得焦点后在外围显示一个边框
+ */
+export class FabricHover extends Disposable {
+  private canvasEvents
+
+  private lineWidth = 2
+  private hoveredTarget: FabricObject | undefined
+
+  constructor(private readonly canvas: Canvas) {
+    super()
+
+    this.canvasEvents = {
+      'mouse:out': this.clearBorder.bind(this),
+      'mouse:over': this.drawBorder.bind(this),
+    }
+
+    canvas.on(this.canvasEvents)
+
+    this._register(
+      addDisposableListener(this.canvas.upperCanvasEl, 'mouseout', () => {
+        if (this.canvas.contextTopDirty && this.hoveredTarget) {
+          this.clearContextTop(this.hoveredTarget.group || this.hoveredTarget)
+          this.hoveredTarget = undefined
+        }
+      }),
+    )
+    this.initWatch()
+  }
+
+  private clearContextTop(target: FabricObject, restoreManually = false) {
+    const ctx = this.canvas.contextTop
+    ctx.save()
+    ctx.transform(...this.canvas.viewportTransform)
+    target.transform(ctx)
+    const { strokeWidth, scaleX, scaleY, strokeUniform } = target
+    const zoom = this.canvas.getZoom()
+    // we add 4 pixel, to be sure to do not leave any pixel out
+    const width = target.width + 4 / zoom + (strokeUniform ? strokeWidth / scaleX : strokeWidth)
+    const height = target.height + 4 / zoom + (strokeUniform ? strokeWidth / scaleY : strokeWidth)
+    ctx.clearRect(-width / 2, -height / 2, width, height)
+    restoreManually || ctx.restore()
+    return ctx
+  }
+
+  private clearBorder(e: CanvasEvents['mouse:over']) {
+    const target = e.target
+
+    this.hoveredTarget = undefined
+
+    if (!target || target === this.canvas._activeObject) return
+
+    this.clearBorderByObject(target)
+  }
+
+  private clearBorderByObject(target: FabricObject) {
+
+    if (this.canvas.contextTopDirty) {
+      this.clearContextTop(target)
+    }
+  }
+
+  private drawBorder(e: CanvasEvents['mouse:out']) {
+    const target = e.target
+
+    if (!target || target === this.canvas._activeObject) return
+
+    this.drawBorderByObject(target)
+  }
+
+  private drawBorderByObject(target: FabricObject) {
+
+    this.hoveredTarget = target
+
+    const ctx = this.clearContextTop(target, true)
+    if (!ctx) return
+
+    const object = clone(target)
+
+    // 文字特殊处理,显示下划线
+    if (object instanceof Textbox && object.isType('Textbox')) {
+      this.showUnderline(ctx, object as Textbox)
+      return
+    }
+    if (object instanceof IText && object.isType('IText')) {
+      this.showUnderline(ctx, object as Textbox)
+      return
+    }
+    // 分组特殊处理,显示矩形边框
+    if (check.isCollection(object) || object.isType('ArcText')) {
+      object._render = Rect.prototype._render
+    }
+
+    const { strokeWidth, strokeUniform } = object
+
+    let { width, height } = object
+
+    width += strokeUniform ? strokeWidth / object.scaleX : strokeWidth
+    height += strokeUniform ? strokeWidth / object.scaleY : strokeWidth
+
+    const totalObjectScaling = object.getTotalObjectScaling()
+
+    const lineWidth = Math.min(
+      this.lineWidth,
+      width * totalObjectScaling.x,
+      height * totalObjectScaling.y,
+    )
+
+    width -= lineWidth / totalObjectScaling.x
+    height -= lineWidth / totalObjectScaling.y
+
+    object.set({
+      width,
+      height,
+      stroke: 'rgb(60,126,255)',
+      strokeWidth: lineWidth,
+      strokeDashArray: null,
+      strokeDashOffset: 0,
+      strokeLineCap: 'butt',
+      strokeLineJoin: 'miter',
+      strokeMiterLimit: 4,
+    })
+
+    object._renderPaintInOrder = () => {
+      ctx.save()
+      const scaling = object.getTotalObjectScaling()
+      ctx.scale(1 / scaling.x, 1 / scaling.y)
+      object._setLineDash(ctx, object.strokeDashArray)
+      object._setStrokeStyles(ctx, object)
+      ctx.stroke()
+      ctx.restore()
+    }
+
+    object._render(ctx)
+
+    ctx.restore()
+    this.canvas.contextTopDirty = true
+  }
+
+  public showUnderline(ctx: CanvasRenderingContext2D, object: Textbox) {
+    object.underline = true
+    object.fill = 'rgb(60,126,255)'
+    object._renderTextDecoration(ctx, 'underline')
+    object._drawClipPath(ctx, object.clipPath)
+    ctx.restore()
+    this.canvas.contextTopDirty = true
+  }
+
+  public initWatch() {
+    const mainStore = useMainStore()
+    const { hoveredObject, leavedObject } = storeToRefs(mainStore)
+    computed(() => {
+      if (hoveredObject.value) this.drawBorderByObject(hoveredObject.value as FabricObject)
+      else this.clearBorderByObject(leavedObject.value as FabricObject)
+    })
+  }
+
+  public dispose(): void {
+    super.dispose()
+    this.canvas.off(this.canvasEvents)
+  }
+}

+ 600 - 0
src/app/fabricRuler.ts

@@ -0,0 +1,600 @@
+import { Keybinding } from './keybinding'
+import { Disposable } from '@/utils/lifecycle'
+import { computed, watchEffect } from 'vue'
+// import { useThemes } from '@/hooks/useThemes'
+import { DesignUnitMode } from '@/configs/background'
+import { PiBy180, isMobile } from '@/utils/common'
+import { TAxis, Point, Rect as fabricRect, Object as FabricObject, TPointerEventInfo, TPointerEvent } from 'fabric'
+import { useMainStore, useTemplatesStore } from '@/store'
+import { storeToRefs } from 'pinia'
+import { px2mm } from '@/utils/image'
+import { ElementNames } from '@/types/elements'
+import { FabricCanvas } from './fabricCanvas'
+import { ReferenceLine } from '@/extension/object/ReferenceLine'
+import { WorkSpaceDrawType } from '@/configs/canvas'
+
+type Rect = { left: number; top: number; width: number; height: number }
+
+/**
+ * 配置
+ */
+export interface RulerOptions {
+  /**
+   * 标尺宽高
+   * @default 10
+   */
+  ruleSize?: number
+
+  /**
+   * 字体大小
+   * @default 10
+   */
+  fontSize?: number
+
+  /**
+   * 是否开启标尺
+   * @default false
+   */
+  enabled?: boolean
+
+  /**
+   * 背景颜色
+   */
+  backgroundColor?: string
+
+  /**
+   * 文字颜色
+   */
+  textColor?: string
+
+  /**
+   * 边框颜色
+   */
+  borderColor?: string
+
+  /**
+   * 高亮颜色
+   */
+  highlightColor?: string
+  /**
+   * 高亮颜色
+   */
+  unitName: string
+
+}
+
+export type HighlightRect = {skip?: TAxis} & Rect
+
+export class FabricRuler extends Disposable {
+  private canvasEvents
+  public lastCursor: string
+  public workSpaceDraw?: fabricRect
+  public options: Required<RulerOptions>
+  public tempReferenceLine?: ReferenceLine
+  private activeOn: string = "up"
+  private objectRect: undefined | { 
+    x: HighlightRect[],
+    y: HighlightRect[]
+  }
+
+  constructor(private readonly canvas: FabricCanvas) {
+    super()
+    this.lastCursor = this.canvas.defaultCursor
+    // 合并默认配置
+    this.options = Object.assign({
+      ruleSize: 20,
+      fontSize: 8,
+      enabled: isMobile() ? false : true,
+    })
+
+    // const { isDark } = useThemes()
+    const isDark = false
+    
+    const { unitMode } = storeToRefs(useMainStore())
+    watchEffect(() => {
+      const unitName = DesignUnitMode.filter(ele => ele.id === unitMode.value)[0].name
+      this.options = {
+        ...this.options,
+        ...(isDark 
+          ? {
+              backgroundColor: '#242424',
+              borderColor: '#555',
+              highlightColor: '#165dff3b',
+              textColor: '#ddd',
+              unitName: unitName,
+            }
+          : {
+              backgroundColor: '#fff',
+              borderColor: '#ccc',
+              highlightColor: '#165dff3b',
+              textColor: '#444',
+              unitName: unitName,
+            }),
+      }
+      this.render({ ctx: this.canvas.contextContainer })
+    })
+    
+    this.canvasEvents = {
+      'after:render': this.render.bind(this),
+      'mouse:move': this.mouseMove.bind(this),
+      'mouse:down': this.mouseDown.bind(this),
+      'mouse:up': this.mouseUp.bind(this),
+      'referenceline:moving': this.referenceLineMoving.bind(this),
+      'referenceline:mouseup': this.referenceLineMouseup.bind(this),
+    }
+    this.enabled = this.options.enabled
+    canvas.ruler = this
+  }
+
+  public getPointHover(point: Point): 'vertical' | 'horizontal' | '' {
+    if (
+      new fabricRect({
+        left: 0,
+        top: 0,
+        width: this.options.ruleSize,
+        height: this.canvas.height,
+        absolutePositioned: true,
+      }).containsPoint(point)
+    ) {
+      return 'vertical';
+    } else if (
+      new fabricRect({
+        left: 0,
+        top: 0,
+        width: this.canvas.width, 
+        height: this.options.ruleSize,
+        absolutePositioned: true,
+      }).containsPoint(point)
+    ) {
+      return 'horizontal';
+    }
+    return '';
+  }
+
+  private mouseMove(e: TPointerEventInfo<TPointerEvent>) {
+    if (!e.viewportPoint) return
+    if (this.tempReferenceLine && e.scenePoint) {
+      const pos: Partial<ReferenceLine> = {};
+      if (this.tempReferenceLine.axis === 'horizontal') {
+        pos.top = e.scenePoint.y;
+      } 
+      else {
+        pos.left = e.scenePoint.x;
+      }
+      this.tempReferenceLine.set({ ...pos, visible: true });
+      this.canvas.renderAll();
+      const event = this.getCommonEventInfo(e) as any;
+      this.canvas.fire('object:moving', event);
+      this.tempReferenceLine.fire('moving', event);
+    }
+    const status = this.getPointHover(e.viewportPoint)
+    this.canvas.defaultCursor = this.lastCursor
+    if (!status) return
+    this.lastCursor = this.canvas.defaultCursor
+    this.canvas.defaultCursor = status === 'horizontal' ? 'ns-resize' : 'ew-resize';
+  }
+
+  private mouseDown(e: TPointerEventInfo<TPointerEvent>) {
+    const pointHover = this.getPointHover(e.viewportPoint)
+    if (!pointHover) return
+    if (this.activeOn === 'up') {
+      this.canvas.selection = false
+      this.activeOn = 'down'
+      const point = pointHover === 'horizontal' ? e.viewportPoint.y : e.viewportPoint.x
+      this.tempReferenceLine = new ReferenceLine(
+        point,
+        {
+          type: 'ReferenceLine',
+          axis: pointHover,
+          visible: false,
+          name: 'ReferenceLine',
+          hasControls: false,
+          hasBorders: false,
+          stroke: 'pink',
+          fill: 'pink',
+          originX: 'center',
+          originY: 'center',
+          padding: 4,
+          globalCompositeOperation: 'difference',
+        }
+      );
+      this.canvas.add(this.tempReferenceLine)
+      const templatesStore = useTemplatesStore()
+      templatesStore.addElement(this.tempReferenceLine)
+      this.canvas.setActiveObject(this.tempReferenceLine)
+      this.canvas._setupCurrentTransform(e.e, this.tempReferenceLine, true)
+      this.tempReferenceLine.fire('down', this.getCommonEventInfo(e));
+    }
+  }
+
+  private getCommonEventInfo(e: TPointerEventInfo<TPointerEvent>) {
+    if (!this.tempReferenceLine || !e.scenePoint) return;
+    return {
+      e: e.e,
+      transform: this.tempReferenceLine.get('transform'),
+      pointer: {
+        x: e.scenePoint.x,
+        y: e.scenePoint.y,
+      },
+      target: this.tempReferenceLine,
+    };
+  }
+
+  private mouseUp(e: TPointerEventInfo<TPointerEvent>) {
+    if (this.activeOn !== 'down') return;
+    this.canvas.selection = true
+    this.tempReferenceLine!.selectable = false
+    this.canvas.renderAll()
+    this.activeOn = 'up';
+    // @ts-ignore
+    this.tempReferenceLine?.fire('up', this.getCommonEventInfo(e));
+    this.tempReferenceLine = undefined;
+  }
+
+  public setWorkSpaceDraw() {
+    this.workSpaceDraw = this.canvas.getObjects().filter(item => item.id === WorkSpaceDrawType)[0] as fabricRect
+  }
+
+  public isRectOut(object: FabricObject, target: ReferenceLine): boolean {
+    // const { top, height, left, width } = object;
+
+    // if (top === undefined || height === undefined || left === undefined || width === undefined) {
+    //   return false;
+    // }
+
+    // const targetRect = target.getBoundingRect(true, true);
+    // const {
+    //   top: targetTop,
+    //   height: targetHeight,
+    //   left: targetLeft,
+    //   width: targetWidth,
+    // } = targetRect;
+
+    // if (target.isHorizontal() && (top > targetTop + 1 || top + height < targetTop + targetHeight - 1)) {
+    //   return true;
+    // } 
+    // else if (!target.isHorizontal() && (left > targetLeft + 1 || left + width < targetLeft + targetWidth - 1)) {
+    //   return true;
+    // }
+
+    return false;
+  };
+
+  referenceLineMoving(e: any) {
+    if (!this.workSpaceDraw) {
+      this.setWorkSpaceDraw();
+      return;
+    }
+    const { target } = e;
+    if (this.isRectOut(this.workSpaceDraw, target)) {
+      target.moveCursor = 'not-allowed';
+    }
+  } 
+
+  referenceLineMouseup(e: any) {
+    if (!this.workSpaceDraw) {
+      this.setWorkSpaceDraw();
+      return;
+    }
+    const { target } = e;
+    if (this.isRectOut(this.workSpaceDraw, target)) {
+      this.canvas.remove(target);
+      this.canvas.setCursor(this.canvas.defaultCursor ?? '');
+    }
+  }
+
+  public get enabled() {
+    return this.options.enabled
+  }
+
+  public set enabled(value) {
+    this.options.enabled = value
+    if (value) {
+      this.canvas.on(this.canvasEvents)
+      this.render({ ctx: this.canvas.contextContainer })
+    } 
+    else {
+      this.canvas.off(this.canvasEvents)
+      this.canvas.requestRenderAll()
+    }
+  }
+
+  /**
+   * 获取画板尺寸
+   */
+  private getSize() {
+    return {
+      width: this.canvas.width,
+      height: this.canvas.height,
+    }
+  }
+
+  private render({ ctx }: { ctx: CanvasRenderingContext2D }) {
+    if (ctx !== this.canvas.contextContainer) return
+
+    const { viewportTransform: vpt } = this.canvas
+
+    // 计算元素矩形
+    this.calcObjectRect()
+
+    // 绘制尺子
+    this.draw({
+      ctx,
+      isHorizontal: true,
+      rulerLength: this.getSize().width,
+      startCalibration: -(vpt[4] / vpt[0]),
+    })
+    this.draw({
+      ctx,
+      isHorizontal: false,
+      rulerLength: this.getSize().height,
+      startCalibration: -(vpt[5] / vpt[3]),
+    })
+
+    const { borderColor, backgroundColor, ruleSize, textColor } = this.options
+
+    this.darwRect(ctx, {
+      left: 0,
+      top: 0,
+      width: ruleSize,
+      height: ruleSize,
+      fill: backgroundColor,
+      stroke: borderColor,
+    })
+
+    this.darwText(ctx, {
+      text: this.options.unitName,
+      left: ruleSize / 2,
+      top: ruleSize / 2,
+      align: 'center',
+      baseline: 'middle',
+      fill: textColor,
+    })
+  }
+
+  private draw(opt: {ctx: CanvasRenderingContext2D, isHorizontal: boolean, rulerLength: number, startCalibration: number}) {
+    const { ctx, isHorizontal, rulerLength, startCalibration } = opt
+    const zoom = this.canvas.getZoom()
+
+    const gap = this.getGap(zoom)
+    const unitLength = Math.ceil(rulerLength / zoom)
+    const startValue = Math.floor(startCalibration / gap) * gap
+    const startOffset = startValue - startCalibration
+
+    const canvasSize = this.getSize()
+
+    const { textColor, borderColor, ruleSize, highlightColor } = this.options
+
+    // 文字顶部偏移
+    const padding = 2.5
+
+    // 背景
+    this.darwRect(ctx, {
+      left: 0,
+      top: 0,
+      width: isHorizontal ? canvasSize.width : ruleSize,
+      height: isHorizontal ? ruleSize : canvasSize.height,
+      fill: this.options.backgroundColor,
+      stroke: this.options.borderColor,
+    })
+
+    // 标尺刻度线显示
+    for (let pos = 0; pos + startOffset <= unitLength; pos += gap) {
+      for (let index = 0; index < 10; index++) {
+        const position = Math.round((startOffset + pos + (gap * index) / 10) * zoom)
+        const isMajorLine = index === 0
+        const [left, top] = isHorizontal ? [position, isMajorLine ? 0 : ruleSize - 8] : [isMajorLine ? 0 : ruleSize - 8, position]
+        const [width, height] = isHorizontal ? [0, ruleSize - top] : [ruleSize - left, 0]
+        this.darwLine(ctx, {
+          left,
+          top,
+          width,
+          height,
+          stroke: borderColor,
+        })
+      }
+    }
+
+    // 标尺蓝色遮罩
+    if (this.objectRect) {
+      const axis = isHorizontal ? 'x' : 'y'
+      this.objectRect[axis].forEach((rect) => {
+        // 跳过指定矩形
+        if (rect.skip === axis) return
+
+        const [left, top, width, height] = isHorizontal ? [(rect.left - startCalibration) * zoom, 0, rect.width * zoom, ruleSize] : [0, (rect.top - startCalibration) * zoom, ruleSize, rect.height * zoom]
+
+        // 高亮遮罩
+        // ctx.save()
+        this.darwRect(ctx, {
+          left,
+          top,
+          width,
+          height,
+          fill: highlightColor,
+        })
+        // ctx.restore()
+      })
+    }
+
+    // 标尺文字显示
+    for (let pos = 0; pos + startOffset <= unitLength; pos += gap) {
+      const position = (startOffset + pos) * zoom
+      let textValue = (startValue + pos).toString()
+      if (this.options.unitName === 'mm') {
+        textValue = px2mm(startValue + pos).toFixed(0)
+      }
+      const [left, top, angle] = isHorizontal ? [position + 6, padding, 0] : [padding, position - 6, -90]
+
+      this.darwText(ctx, {
+        text: textValue,
+        left,
+        top,
+        fill: textColor,
+        angle,
+      })
+    }
+    // draw end
+  }
+
+  private getGap(zoom: number) {
+    const zooms = [0.02, 0.03, 0.05, 0.1, 0.2, 0.5, 1, 2, 5]
+    const gaps = [5000, 2500, 1000, 500, 200, 100, 50, 20, 10]
+
+    let i = 0
+    while (i < zooms.length && zooms[i] < zoom) {
+      i++
+    }
+
+    return gaps[i - 1] || 10000
+  }
+
+  private darwRect(
+    ctx: CanvasRenderingContext2D,
+    {
+      left,
+      top,
+      width,
+      height,
+      fill,
+      stroke,
+      strokeWidth,
+    }: {
+      left: number
+      top: number
+      width: number
+      height: number
+      fill?: string | CanvasGradient | CanvasPattern
+      stroke?: string
+      strokeWidth?: number
+    },
+  ) {
+    ctx.save()
+    ctx.beginPath()
+    fill && (ctx.fillStyle = fill)
+    ctx.rect(left, top, width, height)
+    ctx.fill()
+    if (stroke) {
+      ctx.strokeStyle = stroke
+      ctx.lineWidth = strokeWidth ?? 1
+      ctx.stroke()
+    }
+    ctx.restore()
+  }
+
+  private darwText(
+    ctx: CanvasRenderingContext2D,
+    {
+      left,
+      top,
+      text,
+      fill,
+      align,
+      angle,
+      fontSize,
+      baseline,
+    }: {
+      left: number
+      top: number
+      text: string
+      fill?: string | CanvasGradient | CanvasPattern
+      align?: CanvasTextAlign
+      baseline?: CanvasTextBaseline
+      angle?: number
+      fontSize?: number
+    },
+  ) {
+    ctx.save()
+    fill && (ctx.fillStyle = fill)
+    ctx.textAlign = align ?? 'left'
+    ctx.textBaseline = baseline ?? 'top'
+    ctx.font = `${fontSize ?? 12}px Helvetica`
+    if (angle) {
+      ctx.translate(left, top)
+      ctx.rotate(PiBy180 * angle)
+      ctx.translate(-left, -top)
+    }
+    ctx.fillText(text, left, top)
+    ctx.restore()
+  }
+
+  private darwLine(
+    ctx: CanvasRenderingContext2D,
+    {
+      left,
+      top,
+      width,
+      height,
+      stroke,
+      lineWidth,
+    }: {
+      left: number
+      top: number
+      width: number
+      height: number
+      stroke?: string | CanvasGradient | CanvasPattern
+      lineWidth?: number
+    },
+  ) {
+    ctx.save()
+    ctx.beginPath()
+    stroke && (ctx.strokeStyle = stroke)
+    ctx.lineWidth = lineWidth ?? 1
+    ctx.moveTo(left, top)
+    ctx.lineTo(left + width, top + height)
+    ctx.stroke()
+    ctx.restore()
+  }
+
+  private calcObjectRect() {
+    const activeObjects = this.canvas.getActiveObjects()
+    if (activeObjects.length === 0) {
+      this.objectRect = undefined
+      return
+    }
+    if (activeObjects[0].name.toLowerCase() === ElementNames.REFERENCELINE) {
+      this.objectRect = undefined
+      return
+    }
+    const allRect = activeObjects.reduce((rects, obj) => {
+      const rect: HighlightRect = obj.getBoundingRect()
+      rects.push(rect)
+      return rects
+    }, [] as HighlightRect[])
+    if (allRect.length === 0) return
+    this.objectRect = {
+      x: this.mergeLines(allRect, true),
+      y: this.mergeLines(allRect, false),
+    }
+  }
+
+  private mergeLines(rect: Rect[], isHorizontal: boolean) {
+    const axis = isHorizontal ? 'left' : 'top'
+    const length = isHorizontal ? 'width' : 'height'
+    // 先按照 axis 的大小排序
+    rect.sort((a, b) => a[axis] - b[axis])
+    const mergedLines = []
+    let currentLine = Object.assign({}, rect[0])
+    for (let i = 1; i < rect.length; i++) {
+      const line = Object.assign({}, rect[i])
+      if (currentLine[axis] + currentLine[length] >= line[axis]) {
+        // 当前线段和下一个线段相交,合并宽度
+        currentLine[length] =
+          Math.max(currentLine[axis] + currentLine[length], line[axis] + line[length]) -
+          currentLine[axis]
+      } else {
+        // 当前线段和下一个线段不相交,将当前线段加入结果数组中,并更新当前线段为下一个线段
+        mergedLines.push(currentLine)
+        currentLine = Object.assign({}, line)
+      }
+    }
+    // 加入数组
+    mergedLines.push(currentLine)
+    return mergedLines
+  }
+
+  public dispose(): void {
+    super.dispose()
+    this.enabled = false
+  }
+}

+ 231 - 0
src/app/fabricTool.ts

@@ -0,0 +1,231 @@
+import { Point, Canvas } from 'fabric'
+import { watch, computed } from 'vue'
+import { storeToRefs } from 'pinia'
+import { Disposable } from '@/utils/lifecycle'
+import useCanvasSwipe from '@/hooks/useCanvasSwipe'
+import { useKeyboardStore } from '@/store'
+import { useActiveElement, toValue } from '@vueuse/core'
+
+type ToolOption = {
+  defaultCursor: string
+  skipTargetFind: boolean
+  selection: boolean
+}
+
+type ToolType = 'move' | 'handMove' | 'shape'
+
+
+export class FabricTool extends Disposable {
+
+  private options: Record<ToolType, ToolOption> = {
+    move: {
+      defaultCursor: 'default',
+      skipTargetFind: false,
+      selection: true,
+    },
+    handMove: {
+      defaultCursor: 'grab',
+      skipTargetFind: true,
+      selection: false,
+    },
+    shape: {
+      defaultCursor: 'crosshair',
+      skipTargetFind: true,
+      selection: false,
+    },
+  }
+
+  private _handMoveActivate = false
+
+  private get handMoveActivate() {
+    return this._handMoveActivate
+  }
+
+  private set handMoveActivate(value) {
+    this._handMoveActivate = value
+  }
+
+  constructor(private readonly canvas: Canvas) {
+    super()
+    this.initHandMove()
+  }
+
+  private applyOption(tool: ToolType) {
+    const { defaultCursor, skipTargetFind, selection } = this.options[tool]
+
+    this.canvas.defaultCursor = defaultCursor
+    this.canvas.setCursor(defaultCursor)
+    this.canvas.skipTargetFind = skipTargetFind
+    this.canvas.selection = selection
+  }
+
+  // private switchShape(shape: 'board' | 'rect' | 'ellipse' | 'triangle' | 'text') {
+  //   const { canvas } = this
+  //   let coordsStart: Point | undefined
+  //   let tempObject: FabricObject | undefined
+  //   const { stop, isSwiping } = useFabricSwipe({
+  //     onSwipeStart: (e) => {
+  //       if (e.button !== 1 || this.space.value) return
+  //       /*
+  //        * 只有mouseMove的时候isSwiping才会为true
+  //        * mouseUp会判断isSwiping的值来决定是否执行onSwipeEnd
+  //        * 这里强制设置成true,让点击也可执行onSwipeEnd
+  //        */
+  //       isSwiping.value = true
+  //       // 获得坐标
+  //       coordsStart = e.pointer
+  //       // 创建形状
+  //       switch (shape) {
+  //         case 'board':
+  //           tempObject = new Board([], {
+  //             fill: '',
+  //           })
+  //           break
+  //         case 'rect':
+  //           tempObject = new Rect({})
+  //           break
+  //         case 'ellipse':
+  //           tempObject = new Ellipse({
+  //             rx: 50,
+  //             ry: 50,
+  //           })
+  //           break
+  //         case 'triangle':
+  //           tempObject = new Triangle({})
+  //           break
+  //         case 'text':
+  //           tempObject = new Textbox('', {})
+  //           break
+  //       }
+  //       tempObject.set({
+  //         left: coordsStart.x,
+  //         top: coordsStart.y,
+  //         width: 100,
+  //         height: 100,
+  //         scaleX: 0.01,
+  //         scaleY: 0.01,
+  //         hideOnLayer: true,
+  //       })
+  //       // 不发送ObjectAdded事件
+  //       tempObject.noEventObjectAdded = true
+  //       // 添加对象到画板
+  //       const board = this.canvas._searchPossibleTargets(
+  //         this.canvas.getObjects('Board'),
+  //         e.absolutePointer,
+  //       ) as Board | undefined
+  //       const parent = board || canvas
+  //       parent.add(tempObject)
+  //       // 取消不发送
+  //       tempObject.noEventObjectAdded = false
+  //       // 设置激活对象
+  //       canvas.setActiveObject(tempObject)
+  //       tempObject.__corner = 'br'
+  //       canvas._setupCurrentTransform(e.e, tempObject, true)
+  //     },
+  //     onSwipeEnd: (e) => {
+  //       if (!tempObject) return
+  //       console.log('onSwipeEnd:', tempObject)
+  //       // 如果点击画板,没有移动,设置默认宽高
+  //       if (tempObject.scaleX <= 0.01 && tempObject.scaleY <= 0.01) {
+  //         tempObject.set({
+  //           left: tempObject.left - 50,
+  //           top: tempObject.top - 50,
+  //           scaleX: 1,
+  //           scaleY: 1,
+  //         })
+  //       }
+  //       // 设置宽高缩放
+  //       tempObject.set({
+  //         width: tempObject.getScaledWidth(),
+  //         height: tempObject.getScaledHeight(),
+  //         scaleX: 1,
+  //         scaleY: 1,
+  //         hideOnLayer: false,
+  //       })
+  //       // 特殊形状处理
+  //       if (tempObject instanceof Board) {
+  //         tempObject.set({
+  //           fill: '#ffffff',
+  //         })
+  //       } else if (tempObject instanceof Ellipse) {
+  //         tempObject.set({
+  //           rx: tempObject.width / 2,
+  //           ry: tempObject.height / 2,
+  //         })
+  //       } else if (tempObject instanceof Textbox) {
+  //         tempObject.set({
+  //           text: '输入文本',
+  //         })
+  //         canvas.defaultCursor = 'default'
+  //         tempObject.enterEditing(e.e)
+  //         tempObject.selectAll()
+  //       }
+  //       // 通知事件
+  //       if (!tempObject.group) {
+  //         canvas._onObjectAdded(tempObject)
+  //       }
+  //       canvas.fire('selection:updated')
+  //       canvas.requestRenderAll()
+  //       tempObject = undefined
+  //       useAppStore().activeTool = 'move'
+  //     },
+  //   })
+  //   this.toolStop = stop
+  // }
+
+
+  /**
+   *鼠标中键拖动视窗
+   */
+  private initHandMove() {
+    const canvas = this.canvas
+
+    /** 鼠标移动开始的vpt */
+    let vpt = canvas.viewportTransform
+    const { spaceKeyState } = storeToRefs(useKeyboardStore())
+    const { lengthX, lengthY, isSwiping } = useCanvasSwipe({
+      onSwipeStart: (e: any) => {
+        if (e.e.buttons === 2 || (spaceKeyState.value && e.e.buttons === 1)) {
+          isSwiping.value = true
+          vpt = canvas.viewportTransform
+          this.handMoveActivate = true
+          this.applyOption('handMove')
+          canvas.setCursor('grab')
+        }
+      },
+      onSwipe: () => {
+        if (!this.handMoveActivate) return
+
+        canvas.setCursor('grab')
+
+        requestAnimationFrame(() => {
+          const deltaPoint = new Point(lengthX.value, lengthY.value).scalarDivide(canvas.getZoom()).transform(vpt).scalarMultiply(-1)
+          canvas.absolutePan(deltaPoint, true)
+        })
+      },
+      onSwipeEnd: () => {
+        // 恢复鼠标指针
+        this.applyOption(spaceKeyState.value ? 'handMove' : 'move')
+        if (!this.handMoveActivate) return
+        // 关闭 handMove
+        if (!spaceKeyState.value) {
+          this.handMoveActivate = false
+        }
+      },
+    })
+
+    // 空格键切换移动工具
+    const activeElement = useActiveElement()
+    const activeElementHasInput = computed(() => activeElement.value?.tagName !== 'INPUT' && activeElement.value?.tagName !== 'TEXTAREA')
+    
+    
+    watch(
+      computed(() => [spaceKeyState.value, activeElementHasInput.value].every((i) => toValue(i))),
+      (space) => {
+        this.applyOption(space ? 'handMove' : 'move')
+        if (isSwiping.value) return
+        this.handMoveActivate = space
+      },
+    )
+  }
+}

+ 140 - 0
src/app/fabricTouch.ts

@@ -0,0 +1,140 @@
+import { Point, Canvas } from 'fabric'
+import { debounce } from 'lodash-es'
+import { Disposable } from '@/utils/lifecycle'
+import { useFabricStore } from '@/store'
+
+export const MIN_ZOOM = 0.03
+export const MAX_ZOOM = 5
+
+
+export class FabricTouch extends Disposable {
+  isTwoTouch = false
+  isDragging = false
+  startDistance = 1
+  startX = 0 
+  startY = 0
+  startScale = 1
+  lastPan?: Point
+
+  constructor(private readonly canvas: Canvas) {
+    super()
+    this.initTouchEvent()
+  }
+
+  initTouchEvent() {
+    const canvas = this.canvas?.upperCanvasEl
+    if (canvas) {
+      canvas.addEventListener('touchstart', this.touchStartHandle, { passive: false })
+      canvas.addEventListener('touchmove', this.touchMoveHandle, { passive: false })
+      canvas.addEventListener('touchend', this.touchEndHandle, { passive: false })
+    }
+  }
+
+  removeTouchEvent() {
+    const canvas = this.canvas?.upperCanvasEl
+    if (canvas) {
+      canvas.removeEventListener('touchstart', this.touchStartHandle)
+      canvas.removeEventListener('touchmove', this.touchMoveHandle)
+      canvas.removeEventListener('touchend', this.touchEndHandle)
+    }
+  }
+
+  touchStartHandle = (e: TouchEvent) => {
+    e.preventDefault()
+    const canvas = this.canvas
+    if (!canvas) return
+    const touches = e.touches
+
+    // brushMouseMixin.updateIsDisableDraw(touches.length >= 2)
+
+    if (touches.length === 2) {
+      canvas.isDrawingMode = true
+      this.isTwoTouch = true
+      const touch1 = touches[0]
+      const touch2 = touches[1]
+      this.startDistance = Math.hypot(
+        touch2.pageX - touch1.pageX,
+        touch2.pageY - touch1.pageY
+      )
+
+      this.startX = (touch1.pageX + touch2.pageX) / 2
+      this.startY = (touch1.pageY + touch2.pageY) / 2
+      this.startScale = canvas.getZoom()
+    }
+  }
+  touchMoveHandle = (e: TouchEvent) => {
+    e.preventDefault()
+
+    const canvas = this.canvas
+    if (!canvas) return
+    const touches = e.touches
+
+    if (touches.length === 2) {
+      const touch1 = touches[0]
+      const touch2 = touches[1]
+
+      const currentDistance = Math.hypot(
+        touch2.pageX - touch1.pageX,
+        touch2.pageY - touch1.pageY
+      )
+
+      const x = (touch1.pageX + touch2.pageX) / 2
+      const y = (touch1.pageY + touch2.pageY) / 2
+
+      // Calculate zoom
+      let zoom = this.startScale * (currentDistance / this.startDistance)
+      zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
+      // if (!useBoardStore.getState().isObjectCaching) {
+      //   fabric.Object.prototype.set({
+      //     objectCaching: true
+      //   })
+      // }
+      canvas.zoomToPoint(new Point(this.startX, this.startY), zoom)
+      // paintBoard.evnet?.zoomEvent.updateZoomPercentage(true, zoom)
+
+      // Calculate drag distance
+      const currentPan = new Point(x - this.startX, y - this.startY)
+      const fabricStore = useFabricStore()
+      fabricStore.setZoom(zoom)
+      // move canvas
+      if (!this.isDragging) {
+        this.isDragging = true
+        this.lastPan = currentPan
+      } else if (this.lastPan) {
+        // if (!useBoardStore.getState().isObjectCaching) {
+        //   fabric.Object.prototype.set({
+        //     objectCaching: true
+        //   })
+        // }
+        canvas.relativePan(
+          new Point(
+            currentPan.x - this.lastPan.x,
+            currentPan.y - this.lastPan.y
+          )
+        )
+        this.lastPan = currentPan
+        this.saveTransform()
+      }
+    }
+  }
+  touchEndHandle = (e: TouchEvent) => {
+    this.isDragging = false
+    this.canvas.isDrawingMode = false
+    if (this.isTwoTouch && e.touches.length === 0) {
+      this.isTwoTouch = false
+    }
+  }
+
+  saveTransform = debounce(() => {
+    const transform = this.canvas?.viewportTransform
+    if (transform) {
+      // useFileStore.getState().updateTransform(transform)
+      // if (!useBoardStore.getState().isObjectCaching) {
+      //   fabric.Object.prototype.set({
+      //     objectCaching: false
+      //   })
+      // }
+      this.canvas?.requestRenderAll()
+    }
+  }, 500)
+}

+ 174 - 0
src/app/hoverBorders.ts

@@ -0,0 +1,174 @@
+
+import { Object as FabricObject, CanvasEvents, Canvas, Rect, Textbox, IText } from 'fabric'
+import { clone } from 'lodash-es'
+import { check } from '@/utils/check'
+import { Disposable } from '@/utils/lifecycle'
+import { addDisposableListener } from '@/utils/dom'
+import { useMainStore } from '@/store'
+import { storeToRefs } from 'pinia'
+import { computed, watch } from 'vue'
+
+/**
+ * 对象获得焦点后在外围显示一个边框
+ */
+export class HoverBorders extends Disposable {
+  private canvasEvents
+
+  private lineWidth = 2
+  private hoveredTarget: FabricObject | undefined
+
+  constructor(private readonly canvas: Canvas) {
+    super()
+
+    this.canvasEvents = {
+      // 'mouse:out': this.drawBorder.bind(this),
+      // 'mouse:over': this.clearBorder.bind(this),
+      'mouse:out': this.clearBorder.bind(this),
+      'mouse:over': this.drawBorder.bind(this),
+    }
+
+    canvas.on(this.canvasEvents)
+
+    this._register(
+      addDisposableListener(this.canvas.upperCanvasEl, 'mouseout', () => {
+        if (this.canvas.contextTopDirty && this.hoveredTarget) {
+          this.clearContextTop(this.hoveredTarget.group || this.hoveredTarget)
+          this.hoveredTarget = undefined
+        }
+      }),
+    )
+    this.initWatch()
+  }
+
+  private clearContextTop(target: FabricObject, restoreManually = false) {
+    const ctx = this.canvas.contextTop
+    ctx.save()
+    ctx.transform(...this.canvas.viewportTransform)
+    target.transform(ctx)
+    const { strokeWidth, scaleX, scaleY, strokeUniform } = target
+    const zoom = this.canvas.getZoom()
+    // we add 4 pixel, to be sure to do not leave any pixel out
+    const width = target.width + 4 / zoom + (strokeUniform ? strokeWidth / scaleX : strokeWidth)
+    const height = target.height + 4 / zoom + (strokeUniform ? strokeWidth / scaleY : strokeWidth)
+    ctx.clearRect(-width / 2, -height / 2, width, height)
+    restoreManually || ctx.restore()
+    return ctx
+  }
+
+  private clearBorder(e: CanvasEvents['mouse:over']) {
+    const target = e.target
+
+    this.hoveredTarget = undefined
+
+    if (!target || target === this.canvas._activeObject) return
+
+    this.clearBorderByObject(target)
+  }
+
+  private clearBorderByObject(target: FabricObject) {
+
+    if (this.canvas.contextTopDirty) {
+      this.clearContextTop(target)
+    }
+  }
+
+  private drawBorder(e: CanvasEvents['mouse:out']) {
+    const target = e.target
+
+    if (!target || target === this.canvas._activeObject) return
+
+    this.drawBorderByObject(target)
+  }
+
+  private drawBorderByObject(target: FabricObject) {
+
+    this.hoveredTarget = target
+
+    const ctx = this.clearContextTop(target, true)
+    if (!ctx) return
+
+    const object = clone(target)
+
+    // 文字特殊处理,显示下划线
+    if (object instanceof Textbox && object.isType('Textbox')) {
+      this.showUnderline(ctx, object as Textbox)
+      return
+    }
+    if (object instanceof IText && object.isType('IText')) {
+      this.showUnderline(ctx, object as Textbox)
+      return
+    }
+    // 分组特殊处理,显示矩形边框
+    if (check.isCollection(object) || object.isType('ArcText')) {
+      object._render = Rect.prototype._render
+    }
+
+    const { strokeWidth, strokeUniform } = object
+
+    let { width, height } = object
+
+    width += strokeUniform ? strokeWidth / object.scaleX : strokeWidth
+    height += strokeUniform ? strokeWidth / object.scaleY : strokeWidth
+
+    const totalObjectScaling = object.getTotalObjectScaling()
+
+    const lineWidth = Math.min(
+      this.lineWidth,
+      width * totalObjectScaling.x,
+      height * totalObjectScaling.y,
+    )
+
+    width -= lineWidth / totalObjectScaling.x
+    height -= lineWidth / totalObjectScaling.y
+
+    object.set({
+      width,
+      height,
+      stroke: 'rgb(60,126,255)',
+      strokeWidth: lineWidth,
+      strokeDashArray: null,
+      strokeDashOffset: 0,
+      strokeLineCap: 'butt',
+      strokeLineJoin: 'miter',
+      strokeMiterLimit: 4,
+    })
+
+    object._renderPaintInOrder = () => {
+      ctx.save()
+      const scaling = object.getTotalObjectScaling()
+      ctx.scale(1 / scaling.x, 1 / scaling.y)
+      object._setLineDash(ctx, object.strokeDashArray)
+      object._setStrokeStyles(ctx, object)
+      ctx.stroke()
+      ctx.restore()
+    }
+
+    object._render(ctx)
+
+    ctx.restore()
+    this.canvas.contextTopDirty = true
+  }
+
+  public showUnderline(ctx: CanvasRenderingContext2D, object: Textbox) {
+    object.underline = true
+    object.fill = 'rgb(60,126,255)'
+    object._renderTextDecoration(ctx, 'underline')
+    object._drawClipPath(ctx, object.clipPath)
+    ctx.restore()
+    this.canvas.contextTopDirty = true
+  }
+
+  public initWatch() {
+    const mainStore = useMainStore()
+    const { hoveredObject, leavedObject } = storeToRefs(mainStore)
+    computed(() => {
+      if (hoveredObject.value) this.drawBorderByObject(hoveredObject.value as FabricObject)
+      else this.clearBorderByObject(leavedObject.value as FabricObject)
+    })
+  }
+
+  public dispose(): void {
+    super.dispose()
+    this.canvas.off(this.canvasEvents)
+  }
+}

+ 24 - 0
src/app/instantiation/descriptors.ts

@@ -0,0 +1,24 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+export class SyncDescriptor<T> {
+  readonly ctor: any
+  readonly staticArguments: any[]
+  readonly supportsDelayedInstantiation: boolean
+
+  constructor(
+    ctor: new (...args: any[]) => T,
+    staticArguments: any[] = [],
+    supportsDelayedInstantiation = false,
+  ) {
+    this.ctor = ctor
+    this.staticArguments = staticArguments
+    this.supportsDelayedInstantiation = supportsDelayedInstantiation
+  }
+}
+
+export interface SyncDescriptor0<T> {
+  readonly ctor: new () => T
+}

+ 52 - 0
src/app/instantiation/extensions.ts

@@ -0,0 +1,52 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { SyncDescriptor } from './descriptors'
+import { BrandedService, ServiceIdentifier } from './instantiation'
+
+const _registry: [ServiceIdentifier<any>, SyncDescriptor<any>][] = []
+
+export const enum InstantiationType {
+  /**
+   * Instantiate this service as soon as a consumer depends on it. _Note_ that this
+   * is more costly as some upfront work is done that is likely not needed
+   */
+  Eager = 0,
+
+  /**
+   * Instantiate this service as soon as a consumer uses it. This is the _better_
+   * way of registering a service.
+   */
+  Delayed = 1,
+}
+
+export function registerSingleton<T, Services extends BrandedService[]>(
+  id: ServiceIdentifier<T>,
+  ctor: new (...services: Services) => T,
+  supportsDelayedInstantiation: InstantiationType,
+): void
+export function registerSingleton<T, Services extends BrandedService[]>(
+  id: ServiceIdentifier<T>,
+  descriptor: SyncDescriptor<any>,
+): void
+export function registerSingleton<T, Services extends BrandedService[]>(
+  id: ServiceIdentifier<T>,
+  ctorOrDescriptor: { new (...services: Services): T } | SyncDescriptor<any>,
+  supportsDelayedInstantiation?: boolean | InstantiationType,
+): void {
+  if (!(ctorOrDescriptor instanceof SyncDescriptor)) {
+    ctorOrDescriptor = new SyncDescriptor<T>(
+      ctorOrDescriptor as new (...args: any[]) => T,
+      [],
+      Boolean(supportsDelayedInstantiation),
+    )
+  }
+
+  _registry.push([id, ctorOrDescriptor])
+}
+
+export function getSingletonServiceDescriptors(): [ServiceIdentifier<any>, SyncDescriptor<any>][] {
+  return _registry
+}

+ 108 - 0
src/app/instantiation/graph.ts

@@ -0,0 +1,108 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+export class Node<T> {
+  readonly incoming = new Map<string, Node<T>>()
+  readonly outgoing = new Map<string, Node<T>>()
+
+  constructor(readonly key: string, readonly data: T) {}
+}
+
+export class Graph<T> {
+  private readonly _nodes = new Map<string, Node<T>>()
+
+  constructor(private readonly _hashFn: (element: T) => string) {
+    // empty
+  }
+
+  roots(): Node<T>[] {
+    const ret: Node<T>[] = []
+    for (const node of this._nodes.values()) {
+      if (node.outgoing.size === 0) {
+        ret.push(node)
+      }
+    }
+    return ret
+  }
+
+  insertEdge(from: T, to: T): void {
+    const fromNode = this.lookupOrInsertNode(from)
+    const toNode = this.lookupOrInsertNode(to)
+
+    fromNode.outgoing.set(toNode.key, toNode)
+    toNode.incoming.set(fromNode.key, fromNode)
+  }
+
+  removeNode(data: T): void {
+    const key = this._hashFn(data)
+    this._nodes.delete(key)
+    for (const node of this._nodes.values()) {
+      node.outgoing.delete(key)
+      node.incoming.delete(key)
+    }
+  }
+
+  lookupOrInsertNode(data: T): Node<T> {
+    const key = this._hashFn(data)
+    let node = this._nodes.get(key)
+
+    if (!node) {
+      node = new Node(key, data)
+      this._nodes.set(key, node)
+    }
+
+    return node
+  }
+
+  lookup(data: T): Node<T> | undefined {
+    return this._nodes.get(this._hashFn(data))
+  }
+
+  isEmpty(): boolean {
+    return this._nodes.size === 0
+  }
+
+  toString(): string {
+    const data: string[] = []
+    for (const [key, value] of this._nodes) {
+      data.push(
+        `${key}\n\t(-> incoming)[${[...value.incoming.keys()].join(', ')}]\n\t(outgoing ->)[${[
+          ...value.outgoing.keys(),
+        ].join(',')}]\n`,
+      )
+    }
+    return data.join('\n')
+  }
+
+  /**
+   * This is brute force and slow and **only** be used
+   * to trouble shoot.
+   */
+  findCycleSlow() {
+    for (const [id, node] of this._nodes) {
+      const seen = new Set<string>([id])
+      const res = this._findCycle(node, seen)
+      if (res) {
+        return res
+      }
+    }
+    return undefined
+  }
+
+  private _findCycle(node: Node<T>, seen: Set<string>): string | undefined {
+    for (const [id, outgoing] of node.outgoing) {
+      if (seen.has(id)) {
+        return [...seen, id].join(' -> ')
+      }
+      seen.add(id)
+      const value = this._findCycle(outgoing, seen)
+      if (value) {
+        return value
+      }
+      seen.delete(id)
+    }
+    return undefined
+  }
+}

+ 106 - 0
src/app/instantiation/instantiation.ts

@@ -0,0 +1,106 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as descriptors from './descriptors'
+import { ServiceCollection } from './serviceCollection'
+
+// ------ internal util
+export const _util = {
+  serviceIds: new Map<string, ServiceIdentifier<any>>(),
+  DI_TARGET: '$di$target',
+  DI_DEPENDENCIES: '$di$dependencies',
+  getServiceDependencies(ctor: any): { id: ServiceIdentifier<any>; index: number }[] {
+    return ctor[this.DI_DEPENDENCIES] || []
+  },
+}
+
+// --- interfaces ------
+
+export type BrandedService = { _serviceBrand: undefined }
+
+export interface IConstructorSignature<T, Args extends any[] = []> {
+  new <Services extends BrandedService[]>(...args: [...Args, ...Services]): T
+}
+
+export interface ServicesAccessor {
+  get<T>(id: ServiceIdentifier<T>): T
+}
+
+export const IInstantiationService = createDecorator<IInstantiationService>('instantiationService')
+
+/**
+ * Given a list of arguments as a tuple, attempt to extract the leading, non-service arguments
+ * to their own tuple.
+ */
+export type GetLeadingNonServiceArgs<TArgs extends any[]> = TArgs extends []
+  ? []
+  : TArgs extends [...infer TFirst, BrandedService]
+  ? GetLeadingNonServiceArgs<TFirst>
+  : TArgs
+
+export interface IInstantiationService {
+  readonly _serviceBrand: undefined
+
+  /**
+   * Synchronously creates an instance that is denoted by the descriptor
+   */
+  createInstance<T>(descriptor: descriptors.SyncDescriptor0<T>): T
+  createInstance<Ctor extends new (...args: any[]) => any, R extends InstanceType<Ctor>>(
+    ctor: Ctor,
+    ...args: GetLeadingNonServiceArgs<ConstructorParameters<Ctor>>
+  ): R
+
+  /**
+   * Calls a function with a service accessor.
+   */
+  invokeFunction<R, TS extends any[] = []>(
+    fn: (accessor: ServicesAccessor, ...args: TS) => R,
+    ...args: TS
+  ): R
+
+  /**
+   * Creates a child of this service which inherits all current services
+   * and adds/overwrites the given services.
+   */
+  createChild(services: ServiceCollection): IInstantiationService
+}
+
+/**
+ * Identifies a service of type `T`.
+ */
+export interface ServiceIdentifier<T> {
+  (...args: any[]): void
+  type: T
+}
+
+function storeServiceDependency(id: Function, target: Function, index: number): void {
+  if ((target as any)[_util.DI_TARGET] === target) {
+    ;(target as any)[_util.DI_DEPENDENCIES].push({ id, index })
+  } else {
+    ;(target as any)[_util.DI_DEPENDENCIES] = [{ id, index }]
+    ;(target as any)[_util.DI_TARGET] = target
+  }
+}
+
+/**
+ * The *only* valid way to create a {{ServiceIdentifier}}.
+ */
+export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
+  if (_util.serviceIds.has(serviceId)) {
+    return _util.serviceIds.get(serviceId)!
+  }
+
+  const id = <any>function (target: Function, key: string, index: number): any {
+    if (arguments.length !== 3) {
+      throw new Error('@IServiceName-decorator can only be used to decorate a parameter')
+    }
+    storeServiceDependency(id, target, index)
+  }
+
+  id.toString = () => serviceId
+
+  _util.serviceIds.set(serviceId, id)
+  return id
+}

+ 471 - 0
src/app/instantiation/instantiationService.ts

@@ -0,0 +1,471 @@
+import { SyncDescriptor, SyncDescriptor0 } from './descriptors'
+import { Graph } from './graph'
+import {
+  GetLeadingNonServiceArgs,
+  IInstantiationService,
+  ServiceIdentifier,
+  ServicesAccessor,
+  _util,
+} from './instantiation'
+import { ServiceCollection } from './serviceCollection'
+import { IdleValue } from '@/utils/async'
+import { LinkedList } from '@/utils/linkedList'
+import { toDisposable, IDisposable, DisposableStore } from '@/utils/lifecycle'
+
+interface Event<T> {
+  (
+    listener: (e: T) => any,
+    thisArgs?: any,
+    disposables?: IDisposable[] | DisposableStore,
+  ): IDisposable
+}
+
+// TRACING
+const _enableAllTracing = false
+// || "TRUE" // DO NOT CHECK IN!
+
+class CyclicDependencyError extends Error {
+  constructor(graph: Graph<any>) {
+    super('cyclic dependency between services')
+    this.message =
+      graph.findCycleSlow() ?? `UNABLE to detect cycle, dumping graph: \n${graph.toString()}`
+  }
+}
+
+export class InstantiationService implements IInstantiationService {
+  declare readonly _serviceBrand: undefined
+
+  readonly _globalGraph?: Graph<string>
+  private _globalGraphImplicitDependency?: string
+
+  constructor(
+    private readonly _services: ServiceCollection = new ServiceCollection(),
+    private readonly _strict: boolean = false,
+    private readonly _parent?: InstantiationService,
+    private readonly _enableTracing: boolean = _enableAllTracing,
+  ) {
+    this._services.set(IInstantiationService, this)
+    this._globalGraph = _enableTracing ? _parent?._globalGraph ?? new Graph((e) => e) : undefined
+  }
+
+  createChild(services: ServiceCollection): IInstantiationService {
+    return new InstantiationService(services, this._strict, this, this._enableTracing)
+  }
+
+  invokeFunction<R, TS extends any[] = []>(
+    fn: (accessor: ServicesAccessor, ...args: TS) => R,
+    ...args: TS
+  ): R {
+    const _trace = Trace.traceInvocation(this._enableTracing, fn)
+    let _done = false
+    try {
+      const accessor: ServicesAccessor = {
+        get: <T>(id: ServiceIdentifier<T>) => {
+          if (_done) {
+            throw new Error(
+              'service accessor is only valid during the invocation of its target method',
+            )
+          }
+
+          const result = this._getOrCreateServiceInstance(id, _trace)
+          if (!result) {
+            throw new Error(`[invokeFunction] unknown service '${id}'`)
+          }
+          return result
+        },
+      }
+      return fn(accessor, ...args)
+    } finally {
+      _done = true
+      _trace.stop()
+    }
+  }
+
+  createInstance<T>(descriptor: SyncDescriptor0<T>): T
+  createInstance<Ctor extends new (...args: any[]) => any, R extends InstanceType<Ctor>>(
+    ctor: Ctor,
+    ...args: GetLeadingNonServiceArgs<ConstructorParameters<Ctor>>
+  ): R
+  createInstance(ctorOrDescriptor: any | SyncDescriptor<any>, ...rest: any[]): any {
+    let _trace: Trace
+    let result: any
+    if (ctorOrDescriptor instanceof SyncDescriptor) {
+      _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor.ctor)
+      result = this._createInstance(
+        ctorOrDescriptor.ctor,
+        ctorOrDescriptor.staticArguments.concat(rest),
+        _trace,
+      )
+    } else {
+      _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor)
+      result = this._createInstance(ctorOrDescriptor, rest, _trace)
+    }
+    _trace.stop()
+    return result
+  }
+
+  private _createInstance<T>(ctor: any, args: any[] = [], _trace: Trace): T {
+    // arguments defined by service decorators
+    const serviceDependencies = _util.getServiceDependencies(ctor).sort((a, b) => a.index - b.index)
+    const serviceArgs: any[] = []
+    for (const dependency of serviceDependencies) {
+      const service = this._getOrCreateServiceInstance(dependency.id, _trace)
+      if (!service) {
+        this._throwIfStrict(
+          `[createInstance] ${ctor.name} depends on UNKNOWN service ${dependency.id}.`,
+          false,
+        )
+      }
+      serviceArgs.push(service)
+    }
+
+    const firstServiceArgPos =
+      serviceDependencies.length > 0 ? serviceDependencies[0].index : args.length
+
+    // check for argument mismatches, adjust static args if needed
+    if (args.length !== firstServiceArgPos) {
+      console.trace(
+        `[createInstance] First service dependency of ${ctor.name} at position ${
+          firstServiceArgPos + 1
+        } conflicts with ${args.length} static arguments`,
+      )
+
+      const delta = firstServiceArgPos - args.length
+      if (delta > 0) {
+        args = args.concat(new Array(delta))
+      } else {
+        args = args.slice(0, firstServiceArgPos)
+      }
+    }
+
+    // now create the instance
+    return Reflect.construct<any, T>(ctor, args.concat(serviceArgs))
+  }
+
+  private _setServiceInstance<T>(id: ServiceIdentifier<T>, instance: T): void {
+    if (this._services.get(id) instanceof SyncDescriptor) {
+      this._services.set(id, instance)
+    } else if (this._parent) {
+      this._parent._setServiceInstance(id, instance)
+    } else {
+      throw new Error('illegalState - setting UNKNOWN service instance')
+    }
+  }
+
+  private _getServiceInstanceOrDescriptor<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> {
+    const instanceOrDesc = this._services.get(id)
+    if (!instanceOrDesc && this._parent) {
+      return this._parent._getServiceInstanceOrDescriptor(id)
+    } else {
+      return instanceOrDesc
+    }
+  }
+
+  protected _getOrCreateServiceInstance<T>(id: ServiceIdentifier<T>, _trace: Trace): T {
+    if (this._globalGraph && this._globalGraphImplicitDependency) {
+      this._globalGraph.insertEdge(this._globalGraphImplicitDependency, String(id))
+    }
+    const thing = this._getServiceInstanceOrDescriptor(id)
+    if (thing instanceof SyncDescriptor) {
+      return this._safeCreateAndCacheServiceInstance(id, thing, _trace.branch(id, true))
+    } else {
+      _trace.branch(id, false)
+      return thing
+    }
+  }
+
+  private readonly _activeInstantiations = new Set<ServiceIdentifier<any>>()
+
+  private _safeCreateAndCacheServiceInstance<T>(
+    id: ServiceIdentifier<T>,
+    desc: SyncDescriptor<T>,
+    _trace: Trace,
+  ): T {
+    if (this._activeInstantiations.has(id)) {
+      throw new Error(`illegal state - RECURSIVELY instantiating service '${id}'`)
+    }
+    this._activeInstantiations.add(id)
+    try {
+      return this._createAndCacheServiceInstance(id, desc, _trace)
+    } finally {
+      this._activeInstantiations.delete(id)
+    }
+  }
+
+  private _createAndCacheServiceInstance<T>(
+    id: ServiceIdentifier<T>,
+    desc: SyncDescriptor<T>,
+    _trace: Trace,
+  ): T {
+    type Triple = { id: ServiceIdentifier<any>; desc: SyncDescriptor<any>; _trace: Trace }
+    const graph = new Graph<Triple>((data) => data.id.toString())
+
+    let cycleCount = 0
+    const stack = [{ id, desc, _trace }]
+    while (stack.length) {
+      const item = stack.pop()!
+      graph.lookupOrInsertNode(item)
+
+      // a weak but working heuristic for cycle checks
+      if (cycleCount++ > 1000) {
+        throw new CyclicDependencyError(graph)
+      }
+
+      // check all dependencies for existence and if they need to be created first
+      for (const dependency of _util.getServiceDependencies(item.desc.ctor)) {
+        const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id)
+        if (!instanceOrDesc) {
+          this._throwIfStrict(
+            `[createInstance] ${id} depends on ${dependency.id} which is NOT registered.`,
+            true,
+          )
+        }
+
+        // take note of all service dependencies
+        this._globalGraph?.insertEdge(String(item.id), String(dependency.id))
+
+        if (instanceOrDesc instanceof SyncDescriptor) {
+          const d = {
+            id: dependency.id,
+            desc: instanceOrDesc,
+            _trace: item._trace.branch(dependency.id, true),
+          }
+          graph.insertEdge(item, d)
+          stack.push(d)
+        }
+      }
+    }
+
+    // eslint-disable-next-line no-constant-condition
+    while (true) {
+      const roots = graph.roots()
+
+      // if there is no more roots but still
+      // nodes in the graph we have a cycle
+      if (roots.length === 0) {
+        if (!graph.isEmpty()) {
+          throw new CyclicDependencyError(graph)
+        }
+        break
+      }
+
+      for (const { data } of roots) {
+        // Repeat the check for this still being a service sync descriptor. That's because
+        // instantiating a dependency might have side-effect and recursively trigger instantiation
+        // so that some dependencies are now fullfilled already.
+        const instanceOrDesc = this._getServiceInstanceOrDescriptor(data.id)
+        if (instanceOrDesc instanceof SyncDescriptor) {
+          // create instance and overwrite the service collections
+          const instance = this._createServiceInstanceWithOwner(
+            data.id,
+            data.desc.ctor,
+            data.desc.staticArguments,
+            data.desc.supportsDelayedInstantiation,
+            data._trace,
+          )
+          this._setServiceInstance(data.id, instance)
+        }
+        graph.removeNode(data)
+      }
+    }
+    return <T>this._getServiceInstanceOrDescriptor(id)
+  }
+
+  private _createServiceInstanceWithOwner<T>(
+    id: ServiceIdentifier<T>,
+    ctor: any,
+    args: any[] = [],
+    supportsDelayedInstantiation: boolean,
+    _trace: Trace,
+  ): T {
+    if (this._services.get(id) instanceof SyncDescriptor) {
+      return this._createServiceInstance(id, ctor, args, supportsDelayedInstantiation, _trace)
+    } else if (this._parent) {
+      return this._parent._createServiceInstanceWithOwner(
+        id,
+        ctor,
+        args,
+        supportsDelayedInstantiation,
+        _trace,
+      )
+    } else {
+      throw new Error(`illegalState - creating UNKNOWN service instance ${ctor.name}`)
+    }
+  }
+
+  private _createServiceInstance<T>(
+    id: ServiceIdentifier<T>,
+    ctor: any,
+    args: any[] = [],
+    supportsDelayedInstantiation: boolean,
+    _trace: Trace,
+  ): T {
+    if (!supportsDelayedInstantiation) {
+      // eager instantiation
+      return this._createInstance(ctor, args, _trace)
+    } else {
+      const child = new InstantiationService(undefined, this._strict, this, this._enableTracing)
+      child._globalGraphImplicitDependency = String(id)
+
+      // Return a proxy object that's backed by an idle value. That
+      // strategy is to instantiate services in our idle time or when actually
+      // needed but not when injected into a consumer
+
+      // return "empty events" when the service isn't instantiated yet
+      const earlyListeners = new Map<string, LinkedList<Parameters<Event<any>>>>()
+
+      const idle = new IdleValue<any>(() => {
+        const result = child._createInstance<T>(ctor, args, _trace)
+
+        // early listeners that we kept are now being subscribed to
+        // the real service
+        for (const [key, values] of earlyListeners) {
+          const candidate = <Event<any>>(<any>result)[key]
+          if (typeof candidate === 'function') {
+            for (const listener of values) {
+              candidate.apply(result, listener)
+            }
+          }
+        }
+        earlyListeners.clear()
+
+        return result
+      })
+      return <T>new Proxy(Object.create(null), {
+        get(target: any, key: PropertyKey): any {
+          if (!idle.isInitialized) {
+            // looks like an event
+            if (typeof key === 'string' && (key.startsWith('onDid') || key.startsWith('onWill'))) {
+              let list = earlyListeners.get(key)
+              if (!list) {
+                list = new LinkedList()
+                earlyListeners.set(key, list)
+              }
+              const event: Event<any> = (callback, thisArg, disposables) => {
+                const rm = list!.push([callback, thisArg, disposables])
+                return toDisposable(rm)
+              }
+              return event
+            }
+          }
+
+          // value already exists
+          if (key in target) {
+            return target[key]
+          }
+
+          // create value
+          const obj = idle.value
+          let prop = obj[key]
+          if (typeof prop !== 'function') {
+            return prop
+          }
+          prop = prop.bind(obj)
+          target[key] = prop
+          return prop
+        },
+        set(_target: T, p: PropertyKey, value: any): boolean {
+          idle.value[p] = value
+          return true
+        },
+        getPrototypeOf(_target: T) {
+          return ctor.prototype
+        },
+      })
+    }
+  }
+
+  private _throwIfStrict(msg: string, printWarning: boolean): void {
+    if (printWarning) {
+      console.warn(msg)
+    }
+    if (this._strict) {
+      throw new Error(msg)
+    }
+  }
+}
+
+//#region -- tracing ---
+
+const enum TraceType {
+  None = 0,
+  Creation = 1,
+  Invocation = 2,
+  Branch = 3,
+}
+
+export class Trace {
+  static all = new Set<string>()
+
+  private static readonly _None = new (class extends Trace {
+    constructor() {
+      super(TraceType.None, null)
+    }
+    override stop() {}
+    override branch() {
+      return this
+    }
+  })()
+
+  static traceInvocation(_enableTracing: boolean, ctor: any): Trace {
+    return !_enableTracing
+      ? Trace._None
+      : new Trace(
+          TraceType.Invocation,
+          ctor.name || new Error().stack!.split('\n').slice(3, 4).join('\n'),
+        )
+  }
+
+  static traceCreation(_enableTracing: boolean, ctor: any): Trace {
+    return !_enableTracing ? Trace._None : new Trace(TraceType.Creation, ctor.name)
+  }
+
+  private static _totals = 0
+  private readonly _start: number = Date.now()
+  private readonly _dep: [ServiceIdentifier<any>, boolean, Trace?][] = []
+
+  private constructor(readonly type: TraceType, readonly name: string | null) {}
+
+  branch(id: ServiceIdentifier<any>, first: boolean): Trace {
+    const child = new Trace(TraceType.Branch, id.toString())
+    this._dep.push([id, first, child])
+    return child
+  }
+
+  stop() {
+    const dur = Date.now() - this._start
+    Trace._totals += dur
+
+    let causedCreation = false
+
+    function printChild(n: number, trace: Trace) {
+      const res: string[] = []
+      const prefix = new Array(n + 1).join('\t')
+      for (const [id, first, child] of trace._dep) {
+        if (first && child) {
+          causedCreation = true
+          res.push(`${prefix}CREATES -> ${id}`)
+          const nested = printChild(n + 1, child)
+          if (nested) {
+            res.push(nested)
+          }
+        } else {
+          res.push(`${prefix}uses -> ${id}`)
+        }
+      }
+      return res.join('\n')
+    }
+
+    const lines = [
+      `${this.type === TraceType.Creation ? 'CREATE' : 'CALL'} ${this.name}`,
+      `${printChild(1, this)}`,
+      `DONE, took ${dur.toFixed(2)}ms (grand total ${Trace._totals.toFixed(2)}ms)`,
+    ]
+
+    if (dur > 2 || causedCreation) {
+      Trace.all.add(lines.join('\n'))
+    }
+  }
+}
+
+//#endregion

+ 34 - 0
src/app/instantiation/serviceCollection.ts

@@ -0,0 +1,34 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { ServiceIdentifier } from './instantiation'
+import { SyncDescriptor } from './descriptors'
+
+export class ServiceCollection {
+  private _entries = new Map<ServiceIdentifier<any>, any>()
+
+  constructor(...entries: [ServiceIdentifier<any>, any][]) {
+    for (const [id, service] of entries) {
+      this.set(id, service)
+    }
+  }
+
+  set<T>(
+    id: ServiceIdentifier<T>,
+    instanceOrDescriptor: T | SyncDescriptor<T>,
+  ): T | SyncDescriptor<T> {
+    const result = this._entries.get(id)
+    this._entries.set(id, instanceOrDescriptor)
+    return result
+  }
+
+  has(id: ServiceIdentifier<any>): boolean {
+    return this._entries.has(id)
+  }
+
+  get<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> {
+    return this._entries.get(id)
+  }
+}

+ 63 - 0
src/app/keybinding.ts

@@ -0,0 +1,63 @@
+import mousetrap, { ExtendedKeyboardEvent } from 'mousetrap'
+import { isArray, isFunction, isObject, isString } from 'lodash-es'
+import { createDecorator } from './instantiation/instantiation'
+import { registerSingleton, InstantiationType } from './instantiation/extensions'
+import { runWhenIdle } from '@/utils/async'
+
+export const IKeybindingService = createDecorator<Keybinding>('Keybinding')
+
+type Callback = (e: ExtendedKeyboardEvent, combo: string) => void
+
+export class Keybinding extends mousetrap {
+  declare readonly _serviceBrand: undefined
+
+  public mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
+
+  constructor() {
+    super()
+  }
+
+  /**
+   * Overwrites default Mousetrap.bind method to optionally accept
+   * an object to bind multiple key events in a single call
+   *
+   * You can pass it in like:
+   *
+   * Mousetrap.bind({
+   *     'a': function() { console.log('a'); },
+   *     'b': function() { console.log('b'); }
+   * });
+   *
+   * And can optionally pass in 'keypress', 'keydown', or 'keyup'
+   * as a second argument
+   *
+   */
+  override bind(keys: string | string[], callback: Callback, action?: string): this
+  override bind(keys: { [key: string]: Callback }, action?: string): this
+  override bind(
+    keys: string | string[] | { [key: string]: Callback },
+    callbackOrAction?: string | Callback,
+    action?: string,
+  ) {
+    if ((isString(keys) || isArray(keys)) && isFunction(callbackOrAction)) {
+      return super.bind(keys, callbackOrAction, action)
+    }
+
+    if (isObject(keys) && !isArray(keys) && (!callbackOrAction || isString(callbackOrAction))) {
+      for (const key in keys) {
+        super.bind(key, keys[key], callbackOrAction)
+      }
+    }
+
+    return this
+  }
+
+  override trigger(keys: string, action?: string | undefined) {
+    runWhenIdle(() => {
+      super.trigger(keys, action)
+    })
+    return this
+  }
+}
+
+registerSingleton(IKeybindingService, Keybinding, InstantiationType.Eager)

+ 155 - 0
src/app/wheelScroll.ts

@@ -0,0 +1,155 @@
+
+import { CanvasEvents, Canvas, Point, TPointerEvent, TPointerEventInfo } from 'fabric'
+import { useIntervalFn, useMagicKeys } from '@vueuse/core'
+import { Disposable, toDisposable } from '@/utils/lifecycle'
+import { debounce } from 'lodash-es'
+import { storeToRefs } from 'pinia'
+import { useFabricStore } from '@/store'
+
+/**
+ * 画板默认滚动行为
+ */
+export class WheelScroll extends Disposable {
+  private edgeMoveStatus = true
+
+  constructor(private readonly canvas: Canvas) {
+    super()
+    this.initWhellScroll()
+    this.initEdgeMove()
+  }
+
+  /**
+   * 鼠标滚动
+   */
+  private initWhellScroll() {
+    const { ctrl, cmd, shift } = useMagicKeys()
+    const fabricStore = useFabricStore()
+    const { zoom } = storeToRefs(fabricStore)
+    const mouseWheel = (e: CanvasEvents['mouse:wheel']) => {
+      e.e.preventDefault()
+      e.e.stopPropagation()
+      const { deltaX, deltaY, offsetX, offsetY } = e.e
+      // 缩放视窗
+      if (ctrl.value || cmd.value) {
+        const zoomFactor = Math.abs(deltaY) < 10 ? deltaY * 2 : deltaY / 3
+        const canvasZoom = this.canvas.getZoom()
+        let zoomVal = canvasZoom * (1 - zoomFactor / 200)
+        if (zoomVal > 0.97 && zoomVal < 1.03) {
+          zoomVal = 1
+        }
+        zoom.value = zoomVal
+        this.canvas.zoomToPoint(new Point(offsetX, offsetY), zoomVal)
+        this.setCoords()
+        return
+      }
+      
+      // 滚动画布
+      const deltaPoint = new Point()
+      if (shift.value) {
+        deltaPoint.x = deltaY > 0 ? -20 : 20
+      } else {
+        deltaPoint.y = deltaY > 0 ? -20 : 20
+      }
+      this.canvas.relativePan(deltaPoint)
+      this.setCoords()
+    }
+
+    this.canvas.on('mouse:wheel', mouseWheel)
+    this._register(
+      toDisposable(() => {
+        this.canvas.off('mouse:wheel', mouseWheel)
+      }),
+    )
+  }
+
+  /**
+   * 更新所有元素坐标
+   */
+  private setCoords = debounce(() => {
+    const { renderOnAddRemove } = this.canvas
+    this.canvas.renderOnAddRemove = false
+    this.canvas.setViewportTransform(this.canvas.viewportTransform)
+    this.canvas.renderOnAddRemove = renderOnAddRemove
+  }, 150)
+
+  /**
+   * 边缘移动
+   */
+  private initEdgeMove() {
+    let event: TPointerEventInfo<TPointerEvent> | undefined
+
+    /** 是否需要执行setCoords */
+    let needSetCoords = false
+
+    const { pause, resume } = useIntervalFn(() => {
+        if (!event) return
+
+        const A = new Point(24, 24)
+        const B = new Point(this.canvas.width, this.canvas.height).subtract(A)
+        const [pos, distance] = this.judgePosition(event.absolutePointer, A, B)
+        if (pos === 0) return
+
+        let deltaPoint = new Point()
+        const amount = Math.min(distance, 20)
+        if (pos & 1) deltaPoint.x = amount
+        if (pos & 2) deltaPoint.x = -amount
+        if (pos & 4) deltaPoint.y = amount
+        if (pos & 8) deltaPoint.y = -amount
+
+        // 移动到四个角落,减速
+        if (deltaPoint.x !== 0 && deltaPoint.y !== 0) {
+          deltaPoint = deltaPoint.scalarDivide(1.5)
+        }
+
+        this.canvas.relativePan(deltaPoint)
+        this.canvas._onMouseMove(event.e)
+        needSetCoords = true
+      },
+      16, // 1000 / 60
+      {
+        immediate: false,
+      },
+    )
+
+    // const { isSwiping } = useFabricSwipe({
+    //   onSwipeStart: () => {
+    //     if (!this.edgeMoveStatus) return
+    //     isSwiping.value = true
+    //     resume()
+    //   },
+    //   onSwipe: (e) => {
+    //     if (!this.edgeMoveStatus) return
+    //     event = e
+    //   },
+    //   onSwipeEnd: () => {
+    //     pause()
+    //     event = undefined
+    //     if (needSetCoords) {
+    //       this.setCoords()
+    //       needSetCoords = false
+    //     }
+    //   },
+    // })
+
+    // this.eventbus.on('setEdgeMoveStatus', (value) => {
+    //   this.edgeMoveStatus = value
+    // })
+  }
+
+  /**
+   * 判断点T相对于矩形的位置和距离
+   * @param {Point} T - 待判断的点
+   * @param {Point} A - 矩形左上角的点
+   * @param {Point} B - 矩形右下角的点
+   * @returns {Array} 第一个元素是pos,第二个元素是distance
+   */
+  private judgePosition(T: Point, A: Point, B: Point): [number, number] {
+    let pos = 0
+    let distance = 0
+    if (T.x < A.x) (pos |= 1), (distance += A.x - T.x)
+    else if (T.x > B.x) (pos |= 2), (distance += T.x - B.x)
+    if (T.y < A.y) (pos |= 4), (distance += A.y - T.y)
+    else if (T.y > B.y) (pos |= 8), (distance += T.y - B.y)
+    return [pos, distance]
+  }
+}

BIN
src/assets/fonts/xuminY.TTF


BIN
src/assets/fonts/仓耳小丸子.ttf


BIN
src/assets/fonts/优设标题黑.ttf


BIN
src/assets/fonts/字制区喜脉体.ttf


BIN
src/assets/fonts/峰广明锐体.ttf


BIN
src/assets/fonts/得意黑.ttf


BIN
src/assets/fonts/摄图摩登小方体.ttf


BIN
src/assets/fonts/站酷快乐体.ttf


BIN
src/assets/fonts/素材集市康康体.ttf


BIN
src/assets/fonts/素材集市酷方体.ttf


BIN
src/assets/fonts/途牛类圆体.ttf


BIN
src/assets/fonts/锐字真言体.ttf


BIN
src/assets/images/escheresque.png


BIN
src/assets/images/greyfloral.png


BIN
src/assets/images/honey_im_subtle.png


BIN
src/assets/images/index.png


BIN
src/assets/images/loading.gif


BIN
src/assets/images/nasty_fabric.png


BIN
src/assets/images/retina_wood.png


BIN
src/assets/images/rotate.png


BIN
src/assets/logo.png


Fichier diff supprimé car celui-ci est trop grand
+ 13 - 0
src/assets/logo.svg


+ 3 - 0
src/assets/style/element-plus.scss

@@ -0,0 +1,3 @@
+.el-row {
+  width: 100%;
+}

+ 0 - 0
src/assets/style/font.scss


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff