├── .prettierrc ├── src ├── styles │ ├── index.scss │ └── icon │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ ├── iconfont.woff2 │ │ └── iconfont.css ├── assets │ ├── upload-file-bg.png │ ├── circle.svg │ ├── square.svg │ ├── triangle.svg │ ├── line.svg │ ├── editor │ │ ├── edgecontrol.svg │ │ ├── middlecontrol.svg │ │ ├── middlecontrolhoz.svg │ │ └── rotateicon.svg │ ├── module-close.svg │ ├── qrcode.svg │ ├── upload.svg │ ├── h1.svg │ ├── workareaCursor.svg │ ├── h2.svg │ ├── Aa.svg │ ├── 3d.svg │ └── upload-file-image.svg ├── constants │ ├── panel.ts │ ├── workspace.ts │ ├── sidebar.ts │ ├── material.ts │ ├── addtab.ts │ └── size.ts ├── core │ ├── ImageHandler.ts │ ├── FabricHandler.ts │ ├── ControlHandler.ts │ ├── UtilsHandler.ts │ └── WorkareaHandler.ts ├── router │ └── index.ts ├── shims-vue.d.ts ├── common │ ├── guides │ │ ├── types.ts │ │ └── index.vue │ ├── panel-block.vue │ ├── waterfall.vue │ └── popover.vue ├── main.ts ├── App.vue ├── components │ ├── panel │ │ ├── color.vue │ │ ├── opactiy.vue │ │ ├── checkbox.vue │ │ ├── data-input.vue │ │ ├── bar.vue │ │ ├── slider.vue │ │ └── workspace-size.vue │ ├── workarea │ │ ├── canvas.vue │ │ ├── scale-bar.vue │ │ └── workspace.vue │ └── sidebar │ │ └── bar.vue ├── types │ ├── global.d.ts │ ├── extends.d.ts │ └── utils.ts ├── utils │ ├── _.ts │ └── getFileType.ts ├── store │ └── index.ts ├── service │ └── request.ts ├── page │ └── editor.vue ├── layout │ ├── Framework.vue │ └── Nav.vue └── parse │ └── psd.js ├── .browserslistrc ├── commitlint.config.cjs ├── babel.config.js ├── .husky ├── pre-commit └── commit-msg ├── .eslintignore ├── .lintstagedrc.json ├── vue.config.js ├── .gitignore ├── .eslintrc.js ├── public └── index.html ├── .github └── workflows │ └── pages.yml ├── README.md ├── tsconfig.json ├── vite.config.ts ├── package.json └── index.html /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2 3 | } 4 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "./icon/iconfont.css"; 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"], 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/gif/fabricGif.js 2 | src/gif/gifToSprite.js 3 | src/gifjs/gif.js 4 | src/gifjs/gif.worker.js -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx}": ["prettier --write ."], 3 | "*.md": ["prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/upload-file-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixin-fang/vue-design-editor/HEAD/src/assets/upload-file-bg.png -------------------------------------------------------------------------------- /src/styles/icon/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixin-fang/vue-design-editor/HEAD/src/styles/icon/iconfont.ttf -------------------------------------------------------------------------------- /src/styles/icon/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixin-fang/vue-design-editor/HEAD/src/styles/icon/iconfont.woff -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /src/styles/icon/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haixin-fang/vue-design-editor/HEAD/src/styles/icon/iconfont.woff2 -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("@vue/cli-service"); 2 | module.exports = defineConfig({ 3 | transpileDependencies: true, 4 | }); 5 | -------------------------------------------------------------------------------- /src/constants/panel.ts: -------------------------------------------------------------------------------- 1 | export const panel = [ 2 | { 3 | text: "设计", 4 | type: "design", 5 | }, 6 | { 7 | text: "动画", 8 | type: "animate", 9 | showList: ["textbox", "FontCustom", "Image"], 10 | }, 11 | ]; 12 | -------------------------------------------------------------------------------- /src/core/ImageHandler.ts: -------------------------------------------------------------------------------- 1 | import Handler from "./handler"; 2 | 3 | class ImageHandler { 4 | private handler: Handler; 5 | constructor(handler: Handler) { 6 | this.handler = handler; 7 | } 8 | } 9 | 10 | export default ImageHandler; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /src/assets/circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router"; 2 | import Editor from "../page/editor.vue"; 3 | 4 | const routes: Array = [ 5 | { 6 | path: "/", 7 | name: "home", 8 | component: Editor, 9 | }, 10 | ]; 11 | 12 | const router = createRouter({ 13 | history: createWebHashHistory(), 14 | routes, 15 | }); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module "*.vue" { 3 | import type { DefineComponent } from "vue"; 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | 8 | declare module "element-plus"; 9 | 10 | declare module "uuid"; 11 | 12 | declare module "*.svg"; 13 | 14 | interface Window { 15 | showOpenFilePicker: (params: any) => any; 16 | } 17 | 18 | declare module "tojson.js"; 19 | -------------------------------------------------------------------------------- /src/common/guides/types.ts: -------------------------------------------------------------------------------- 1 | import { MethodInterface } from "framework-utils"; 2 | import VanillaGuides, { GuideOptions, GuidesInterface } from "@scena/guides"; 3 | 4 | export interface VueGuidesInterface 5 | extends MethodInterface { 6 | name: string; 7 | $el: HTMLElement; 8 | $props: GuideOptions & { vueStyle: Record }; 9 | $refs: any; 10 | $emit(name: string, e: any): void; 11 | $_guides: VanillaGuides; 12 | } 13 | -------------------------------------------------------------------------------- /src/assets/square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import store from "./store"; 5 | // import ElementPlus from "element-plus"; 6 | import "element-plus/dist/index.css"; 7 | // import zhCn from "element-plus/es/locale/lang/zh-cn"; 8 | import "./style.css"; 9 | import "./styles/index.scss"; 10 | 11 | import Popover from "@/common/popover.vue"; 12 | const app = createApp(App); 13 | 14 | app.use(store).use(router).component("popover", Popover).mount("#app"); 15 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 7 | 28 | -------------------------------------------------------------------------------- /src/assets/triangle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | "plugin:prettier/recommended", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | }, 15 | rules: { 16 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 17 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 18 | "vue/multi-word-component-names": "off", 19 | "@typescript-eslint/no-explicit-any": ["off"], 20 | "@typescript-eslint/no-var-requires": 0, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2.3.1 12 | with: 13 | persist-credentials: false 14 | - name: Install and Build 15 | run: | 16 | npm install 17 | npm run-script build 18 | - name: Deploy 19 | uses: JamesIves/github-pages-deploy-action@3.7.1 20 | with: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | BRANCH: gh-pages 23 | FOLDER: dist 24 | CLEAN: true 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## vue-design-editor 2 | 3 | [Demo](https://haixin-fang.github.io/vue-design-editor/) 4 | 5 | `fabric.js and Vue3 based image editor, can customize fonts, materials, design templates.` 6 | 7 | ## 功能 8 | 9 | - 图片编辑 10 | - 图片裁剪 11 | - psd 解析 12 | - psd 同步为模板 13 | - 解析 psd 切片 14 | - pdf 解析 15 | - ppt 解析 (pptxtojson,pptxgenjs) 16 | - 滤镜 17 | - 动画 18 | - 蒙版 19 | - 组合/拆分组合 20 | - 图层及顺序调整 21 | - 撤销/重做 22 | - 背景属性设置 23 | - 外观属性/字体属性/描边/阴影 24 | - 自定义字体 25 | - 自定义模板素材 26 | - 快捷键 27 | - 右键菜单 28 | - 辅助线 29 | - 标尺 ✅ 30 | - 图片替换 31 | - 图片滤镜 32 | - 国际化 33 | - 支持导出 pdf, png, jpg, gif, mp4, ppt 等 34 | 35 | ## 启动 36 | 37 | ```js 38 | npm i 39 | npm run dev 40 | ``` 41 | 42 | ## 打包 43 | 44 | ```js 45 | npm run build 46 | ``` 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "jsx": "preserve", 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "useDefineForClassFields": true, 10 | "sourceMap": true, 11 | "declaration": true, 12 | "baseUrl": ".", 13 | "module": "ESNext", 14 | "moduleResolution": "node", 15 | "target": "esnext", 16 | "outDir": "lib", 17 | "lib": ["dom", "esnext"], 18 | "paths": { 19 | "@/*": ["src/*"] 20 | } 21 | }, 22 | "include": [ 23 | "src/**/*.ts", 24 | "src/**/*.tsx", 25 | "src/**/*.d.ts", 26 | "src/**/*.vue", 27 | "tests/**/*.ts", 28 | "tests/**/*.tsx" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/constants/workspace.ts: -------------------------------------------------------------------------------- 1 | export const workareaOption = { 2 | trigger: "color", 3 | width: 260, 4 | height: 641, 5 | lockScalingX: true, 6 | lockScalingY: true, 7 | scaleX: 1, 8 | scaleY: 1, 9 | fill: "#fff", 10 | hasBorders: false, 11 | hasControls: false, 12 | selectable: false, 13 | lockMovementX: true, 14 | lockMovementY: true, 15 | hoverCursor: "default", 16 | name: "", 17 | id: "workarea", 18 | unit: "px", 19 | // type: "workarea", 20 | isElement: false, 21 | }; 22 | export const objectOption = { 23 | rotation: 0, 24 | centeredRotation: true, 25 | strokeUniform: true, 26 | hoverCursor: "pointer", 27 | visible: true, 28 | }; 29 | export const propertiesToInclude = [ 30 | "id", 31 | "name", 32 | "src", 33 | "backgroundColor", 34 | "type", 35 | ]; 36 | -------------------------------------------------------------------------------- /src/components/panel/color.vue: -------------------------------------------------------------------------------- 1 | 12 | 39 | -------------------------------------------------------------------------------- /src/assets/editor/edgecontrol.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/editor/middlecontrol.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "path"; 4 | import ViteTransfrom from "vite-plugin-require-transform"; 5 | 6 | export default defineConfig({ 7 | plugins: [vue(), ViteTransfrom()], 8 | server: { 9 | host: "0.0.0.0", 10 | port: 8888, 11 | }, 12 | resolve: { 13 | alias: [ 14 | { 15 | find: "@", 16 | replacement: path.resolve(__dirname, "./src"), 17 | }, 18 | ], 19 | }, 20 | base: "/vue-design-editor", 21 | build: { 22 | cssCodeSplit: false, // 如果设置为false,整个项目中的所有 CSS 将被提取到一个 CSS 文件中 23 | sourcemap: false, // 构建后是否生成 source map 文件。如果为 true,将会创建一个独立的 source map 文件 24 | target: "esnext", // 设置最终构建的浏览器兼容目标。默认值是一个 Vite 特有的值——'modules' 还可设置为 'es2015' 'es2016'等 25 | minify: "esbuild", // 'terser' 相对较慢,但大多数情况下构建后的文件体积更小。'esbuild' 最小化混淆更快但构建后的文件相对更大。 26 | rollupOptions: { 27 | input: { 28 | main: path.resolve(__dirname, "index.html"), 29 | }, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/assets/module-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/editor/middlecontrolhoz.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fabric/fabric-impl" { 2 | // Common 3 | class Gif {} 4 | class Arrow {} 5 | // Element 6 | class Iframe {} 7 | class Chart {} 8 | class Element {} 9 | class Video {} 10 | // Node 11 | class Node {} 12 | // Link 13 | class Link {} 14 | class CurvedLink {} 15 | class OrthogonalLink {} 16 | class Cube {} 17 | // SVG 18 | class Svg {} 19 | 20 | interface ICreateProperties { 21 | type: "string"; 22 | initialize(options: any): void; 23 | toObject?(propertiesToInclude: string[]): void; 24 | _render?(ctx: CanvasRenderingContext2D): void; 25 | } 26 | 27 | interface IUtilClass { 28 | /** 29 | * Helper for creation of "classes". 30 | * @param [parent] optional "Class" to inherit from 31 | * @param [properties] Properties shared by all instances of this class 32 | * (be careful modifying objects defined here as this would affect all instances) 33 | */ 34 | createClass(parent: any, properties?: ICreateProperties); 35 | } 36 | 37 | type IUtil = fabric.IUtil & IUtilClass; 38 | 39 | export const util: IUtil; 40 | } 41 | -------------------------------------------------------------------------------- /src/types/extends.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace fabric { 2 | export interface Canvas { 3 | contextTop: CanvasRenderingContext2D; 4 | lowerCanvasEl: HTMLElement; 5 | _currentTransform: unknown; 6 | _centerObject: (obj: fabric.Object, center: fabric.Point) => fabric.Canvas; 7 | } 8 | 9 | export interface Control { 10 | rotate: number; 11 | } 12 | 13 | function ControlMouseEventHandler( 14 | eventData: MouseEvent, 15 | transformData: Transform, 16 | x: number, 17 | y: number 18 | ): boolean; 19 | function ControlStringHandler( 20 | eventData: MouseEvent, 21 | control: fabric.Control, 22 | fabricObject: fabric.Object 23 | ): string; 24 | export const controlsUtils: { 25 | rotationWithSnapping: ControlMouseEventHandler; 26 | scalingEqually: ControlMouseEventHandler; 27 | scalingYOrSkewingX: ControlMouseEventHandler; 28 | scalingXOrSkewingY: ControlMouseEventHandler; 29 | 30 | scaleCursorStyleHandler: ControlStringHandler; 31 | scaleSkewCursorStyleHandler: ControlStringHandler; 32 | scaleOrSkewActionName: ControlStringHandler; 33 | rotationStyleHandler: ControlStringHandler; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | 3 | export interface WorkareaOption { 4 | id: string; 5 | /** 6 | * 链接 7 | * @type {string} 8 | */ 9 | src: string; 10 | /** 11 | * 图片File或blob 12 | * @type {File} 13 | */ 14 | file?: File; 15 | /** 16 | * 工作区默认宽度 17 | * @type {number} 18 | */ 19 | width: number; 20 | /** 21 | * 工作区默认高度 22 | * @type {number} 23 | */ 24 | height: number; 25 | /** 26 | * 工作区背景图 27 | * @type {string} 28 | */ 29 | backgroundColor?: string; 30 | /** 31 | * 素材类型 32 | * @type {string} 33 | */ 34 | type?: string; 35 | 36 | path?: any; 37 | 38 | filters?: any[]; 39 | 40 | set: (key: string, value: any) => void; 41 | 42 | setSrc: ( 43 | ley: any, 44 | cb: () => void, 45 | option: { dirty: boolean; crossOrigin?: "Anonymous"; [key: string]: any } 46 | ) => void; 47 | 48 | toGroup?: () => any; 49 | } 50 | /** 51 | * 画布元素 52 | */ 53 | export type WorkareaObject = FabricImage & WorkareaOption; 54 | 55 | export type FabricImage = fabric.Image & Partial; 56 | 57 | export type FabricRect = fabric.Rect & 58 | Partial & { unit?: string }; 59 | -------------------------------------------------------------------------------- /src/constants/sidebar.ts: -------------------------------------------------------------------------------- 1 | export const modulelist = [ 2 | { 3 | type: "add", 4 | text: "添加", 5 | drag: false, 6 | icon: "icon-tianjia", 7 | }, 8 | { 9 | type: "template", 10 | text: "模板", 11 | drag: false, 12 | icon: "icon-templet-", 13 | }, 14 | { 15 | type: "material", 16 | text: "素材", 17 | drag: false, 18 | show: true, //默认展示 19 | icon: "icon-xingzhuang", 20 | fileType: ["jpg", "png", "gif", "svg"], 21 | }, 22 | { 23 | type: "text", 24 | text: "文字", 25 | drag: true, 26 | icon: "icon-text", 27 | options: { 28 | type: "FontCustom", 29 | text: "", 30 | width: 60, 31 | height: 30, 32 | fontSize: 32, 33 | name: "New text", 34 | }, 35 | }, 36 | { 37 | type: "Image", 38 | text: "图片", 39 | drag: false, 40 | icon: "icon-editor-background", 41 | fileType: ["jpg", "png", "gif", "svg"], 42 | }, 43 | { 44 | type: "Video", 45 | text: "视频", 46 | drag: false, 47 | icon: "icon-shipinbofang", 48 | fileType: ["mp4"], 49 | }, 50 | { 51 | type: "qrcode", 52 | text: "二维码", 53 | drag: false, 54 | icon: "icon-erweima1", 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /src/constants/material.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | title: "卡通", 4 | interval: [460, 489], 5 | }, 6 | { 7 | title: "水果", 8 | interval: [386, 409], 9 | }, 10 | { 11 | title: "体育", 12 | interval: [410, 459], 13 | }, 14 | { 15 | title: "秋天", 16 | interval: [40, 49], 17 | }, 18 | { 19 | title: "icons", 20 | }, 21 | { 22 | title: "计算机", 23 | interval: [50, 75], 24 | }, 25 | { 26 | title: "卡通水果", 27 | interval: [76, 89], 28 | }, 29 | { 30 | title: "服饰", 31 | interval: [89, 136], 32 | }, 33 | { 34 | title: "旗子", 35 | interval: [137, 151], 36 | }, 37 | { 38 | title: "树木", 39 | interval: [152, 181], 40 | }, 41 | { 42 | title: "食物", 43 | interval: [182, 201], 44 | }, 45 | { 46 | title: "服饰", 47 | interval: [202, 222], 48 | }, 49 | { 50 | title: "奖牌", 51 | interval: [223, 252], 52 | }, 53 | { 54 | title: "商务", 55 | interval: [253, 261], 56 | }, 57 | { 58 | title: "活动", 59 | interval: [262, 270], 60 | }, 61 | { 62 | title: "卡通水果", 63 | interval: [271, 300], 64 | }, 65 | { 66 | title: "复古", 67 | interval: [301, 350], 68 | }, 69 | { 70 | title: "卡通", 71 | interval: [351, 385], 72 | }, 73 | { 74 | title: "动物", 75 | interval: [490, 519], 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /src/common/panel-block.vue: -------------------------------------------------------------------------------- 1 | 11 | 22 | 57 | -------------------------------------------------------------------------------- /src/constants/addtab.ts: -------------------------------------------------------------------------------- 1 | export const addTab = [ 2 | { 3 | type: "image", 4 | title: "图片/视频/psd", 5 | list: [ 6 | { 7 | type: "upload", 8 | icon: require("@/assets/upload.svg"), 9 | title: "本地上传", 10 | }, 11 | ], 12 | }, 13 | { 14 | type: "text", 15 | title: "文字", 16 | list: [ 17 | { 18 | type: "h1", 19 | icon: require("@/assets/h1.svg"), 20 | title: "标题", 21 | }, 22 | { 23 | type: "h2", 24 | icon: require("@/assets/h2.svg"), 25 | title: "副标题", 26 | }, 27 | { 28 | type: "Aa", 29 | icon: require("@/assets/Aa.svg"), 30 | title: "正文", 31 | }, 32 | { 33 | type: "Aa", 34 | icon: require("@/assets/3d.svg"), 35 | title: "3D文字", 36 | }, 37 | ], 38 | }, 39 | { 40 | type: "shape", 41 | title: "形状", 42 | list: [ 43 | { 44 | type: "square", 45 | icon: require("@/assets/square.svg"), 46 | }, 47 | { 48 | type: "triangle", 49 | icon: require("@/assets/triangle.svg"), 50 | }, 51 | { 52 | type: "circle", 53 | icon: require("@/assets/circle.svg"), 54 | }, 55 | { 56 | type: "line", 57 | icon: require("@/assets/line.svg"), 58 | }, 59 | ], 60 | }, 61 | { 62 | type: "component", 63 | title: "组件", 64 | list: [ 65 | { 66 | type: "qrcode", 67 | icon: require("@/assets/qrcode.svg"), 68 | title: "二维码", 69 | }, 70 | ], 71 | }, 72 | ]; 73 | -------------------------------------------------------------------------------- /src/assets/qrcode.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/assets/editor/rotateicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/panel/opactiy.vue: -------------------------------------------------------------------------------- 1 | 34 | 61 | 73 | -------------------------------------------------------------------------------- /src/utils/_.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | debounce(fn: () => any, wait: number) { 3 | let timer: any = null; 4 | return function () { 5 | if (timer !== null) { 6 | clearTimeout(timer); 7 | } 8 | timer = setTimeout(fn, wait); 9 | }; 10 | }, 11 | sleep(timeout: number) { 12 | return new Promise((resolve) => { 13 | setTimeout(resolve, timeout); 14 | }); 15 | }, 16 | deepClone(target: any) { 17 | // 定义一个变量 18 | let result: any; 19 | // 如果当前需要深拷贝的是一个对象的话 20 | if (typeof target === "object") { 21 | // 如果是一个数组的话 22 | if (Array.isArray(target)) { 23 | result = []; // 将result赋值为一个数组,并且执行遍历 24 | for (let i = 0; i < target.length; i++) { 25 | // 递归克隆数组中的每一项 26 | if (Object.prototype.hasOwnProperty.call(target, i)) { 27 | result.push(this.deepClone(target[i])); 28 | } 29 | } 30 | // 判断如果当前的值是null的话;直接赋值为null 31 | } else if (target === null) { 32 | result = null; 33 | // 判断如果当前的值是一个RegExp对象的话,直接赋值 34 | } else if (target.constructor === RegExp) { 35 | result = target; 36 | } else { 37 | // 否则是普通对象,直接for in循环,递归赋值对象的所有值 38 | result = {}; 39 | for (const i in target) { 40 | if (Object.prototype.hasOwnProperty.call(target, i)) { 41 | result[i] = this.deepClone(target[i]); 42 | } 43 | } 44 | } 45 | // 如果不是对象的话,就是基本数据类型,那么直接赋值 46 | } else { 47 | result = target; 48 | } 49 | // 返回最终结果 50 | return result; 51 | }, 52 | isPromise(obj: any) { 53 | return ( 54 | !!obj && //有实际含义的变量才执行方法,变量null,undefined和''空串都为false 55 | (typeof obj === "object" || typeof obj === "function") && // 初始promise 或 promise.then返回的 56 | typeof obj.then === "function" 57 | ); 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /src/common/guides/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 70 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "vuex"; 2 | import request from "@/service/request"; 3 | 4 | export default createStore({ 5 | state: { 6 | isClose: true, 7 | activeModule: null, 8 | imageList: [], 9 | loadOk: false, 10 | icons: [], 11 | selectedItem: null, // 选中的画布元素 12 | workspace: null, // 画布对象 13 | }, 14 | getters: {}, 15 | mutations: { 16 | setClose(state, value) { 17 | state.isClose = value; 18 | }, 19 | setActiveModule(state, value) { 20 | state.activeModule = value; 21 | }, 22 | Ok(state, value) { 23 | state.loadOk = true; 24 | state.imageList = value; 25 | }, 26 | COMMIT_ICONS(state, value) { 27 | state.icons = value; 28 | }, 29 | setActivateItem(state, value) { 30 | state.selectedItem = null; 31 | state.selectedItem = value; 32 | }, 33 | setWorkarea(state, value) { 34 | state.workspace = value; 35 | }, 36 | }, 37 | actions: { 38 | getMaterial({ commit }) { 39 | const pro = []; 40 | pro.push( 41 | request({ 42 | url: "https://haixin-fang.github.io/vue-design-editor-static/imglist.json", 43 | timeout: 10000, 44 | methods: "get", 45 | }) 46 | ); 47 | pro.push( 48 | request({ 49 | url: "https://haixin-fang.github.io/icons/bootstrap-icons.json", 50 | timeout: 10000, 51 | methods: "get", 52 | }) 53 | ); 54 | Promise.all(pro).then((data) => { 55 | if (Array.isArray(data)) { 56 | if (data[0]) { 57 | commit("Ok", data[0]); 58 | } 59 | if (data[1]) { 60 | const values = Object.keys(data[1]); 61 | const icons: any = []; 62 | values.forEach((item) => { 63 | const url = `https://haixin-fang.github.io/icons/icons/${item}.svg`; 64 | icons.push({ 65 | url, 66 | }); 67 | }); 68 | commit("COMMIT_ICONS", icons); 69 | } 70 | } 71 | }); 72 | }, 73 | }, 74 | modules: {}, 75 | }); 76 | -------------------------------------------------------------------------------- /src/assets/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-design-editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "基于fabric.js和Vue的图片、视频编辑器", 6 | "keywords": [ 7 | "fabric.js", 8 | "vue", 9 | "vue3", 10 | "element-plus", 11 | "图片编辑器" 12 | ], 13 | "scripts": { 14 | "dev": "vite", 15 | "serve": "vue-cli-service serve", 16 | "build": "vite build", 17 | "lint": "vue-cli-service lint", 18 | "prepare": "husky install", 19 | "commit": "git-cz" 20 | }, 21 | "dependencies": { 22 | "@element-plus/icons-vue": "^2.1.0", 23 | "@scena/guides": "^0.28.0", 24 | "@vueuse/core": "^10.6.0", 25 | "axios": "^1.4.0", 26 | "buffer": "^6.0.3", 27 | "colorpickers": "^1.0.4", 28 | "core-js": "^3.8.3", 29 | "element-plus": "^2.3.8", 30 | "epic-spinners": "^2.0.0", 31 | "fabric": "^5.3.0", 32 | "framework-utils": "^1.1.0", 33 | "lodash-es": "^4.17.21", 34 | "psd.js": "3.6.3", 35 | "tojson.js": "^1.0.6", 36 | "uuid": "^9.0.0", 37 | "vite-plugin-require-transform": "^1.0.21", 38 | "vue": "^3.2.13", 39 | "vue-router": "^4.0.3", 40 | "vuex": "^4.0.0" 41 | }, 42 | "devDependencies": { 43 | "@commitlint/config-conventional": "^17.1.0", 44 | "@types/fabric": "^5.3.3", 45 | "@typescript-eslint/eslint-plugin": "^5.4.0", 46 | "@typescript-eslint/parser": "^5.4.0", 47 | "@vitejs/plugin-vue": "^4.2.3", 48 | "@vue/cli-plugin-babel": "~5.0.0", 49 | "@vue/cli-plugin-eslint": "~5.0.0", 50 | "@vue/cli-plugin-router": "~5.0.0", 51 | "@vue/cli-plugin-typescript": "~5.0.0", 52 | "@vue/cli-plugin-vuex": "~5.0.0", 53 | "@vue/cli-service": "~5.0.0", 54 | "@vue/eslint-config-typescript": "^9.1.0", 55 | "commitizen": "^4.2.5", 56 | "commitlint": "^17.1.1", 57 | "cz-conventional-changelog": "^3.3.0", 58 | "eslint": "^7.32.0", 59 | "eslint-config-prettier": "^8.3.0", 60 | "eslint-plugin-prettier": "^4.0.0", 61 | "eslint-plugin-vue": "^8.0.3", 62 | "husky": "^8.0.1", 63 | "lint-staged": "^13.0.3", 64 | "prettier": "^2.4.1", 65 | "sass": "^1.32.7", 66 | "sass-loader": "^12.0.0", 67 | "typescript": "~4.5.5", 68 | "vite": "^4.4.3", 69 | "vite-plugin-require-transform": "^1.0.21" 70 | }, 71 | "config": { 72 | "commitizen": { 73 | "path": "./node_modules/cz-conventional-changelog" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/service/request.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { ElMessage } from "element-plus"; 3 | 4 | const service = axios.create({ 5 | timeout: 5000, // request timeout 6 | }); 7 | 8 | // 添加请求拦截器 9 | axios.interceptors.request.use( 10 | function (config) { 11 | return config; 12 | }, 13 | function (error) { 14 | // 对请求错误做些什么 15 | return Promise.reject(error); 16 | } 17 | ); 18 | 19 | // request interceptor 20 | service.interceptors.request.use( 21 | (config) => { 22 | return config; 23 | }, 24 | (error) => { 25 | console.log(error); // for debug 26 | return Promise.reject(error); 27 | } 28 | ); 29 | 30 | // response interceptor 31 | service.interceptors.response.use( 32 | (response) => { 33 | if (!response) { 34 | // return res 35 | console.log("无数据返回"); 36 | throw new Error("无数据返回"); 37 | } else { 38 | return response.data; 39 | } 40 | }, 41 | (error) => { 42 | console.log("err" + error); // for debug 43 | ElMessage({ 44 | message: error.message, 45 | type: "error", 46 | duration: 3 * 1000, 47 | showClose: true, 48 | }); 49 | return Promise.reject(error); 50 | } 51 | ); 52 | 53 | function autoSend(config: any, retryTimes: any, resolve: any, reject: any) { 54 | service(config) 55 | .then((response: any) => { 56 | if (config.retry) { 57 | response.retry = retryTimes - config.retry; 58 | } 59 | resolve(response); 60 | }) 61 | .catch((error) => { 62 | config.retry--; 63 | if (config.retry >= 0) { 64 | setTimeout(() => { 65 | autoSend(config, retryTimes, resolve, reject); 66 | }, 10); 67 | } else { 68 | error.retry = retryTimes; 69 | reject(error); 70 | } 71 | }); 72 | } 73 | 74 | function Service(config: any) { 75 | return new Promise((resolve, reject) => { 76 | if (!config || !config.url) { 77 | reject({ 78 | errorMsg: "请求对象不完整", 79 | }); 80 | } 81 | if (!config.type) { 82 | config.type = "get"; 83 | } 84 | 85 | if (!config.timeout) { 86 | config.timeout = 5000; 87 | } 88 | 89 | if (typeof config.retry != "number") { 90 | config.retry = 3; 91 | } 92 | 93 | autoSend(config, config.retry, resolve, reject); 94 | }).then((data) => { 95 | return data; 96 | }); 97 | } 98 | export default Service; 99 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-design-editor 7 | 34 | 69 | 70 | 71 |
72 |
73 |
74 |
正在加载中,请稍等 ...
75 |
76 |
77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/assets/h1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/panel/checkbox.vue: -------------------------------------------------------------------------------- 1 | 29 | 76 | 125 | -------------------------------------------------------------------------------- /src/assets/workareaCursor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/workarea/canvas.vue: -------------------------------------------------------------------------------- 1 | 11 | 92 | 93 | 113 | -------------------------------------------------------------------------------- /src/common/waterfall.vue: -------------------------------------------------------------------------------- 1 | 8 | 91 | 106 | -------------------------------------------------------------------------------- /src/assets/h2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/Aa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/panel/data-input.vue: -------------------------------------------------------------------------------- 1 | 31 | 70 | 149 | -------------------------------------------------------------------------------- /src/components/panel/bar.vue: -------------------------------------------------------------------------------- 1 | 65 | 103 | 155 | -------------------------------------------------------------------------------- /src/assets/3d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/page/editor.vue: -------------------------------------------------------------------------------- 1 | 42 | 144 | 152 | -------------------------------------------------------------------------------- /src/styles/icon/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "iconfont"; /* Project id 4171024 */ 3 | src: url("iconfont.woff2?t=1690378275173") format("woff2"), 4 | url("iconfont.woff?t=1690378275173") format("woff"), 5 | url("iconfont.ttf?t=1690378275173") format("truetype"); 6 | } 7 | 8 | .iconfont { 9 | font-family: "iconfont" !important; 10 | font-size: 16px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .icon-tianjia:before { 17 | content: "\e637"; 18 | } 19 | 20 | .icon-editor-background:before { 21 | content: "\e829"; 22 | } 23 | 24 | .icon-erweima1:before { 25 | content: "\e600"; 26 | } 27 | 28 | .icon-text:before { 29 | content: "\e6bc"; 30 | } 31 | 32 | .icon-link-outline:before { 33 | content: "\e9d2"; 34 | } 35 | 36 | .icon-unlink-outline:before { 37 | content: "\ea38"; 38 | } 39 | 40 | .icon-fuzhi:before { 41 | content: "\e624"; 42 | } 43 | 44 | .icon-GitHub:before { 45 | content: "\ea0a"; 46 | } 47 | 48 | .icon-zitishanchuxian:before { 49 | content: "\ec84"; 50 | } 51 | 52 | .icon-zitixiahuaxian:before { 53 | content: "\ec85"; 54 | } 55 | 56 | .icon-zuoduiqi:before { 57 | content: "\ec86"; 58 | } 59 | 60 | .icon-xingzhuang:before { 61 | content: "\e82b"; 62 | } 63 | 64 | .icon-delete:before { 65 | content: "\e61b"; 66 | } 67 | 68 | .icon-layer:before { 69 | content: "\e61c"; 70 | } 71 | 72 | .icon-templet-:before { 73 | content: "\e645"; 74 | } 75 | 76 | .icon-chexiao:before { 77 | content: "\e63b"; 78 | } 79 | 80 | .icon-huifu:before { 81 | content: "\e63d"; 82 | } 83 | 84 | .icon-liangduanduiqi:before { 85 | content: "\e61d"; 86 | } 87 | 88 | .icon-suoding_huaban:before { 89 | content: "\e625"; 90 | } 91 | 92 | .icon-wenzigongju-shupai:before { 93 | content: "\e82f"; 94 | } 95 | 96 | .icon-lvjing:before { 97 | content: "\e73f"; 98 | } 99 | 100 | .icon-italic:before { 101 | content: "\e61e"; 102 | } 103 | 104 | .icon-zuoyoufanzhuan:before { 105 | content: "\e622"; 106 | } 107 | 108 | .icon-shangxiafanzhuan:before { 109 | content: "\e623"; 110 | } 111 | 112 | .icon-wenzi:before { 113 | content: "\e674"; 114 | } 115 | 116 | .icon-wenzijianju:before { 117 | content: "\e6b3"; 118 | } 119 | 120 | .icon-erweima:before { 121 | content: "\e642"; 122 | } 123 | 124 | .icon-biaochi:before { 125 | content: "\e627"; 126 | } 127 | 128 | .icon-shuipingjuzhongduiqi:before { 129 | content: "\e63f"; 130 | } 131 | 132 | .icon-zuoduiqi1:before { 133 | content: "\edfd"; 134 | } 135 | 136 | .icon-xiaduiqi:before { 137 | content: "\edfe"; 138 | } 139 | 140 | .icon-shangduiqi:before { 141 | content: "\edff"; 142 | } 143 | 144 | .icon-youduiqi1:before { 145 | content: "\ee00"; 146 | } 147 | 148 | .icon-chuizhijuzhongduiqi:before { 149 | content: "\e628"; 150 | } 151 | 152 | .icon-actualsize:before { 153 | content: "\e666"; 154 | } 155 | 156 | .icon-hand-grab-o:before { 157 | content: "\e6ac"; 158 | } 159 | 160 | .icon-xuanze:before { 161 | content: "\e62b"; 162 | } 163 | 164 | .icon-cengji:before { 165 | content: "\e630"; 166 | } 167 | 168 | .icon-CZ_021:before { 169 | content: "\e62c"; 170 | } 171 | 172 | .icon-CZ_023:before { 173 | content: "\e62d"; 174 | } 175 | 176 | .icon-zhidi1:before { 177 | content: "\e640"; 178 | } 179 | 180 | .icon-quanping1:before { 181 | content: "\e8fa"; 182 | } 183 | 184 | .icon-tuichuquanping:before { 185 | content: "\e8fb"; 186 | } 187 | 188 | .icon-jinzhi:before { 189 | content: "\e60d"; 190 | } 191 | 192 | .icon-line-sipxiguangongju:before { 193 | content: "\e6ed"; 194 | } 195 | 196 | .icon-slice-fill:before { 197 | content: "\e63e"; 198 | } 199 | 200 | .icon-huizhi-01:before { 201 | content: "\e6e1"; 202 | } 203 | 204 | .icon-huanyuan:before { 205 | content: "\e70f"; 206 | } 207 | 208 | .icon-biankuang:before { 209 | content: "\e643"; 210 | } 211 | 212 | .icon-chicun:before { 213 | content: "\e601"; 214 | } 215 | 216 | .icon-tubiaozhizuomoban:before { 217 | content: "\e602"; 218 | } 219 | 220 | .icon-palette:before { 221 | content: "\e70e"; 222 | } 223 | 224 | .icon-unlock:before { 225 | content: "\e649"; 226 | } 227 | 228 | .icon-caijian:before { 229 | content: "\e612"; 230 | } 231 | 232 | .icon-liebiao:before { 233 | content: "\e616"; 234 | } 235 | 236 | .icon-shipinbofang:before { 237 | content: "\e618"; 238 | } 239 | 240 | .icon-zhaopian:before { 241 | content: "\e619"; 242 | } 243 | 244 | .icon-background:before { 245 | content: "\e61a"; 246 | } 247 | 248 | .icon-juzhongduiqi:before { 249 | content: "\ec80"; 250 | } 251 | 252 | .icon-youduiqi:before { 253 | content: "\ec82"; 254 | } 255 | 256 | .icon-zitijiacu:before { 257 | content: "\ec83"; 258 | } 259 | -------------------------------------------------------------------------------- /src/layout/Framework.vue: -------------------------------------------------------------------------------- 1 | 58 | 106 | 172 | -------------------------------------------------------------------------------- /src/components/sidebar/bar.vue: -------------------------------------------------------------------------------- 1 | 15 | 116 | 176 | -------------------------------------------------------------------------------- /src/core/FabricHandler.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import Handler from "./handler"; 3 | import { WorkareaOption, WorkareaObject, FabricImage } from "@/types/utils"; 4 | 5 | class FabricHandler { 6 | private handler: Handler; 7 | constructor(handler: Handler) { 8 | this.handler = handler; 9 | } 10 | 11 | textbox({ text, ...option }: any) { 12 | option.fontSize = parseInt(option.fontSize); 13 | return new fabric.Textbox(text, option); 14 | } 15 | 16 | FontCustom(option: WorkareaObject) { 17 | return this.textbox(option); 18 | } 19 | 20 | gradient(selectedItem: WorkareaObject, options: any) { 21 | if (!selectedItem) { 22 | selectedItem = this.handler.canvas.getActiveObject(); 23 | } 24 | let gradient; 25 | if (options.type == "radial-gradient") { 26 | const { width, height } = selectedItem; 27 | const r = width > height ? width / 2 : height / 2; 28 | const coords = { 29 | r1: r, // 该属性仅径向渐变可用,外圆半径 30 | r2: 0, // 该属性仅径向渐变可用,外圆半径 31 | x1: width / 2, // 焦点的x坐标 32 | y1: height / 2, // 焦点的y坐标 33 | x2: width / 2, // 中心点的x坐标 34 | y2: height / 2, // 中心点的y坐标 35 | }; 36 | const colorStops = options.colorStops.map((item: any) => { 37 | const offset = Number(item.length.value) / 100; 38 | const [red, green, blue, alpha] = item.value; 39 | const color = `rgba(${red}, ${green}, ${blue}, ${alpha})`; 40 | return { offset, color }; 41 | }); 42 | gradient = new fabric.Gradient({ 43 | type: "radial", 44 | coords, 45 | colorStops, 46 | }); 47 | } else { 48 | // 渐变 49 | const { 50 | x0: x1, 51 | y0: y1, 52 | x1: x2, 53 | y1: y2, 54 | } = this.handler.utils.calculateGradientCoordinate( 55 | selectedItem.width, 56 | selectedItem.height, 57 | Number(options.orientation.value) 58 | ); 59 | const colorStops = options.colorStops.map((item: any) => { 60 | const offset = Number(item.length.value) / 100; 61 | const [red, green, blue, alpha] = item.value; 62 | const color = `rgba(${red}, ${green}, ${blue}, ${alpha})`; 63 | return { offset, color }; 64 | }); 65 | gradient = new fabric.Gradient({ 66 | type: "linear", // linear or radial 67 | gradientUnits: "pixels", // pixels or pencentage 像素 或者 百分比 68 | coords: { x1, y1, x2, y2 }, // 至少2个坐标对(x1,y1和x2,y2)将定义渐变在对象上的扩展方式 69 | colorStops: colorStops, 70 | }); 71 | } 72 | 73 | selectedItem.set({ 74 | fill: gradient, 75 | backgroundColor: options.gradientColor, 76 | }); 77 | } 78 | 79 | async Image(options: WorkareaObject) { 80 | const type = 81 | this.handler.utils.getFileType(options.src) || 82 | this.handler.utils.getBase64ImageType(options.src); 83 | if (type == "svg") { 84 | options.type = "svg"; 85 | return this.addSvg(options); 86 | } else if (type && ["jpg", "png", "jpeg"].includes(type)) { 87 | return this.addImage(options); 88 | } 89 | } 90 | 91 | addSvg(obj: WorkareaOption) { 92 | return new Promise((resolve) => { 93 | const { src, path, ...otherOption } = obj; 94 | const { width } = this.handler.workareaHandler.option; 95 | if (!src) { 96 | const res = new fabric.Path(path); 97 | resolve(res); 98 | } else if (src) { 99 | fabric.loadSVGFromURL(src, (objects, options) => { 100 | const activeObject: any = fabric.util.groupSVGElements( 101 | objects, 102 | options 103 | ); 104 | if (activeObject.width > width) { 105 | const scale = width / activeObject.width; 106 | activeObject.set({ 107 | scaleX: scale, 108 | scaleY: scale, 109 | }); 110 | } 111 | activeObject.set({ 112 | crossOrigin: "Anonymous", 113 | src, 114 | ...otherOption, 115 | }); 116 | resolve(activeObject); 117 | }); 118 | } 119 | }); 120 | } 121 | 122 | async addImage(obj: FabricImage) { 123 | const { src, ...otherOption } = obj; 124 | const { objectOption } = this.handler; 125 | const imageUrl = new Image(); 126 | imageUrl.crossOrigin = "Anonymous"; //这里是主要添加的属性 127 | // 只有在单个图片素材导入情况下才自适应 128 | const { width } = this.handler.workareaHandler.workspace as any; 129 | if ( 130 | otherOption.width && 131 | this.handler.workareaHandler && 132 | this.handler.workareaHandler.workspace && 133 | otherOption.width > width && 134 | !this.handler.isimporting 135 | ) { 136 | const scale = width / otherOption.width; 137 | otherOption.scaleX = scale; 138 | otherOption.scaleY = scale; 139 | } 140 | const canvasImage = new fabric.Image(imageUrl, { 141 | clipPath: null, 142 | ...JSON.parse(JSON.stringify(objectOption)), 143 | ...otherOption, 144 | }); 145 | canvasImage.crossOrigin = "Anonymous"; //这里是主要添加的属性 146 | await this.handler.setImage(canvasImage, src); 147 | return canvasImage; 148 | } 149 | 150 | async background(options: WorkareaObject) { 151 | return this.addImage(options); 152 | } 153 | } 154 | 155 | export default FabricHandler; 156 | -------------------------------------------------------------------------------- /src/components/panel/slider.vue: -------------------------------------------------------------------------------- 1 | 34 | 167 | 239 | -------------------------------------------------------------------------------- /src/constants/size.ts: -------------------------------------------------------------------------------- 1 | export const size = [ 2 | { 3 | material_type: "canvas_size", 4 | id: 2931, 5 | title: "手机海报", 6 | material: { unit: "px", name: "手机海报", width: "1242", height: "2208" }, 7 | }, 8 | { 9 | material_type: "canvas_size", 10 | id: 2932, 11 | title: "公众号首图 ", 12 | material: { unit: "px", name: "公众号首图 ", width: "900", height: "383" }, 13 | }, 14 | { 15 | material_type: "canvas_size", 16 | id: 2933, 17 | title: "公众号次图", 18 | material: { unit: "px", name: "公众号次图", width: "500", height: "500" }, 19 | }, 20 | { 21 | material_type: "canvas_size", 22 | id: 12425, 23 | title: "小红书配图", 24 | material: { unit: "px", name: "小红书配图", width: "1242", height: "1660" }, 25 | }, 26 | { 27 | material_type: "canvas_size", 28 | id: 2934, 29 | title: "商品主图", 30 | material: { unit: "px", name: "商品主图", width: "800", height: "800" }, 31 | }, 32 | { 33 | material_type: "canvas_size", 34 | id: 2935, 35 | title: "小程序封面", 36 | material: { unit: "px", name: "小程序封面", width: "520", height: "416" }, 37 | }, 38 | { 39 | material_type: "canvas_size", 40 | id: 2936, 41 | title: "电商横版海报", 42 | material: { 43 | unit: "px", 44 | name: "电商横版海报", 45 | width: "1200", 46 | height: "675", 47 | }, 48 | }, 49 | { 50 | material_type: "canvas_size", 51 | id: 2937, 52 | title: "电商竖版海报", 53 | material: { 54 | unit: "px", 55 | name: "电商竖版海报", 56 | width: "1200", 57 | height: "1920", 58 | }, 59 | }, 60 | { 61 | material_type: "canvas_size", 62 | id: 2938, 63 | title: "电商全屏海报", 64 | material: { 65 | unit: "px", 66 | name: "电商全屏海报", 67 | width: "1920", 68 | height: "700", 69 | }, 70 | }, 71 | { 72 | material_type: "canvas_size", 73 | id: 2939, 74 | title: "店招", 75 | material: { unit: "px", name: "店招", width: "1920", height: "150" }, 76 | }, 77 | { 78 | material_type: "canvas_size", 79 | id: 2940, 80 | title: "手机端店铺首页 ", 81 | material: { 82 | unit: "px", 83 | name: "手机端店铺首页 ", 84 | width: "750", 85 | height: "1000", 86 | }, 87 | }, 88 | { 89 | material_type: "canvas_size", 90 | id: 12427, 91 | title: "横版视频封面", 92 | material: { 93 | unit: "px", 94 | name: "横版视频封面", 95 | width: "1920", 96 | height: "1080", 97 | }, 98 | }, 99 | { 100 | material_type: "canvas_size", 101 | id: 12428, 102 | title: "竖版视频封面", 103 | material: { 104 | unit: "px", 105 | name: "竖版视频封面", 106 | width: "1080", 107 | height: "1260", 108 | }, 109 | }, 110 | { 111 | material_type: "canvas_size", 112 | id: 12426, 113 | title: "竖版直播背景", 114 | material: { 115 | unit: "px", 116 | name: "竖版直播背景", 117 | width: "1242", 118 | height: "2690", 119 | }, 120 | }, 121 | { 122 | material_type: "canvas_size", 123 | id: 2941, 124 | title: "PPT(16:9)", 125 | material: { 126 | unit: "px", 127 | name: "PPT(16:9)", 128 | width: "1920", 129 | height: "1080", 130 | }, 131 | }, 132 | { 133 | material_type: "canvas_size", 134 | id: 2942, 135 | title: "名片", 136 | material: { 137 | unit: "mm", 138 | name: "名片", 139 | width: "96", 140 | dpi: "300", 141 | height: "60", 142 | }, 143 | }, 144 | { 145 | material_type: "canvas_size", 146 | id: 2949, 147 | title: "张贴海报", 148 | material: { 149 | unit: "mm", 150 | name: "张贴海报", 151 | width: "426", 152 | dpi: "300", 153 | height: "576", 154 | }, 155 | }, 156 | { 157 | material_type: "canvas_size", 158 | id: 2948, 159 | title: "2m易拉宝", 160 | material: { 161 | unit: "cm", 162 | name: "2m易拉宝", 163 | width: "80", 164 | dpi: "150", 165 | height: "200", 166 | }, 167 | }, 168 | { 169 | material_type: "canvas_size", 170 | id: 2947, 171 | title: "1.8m展架", 172 | material: { 173 | unit: "cm", 174 | name: "1.8m展架", 175 | width: "80", 176 | dpi: "150", 177 | height: "180", 178 | }, 179 | }, 180 | { 181 | material_type: "canvas_size", 182 | id: 2946, 183 | title: "优惠券", 184 | material: { 185 | unit: "mm", 186 | name: "优惠券", 187 | width: "168", 188 | dpi: "300", 189 | height: "66", 190 | }, 191 | }, 192 | { 193 | material_type: "canvas_size", 194 | id: 2945, 195 | title: "门票", 196 | material: { 197 | unit: "mm", 198 | name: "门票", 199 | width: "206", 200 | dpi: "300", 201 | height: "86", 202 | }, 203 | }, 204 | { 205 | material_type: "canvas_size", 206 | id: 2944, 207 | title: "三折页", 208 | material: { 209 | unit: "mm", 210 | name: "三折页", 211 | width: "291", 212 | dpi: "300", 213 | height: "216", 214 | }, 215 | }, 216 | { 217 | material_type: "canvas_size", 218 | id: 2943, 219 | title: "画册", 220 | material: { 221 | unit: "mm", 222 | name: "画册", 223 | width: "426", 224 | dpi: "300", 225 | height: "291", 226 | }, 227 | }, 228 | { 229 | material_type: "canvas_size", 230 | id: 2950, 231 | title: "A3纸张", 232 | material: { 233 | unit: "mm", 234 | name: "A3纸张", 235 | width: "291", 236 | dpi: "300", 237 | height: "420", 238 | }, 239 | }, 240 | { 241 | material_type: "canvas_size", 242 | id: 2951, 243 | title: "A4纸张", 244 | material: { 245 | unit: "mm", 246 | name: "A4纸张", 247 | width: "210", 248 | dpi: "300", 249 | height: "297", 250 | }, 251 | }, 252 | { 253 | material_type: "canvas_size", 254 | id: 2952, 255 | title: "A5纸张", 256 | material: { 257 | unit: "mm", 258 | name: "A5纸张", 259 | width: "148", 260 | dpi: "300", 261 | height: "210", 262 | }, 263 | }, 264 | ]; 265 | -------------------------------------------------------------------------------- /src/layout/Nav.vue: -------------------------------------------------------------------------------- 1 | 59 | 124 | 223 | -------------------------------------------------------------------------------- /src/core/ControlHandler.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import verticalImg from "@/assets/editor/middlecontrol.svg"; 3 | import horizontalImg from "@/assets/editor/middlecontrolhoz.svg"; 4 | import edgeImg from "@/assets/editor/edgecontrol.svg"; 5 | import rotateImg from "@/assets/editor/rotateicon.svg"; 6 | 7 | /** 8 | * 实际场景: 在进行某个对象缩放的时候,由于fabricjs默认精度使用的是toFixed(2)。 9 | * 此处为了缩放的精度更准确一些,因此将NUM_FRACTION_DIGITS默认值改为4,即toFixed(4). 10 | */ 11 | fabric.Object.NUM_FRACTION_DIGITS = 4; 12 | 13 | function drawImg( 14 | ctx: CanvasRenderingContext2D, 15 | left: number, 16 | top: number, 17 | img: HTMLImageElement, 18 | wSize: number, 19 | hSize: number, 20 | angle: number | undefined 21 | ) { 22 | if (angle === undefined) return; 23 | ctx.save(); 24 | ctx.translate(left, top); 25 | ctx.rotate(fabric.util.degreesToRadians(angle)); 26 | ctx.drawImage(img, -wSize / 2, -hSize / 2, wSize, hSize); 27 | ctx.restore(); 28 | } 29 | 30 | // 中间横杠 31 | function intervalControl() { 32 | const verticalImgIcon = document.createElement("img"); 33 | verticalImgIcon.src = verticalImg; 34 | 35 | const horizontalImgIcon = document.createElement("img"); 36 | horizontalImgIcon.src = horizontalImg; 37 | 38 | function renderIcon( 39 | ctx: CanvasRenderingContext2D, 40 | left: number, 41 | top: number, 42 | styleOverride: any, 43 | fabricObject: fabric.Object 44 | ) { 45 | drawImg(ctx, left, top, verticalImgIcon, 20, 25, fabricObject.angle); 46 | } 47 | 48 | function renderIconHoz( 49 | ctx: CanvasRenderingContext2D, 50 | left: number, 51 | top: number, 52 | styleOverride: any, 53 | fabricObject: fabric.Object 54 | ) { 55 | drawImg(ctx, left, top, horizontalImgIcon, 25, 20, fabricObject.angle); 56 | } 57 | // 中间横杠 58 | fabric.Object.prototype.controls.ml = new fabric.Control({ 59 | x: -0.5, 60 | y: 0, 61 | offsetX: -1, 62 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 63 | actionHandler: fabric.controlsUtils.scalingXOrSkewingY, 64 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 65 | render: renderIcon, 66 | }); 67 | 68 | fabric.Object.prototype.controls.mr = new fabric.Control({ 69 | x: 0.5, 70 | y: 0, 71 | offsetX: 1, 72 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 73 | actionHandler: fabric.controlsUtils.scalingXOrSkewingY, 74 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 75 | render: renderIcon, 76 | }); 77 | 78 | fabric.Object.prototype.controls.mb = new fabric.Control({ 79 | x: 0, 80 | y: 0.5, 81 | offsetY: 1, 82 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 83 | actionHandler: fabric.controlsUtils.scalingYOrSkewingX, 84 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 85 | render: renderIconHoz, 86 | }); 87 | 88 | fabric.Object.prototype.controls.mt = new fabric.Control({ 89 | x: 0, 90 | y: -0.5, 91 | offsetY: -1, 92 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 93 | actionHandler: fabric.controlsUtils.scalingYOrSkewingX, 94 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 95 | render: renderIconHoz, 96 | }); 97 | } 98 | 99 | // 顶点 100 | function peakControl() { 101 | const img = document.createElement("img"); 102 | img.src = edgeImg; 103 | 104 | function renderIconEdge( 105 | ctx: CanvasRenderingContext2D, 106 | left: number, 107 | top: number, 108 | styleOverride: any, 109 | fabricObject: fabric.Object 110 | ) { 111 | drawImg(ctx, left, top, img, 25, 25, fabricObject.angle); 112 | } 113 | // 四角图标 114 | fabric.Object.prototype.controls.tl = new fabric.Control({ 115 | x: -0.5, 116 | y: -0.5, 117 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 118 | actionHandler: fabric.controlsUtils.scalingEqually, 119 | render: renderIconEdge, 120 | }); 121 | fabric.Object.prototype.controls.bl = new fabric.Control({ 122 | x: -0.5, 123 | y: 0.5, 124 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 125 | actionHandler: fabric.controlsUtils.scalingEqually, 126 | render: renderIconEdge, 127 | }); 128 | fabric.Object.prototype.controls.tr = new fabric.Control({ 129 | x: 0.5, 130 | y: -0.5, 131 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 132 | actionHandler: fabric.controlsUtils.scalingEqually, 133 | render: renderIconEdge, 134 | }); 135 | fabric.Object.prototype.controls.br = new fabric.Control({ 136 | x: 0.5, 137 | y: 0.5, 138 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 139 | actionHandler: fabric.controlsUtils.scalingEqually, 140 | render: renderIconEdge, 141 | }); 142 | } 143 | 144 | // 旋转 145 | function rotationControl() { 146 | const img = document.createElement("img"); 147 | img.src = rotateImg; 148 | function renderIconRotate( 149 | ctx: CanvasRenderingContext2D, 150 | left: number, 151 | top: number, 152 | styleOverride: any, 153 | fabricObject: fabric.Object 154 | ) { 155 | drawImg(ctx, left, top, img, 40, 40, fabricObject.angle); 156 | } 157 | // 旋转图标 158 | fabric.Object.prototype.controls.mtr = new fabric.Control({ 159 | x: 0, 160 | y: 0.5, 161 | cursorStyleHandler: fabric.controlsUtils.rotationStyleHandler, 162 | actionHandler: fabric.controlsUtils.rotationWithSnapping, 163 | offsetY: 30, 164 | // withConnecton: false, 165 | actionName: "rotate", 166 | render: renderIconRotate, 167 | }); 168 | } 169 | 170 | class ControlsPlugin { 171 | constructor() { 172 | this.init(); 173 | } 174 | init() { 175 | // 顶点图标 176 | peakControl(); 177 | // 中间横杠图标 178 | intervalControl(); 179 | // 旋转图标 180 | rotationControl(); 181 | 182 | // 选中样式 183 | fabric.Object.prototype.set({ 184 | transparentCorners: false, 185 | borderColor: "#51B9F9", 186 | cornerColor: "#FFF", 187 | borderScaleFactor: 2.5, 188 | cornerStyle: "circle", 189 | cornerStrokeColor: "#0E98FC", 190 | borderOpacityWhenMoving: 1, 191 | }); 192 | // textbox保持一致 193 | fabric.Textbox.prototype.controls = fabric.Object.prototype.controls; 194 | } 195 | 196 | destroy() { 197 | console.log("pluginDestroy"); 198 | } 199 | } 200 | 201 | export default ControlsPlugin; 202 | -------------------------------------------------------------------------------- /src/common/popover.vue: -------------------------------------------------------------------------------- 1 | 15 | 176 | 238 | -------------------------------------------------------------------------------- /src/assets/upload-file-image.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/UtilsHandler.ts: -------------------------------------------------------------------------------- 1 | import Handler from "./handler"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { WorkareaObject } from "@/types/utils"; 4 | class UtilsHandler { 5 | private handler: Handler; 6 | constructor(handler: Handler) { 7 | this.handler = handler; 8 | } 9 | getFileType(src: string) { 10 | if (!src || src.indexOf("base64") != -1) return; 11 | src = src.split("?")[0]; 12 | if (src) { 13 | const number = src.lastIndexOf("."); 14 | const typeStr = src.substring(number + 1); 15 | return typeStr; 16 | } 17 | return ""; 18 | } 19 | 20 | // 根据base64获取图片类型 21 | getBase64ImageType(src: string) { 22 | if (!src) return; 23 | const reg = /data:image\/([a-zA-Z]*);/; 24 | const res = src.match(reg); 25 | if (res && res[1]) { 26 | return res[1]; 27 | } else { 28 | return "png"; 29 | } 30 | } 31 | 32 | uuid() { 33 | return uuidv4(); 34 | } 35 | 36 | find = (obj: WorkareaObject): WorkareaObject | null => this.findById(obj.id); 37 | 38 | findById = (id: string): WorkareaObject | null => { 39 | let findObject = null; 40 | const exist = this.handler.canvas 41 | .getObjects() 42 | .some((obj: WorkareaObject) => { 43 | if (obj.id === id) { 44 | findObject = obj; 45 | return true; 46 | } 47 | return false; 48 | }); 49 | if (!exist) { 50 | return null; 51 | } 52 | return findObject; 53 | }; 54 | 55 | dataURItoBlob(dataURI: string) { 56 | let byteString; 57 | if (dataURI.split(",")[0].indexOf("base64") >= 0) 58 | byteString = atob(dataURI.split(",")[1]); 59 | else byteString = unescape(dataURI.split(",")[1]); 60 | const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0]; 61 | const ia = new Uint8Array(byteString.length); 62 | for (let i = 0; i < byteString.length; i++) { 63 | ia[i] = byteString.charCodeAt(i); 64 | } 65 | return new Blob([ia], { 66 | type: mimeString, 67 | }); 68 | } 69 | 70 | fileToBase64 = async (file: File): Promise => { 71 | return new Promise((resolve) => { 72 | const reader = new FileReader(); 73 | reader.readAsDataURL(file); 74 | reader.onload = function (e: any) { 75 | // e.target.result 即为base64结果 76 | resolve(e.target.result); 77 | }; 78 | }); 79 | }; 80 | 81 | fileUpload = async (file: File, name: string, type: string) => { 82 | const src = await this.handler.utils.fileToBase64(file); 83 | if (src) { 84 | const image = new Image(); 85 | image.src = src; 86 | const options: any = { 87 | name, 88 | type, 89 | src, 90 | }; 91 | await new Promise((resolve) => { 92 | image.onload = () => { 93 | options.width = image.width; 94 | options.height = image.height; 95 | resolve(true); 96 | }; 97 | }); 98 | let marterialObject; 99 | if (type == "background") { 100 | marterialObject = this.handler.workareaHandler.setBgImage(options); 101 | } else { 102 | marterialObject = this.handler.add(options); 103 | } 104 | return marterialObject; 105 | } 106 | }; 107 | 108 | async download(base64: string, filename: string) { 109 | const blob = await this.dataURItoBlob(base64); 110 | const a = document.createElement("a"); 111 | // 兼容webkix浏览器,处理webkit浏览器中href自动添加blob前缀,默认在浏览器打开而不是下载 112 | const URL = window.URL || window.webkitURL; 113 | // 根据解析后的blob对象创建URL 对象 114 | const herf = URL.createObjectURL(blob); 115 | // 下载链接 116 | a.href = herf; 117 | // 下载文件名,如果后端没有返回,可以自己写a.download = '文件.pdf' 118 | a.download = filename || "vue-design-editor"; 119 | document.body.appendChild(a); 120 | a.click(); 121 | document.body.removeChild(a); 122 | // 在内存中移除URL 对象 123 | window.URL.revokeObjectURL(herf); 124 | } 125 | 126 | calculateGradientCoordinate(width: number, height: number, angle = 180) { 127 | if (angle >= 360) angle = angle - 360; 128 | if (angle < 0) angle = angle + 360; 129 | angle = Math.round(angle); 130 | 131 | // 当渐变轴垂直于矩形水平边上的两种结果 132 | if (angle === 0) { 133 | return { 134 | x0: Math.round(width / 2), 135 | y0: height, 136 | x1: Math.round(width / 2), 137 | y1: 0, 138 | }; 139 | } 140 | if (angle === 180) { 141 | return { 142 | x0: Math.round(width / 2), 143 | y0: 0, 144 | x1: Math.round(width / 2), 145 | y1: height, 146 | }; 147 | } 148 | 149 | // 当渐变轴垂直于矩形垂直边上的两种结果 150 | if (angle === 90) { 151 | return { 152 | x0: 0, 153 | y0: Math.round(height / 2), 154 | x1: width, 155 | y1: Math.round(height / 2), 156 | }; 157 | } 158 | if (angle === 270) { 159 | return { 160 | x0: width, 161 | y0: Math.round(height / 2), 162 | x1: 0, 163 | y1: Math.round(height / 2), 164 | }; 165 | } 166 | 167 | // 从矩形左下角至右上角的对角线的角度 168 | const alpha = Math.round( 169 | (Math.asin(width / Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2))) * 170 | 180) / 171 | Math.PI 172 | ); 173 | 174 | // 当渐变轴分别于矩形的两条对角线重合情况下的四种结果 175 | if (angle === alpha) { 176 | return { 177 | x0: 0, 178 | y0: height, 179 | x1: width, 180 | y1: 0, 181 | }; 182 | } 183 | if (angle === 180 - alpha) { 184 | return { 185 | x0: 0, 186 | y0: 0, 187 | x1: width, 188 | y1: height, 189 | }; 190 | } 191 | if (angle === 180 + alpha) { 192 | return { 193 | x0: width, 194 | y0: 0, 195 | x1: 0, 196 | y1: height, 197 | }; 198 | } 199 | if (angle === 360 - alpha) { 200 | return { 201 | x0: width, 202 | y0: height, 203 | x1: 0, 204 | y1: 0, 205 | }; 206 | } 207 | 208 | // 以矩形的中点为坐标原点,向上为Y轴正方向,向右为X轴正方向建立直角坐标系 209 | let x0 = 0, 210 | y0 = 0, 211 | x1 = 0, 212 | y1 = 0; 213 | 214 | // 当渐变轴与矩形的交点落在水平线上 215 | if ( 216 | angle < alpha || // 处于第一象限 217 | (angle > 180 - alpha && angle < 180) || // 处于第二象限 218 | (angle > 180 && angle < 180 + alpha) || // 处于第三象限 219 | angle > 360 - alpha // 处于第四象限 220 | ) { 221 | // 将角度乘以(PI/180)即可转换为弧度 222 | const radian = (angle * Math.PI) / 180; 223 | // 当在第一或第四象限,y是height / 2,否则y是-height / 2 224 | const y = angle < alpha || angle > 360 - alpha ? height / 2 : -height / 2; 225 | const x = Math.tan(radian) * y; 226 | // 当在第一或第二象限,l是width / 2 - x,否则l是-width / 2 - x 227 | const l = 228 | angle < alpha || (angle > 180 - alpha && angle < 180) 229 | ? width / 2 - x 230 | : -width / 2 - x; 231 | const n = Math.pow(Math.sin(radian), 2) * l; 232 | x1 = x + n; 233 | y1 = y + n / Math.tan(radian); 234 | x0 = -x1; 235 | y0 = -y1; 236 | } 237 | 238 | // 当渐变轴与矩形的交点落在垂直线上 239 | if ( 240 | (angle > alpha && angle < 90) || // 处于第一象限 241 | (angle > 90 && angle < 180 - alpha) || // 处于第二象限 242 | (angle > 180 + alpha && angle < 270) || // 处于第三象限 243 | (angle > 270 && angle < 360 - alpha) // 处于第四象限 244 | ) { 245 | // 将角度乘以(PI/180)即可转换为弧度 246 | const radian = ((90 - angle) * Math.PI) / 180; 247 | // 当在第一或第二象限,x是width / 2,否则x是-width / 2 248 | const x = 249 | (angle > alpha && angle < 90) || (angle > 90 && angle < 180 - alpha) 250 | ? width / 2 251 | : -width / 2; 252 | const y = Math.tan(radian) * x; 253 | // 当在第一或第四象限,l是height / 2 - y,否则l是-height / 2 - y 254 | const l = 255 | (angle > alpha && angle < 90) || (angle > 270 && angle < 360 - alpha) 256 | ? height / 2 - y 257 | : -height / 2 - y; 258 | const n = Math.pow(Math.sin(radian), 2) * l; 259 | x1 = x + n / Math.tan(radian); 260 | y1 = y + n; 261 | x0 = -x1; 262 | y0 = -y1; 263 | } 264 | 265 | // 坐标系更改为canvas标准,Y轴向下为正方向 266 | x0 = Math.round(x0 + width / 2); 267 | y0 = Math.round(height / 2 - y0); 268 | x1 = Math.round(x1 + width / 2); 269 | y1 = Math.round(height / 2 - y1); 270 | 271 | return { x0, y0, x1, y1 }; 272 | } 273 | } 274 | 275 | export default UtilsHandler; 276 | -------------------------------------------------------------------------------- /src/components/workarea/scale-bar.vue: -------------------------------------------------------------------------------- 1 | 66 | 235 | 326 | -------------------------------------------------------------------------------- /src/core/WorkareaHandler.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import Handler from "./handler"; 3 | import { 4 | WorkareaOption, 5 | FabricRect, 6 | FabricImage, 7 | WorkareaObject, 8 | } from "@/types/utils"; 9 | 10 | class EditorWorkspace { 11 | canvas: fabric.Canvas; 12 | workspaceEl: HTMLElement; 13 | workspace: FabricRect | null; 14 | bgObject: any | null; 15 | option: WorkareaOption; 16 | handler: Handler; 17 | unitEnum: any; 18 | constructor(handler: Handler) { 19 | this.handler = handler; 20 | this.canvas = handler.canvas; 21 | const container = handler.container; 22 | if (!container) { 23 | throw new Error("element #workspace is missing, plz check!"); 24 | } 25 | this.workspaceEl = container; 26 | this.workspace = null; 27 | this.bgObject = null; 28 | this.option = handler.workareaOption; 29 | this.initialize(); 30 | // 像素转换, 1cm等于37.9像素等 31 | this.unitEnum = { 32 | cm: 37.9, 33 | mm: 3.78, 34 | px: 1, 35 | }; 36 | } 37 | 38 | initialize() { 39 | this._initBackground(); 40 | this._initWorkspace(); 41 | this._initResizeObserve(); 42 | } 43 | 44 | // 初始化背景 45 | _initBackground() { 46 | this.canvas.backgroundImage = ""; 47 | this.canvas 48 | .setWidth(this.workspaceEl.offsetWidth) 49 | .setHeight(this.workspaceEl.offsetHeight); 50 | } 51 | 52 | // 初始化画布 53 | _initWorkspace() { 54 | const workspace = new fabric.Rect(this.option); 55 | this.canvas.add(workspace); 56 | this.canvas.renderAll(); 57 | this.workspace = workspace; 58 | this.auto(); 59 | } 60 | 61 | /** 62 | * 设置画布中心到指定对象中心点上 63 | * @param {Object} obj 指定的对象 64 | */ 65 | setCenterFromObject(obj: fabric.Rect) { 66 | const { canvas } = this; 67 | const objCenter = obj.getCenterPoint(); 68 | const viewportTransform = canvas.viewportTransform; 69 | if ( 70 | canvas.width === undefined || 71 | canvas.height === undefined || 72 | !viewportTransform 73 | ) 74 | return; 75 | const ruleWidth = 10; // 标尺宽度 76 | // viewportTransform[0] x轴缩放 77 | viewportTransform[4] = 78 | canvas.width / 2 - objCenter.x * viewportTransform[0] + ruleWidth; 79 | viewportTransform[5] = 80 | canvas.height / 2 - objCenter.y * viewportTransform[3]; 81 | canvas.setViewportTransform(viewportTransform); 82 | canvas.renderAll(); 83 | } 84 | 85 | // 初始化监听器 86 | _initResizeObserve() { 87 | const resizeObserver = new ResizeObserver(() => { 88 | this.auto(); 89 | }); 90 | resizeObserver.observe(this.workspaceEl); 91 | } 92 | 93 | setSize(width: number, height: number) { 94 | this._initBackground(); 95 | const unit = this.workspace?.unit || "px"; 96 | this.option.width = width * this.unitEnum[unit]; 97 | this.option.height = height * this.unitEnum[unit]; 98 | // 重新设置workspace 99 | this.workspace = this.handler.utils.findById("workarea") as fabric.Rect; 100 | if (!this.workspace) { 101 | this.initialize(); 102 | } 103 | this.workspace.set("width", this.option.width); 104 | this.workspace.set("height", this.option.height); 105 | this.auto(); 106 | } 107 | 108 | getBili() { 109 | return this.option.width / this.option.height; 110 | } 111 | 112 | setWidth(width: number) { 113 | this._initBackground(); 114 | const unit = this.workspace?.unit || "px"; 115 | this.option.width = width * this.unitEnum[unit]; 116 | // 重新设置workspace 117 | this.workspace = this.handler.utils.findById("workarea") as fabric.Rect; 118 | this.workspace.set("width", width); 119 | this.auto(); 120 | } 121 | 122 | setHeight(height: number) { 123 | this._initBackground(); 124 | const unit = this.workspace?.unit || "px"; 125 | this.option.height = height * this.unitEnum[unit]; 126 | // 重新设置workspace 127 | this.workspace = this.handler.utils.findById("workarea") as fabric.Rect; 128 | this.workspace.set("height", height); 129 | this.auto(); 130 | } 131 | 132 | setZoomAuto(scale: number, cb?: (left?: number, top?: number) => void) { 133 | const { workspaceEl } = this; 134 | const width = workspaceEl.offsetWidth; 135 | const height = workspaceEl.offsetHeight; 136 | this.canvas.setWidth(width); 137 | this.canvas.setHeight(height); 138 | const center = this.canvas.getCenter(); 139 | this.canvas.setViewportTransform(fabric.iMatrix.concat()); 140 | this.canvas.zoomToPoint(new fabric.Point(center.left, center.top), scale); 141 | if (!this.workspace) return; 142 | this.setCenterFromObject(this.workspace); 143 | // this.canvas.centerObject(this.workspace); 144 | 145 | // 超出画布不展示 146 | this.workspace.clone((cloned: fabric.Rect) => { 147 | this.canvas.clipPath = cloned; 148 | this.canvas.requestRenderAll(); 149 | }); 150 | // 背景图自适应 151 | if (this.bgObject) { 152 | const obj = this._getBgPosition(this.bgObject); 153 | this.bgObject.set(obj); 154 | } 155 | if (cb) cb(this.workspace.left, this.workspace.top); 156 | } 157 | 158 | unpdateUnit(unit: string) { 159 | this.workspace?.set("unit", unit); 160 | } 161 | 162 | async setBgImage(options: WorkareaOption) { 163 | const { src } = options || {}; 164 | const editable = false; 165 | const option = { 166 | editable, 167 | hasControls: editable, // 当设置为 `false` 时,对象的控件不显示并且不能用于操作对象 168 | hasBorders: editable, // 当设置为 `false` 时,对象的控制边界不会被渲染 169 | selectable: editable, // 当设置为 `false` 时,不能选择对象进行修改(使用基于点单击或基于组的选择)。但事件仍然发生在它身上。 170 | lockMovementX: !editable, // 当`true`时,对象水平移动被锁定 171 | lockMovementY: !editable, //当`true`时,对象垂直移动被锁定 172 | lockScalingX: !editable, 173 | lockScalingY: !editable, 174 | hoverCursor: "default", 175 | name: "背景图片", 176 | type: "background", 177 | src, 178 | }; 179 | // 去重, 防止出现多个背景元素 180 | const bgObject = this.getBgObject(); 181 | if (bgObject && bgObject.src !== src) { 182 | this.handler.canvas.remove(bgObject); 183 | } 184 | const poiOptions = this._getBgPosition(options); 185 | const newOptions = Object.assign({}, option, poiOptions); 186 | this.bgObject = await this.handler.add(newOptions, false); 187 | if (this.bgObject) { 188 | this.canvas.add(this.bgObject); 189 | this.canvas.sendToBack(this.bgObject); 190 | this.canvas.bringForward(this.bgObject); 191 | } 192 | this.canvas.requestRenderAll(); 193 | return this.bgObject; 194 | } 195 | 196 | // 获取背景元素 197 | getBgObject() { 198 | return this.handler.canvas.getObjects().find((item: any) => { 199 | if (item.type == "background") { 200 | return item; 201 | } 202 | }); 203 | } 204 | 205 | bgToImage() { 206 | if (this.bgObject) { 207 | const editable = true; 208 | const option = { 209 | editable, 210 | hasControls: editable, 211 | hasBorders: editable, 212 | selectable: editable, 213 | lockMovementX: !editable, 214 | lockMovementY: !editable, 215 | lockScalingX: !editable, 216 | lockScalingY: !editable, 217 | hoverCursor: "default", 218 | name: "", 219 | type: "Image", 220 | }; 221 | this.bgObject.set(option); 222 | this.canvas.renderAll(); 223 | console.log(this.canvas.getObjects()); 224 | this.handler.onSelect(this.bgObject); 225 | this.bgObject = null; 226 | } 227 | } 228 | 229 | _getScale() { 230 | const viewPortWidth = this.workspaceEl.offsetWidth; 231 | const viewPortHeight = this.workspaceEl.offsetHeight; 232 | // 按照宽度 233 | if ( 234 | viewPortWidth / viewPortHeight < 235 | this.option.width / this.option.height 236 | ) { 237 | return viewPortWidth / this.option.width; 238 | } // 按照宽度缩放 239 | return viewPortHeight / this.option.height; 240 | } 241 | 242 | /** 243 | * 获取背景图的位置(缩放比例和left、top) 244 | */ 245 | _getBgPosition(bgObject: FabricImage | any) { 246 | const { width, height } = this.workspace as any; 247 | let scale = 1; 248 | if (width > bgObject.width || height > bgObject.height) { 249 | if (width / bgObject.width > height / bgObject.height) { 250 | scale = width / bgObject.width; 251 | } else { 252 | scale = height / bgObject.height; 253 | } 254 | } 255 | // 居中 256 | const bgHeight = bgObject.height * scale; 257 | const bgWidth = bgObject.width * scale; 258 | const bgLeft = -(bgWidth - width) / 2; 259 | const bgTop = -(bgHeight - height) / 2; 260 | return { 261 | left: bgLeft, 262 | top: bgTop, 263 | scaleX: scale, 264 | scaleY: scale, 265 | }; 266 | } 267 | 268 | // 放大 269 | big() { 270 | let zoomRatio = this.canvas.getZoom(); 271 | zoomRatio += 0.05; 272 | // const center = this.canvas.getCenter(); 273 | // this.canvas.zoomToPoint( 274 | // new fabric.Point(center.left, center.top), 275 | // zoomRatio > 5 ? 5 : zoomRatio 276 | // ); 277 | this.setZoomAuto(zoomRatio > 5 ? 5 : zoomRatio); 278 | } 279 | 280 | // 缩小 281 | small() { 282 | let zoomRatio = this.canvas.getZoom(); 283 | zoomRatio -= 0.05; 284 | // const center = this.canvas.getCenter(); 285 | // this.canvas.zoomToPoint( 286 | // new fabric.Point(center.left, center.top), 287 | // zoomRatio < 0 ? 0.01 : zoomRatio 288 | // ); 289 | this.setZoomAuto(zoomRatio < 0 ? 0.01 : zoomRatio); 290 | } 291 | 292 | // 自动缩放 293 | auto() { 294 | const scale = this._getScale(); 295 | this.setZoomAuto(scale - 0.08 < 0.01 ? 0.01 : scale - 0.08); 296 | } 297 | 298 | // 1:1 放大 299 | one() { 300 | this.setZoomAuto(0.8 - 0.08); 301 | this.canvas.requestRenderAll(); 302 | } 303 | 304 | // 获取画布自适应下的比例 305 | getAutoScale() { 306 | return this._getScale(); 307 | } 308 | } 309 | 310 | export default EditorWorkspace; 311 | -------------------------------------------------------------------------------- /src/utils/getFileType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description MIME Type与Byte signature映射表 3 | * @reference https://mimesniff.spec.whatwg.org/#matching-an-image-type-signature 4 | */ 5 | const signatureList = [ 6 | { 7 | mime: "video/mp4", 8 | ext: "mp4", 9 | offset: 4, 10 | signature: [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d], 11 | }, 12 | { 13 | mime: "video/mp4", 14 | ext: "mp4", 15 | offset: 4, 16 | signature: [0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34], 17 | }, 18 | { 19 | mime: "image/jpeg", 20 | ext: "jpeg", 21 | signature: [0xff, 0xd8, 0xff], 22 | }, 23 | { 24 | mime: "image/png", 25 | ext: "png", 26 | signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 27 | }, 28 | { 29 | mime: "image/gif", 30 | ext: "gif", 31 | signature: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], 32 | }, 33 | { 34 | mime: "image/gif", 35 | ext: "gif", 36 | signature: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 37 | }, 38 | { 39 | mime: "image/vnd.adobe.photoshop", 40 | ext: "psd", 41 | signature: [0x38, 0x42, 0x50, 0x53], 42 | }, 43 | { 44 | mime: "image/webp", 45 | ext: "webp", 46 | signature: [ 47 | 0x52, 48 | 0x49, 49 | 0x46, 50 | 0x46, 51 | undefined, 52 | undefined, 53 | undefined, 54 | undefined, 55 | 0x57, 56 | 0x45, 57 | 0x42, 58 | 0x50, 59 | 0x56, 60 | 0x50, 61 | ], 62 | }, 63 | { 64 | mime: "image/x-icon", 65 | ext: "ico", 66 | signature: [0x00, 0x00, 0x01, 0x00], 67 | }, 68 | { 69 | mime: "image/x-icon", 70 | ext: "cur", 71 | signature: [0x00, 0x00, 0x02, 0x00], 72 | }, 73 | { 74 | ext: "tif", 75 | mime: "image/tiff", 76 | signature: [0x4d, 0x4d, 0x0, 0x2a], 77 | }, 78 | { 79 | ext: "tif", 80 | mime: "image/tiff", 81 | signature: [0x49, 0x49, 0x2a, 0x00], 82 | }, 83 | { 84 | ext: "bpg", 85 | mime: "image/bpg", 86 | signature: [0x42, 0x50, 0x47, 0xfb], 87 | }, 88 | { 89 | mime: "application/pdf", 90 | ext: "pdf", 91 | signature: [0x25, 0x50, 0x44, 0x46, 0x2d], 92 | }, 93 | { 94 | mime: "application/ogg", 95 | ext: "ogg", 96 | signature: [0x4f, 0x67, 0x67, 0x53], 97 | }, 98 | { 99 | mime: "audio/midi", 100 | ext: "midi", 101 | signature: [0x4d, 0x54, 0x68, 0x64], 102 | }, 103 | { 104 | mime: "audio/mpeg", 105 | ext: "mp3", 106 | signature: [0x49, 0x44, 0x33], 107 | }, 108 | { 109 | ext: "flac", 110 | mime: "audio/x-flac", 111 | signature: [0x66, 0x4c, 0x61, 0x43], 112 | }, 113 | { 114 | ext: "mpg", 115 | mime: "video/mpeg", 116 | signature: [0x0, 0x0, 0x1, 0xba], 117 | }, 118 | { 119 | ext: "mpg", 120 | mime: "video/mpeg", 121 | signature: [0x0, 0x0, 0x1, 0xb3], 122 | }, 123 | { 124 | mime: "video/x-flv", 125 | ext: "flv", 126 | signature: [0x46, 0x4c, 0x56], 127 | }, 128 | { 129 | mime: "image/bmp", 130 | ext: "bmp", 131 | signature: [0x42, 0x4d], 132 | }, 133 | { 134 | mime: "audio/aiff", 135 | ext: "aiff", 136 | signature: [ 137 | 0x46, 138 | 0x4f, 139 | 0x52, 140 | 0x4d, 141 | undefined, 142 | undefined, 143 | undefined, 144 | undefined, 145 | 0x41, 146 | 0x49, 147 | 0x46, 148 | 0x46, 149 | ], 150 | }, 151 | { 152 | mime: "video/vnd.avi", 153 | ext: "avi", 154 | signature: [ 155 | 0x52, 156 | 0x49, 157 | 0x46, 158 | 0x46, 159 | undefined, 160 | undefined, 161 | undefined, 162 | undefined, 163 | 0x41, 164 | 0x56, 165 | 0x49, 166 | 0x20, 167 | ], 168 | }, 169 | 170 | { 171 | mime: "video/webm", 172 | ext: "webm", 173 | signature: [0x1a, 0x45, 0xdf, 0xa3], 174 | }, 175 | { 176 | mime: "audio/mpeg", 177 | ext: "mp3", 178 | signature: [0xff, 0xfb], 179 | }, 180 | { 181 | mime: "audio/mpeg", 182 | ext: "mp3", 183 | signature: [0xff, 0xf3], 184 | }, 185 | { 186 | mime: "audio/mpeg", 187 | ext: "mp3", 188 | signature: [0xff, 0xf2], 189 | }, 190 | { 191 | mime: "audio/mpeg", 192 | ext: "mp3", 193 | signature: [0xff, 0xfb], 194 | }, 195 | { 196 | mime: "audio/vnd.wave", 197 | ext: "wav", 198 | signature: [ 199 | 0x52, 200 | 0x49, 201 | 0x46, 202 | 0x46, 203 | undefined, 204 | undefined, 205 | undefined, 206 | undefined, 207 | 0x57, 208 | 0x41, 209 | 0x56, 210 | 0x45, 211 | ], 212 | }, 213 | { 214 | mime: "audio/qcelp", 215 | ext: "qcp", 216 | signature: [ 217 | 0x52, 218 | 0x49, 219 | 0x46, 220 | 0x46, 221 | undefined, 222 | undefined, 223 | undefined, 224 | undefined, 225 | 0x51, 226 | 0x4c, 227 | 0x43, 228 | 0x4d, 229 | ], 230 | }, 231 | { 232 | mime: "font/ttf", 233 | ext: "ttf", 234 | signature: [0x00, 0x01, 0x00, 0x00], 235 | }, 236 | { 237 | mime: "font/otf", 238 | ext: "otf", 239 | signature: [0x4f, 0x54, 0x54, 0x4f], 240 | }, 241 | { 242 | mime: "font/collection", 243 | ext: "ttcf", 244 | signature: [0x74, 0x74, 0x63, 0x66], 245 | }, 246 | { 247 | mime: "font/woff", 248 | ext: "woff", 249 | signature: [0x77, 0x4f, 0x46, 0x46], 250 | }, 251 | { 252 | mime: "font/woff2", 253 | ext: "woff2", 254 | signature: [0x77, 0x4f, 0x46, 0x32], 255 | }, 256 | { 257 | mime: "application/x-rar-compressed", 258 | ext: "rar", 259 | signature: [0x52, 0x61, 0x72, 0x20, 0x1a, 0x07, 0x00], 260 | }, 261 | { 262 | mime: "application/x-msdownload", 263 | ext: "exe", 264 | signature: [0x4d, 0x5a], 265 | }, 266 | { 267 | ext: "xz", 268 | mime: "application/x-xz", 269 | signature: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00], 270 | }, 271 | { 272 | ext: "7z", 273 | mime: "application/x-7z-compressed", 274 | signature: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], 275 | }, 276 | { 277 | mime: "application/wasm", 278 | ext: "wasm", 279 | signature: [0x00, 0x61, 0x73, 0x6d], 280 | }, 281 | { 282 | mime: "application/x-nintendo-nes-rom", 283 | ext: "nes", 284 | signature: [0x4e, 0x45, 0x53, 0x1a], 285 | }, 286 | { 287 | ext: "rpm", 288 | mime: "application/x-rpm", 289 | signature: [0xed, 0xab, 0xee, 0xdb], 290 | }, 291 | { 292 | ext: "zst", 293 | mime: "application/zstd", 294 | signature: [0x28, 0xb5, 0x2f, 0xfd], 295 | }, 296 | { 297 | ext: "alias", 298 | mime: "application/x.apple.alias", 299 | signature: [ 300 | 0x62, 0x6f, 0x6f, 0x6b, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x61, 0x72, 0x6b, 301 | 0x00, 0x00, 0x00, 0x00, 302 | ], 303 | }, 304 | { 305 | ext: "deb", 306 | mime: "application/x-deb", 307 | signature: [0x21, 0x3c, 0x61, 0x72, 0x63, 0x68, 0x3e, 0x0a], 308 | }, 309 | { 310 | ext: "blend", 311 | mime: "application/x-blender", 312 | signature: [0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52], 313 | }, 314 | { 315 | ext: "chm", 316 | mime: "application/vnd.ms-htmlhelp", 317 | signature: [ 318 | 0x49, 0x54, 0x53, 0x46, 0x03, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, 319 | ], 320 | }, 321 | { 322 | ext: "rtf", 323 | mime: "application/rtf", 324 | signature: [0x7b, 0x5c, 0x72, 0x74, 0x66, 0x31], 325 | }, 326 | { 327 | ext: "cab", 328 | mime: "application/vnd.ms-cab-compressed", 329 | signature: [0x4d, 0x53, 0x43, 0x46], 330 | }, 331 | { 332 | ext: "cab", 333 | mime: "application/vnd.ms-cab-compressed", 334 | signature: [0x49, 0x53, 0x63, 0x28], 335 | }, 336 | { 337 | ext: "crx", 338 | mime: "application/x-google-chrome-extension", 339 | signature: [0x43, 0x72, 0x32, 0x34], 340 | }, 341 | { 342 | ext: "nes", 343 | mime: "application/x-nintendo-nes-rom", 344 | signature: [0x4e, 0x45, 0x53, 0x1a], 345 | }, 346 | { 347 | ext: "sqlite", 348 | mime: "application/x-sqlite3", 349 | signature: [ 350 | 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 351 | 0x74, 0x20, 0x33, 0x00, 352 | ], 353 | }, 354 | { 355 | ext: "lz", 356 | mime: "application/x-lzip", 357 | signature: [0x4c, 0x5a, 0x49, 0x50], 358 | }, 359 | { 360 | ext: "pcap", 361 | mime: "application/vnd.tcpdump.pcap", 362 | signature: [0xd4, 0xc3, 0xb2, 0xa1], 363 | }, 364 | { 365 | ext: "pcap", 366 | mime: "application/vnd.tcpdump.pcap", 367 | signature: [0xa1, 0xb2, 0xc3, 0xd4], 368 | }, 369 | { 370 | mime: "text/plain", 371 | ext: "txt", 372 | signature: [0xef, 0xbb, 0xbf], 373 | }, 374 | { 375 | mime: "text/plain", 376 | ext: "txt", 377 | signature: [0xfe, 0xff], 378 | }, 379 | { 380 | mime: "text/plain", 381 | ext: "txt", 382 | signature: [0xff, 0xfe], 383 | }, 384 | ]; 385 | 386 | /** 387 | * @description 校验给出的字节数据是否符合某种MIME Type的signature 388 | * @param {Array} bufferss 字节数据 389 | * @param {Object} typeItem 校验项 { signature, offset } 390 | */ 391 | const check = (bufferss: Buffer, { signature, offset = 0 }: any) => { 392 | for (let i = 0, len = signature.length; i < len; i++) { 393 | // 传入字节数据与文件signature不匹配 394 | // 需考虑有offset的情况以及signature中有值为undefined的情况 395 | if (bufferss[i + offset] !== signature[i] && signature[i] !== undefined) 396 | return false; 397 | } 398 | return true; 399 | }; 400 | 401 | /** 402 | * @description 获取文件二进制数据 403 | * @param {File} file 文件对象实例 404 | * @param {Object} options 配置项,指定读取的起止范围 405 | */ 406 | const getArrayBuffer = (file: File, { start, end }: any) => { 407 | return new Promise((reslove, reject) => { 408 | try { 409 | const reader = new FileReader(); 410 | reader.onload = (e: any) => { 411 | const buffers = new Uint8Array(e.target.result); 412 | reslove(buffers); 413 | }; 414 | reader.onerror = (err) => reject(err); 415 | reader.onabort = (err) => reject(err); 416 | reader.readAsArrayBuffer(file.slice(start, end)); 417 | } catch (err) { 418 | reject(err); 419 | } 420 | }); 421 | }; 422 | 423 | /** 424 | * @description 获取文件的真实类型 425 | * @param {File} file 文件对象实例 426 | * @param {Object} options 配置项,指定读取的起止范围 427 | */ 428 | const getFileType = (file: File, options = { start: 0, end: 32 }) => 429 | getArrayBuffer(file, options) 430 | .then((buffers: any) => { 431 | // 找出签名列表中定义好的类型,并返回 432 | for (let i = 0, len = signatureList.length; i < len; i++) { 433 | if (check(buffers, signatureList[i])) { 434 | const { mime, ext } = signatureList[i]; 435 | return { mime, ext }; 436 | } 437 | } 438 | // 未找到则返回file对象中的信息 439 | return { mime: file.type, ext: "" }; 440 | }) 441 | .catch((err) => err); 442 | 443 | export default getFileType; 444 | -------------------------------------------------------------------------------- /src/parse/psd.js: -------------------------------------------------------------------------------- 1 | import PSD from "psd.js"; 2 | import Buffer from "buffer"; 3 | import { v4 as uuid } from "uuid"; 4 | if (typeof window.Buffer === "undefined") { 5 | window.Buffer = Buffer.Buffer; 6 | } 7 | class Psd { 8 | constructor(uploadUrl, uploadCallback) { 9 | // 存储模板json 10 | this.uploadUrl = uploadUrl; 11 | this.uploadCallback = uploadCallback; 12 | } 13 | 14 | async init(file) { 15 | return new Promise((jsonResolve) => { 16 | let fileName = 17 | file.name.substring(0, file.name.lastIndexOf(".")) + ".png"; 18 | // psd文件 19 | const url = URL.createObjectURL(file); 20 | // 解析psd文件 21 | PSD.fromURL(url) 22 | .then(async (psd) => { 23 | const { backgroundImage, width, height } = await this.getPsdBgImage( 24 | psd 25 | ); 26 | // 获取图层数据 27 | const childrens = psd.tree().children(); 28 | // console.log("图层数据", childrens) 29 | let result = []; 30 | const outProArr = this.getPsdJson(childrens, null, result); 31 | Promise.all(outProArr) 32 | .then(() => { 33 | // 结构转化 34 | let newPsdObjArr = []; 35 | // 目的是符合fabric层级 36 | result = this.resReverse(result); 37 | // 工作区结构 38 | let workareaObj = {}; //JSON.parse(JSON.stringify(psdJson.workarea)); 39 | workareaObj.width = width; 40 | workareaObj.height = height; 41 | workareaObj.id = "workarea"; 42 | workareaObj.name = fileName; 43 | newPsdObjArr.push(workareaObj); 44 | result.forEach((obj) => { 45 | newPsdObjArr.push(obj); 46 | }); 47 | return newPsdObjArr; 48 | }) 49 | .then((arr) => { 50 | console.log(JSON.parse(JSON.stringify(arr))); 51 | // 转成json 52 | jsonResolve({ 53 | json: arr, 54 | slImage: backgroundImage, 55 | }); 56 | }); 57 | }) 58 | .catch((e) => { 59 | console.log(e); 60 | }); 61 | }); 62 | } 63 | 64 | // newTextObj.lineHeight = Math.round(fontSize * transY * 100) * 0.01; // 64 ✔ 65 | getRotation(transform) { 66 | let rotation = Math.round( 67 | Math.atan(transform.xy / transform.xx) * (180 / Math.PI) 68 | ); 69 | 70 | if (transform.xx < 0) { 71 | rotation += 180; 72 | } else if (rotation < 0) { 73 | rotation += 360; 74 | } 75 | 76 | return rotation; 77 | } 78 | 79 | getPsdJson(childrenList, resolve, list) { 80 | let outProArr = []; 81 | Array.from(childrenList).forEach((e, i) => { 82 | let outPro = new Promise((res) => { 83 | // 顶级图层/文件夹 84 | if (e.type == "group") { 85 | var i_child = e.children(); // 子图层 86 | let newGroupObj = {}; //JSON.parse(JSON.stringify(psdJson.group)); 87 | newGroupObj.type = "group"; 88 | newGroupObj.left = e.left; 89 | newGroupObj.top = e.top; 90 | newGroupObj.width = e.width; 91 | newGroupObj.height = e.height; 92 | newGroupObj.opacity = e.export().opacity; 93 | newGroupObj.visible = e.export().visible; 94 | newGroupObj.id = uuid(); 95 | newGroupObj.name = e.name; 96 | newGroupObj.objects = []; 97 | e = newGroupObj; 98 | list[i] = e; 99 | return this.getPsdJson(i_child, res, e.objects); 100 | } else { 101 | let itemObj = {}; 102 | itemObj = e; 103 | this.getChildData(childrenList[i]) 104 | .then((a) => { 105 | if (a) { 106 | itemObj.type = a.type; 107 | if (a.type == "text") { 108 | itemObj["tracking"] = a.data; 109 | let newTextObj = {}; // JSON.parse(JSON.stringify(psdJson.FontCustom)); 110 | let exportObj = itemObj.export(); 111 | newTextObj.type = "FontCustom"; 112 | newTextObj.left = itemObj.left; 113 | newTextObj.top = itemObj.top; 114 | newTextObj.width = itemObj.width; 115 | newTextObj.height = itemObj.height; 116 | let color = exportObj.text.font.colors[0]; 117 | newTextObj.fill = `rgb(${color[0]},${color[1]},${color[2]})`; 118 | //todo opacity !=1 赋值 visible=false 赋值 fontFamily 看下默认字体,非默认字体赋值 fontWeight 非normal赋值 fontStyle 非normal赋值 119 | if (exportObj.opacity != 1) { 120 | newTextObj.opacity = exportObj.opacity; 121 | } 122 | if (!exportObj.visible) { 123 | newTextObj.visible = exportObj.visible; 124 | } 125 | if (exportObj.text.font.weights[0] != "normal") { 126 | newTextObj.fontWeight = exportObj.text.font.weights[0]; 127 | } 128 | newTextObj.fontSize = exportObj.text.font.sizes[0]; 129 | if (exportObj.text.font.styles[0] != "normal") { 130 | newTextObj.fontStyle = exportObj.text.font.styles[0]; 131 | } 132 | newTextObj.fontFamily = exportObj.text.font.names[0]; 133 | newTextObj.text = exportObj.text.value; 134 | // newTextObj.underline 135 | // newTextObj.overline 136 | // newTextObj.linethrough 137 | 138 | //todo:exportObj.text.font.alignment[0] 不是left才赋值 139 | //charSpacing !=0 赋值 angle!=0赋值 140 | if (exportObj.text.font.alignment[0] != "left") { 141 | newTextObj.textAlign = exportObj.text.font.alignment[0]; 142 | } 143 | if (itemObj.tracking != 0 && itemObj.tracking) { 144 | newTextObj.charSpacing = itemObj.tracking; 145 | } 146 | // newTextObj.id = uuid(); 147 | newTextObj.name = itemObj.name; 148 | let angleR = this.getRotation(exportObj.text.transform); 149 | if (angleR != 0) { 150 | newTextObj.angle = angleR; //getRotation(exportObj.text.transform); 151 | } 152 | if (newTextObj.fontStyle == "italic") { 153 | newTextObj.width += newTextObj.fontSize; 154 | } 155 | // 解决空格换行问题 156 | if (newTextObj.text.indexOf(" ") != -1) { 157 | let reg = /(^\s*)|(\s*$)/g; 158 | const spaceList = newTextObj.text.match(reg) || []; 159 | let len = 0; 160 | spaceList.forEach((item) => { 161 | len += item.length || 1; 162 | }); 163 | newTextObj.width += newTextObj.fontSize * len; 164 | newTextObj.width = parseInt(newTextObj.width); 165 | } 166 | let transY = exportObj.text.transform.yy; 167 | let fontSize = newTextObj.fontSize; 168 | newTextObj.fontSize = 169 | Math.round(fontSize * transY * 100) * 0.01; // 32 ✔ 170 | 171 | itemObj = newTextObj; 172 | } else if (a.type == "image") { 173 | if (e.isMask) { 174 | res(); 175 | return; 176 | } 177 | itemObj["image"] = a.data; 178 | let newImageObj = {}; //JSON.parse(JSON.stringify(psdJson.Image)); 179 | newImageObj.type = "Image"; 180 | newImageObj.left = itemObj.left; 181 | newImageObj.top = itemObj.top; 182 | newImageObj.width = itemObj.width; 183 | newImageObj.height = itemObj.height; 184 | if (itemObj.maskSvg) { 185 | newImageObj.maskSvg = itemObj.maskSvg; 186 | newImageObj.maskType = "custom"; 187 | newImageObj.poi = itemObj.poi; 188 | } 189 | if (itemObj.export().opacity != 1) { 190 | newImageObj.opacity = itemObj.export().opacity; 191 | } 192 | if (!itemObj.export().visible) { 193 | newImageObj.visible = itemObj.export().visible; 194 | } 195 | // newImageObj.id = uuid(); 196 | newImageObj.name = itemObj.name; 197 | newImageObj.src = itemObj.image; 198 | itemObj = newImageObj; 199 | } 200 | if (list && Array.isArray(list)) { 201 | list[i] = itemObj; 202 | } 203 | res(itemObj); 204 | } else { 205 | res(); 206 | } 207 | }) 208 | .catch((e) => { 209 | console.error(childrenList[i].name, e); 210 | }); 211 | } 212 | }); 213 | outProArr.push(outPro); 214 | }); 215 | if (resolve) { 216 | return Promise.all(outProArr).then(resolve); 217 | } else { 218 | return outProArr; 219 | } 220 | } 221 | getChildData(i) { 222 | return new Promise((resolve) => { 223 | let obj = { 224 | type: "", 225 | data: undefined, 226 | }; 227 | try { 228 | const typeTool = i.get("typeTool"); 229 | // if (i._children.length > 0) { 230 | // // group直接返回未处理group组 231 | // obj.type = "group"; 232 | // obj.data = i; 233 | // resolve(obj); 234 | // } else { 235 | if (typeof typeTool !== "undefined") { 236 | // 文字 237 | if (typeof typeTool.styles().Tracking !== "undefined") { 238 | // 获取字间距 239 | i.tracking = typeTool.styles().Tracking[0]; 240 | } else { 241 | i.tracking = undefined; 242 | } 243 | // 文字返回字间距信息 244 | obj.type = "text"; 245 | obj.data = i.tracking; 246 | resolve(obj); 247 | } else { 248 | const base64 = i.layer.image.toBase64(); 249 | // 图片 250 | var blob = this.dataURItoBlob(base64); 251 | this.slfileUpload(blob, i.name + ".png", base64).then((urlRes) => { 252 | // 子图层图片 253 | i.image = urlRes; 254 | // 图片返回图片url 255 | obj.type = "image"; 256 | obj.data = i.image; 257 | resolve(obj); 258 | }); 259 | } 260 | } catch (e) { 261 | console.log("eee", e); 262 | resolve(); 263 | } 264 | }).catch((e) => { 265 | console.log("eeee", i, e); 266 | }); 267 | } 268 | dataURItoBlob(dataURI) { 269 | var byteString; 270 | if (dataURI.split(",")[0].indexOf("base64") >= 0) 271 | byteString = atob(dataURI.split(",")[1]); 272 | else byteString = unescape(dataURI.split(",")[1]); 273 | var mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0]; 274 | var ia = new Uint8Array(byteString.length); 275 | for (var i = 0; i < byteString.length; i++) { 276 | ia[i] = byteString.charCodeAt(i); 277 | } 278 | return new Blob([ia], { 279 | type: mimeString, 280 | }); 281 | } 282 | slfileUpload(blob, fileName, base64) { 283 | return new Promise((resolve) => { 284 | if (this.uploadUrl) { 285 | /* FormData 是表单数据类 */ 286 | var fd = new FormData(); 287 | var ajax = new XMLHttpRequest(); 288 | /* 把文件添加到表单里 */ 289 | fd.append("file", blob, fileName); 290 | ajax.open("post", this.uploadUrl, true); 291 | ajax.onload = () => { 292 | try { 293 | const url = this.uploadCallback(ajax.responseText); 294 | resolve(url); 295 | } catch (e) { 296 | console.error(e); 297 | resolve(); 298 | } 299 | }; 300 | ajax.send(fd); 301 | } else { 302 | resolve(base64); 303 | } 304 | }); 305 | } 306 | resReverse(group) { 307 | return group.reverse().map((item) => { 308 | if (item.type == "group") { 309 | item.objects = this.resReverse(item.objects); 310 | return item; 311 | } else { 312 | return item; 313 | } 314 | }); 315 | } 316 | async getSliceData(file) { 317 | try { 318 | const result = await file.arrayBuffer(); 319 | const psdFile = Psd.parse(result); 320 | const slices = psdFile.slices; 321 | const newSlices = slices.map((item) => { 322 | let width = item.right - item.left; 323 | let height = item.bottom - item.top; 324 | return { 325 | id: uuid(), 326 | left: item.left, 327 | width, 328 | height, 329 | ps: true, 330 | top: item.top, 331 | }; 332 | }); 333 | return newSlices; 334 | } catch (e) { 335 | return []; 336 | } 337 | } 338 | 339 | createCanvas() { 340 | return document.createElement("canvas"); 341 | } 342 | 343 | getPsdBgImage(psd) { 344 | return new Promise((resolve) => { 345 | const l_background = psd.image.toBase64(); 346 | let img = new Image(); 347 | img.src = l_background; 348 | img.setAttribute("crossOrigin", "Anonymous"); 349 | img.onload = () => { 350 | resolve({ 351 | backgroundImage: l_background, 352 | width: img.width, 353 | height: img.height, 354 | }); 355 | }; 356 | }); 357 | } 358 | } 359 | 360 | export default Psd; 361 | -------------------------------------------------------------------------------- /src/components/panel/workspace-size.vue: -------------------------------------------------------------------------------- 1 | 109 | 200 | 205 | 441 | -------------------------------------------------------------------------------- /src/components/workarea/workspace.vue: -------------------------------------------------------------------------------- 1 | 96 | 358 | 456 | --------------------------------------------------------------------------------