├── .env.staging ├── src ├── styles │ ├── variable.less │ ├── index.less │ └── resetViewUi.less ├── config │ └── constants │ │ └── app.ts ├── components │ ├── inputNumber │ │ └── index.ts │ ├── svgIcon │ │ ├── index.js │ │ └── index.vue │ ├── clone.vue │ ├── del.vue │ ├── lang.vue │ ├── previewCurrent.vue │ ├── importJSON.vue │ ├── dragMode.vue │ ├── replaceImg.vue │ ├── lock.vue │ ├── contextMenu │ │ ├── menuItem.vue │ │ └── index.vue │ ├── fontTmpl.vue │ ├── setSize.vue │ ├── bgBar.vue │ ├── history.vue │ ├── flip.vue │ ├── centerAlign.vue │ ├── importTmpl.vue │ ├── group.vue │ ├── zoom.vue │ ├── save.vue │ ├── importFile.vue │ └── color.vue ├── assets │ ├── fonts │ │ ├── cn │ │ │ ├── 汉体.ttf │ │ │ └── 华康金刚黑.ttf │ │ ├── font.css │ │ └── font.js │ ├── filters │ │ ├── Brownie.png │ │ ├── Invert.png │ │ ├── Sepia.png │ │ ├── Vintage.png │ │ ├── Polaroid.png │ │ ├── BlackWhite.png │ │ ├── Kodachrome.png │ │ └── technicolor.png │ └── editor │ │ ├── edgecontrol.svg │ │ ├── middlecontrol.svg │ │ ├── middlecontrolhoz.svg │ │ └── rotateicon.svg ├── router │ ├── index.ts │ └── routes.ts ├── App.tsx ├── utils │ ├── event │ │ ├── types.ts │ │ └── notifier.ts │ ├── math.ts │ ├── local.ts │ ├── color.ts │ ├── utils.ts │ └── psd.ts ├── plugins │ └── modal.js ├── hooks │ ├── useI18n.js │ └── select.js ├── App.vue ├── env.d.ts ├── main.ts ├── language │ ├── index.ts │ ├── pt.json │ ├── zh.json │ └── en.json ├── core │ ├── objects │ │ └── Arrow.js │ ├── initCenterAlign.ts │ ├── ruler │ │ ├── type.d.ts │ │ ├── index.ts │ │ ├── guideline.ts │ │ └── utils.ts │ ├── initHotKeys.ts │ ├── EditorGroup.ts │ ├── initializeLineDrawing.js │ ├── EditorGroupText.ts │ ├── initControlsRotate.ts │ ├── index.ts │ ├── EditorWorkspace.ts │ └── initControls.ts ├── mixins │ └── select.ts └── views │ └── home │ └── index.module.less ├── .browserslistrc ├── .env.production ├── .env.development ├── .env ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── public └── favicon.ico ├── tsconfig.node.json ├── .editorconfig ├── typings ├── modules.ts └── extends.d.ts ├── .prettierignore ├── .github ├── workflows │ ├── pull_request.yml │ ├── readme.yml │ └── webpack.yml └── FUNDING.yml ├── .gitignore ├── CONTRIBUTING.md ├── tsconfig.json ├── .prettierrc.js ├── LICENSE ├── index.html ├── .eslintrc.js ├── package.json ├── README-en.md ├── vite.config.ts └── CODE_OF_CONDUCT.md /.env.staging: -------------------------------------------------------------------------------- 1 | APP_FLAG=staging -------------------------------------------------------------------------------- /src/styles/variable.less: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | APP_FLAG=prod 2 | APP_FONT_CSS_FILE=free-font.css -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | APP_FLAG=dev 2 | APP_FONT_CSS_FILE=free-font-local.css -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | APP_TITLE=设计编辑器-vue-fabric-editor 2 | APP_REPO=http://static1.nihaojob.com/web/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /src/config/constants/app.ts: -------------------------------------------------------------------------------- 1 | export const LANG = 'lang'; // 多语言key 2 | 3 | export default LANG; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /src/components/inputNumber/index.ts: -------------------------------------------------------------------------------- 1 | import InputNumber from './inputNumber.vue'; 2 | 3 | export default InputNumber; 4 | -------------------------------------------------------------------------------- /src/assets/fonts/cn/汉体.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/fonts/cn/汉体.ttf -------------------------------------------------------------------------------- /src/assets/filters/Brownie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/Brownie.png -------------------------------------------------------------------------------- /src/assets/filters/Invert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/Invert.png -------------------------------------------------------------------------------- /src/assets/filters/Sepia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/Sepia.png -------------------------------------------------------------------------------- /src/assets/filters/Vintage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/Vintage.png -------------------------------------------------------------------------------- /src/assets/fonts/cn/华康金刚黑.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/fonts/cn/华康金刚黑.ttf -------------------------------------------------------------------------------- /src/assets/filters/Polaroid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/Polaroid.png -------------------------------------------------------------------------------- /src/assets/filters/BlackWhite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/BlackWhite.png -------------------------------------------------------------------------------- /src/assets/filters/Kodachrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/Kodachrome.png -------------------------------------------------------------------------------- /src/assets/filters/technicolor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LvHuaiSheng/vue-fabric-editor-element/HEAD/src/assets/filters/technicolor.png -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /typings/modules.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: Declarations for node modules. 3 | */ 4 | 5 | // declare module 'view-ui-plus/dist/locale/zh-CN'; 6 | // declare module 'view-ui-plus/dist/locale/en-US'; 7 | -------------------------------------------------------------------------------- /src/assets/fonts/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: '汉体'; 3 | src: url('./cn/汉体.ttf'); 4 | } 5 | 6 | @font-face { 7 | font-family: '华康金刚黑'; 8 | src: url('./cn/华康金刚黑.ttf'); 9 | } 10 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router'; 2 | import routes from './routes'; 3 | 4 | export default createRouter({ 5 | routes, 6 | history: createWebHashHistory(), 7 | scrollBehavior() { 8 | return { top: 0 }; 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2023-03-06 23:58:13 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2023-03-06 23:58:14 6 | * @Description: file content 7 | */ 8 | import { defineComponent } from 'vue'; 9 | 10 | export default defineComponent({ 11 | setup() { 12 | return () => ; 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/svgIcon/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2022-04-20 17:13:36 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2022-04-20 17:19:46 6 | * @Description: file content 7 | */ 8 | 9 | import svgIcon from './index.vue'; 10 | 11 | export default { 12 | install(Vue) { 13 | Vue.component(svgIcon.name, svgIcon); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/router/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from 'vue-router'; 2 | 3 | const routes: RouteRecordRaw[] = [ 4 | { 5 | path: '/', 6 | component: () => import('@/views/home/index.vue'), 7 | }, 8 | { 9 | path: '/editor', 10 | component: () => import('@/views/home/editor.vue'), 11 | }, 12 | ]; 13 | 14 | export default routes; 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 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 | 25 | yarn.lock -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS with Webpack 2 | 3 | on: 4 | pull_request: 5 | branches: ['main'] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Build 16 | run: | 17 | npm install 18 | npm run build 19 | -------------------------------------------------------------------------------- /src/utils/event/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 自定义事件的类型 3 | */ 4 | 5 | // 选择模式 6 | export enum SelectMode { 7 | ONE = 'one', 8 | MULTI = 'multiple', 9 | } 10 | 11 | export enum SelectOneType { 12 | GROUP = 'group', 13 | POLYGON = 'polygon', 14 | } 15 | 16 | // 选择事件(用于广播) 17 | export enum SelectEvent { 18 | ONE = 'selectOne', 19 | MULTI = 'selectMultiple', 20 | CANCEL = 'selectCancel', 21 | } 22 | -------------------------------------------------------------------------------- /src/assets/fonts/font.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2022-09-05 22:54:14 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2022-09-05 22:59:30 6 | * @Description: 字体文件列表 7 | */ 8 | 9 | const cnList = [ 10 | { 11 | name: '汉体', 12 | fontFamily: '汉体', 13 | }, 14 | { 15 | name: '华康金刚黑', 16 | fontFamily: '华康金刚黑', 17 | }, 18 | ]; 19 | 20 | const enList = []; 21 | 22 | export default [...cnList, ...enList]; 23 | -------------------------------------------------------------------------------- /src/plugins/modal.js: -------------------------------------------------------------------------------- 1 | import { ElLoading } from 'element-plus'; 2 | let loadingInstance; 3 | 4 | export default { 5 | // 打开遮罩层 6 | show(content) { 7 | loadingInstance = ElLoading.service({ 8 | lock: true, 9 | text: content ? content : 'loading', 10 | spinner: 'el-icon-loading', 11 | background: 'rgba(0, 0, 0, 0.7)', 12 | }); 13 | }, 14 | // 关闭遮罩层 15 | hide() { 16 | loadingInstance.close(); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/useI18n.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: bigFace2019 599069310@qq.com 3 | * @Date: 2023-05-10 21:47:33 4 | * @LastEditors: bigFace2019 599069310@qq.com 5 | * @LastEditTime: 2023-05-10 21:50:30 6 | * @FilePath: \vue-fabric-editor\src\hooks\useI18n.js 7 | * @Description: 封装国际化为hook 8 | */ 9 | import { getCurrentInstance } from 'vue'; 10 | export default function useI18n() { 11 | return getCurrentInstance().appContext.config.globalProperties.$t; 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | auto-imports.d.ts 5 | .eslintrc-auto-import.json 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .npmrc 27 | yarn.lock 28 | pnpm-lock.yaml 29 | package-lock.json 30 | -------------------------------------------------------------------------------- /src/styles/index.less: -------------------------------------------------------------------------------- 1 | //@import '~view-ui-plus/src/styles/index.less'; 2 | @import './resetViewUi.less'; 3 | // view-ui iconfont (404问题 https://github.com/view-design/ViewUIPlus/issues/212) 4 | //@ionicons-font-path: '~view-ui-plus/src/styles/common/iconfont/fonts'; 5 | // @primary-color: #8c0776; 6 | body{ 7 | margin: 0; 8 | } 9 | 10 | .el-dropdown-link { 11 | cursor: pointer; 12 | color: var(--el-color-primary); 13 | display: flex; 14 | align-items: center; 15 | } 16 | .el-dropdown-link:focus { 17 | outline: none; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/readme.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | jobs: 7 | contrib-readme-job: 8 | runs-on: ubuntu-latest 9 | name: A job to automate contrib in readme 10 | steps: 11 | - name: Contribute List 12 | uses: akhilmhdh/contributors-readme-action@master 13 | with: 14 | image_size: 80 15 | use_username: true 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS with Webpack 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Build 16 | run: | 17 | npm install 18 | npm run build 19 | 20 | - name: Deploy 21 | uses: peaceiris/actions-gh-pages@v3 22 | with: 23 | personal_token: ${{ secrets.demo }} 24 | publish_dir: ./dist 25 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | -------------------------------------------------------------------------------- /src/components/clone.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /src/utils/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取多边形顶点坐标 3 | * @param edges 变数 4 | * @param radius 半径 5 | * @returns 坐标数组 6 | */ 7 | const getPolygonVertices = (edges: number, radius: number) => { 8 | const vertices = []; 9 | const interiorAngle = (Math.PI * 2) / edges; 10 | let rotationAdjustment = -Math.PI / 2; 11 | if (edges % 2 === 0) { 12 | rotationAdjustment += interiorAngle / 2; 13 | } 14 | for (let i = 0; i < edges; i++) { 15 | // 画圆取顶点坐标 16 | const rad = i * interiorAngle + rotationAdjustment; 17 | vertices.push({ 18 | x: Math.cos(rad) * radius, 19 | y: Math.sin(rad) * radius, 20 | }); 21 | } 22 | return vertices; 23 | }; 24 | 25 | export { getPolygonVertices }; 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 3 | 你好!我们很高兴你有兴趣为 vue-fabric-editor 做出贡献。在提交你的贡献之前,请花点时间阅读以下指南: 4 | 5 | ## 快速上手 6 | vue-fabric-editor 依赖Node.js v16版本进行开发,要确保你本地已安装Node.js。 7 | 8 | ## 第一次贡献 9 | 10 | 如果你还不清楚怎么在 GitHub 上提 Pull Request ,可以阅读下面这篇文章来学习: 11 | 12 | [如何优雅地在 GitHub 上贡献代码](https://segmentfault.com/a/1190000000736629) 13 | 14 | 为了能帮助你开始你的第一次尝试,我们用 [good first issues](https://github.com/nihaojob/vue-fabric-editor/labels/good%20first%20issue) 标记了一些比较容易修复的 bug 和小功能。这些 issue 可以很好地作为你的首次尝试。 15 | 16 | 如果你打算开始处理一个 issue,请先检查一下 issue 下面的留言以确保没有别人正在处理这个 issue。如果当前没有人在处理的话你可以留言告知其他人你将会处理这个 issue,以免别人重复劳动。 17 | 18 | 如果之前有人留言说会处理这个 issue 但是一两个星期都没有动静,那么你也可以接手处理这个 issue,当然还是需要留言告知其他人。 19 | 20 | ## Pull Request 21 | 22 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue'; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any>; 7 | export default component; 8 | } 9 | 10 | declare global { 11 | declare module 'fabric/fabric-impl' { 12 | interface IObjectOptions { 13 | /** 14 | * 标识 15 | */ 16 | id?: string | undefined; 17 | } 18 | } 19 | } 20 | 21 | declare module 'vfe' { 22 | export as namespace vfe; 23 | 24 | export interface ICanvas extends fabric.Canvas { 25 | c: fabric.Canvas; 26 | editor: Editor; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["esnext", "dom"], 14 | "skipLibCheck": true, 15 | "paths": { 16 | "@/*": ["./src/*"] 17 | } 18 | }, 19 | "include": [ 20 | "src/**/*.ts", 21 | "src/**/*.d.ts", 22 | "src/**/*.tsx", 23 | "src/**/*.vue", 24 | "typings/**/*.ts", 25 | "./auto-imports.d.ts" 26 | ], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, // 指定代码长度,超出换行 3 | tabWidth: 2, // tab 键的宽度 4 | useTabs: false, // 不使用tab 5 | semi: true, // 结尾加上分号 6 | singleQuote: true, // 使用单引号 7 | quoteProps: 'as-needed', // 要求对象字面量属性是否使用引号包裹,(‘as-needed’: 没有特殊要求,禁止使用,'consistent': 保持一致 , preserve: 不限制,想用就用) 8 | jsxSingleQuote: false, // jsx 语法中使用单引号 9 | trailingComma: 'es5', // 确保对象的最后一个属性后有逗号 10 | bracketSpacing: true, // 大括号有空格 { name: 'rose' } 11 | jsxBracketSameLine: false, // 在多行JSX元素的最后一行追加 > 12 | arrowParens: 'always', // 箭头函数,单个参数添加括号 13 | requirePragma: false, // 是否严格按照文件顶部的特殊注释格式化代码 14 | insertPragma: false, // 是否在格式化的文件顶部插入Pragma标记,以表明该文件被prettier格式化过了 15 | proseWrap: 'preserve', // 按照文件原样折行 16 | htmlWhitespaceSensitivity: 'ignore', // html文件的空格敏感度,控制空格是否影响布局 17 | endOfLine: 'auto', // 结尾是 \n \r \n\r auto 18 | }; 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://oss.issuehunt.io/r/nihaojob/vue-fabric-editor'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App'; 3 | import router from './router'; 4 | import ElementPlus from 'element-plus'; 5 | import 'element-plus/dist/index.css'; 6 | import 'element-plus/theme-chalk/display.css'; 7 | import './styles/index.less'; 8 | import VueLazyLoad from 'vue3-lazyload'; 9 | import * as elIcons from '@element-plus/icons-vue'; 10 | 11 | // 自定义字体文件 12 | import '@/assets/fonts/font.css'; 13 | // import axios from 'axios'; 14 | 15 | import i18n from './language/index'; 16 | 17 | const app = createApp(App); 18 | // app.config.globalProperties.$http = axios; 19 | //统一注册el-icon图标 20 | for (let icon in elIcons) { 21 | // @ts-ignore 22 | app.component(`ElIcon${icon}`, elIcons[icon]); 23 | } 24 | // @ts-ignore 25 | import modal from './plugins/modal'; 26 | app.config.globalProperties.$Spin = modal; 27 | 28 | app.use(router).use(i18n).use(VueLazyLoad, {}).use(ElementPlus).mount('#app'); 29 | -------------------------------------------------------------------------------- /src/utils/local.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * get localStorage 获取本地存储 3 | * @param { String } key 4 | */ 5 | export function getLocal(key: string) { 6 | if (!key) throw new Error('key is empty'); 7 | const value = localStorage.getItem(key); 8 | return value ? JSON.parse(value) : null; 9 | } 10 | 11 | /** 12 | * set localStorage 设置本地存储 13 | * @param { String } key 14 | * @param value 15 | */ 16 | export function setLocal(key: string, value: unknown) { 17 | if (!key) throw new Error('key is empty'); 18 | if (!value) return; 19 | return localStorage.setItem(key, JSON.stringify(value)); 20 | } 21 | 22 | /** 23 | * remove localStorage 移除某个本地存储 24 | * @param { String } key 25 | */ 26 | export function removeLocal(key: string) { 27 | if (!key) throw new Error('key is empty'); 28 | return localStorage.removeItem(key); 29 | } 30 | 31 | /** 32 | * clear localStorage 清除本地存储 33 | */ 34 | export function clearLocal() { 35 | return localStorage.clear(); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/del.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 17 | 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/assets/editor/middlecontrolhoz.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 秦少卫 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | <%- APP_TITLE %> 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 34 | 35 | -------------------------------------------------------------------------------- /typings/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/language/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | // import zh from 'view-ui-plus/dist/locale/zh-CN'; 3 | // import en from 'view-ui-plus/dist/locale/en-US'; //新版本把'iview'改成'view-design' 4 | import US from './en.json'; 5 | import CN from './zh.json'; 6 | import { getLocal, setLocal } from '@/utils/local'; 7 | import { LANG } from '@/config/constants/app'; 8 | 9 | const messages = { 10 | en: Object.assign(US), //将自己的英文包和iview提供的结合 11 | zh: Object.assign(CN), //将自己的中文包和iview提供的结合 12 | }; 13 | 14 | function getLocalLang() { 15 | let localLang = getLocal(LANG); 16 | if (!localLang) { 17 | let defaultLang = navigator.language; 18 | if (defaultLang) { 19 | // eslint-disable-next-line prefer-destructuring 20 | defaultLang = defaultLang.split('-')[0]; 21 | // eslint-disable-next-line prefer-destructuring 22 | localLang = defaultLang.split('-')[0]; 23 | } 24 | setLocal(LANG, defaultLang); 25 | } 26 | return localLang; 27 | } 28 | const lang = getLocalLang(); 29 | 30 | const i18n = createI18n({ 31 | allowComposition: true, 32 | globalInjection: true, 33 | legacy: false, 34 | locale: lang, 35 | messages, 36 | }); 37 | 38 | export default i18n; 39 | -------------------------------------------------------------------------------- /src/styles/resetViewUi.less: -------------------------------------------------------------------------------- 1 | // viewUi中buttonGroup下几个button的focus状态下样式存在问题,后边的会覆盖前边的box-shadow,这里给focus元素添加z-index. 2 | .ivu-btn:focus { 3 | z-index: 2; 4 | -webkit-box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.2); 5 | box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.2); 6 | } 7 | .preview-modal-wrap { 8 | overflow: hidden; 9 | .ivu-modal-body { 10 | display: flex; 11 | padding: 0; 12 | max-height: calc(100vh - 51px); 13 | //滚动条移动上去才展示,移开不展示滚动条 14 | overflow: overlay; 15 | &::-webkit-scrollbar { 16 | width: 4px; 17 | background-color: #efeae6; 18 | } 19 | &:hover ::-webkit-scrollbar-track-piece { 20 | /*鼠标移动上去再显示滚动条*/ 21 | background-color: #fff; 22 | /* 滚动条的背景颜色 */ 23 | border-radius: 6px; 24 | /* 滚动条的圆角宽度 */ 25 | } 26 | &:hover::-webkit-scrollbar-thumb:hover { 27 | background-color: #c1c1c1; 28 | } 29 | &:hover::-webkit-scrollbar-thumb:vertical { 30 | background-color: #c1c1c1; 31 | border-radius: 6px; 32 | outline: 2px solid #c1c1c1; 33 | outline-offset: -2px; 34 | border: 2px solid #c1c1c1; 35 | } 36 | } 37 | ivu-modal-content { 38 | border-radius: 6px 6px 0 0; 39 | } 40 | .ivu-modal { 41 | top: 0; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/core/objects/Arrow.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2023-01-07 01:15:50 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2023-02-08 00:08:40 6 | * @Description: 箭头元素 7 | */ 8 | import { fabric } from 'fabric'; 9 | 10 | fabric.Arrow = fabric.util.createClass(fabric.Line, { 11 | type: 'arrow', 12 | superType: 'drawing', 13 | initialize(points, options) { 14 | if (!points) { 15 | const { x1, x2, y1, y2 } = options; 16 | points = [x1, y1, x2, y2]; 17 | } 18 | options = options || {}; 19 | this.callSuper('initialize', points, options); 20 | }, 21 | _render(ctx) { 22 | this.callSuper('_render', ctx); 23 | ctx.save(); 24 | const xDiff = this.x2 - this.x1; 25 | const yDiff = this.y2 - this.y1; 26 | const angle = Math.atan2(yDiff, xDiff); 27 | ctx.translate((this.x2 - this.x1) / 2, (this.y2 - this.y1) / 2); 28 | ctx.rotate(angle); 29 | ctx.beginPath(); 30 | // Move 5px in front of line to start the arrow so it does not have the square line end showing in front (0,0) 31 | ctx.moveTo(5, 0); 32 | ctx.lineTo(-5, 5); 33 | ctx.lineTo(-5, -5); 34 | ctx.closePath(); 35 | ctx.fillStyle = this.stroke; 36 | ctx.fill(); 37 | ctx.restore(); 38 | }, 39 | }); 40 | 41 | fabric.Arrow.fromObject = (options, callback) => { 42 | const { x1, x2, y1, y2 } = options; 43 | return callback(new fabric.Arrow([x1, y1, x2, y2], options)); 44 | }; 45 | 46 | export default fabric.Arrow; 47 | -------------------------------------------------------------------------------- /src/components/lang.vue: -------------------------------------------------------------------------------- 1 | 9 | 24 | 25 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/utils/event/notifier.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2022-09-03 19:16:55 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2023-02-09 13:17:11 6 | * @Description: 自定义事件 7 | */ 8 | 9 | import EventEmitter from 'events'; 10 | import { fabric } from 'fabric'; 11 | import { Canvas } from 'fabric/fabric-impl'; 12 | import { SelectEvent } from '@/utils/event/types'; 13 | 14 | /** 15 | * 发布订阅器 16 | */ 17 | class CanvasEventEmitter extends EventEmitter { 18 | handler: Canvas | undefined; 19 | mSelectMode = ''; 20 | 21 | init(handler: CanvasEventEmitter['handler']) { 22 | this.handler = handler; 23 | if (this.handler) { 24 | this.handler.on('selection:created', () => this.selected()); 25 | this.handler.on('selection:updated', () => this.selected()); 26 | this.handler.on('selection:cleared', () => this.selected()); 27 | } 28 | } 29 | 30 | /** 31 | * 暴露单选多选事件 32 | * @private 33 | */ 34 | private selected() { 35 | if (!this.handler) { 36 | throw TypeError('还未初始化'); 37 | } 38 | 39 | const actives = this.handler 40 | .getActiveObjects() 41 | .filter((item) => !(item instanceof fabric.GuideLine)); // 过滤掉辅助线 42 | if (actives && actives.length === 1) { 43 | this.emit(SelectEvent.ONE, actives); 44 | } else if (actives && actives.length > 1) { 45 | this.mSelectMode = 'multiple'; 46 | this.emit(SelectEvent.MULTI, actives); 47 | } else { 48 | this.emit(SelectEvent.CANCEL); 49 | } 50 | } 51 | } 52 | 53 | export default CanvasEventEmitter; 54 | -------------------------------------------------------------------------------- /src/core/initCenterAlign.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2023-02-05 10:30:39 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2023-04-22 22:30:02 6 | * @Description: 居中方式 7 | */ 8 | 9 | import { fabric } from 'fabric'; 10 | 11 | class CenterAlign { 12 | canvas: fabric.Canvas; 13 | constructor(canvas: fabric.Canvas) { 14 | this.canvas = canvas; 15 | } 16 | 17 | centerH(workspace: fabric.Rect, object: fabric.Object) { 18 | return this.canvas._centerObject( 19 | object, 20 | new fabric.Point(workspace.getCenterPoint().x, object.getCenterPoint().y) 21 | ); 22 | } 23 | 24 | center(workspace: fabric.Rect, object: fabric.Object) { 25 | const center = workspace.getCenterPoint(); 26 | return this.canvas._centerObject(object, center); 27 | } 28 | 29 | centerV(workspace: fabric.Rect, object: fabric.Object) { 30 | return this.canvas._centerObject( 31 | object, 32 | new fabric.Point(object.getCenterPoint().x, workspace.getCenterPoint().y) 33 | ); 34 | } 35 | 36 | position(name: 'centerH' | 'center' | 'centerV') { 37 | const anignType = ['centerH', 'center', 'centerV']; 38 | const activeObject = this.canvas.getActiveObject(); 39 | if (anignType.includes(name) && activeObject) { 40 | const defaultWorkspace = this.canvas.getObjects().find((item) => item.id === 'workspace'); 41 | if (defaultWorkspace) { 42 | this[name](defaultWorkspace, activeObject); 43 | } 44 | this.canvas.renderAll(); 45 | } 46 | } 47 | } 48 | 49 | export default CenterAlign; 50 | -------------------------------------------------------------------------------- /src/components/previewCurrent.vue: -------------------------------------------------------------------------------- 1 | 9 | 25 | 26 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:vue/vue3-essential', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | './.eslintrc-auto-import.json', 13 | ], 14 | parser: 'vue-eslint-parser', 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | parser: '@typescript-eslint/parser', 18 | sourceType: 'module', 19 | }, 20 | plugins: ['vue', '@typescript-eslint'], 21 | rules: { 22 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 23 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 24 | 'no-plusplus': 'off', 25 | '@typescript-eslint/no-this-alias': 'off', 26 | '@typescript-eslint/no-var-requires': 'off', 27 | '@typescript-eslint/no-unused-vars': 'off', 28 | 'import/no-unresolved': 'off', 29 | 'vuejs-accessibility/form-control-has-label': 'off', 30 | 'consistent-return': 'off', // 强制统一返回值 31 | 'no-param-reassign': 'off', // 参数重新分配 32 | 'no-underscore-dangle': 'off', // 使用下划线命名 33 | 'comma-spacing': 'off', 34 | 'vuejs-accessibility/click-events-have-key-events': 'off', 35 | 'max-len': 'off', 36 | 'no-unused-expressions': 'off', // 17 37 | 'linebreak-style': 'off', 38 | 'vue/multi-word-component-names': 'off', // 开启组件需要多单词 39 | 'vuejs-accessibility/anchor-has-content': 'off', 40 | }, 41 | overrides: [ 42 | { 43 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'], 44 | env: { 45 | jest: true, 46 | }, 47 | }, 48 | ], 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/importJSON.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /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/hooks/select.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Descripttion: useSelect(原mixin) 类型待优化 3 | * @version: 4 | * @Author: June 5 | * @Date: 2023-04-23 21:10:05 6 | * @LastEditors: June 7 | * @LastEditTime: 2023-05-08 18:20:21 8 | */ 9 | 10 | import { SelectEvent, SelectMode } from '@/utils/event/types'; 11 | // todo 12 | // interface Data { 13 | // mSelectMode: SelectMode | ''; 14 | // mSelectOneType: SelectOneType | ''; 15 | // mSelectId: string[] | ''; 16 | // mSelectIds: string[]; 17 | // mSelectActive: any; 18 | // } 19 | 20 | export default function useSelect() { 21 | const state = reactive({ 22 | mSelectMode: '', 23 | mSelectOneType: '', 24 | mSelectId: '', // 选择id 25 | mSelectIds: [], // 选择id 26 | mSelectActive: [], 27 | }); 28 | 29 | const fabric = inject('fabric'); 30 | const canvas = inject('canvas'); 31 | const event = inject('event'); 32 | 33 | const selectOne = (e) => { 34 | state.mSelectMode = SelectMode.ONE; 35 | state.mSelectId = e[0].id; 36 | state.mSelectOneType = e[0].type; 37 | state.mSelectIds = e.map((item) => item.id); 38 | }; 39 | 40 | const selectMulti = (e) => { 41 | state.mSelectMode = SelectMode.MULTI; 42 | state.mSelectId = ''; 43 | state.mSelectIds = e.map((item) => item.id); 44 | }; 45 | 46 | const selectCancel = () => { 47 | state.mSelectId = ''; 48 | state.mSelectIds = []; 49 | state.mSelectMode = ''; 50 | state.mSelectOneType = ''; 51 | }; 52 | 53 | onMounted(() => { 54 | event.on(SelectEvent.ONE, selectOne); 55 | event.on(SelectEvent.MULTI, selectMulti); 56 | event.on(SelectEvent.CANCEL, selectCancel); 57 | }); 58 | 59 | onBeforeMount(() => { 60 | event.off(SelectEvent.ONE, selectOne); 61 | event.off(SelectEvent.MULTI, selectMulti); 62 | event.off(SelectEvent.CANCEL, selectCancel); 63 | }); 64 | 65 | return { 66 | fabric, 67 | canvas, 68 | mixinState: state, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/components/svgIcon/index.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/components/dragMode.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 75 | 82 | -------------------------------------------------------------------------------- /src/components/replaceImg.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/core/ruler/type.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type CanvasRuler, { Rect } from './ruler'; 3 | 4 | declare module 'fabric/fabric-impl' { 5 | type EventNameExt = 'removed' | EventName; 6 | 7 | export interface Canvas { 8 | _setupCurrentTransform(e: Event, target: fabric.Object, alreadySelected: boolean): void; 9 | } 10 | 11 | export interface IObservable { 12 | on( 13 | eventName: 'guideline:moving' | 'guideline:mouseup', 14 | handler: (event: { e: Event; target: fabric.GuideLine }) => void 15 | ): T; 16 | on(events: { [key: EventName]: (event: { e: Event; target: fabric.GuideLine }) => void }): T; 17 | } 18 | 19 | export interface IGuideLineOptions extends ILineOptions { 20 | axis: 'horizontal' | 'vertical'; 21 | } 22 | 23 | export interface IGuideLineClassOptions extends IGuideLineOptions { 24 | canvas: { 25 | setActiveObject(object: fabric.Object | fabric.GuideLine, e?: Event): Canvas; 26 | remove(...object: (fabric.Object | fabric.GuideLine)[]): T; 27 | } & Canvas; 28 | activeOn: 'down' | 'up'; 29 | initialize(xy: number, objObjects: IGuideLineOptions): void; 30 | callSuper(methodName: string, ...args: unknown[]): any; 31 | getBoundingRect(absolute?: boolean, calculate?: boolean): Rect; 32 | on(eventName: EventNameExt, handler: (e: IEvent) => void): void; 33 | off(eventName: EventNameExt, handler?: (e: IEvent) => void): void; 34 | fire(eventName: EventNameExt, options?: any): T; 35 | isPointOnRuler(e: MouseEvent): 'horizontal' | 'vertical' | false; 36 | bringToFront(): fabric.Object; 37 | isHorizontal(): boolean; 38 | } 39 | 40 | export interface GuideLine extends Line, IGuideLineClassOptions {} 41 | 42 | export class GuideLine extends Line { 43 | constructor(xy: number, objObjects?: IGuideLineOptions); 44 | static fromObject(object: any, callback: any): void; 45 | } 46 | 47 | export interface StaticCanvas { 48 | ruler: InstanceType; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/mixins/select.ts: -------------------------------------------------------------------------------- 1 | import { SelectEvent, SelectMode, SelectOneType } from '@/utils/event/types'; 2 | 3 | interface Data { 4 | mSelectMode: SelectMode | undefined; 5 | mSelectOneType: SelectOneType | undefined; 6 | mSelectId: string; 7 | mSelectIds: string[]; 8 | } 9 | 10 | export default { 11 | inject: ['canvas', 'fabric', 'event'], 12 | data(): Data { 13 | return { 14 | mSelectMode: undefined, 15 | mSelectOneType: undefined, 16 | mSelectId: '', // 选择id 17 | mSelectIds: [], // 选择id 18 | }; 19 | }, 20 | created() { 21 | this.event.on(SelectEvent.ONE, (e) => { 22 | this.mSelectMode = SelectMode.ONE; 23 | this.mSelectId = e[0].id; 24 | this.mSelectOneType = e[0].type; 25 | this.mSelectIds = e.map((item) => item.id); 26 | }); 27 | 28 | this.event.on(SelectEvent.MULTI, (e) => { 29 | this.mSelectMode = SelectMode.MULTI; 30 | this.mSelectId = ''; 31 | this.mSelectIds = e.map((item) => item.id); 32 | }); 33 | 34 | this.event.on(SelectEvent.CANCEL, () => { 35 | this.mSelectId = ''; 36 | this.mSelectIds = []; 37 | this.mSelectMode = ''; 38 | this.mSelectOneType = ''; 39 | }); 40 | }, 41 | methods: { 42 | /** 43 | * @description: 保存data数据 44 | * @param {Object} data 房间详情数据 45 | */ 46 | _mixinSelected({ selected }) { 47 | if (selected.length === 1) { 48 | const selectItem = selected[0]; 49 | this.mSelectMode = SelectMode.ONE; 50 | this.mSelectOneType = selectItem.type; 51 | this.mSelectId = [selectItem.id]; 52 | this.mSelectActive = [selectItem]; 53 | } else if (selected.length > 1) { 54 | this.mSelectMode = SelectMode.MULTI; 55 | this.mSelectActive = selected; 56 | this.mSelectId = selected.map((item) => item.id); 57 | } else { 58 | this._mixinCancel(); 59 | } 60 | }, 61 | /** 62 | * @description: 保存data数据 63 | */ 64 | _mixinCancel() { 65 | this.mSelectMode = ''; 66 | this.mSelectId = []; 67 | this.mSelectActive = []; 68 | this.mSelectOneType = ''; 69 | }, 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /src/core/ruler/index.ts: -------------------------------------------------------------------------------- 1 | import type { Canvas } from 'fabric/fabric-impl'; 2 | import { fabric } from 'fabric'; 3 | import CanvasRuler, { RulerOptions } from './ruler'; 4 | 5 | function initRuler(canvas: Canvas, options?: RulerOptions) { 6 | const ruler = new CanvasRuler({ 7 | canvas, 8 | ...options, 9 | }); 10 | 11 | // 辅助线移动到画板外删除 12 | let workspace: fabric.Object | undefined = undefined; 13 | 14 | /** 15 | * 获取workspace 16 | */ 17 | const getWorkspace = () => { 18 | workspace = canvas.getObjects().find((item) => item.id === 'workspace'); 19 | }; 20 | 21 | /** 22 | * 判断target是否在object矩形外 23 | * @param object 24 | * @param target 25 | * @returns 26 | */ 27 | const isRectOut = (object: fabric.Object, target: fabric.GuideLine): boolean => { 28 | const { top, height, left, width } = object; 29 | 30 | if (top === undefined || height === undefined || left === undefined || width === undefined) { 31 | return false; 32 | } 33 | 34 | const targetRect = target.getBoundingRect(true, true); 35 | const { 36 | top: targetTop, 37 | height: targetHeight, 38 | left: targetLeft, 39 | width: targetWidth, 40 | } = targetRect; 41 | 42 | if ( 43 | target.isHorizontal() && 44 | (top > targetTop + 1 || top + height < targetTop + targetHeight - 1) 45 | ) { 46 | return true; 47 | } else if ( 48 | !target.isHorizontal() && 49 | (left > targetLeft + 1 || left + width < targetLeft + targetWidth - 1) 50 | ) { 51 | return true; 52 | } 53 | 54 | return false; 55 | }; 56 | 57 | canvas.on('guideline:moving', (e) => { 58 | if (!workspace) { 59 | getWorkspace(); 60 | return; 61 | } 62 | const { target } = e; 63 | if (isRectOut(workspace, target)) { 64 | target.moveCursor = 'not-allowed'; 65 | } 66 | }); 67 | 68 | canvas.on('guideline:mouseup', (e) => { 69 | if (!workspace) { 70 | getWorkspace(); 71 | return; 72 | } 73 | const { target } = e; 74 | if (isRectOut(workspace, target)) { 75 | canvas.remove(target); 76 | canvas.setCursor(canvas.defaultCursor ?? ''); 77 | } 78 | }); 79 | return ruler; 80 | } 81 | 82 | export default initRuler; 83 | -------------------------------------------------------------------------------- /src/components/lock.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 81 | 82 | 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "view-ui-project-ts", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "a template project for vue3, ViewUIPlus, TypeScript and Vite.", 6 | "scripts": { 7 | "serve": "npm run dev", 8 | "dev": "vite serve", 9 | "dev:staging": "vite serve --mode=staging", 10 | "dev:prod": "vite serve --mode=production", 11 | "build": "vite build", 12 | "build:staging": "vite build --mode=staging", 13 | "preview": "npm run build && vite preview", 14 | "preview:staging": "npm run build:staging && vite preview --mode=staging", 15 | "prepare": "husky install" 16 | }, 17 | "dependencies": { 18 | "@element-plus/icons-vue": "^2.1.0", 19 | "@vueuse/core": "^10.1.0", 20 | "axios": "^1.3.4", 21 | "color-gradient-picker-vue3": "^1.0.0", 22 | "element-plus": "^2.3.5", 23 | "events": "^3.3.0", 24 | "fabric": "^5.2.1", 25 | "fontfaceobserver": "^2.1.0", 26 | "hotkeys-js": "^3.8.8", 27 | "lodash-es": "^4.17.21", 28 | "number-precision": "^1.6.0", 29 | "psd.js": "^3.6.6", 30 | "uuid": "^8.3.2", 31 | "vue": "^3.2.25", 32 | "vue-i18n": "^9.2.2", 33 | "vue-router": "^4.0.16", 34 | "vue3-lazyload": "^0.3.6" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/cli": "^17.4.2", 38 | "@commitlint/config-conventional": "^17.4.2", 39 | "@types/events": "^3.0.0", 40 | "@types/fabric": "^5.3.0", 41 | "@types/lodash-es": "^4.17.7", 42 | "@types/node": "^18.15.13", 43 | "@types/uuid": "^9.0.1", 44 | "@typescript-eslint/eslint-plugin": "^5.54.1", 45 | "@typescript-eslint/parser": "^5.54.1", 46 | "@vitejs/plugin-vue": "^4.1.0", 47 | "@vitejs/plugin-vue-jsx": "^3.0.1", 48 | "autoprefixer": "^10.4.5", 49 | "eslint": "^7.32.0", 50 | "eslint-config-prettier": "^8.6.0", 51 | "eslint-plugin-prettier": "^4.2.1", 52 | "eslint-plugin-vue": "^9.9.0", 53 | "husky": "^8.0.0", 54 | "less": "^4.1.3", 55 | "less-loader": "^11.1.0", 56 | "lint-staged": "^13.1.1", 57 | "prettier": "2.8.4", 58 | "typescript": "^5.0.4", 59 | "unplugin-auto-import": "^0.16.0", 60 | "vite": "^4.2.1", 61 | "vite-plugin-eslint": "^1.8.1", 62 | "vite-plugin-html": "^3.2.0", 63 | "vite-plugin-vue-setup-extend-plus": "^0.1.0", 64 | "vue-tsc": "^0.34.7" 65 | }, 66 | "lint-staged": { 67 | "*.{ts,tsx,js,vue}": [ 68 | "eslint --fix", 69 | "prettier --write" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/core/initHotKeys.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2022-12-07 23:50:05 4 | * @LastEditors: bamzc 5 | * @LastEditTime: 2023-06-02 16:56:50 6 | * @Description: 快捷键功能 7 | */ 8 | 9 | import hotkeys from 'hotkeys-js'; 10 | // import { cloneDeep } from 'lodash-es'; 11 | import { v4 as uuid } from 'uuid'; 12 | // import { Message } from 'view-design'; 13 | 14 | import type { fabric } from 'fabric'; 15 | 16 | const keyNames = { 17 | lrdu: 'left,right,down,up', // 左右上下 18 | backspace: 'backspace', // backspace键盘 19 | ctrlz: 'ctrl+z', 20 | ctrlc: 'ctrl+c', 21 | ctrlv: 'ctrl+v', 22 | }; 23 | 24 | function copyElement(editor: any, canvas: fabric.Canvas) { 25 | let copyEl: fabric.Object | null = null; 26 | 27 | // 复制 28 | hotkeys(keyNames.ctrlc, () => { 29 | const activeObject = canvas.getActiveObjects(); 30 | if (activeObject.length === 0) return; 31 | editor.copyObj((_copyEl: fabric.Object) => { 32 | copyEl = _copyEl; 33 | }); 34 | }); 35 | // 粘贴 36 | hotkeys(keyNames.ctrlv, () => { 37 | // if (!copyEl) return Message.warning('暂无复制内容'); 38 | if (copyEl) { 39 | editor.pasteObj(copyEl); 40 | } 41 | }); 42 | } 43 | 44 | function initHotkeys(editor: any, canvas: fabric.Canvas) { 45 | // 删除快捷键 46 | hotkeys(keyNames.backspace, () => { 47 | const activeObject = canvas.getActiveObjects(); 48 | if (activeObject) { 49 | activeObject.map((item) => canvas.remove(item)); 50 | canvas.requestRenderAll(); 51 | canvas.discardActiveObject(); 52 | } 53 | }); 54 | 55 | // 移动快捷键 56 | hotkeys(keyNames.lrdu, (event, handler) => { 57 | const activeObject = canvas.getActiveObject(); 58 | if (!activeObject) return; 59 | switch (handler.key) { 60 | case 'left': 61 | if (activeObject.left === undefined) return; 62 | activeObject.set('left', activeObject.left - 1); 63 | break; 64 | case 'right': 65 | if (activeObject.left === undefined) return; 66 | activeObject.set('left', activeObject.left + 1); 67 | break; 68 | case 'down': 69 | if (activeObject.top === undefined) return; 70 | activeObject.set('top', activeObject.top + 1); 71 | break; 72 | case 'up': 73 | if (activeObject.top === undefined) return; 74 | activeObject.set('top', activeObject.top - 1); 75 | break; 76 | default: 77 | } 78 | canvas.renderAll(); 79 | }); 80 | 81 | // 复制粘贴 82 | copyElement(editor, canvas); 83 | } 84 | 85 | export default initHotkeys; 86 | export { keyNames, hotkeys }; 87 | -------------------------------------------------------------------------------- /src/components/contextMenu/menuItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 33 | 34 | 63 | 64 | 117 | -------------------------------------------------------------------------------- /src/core/EditorGroup.ts: -------------------------------------------------------------------------------- 1 | /* 2 | /* 3 | * @Author: 秦少卫 4 | * @Date: 2023-04-20 02:15:09 5 | * @LastEditors: 白召策 6 | * @LastEditTime: 2023-05-31 11:38:33 7 | * @Description: 编辑组内文字 8 | */ 9 | 10 | import { fabric } from 'fabric'; 11 | import { v4 as uuid } from 'uuid'; 12 | 13 | class EditorGroup { 14 | canvas: fabric.Canvas; 15 | constructor(canvas: fabric.Canvas) { 16 | this.canvas = canvas; 17 | this._init(); 18 | } 19 | 20 | // 组内文本输入 21 | _init() { 22 | this.canvas.on('mouse:down', (opt) => { 23 | if (opt.target && opt.target.type === 'group') { 24 | const clickObj = this._getGroupObj(opt); 25 | if (!clickObj) return; 26 | clickObj.selectable = true; 27 | clickObj.hasControls = false; 28 | this.canvas.setActiveObject(clickObj); 29 | } 30 | }); 31 | 32 | this.canvas.on('mouse:dblclick', (opt) => { 33 | if (opt.target && opt.target.type === 'group') { 34 | const clickObj = this._getGroupObj(opt); 35 | if (!clickObj) return; 36 | clickObj.selectable = true; 37 | clickObj.hasControls = false; 38 | if (this.isText(clickObj)) { 39 | this._bedingEditingEvent(clickObj, opt); 40 | this.canvas.setActiveObject(clickObj); 41 | clickObj.enterEditing(); 42 | return; 43 | } 44 | } 45 | }); 46 | } 47 | 48 | // 获取点击区域内的组内元素 49 | _getGroupObj(opt: fabric.IEvent) { 50 | const pointer = this.canvas.getPointer(opt.e, true); 51 | const clickObj = this.canvas._searchPossibleTargets(opt.target?._objects, pointer); 52 | return clickObj; 53 | } 54 | 55 | // 绑定编辑取消事件 56 | _bedingEditingEvent(textObject: fabric.IText, opt: fabric.IEvent) { 57 | if (!opt.target) return; 58 | const left = opt.target.left; 59 | const top = opt.target.top; 60 | const ids = this._unGroup(opt.target) || []; 61 | 62 | const resetGroup = () => { 63 | const groupArr = this.canvas.getObjects().filter((item) => item.id && ids.includes(item.id)); 64 | // 删除元素 65 | groupArr.forEach((item) => this.canvas.remove(item)); 66 | // 生成新组 67 | const group = new fabric.Group([...groupArr]); 68 | group.set('left', left); 69 | group.set('top', top); 70 | group.set('id', uuid()); 71 | textObject.off('editing:exited', resetGroup); 72 | this.canvas.add(group); 73 | this.canvas.discardActiveObject().renderAll(); 74 | }; 75 | // 绑定取消事件 76 | textObject.on('editing:exited', resetGroup); 77 | } 78 | 79 | // 拆分组合并返回ID 80 | _unGroup(activeObj: fabric.Group) { 81 | const ids: string[] = []; 82 | if (!activeObj) return; 83 | activeObj.getObjects().forEach((item) => { 84 | const id = uuid(); 85 | ids.push(id); 86 | item.set('id', id); 87 | }); 88 | activeObj.toActiveSelection(); 89 | return ids; 90 | } 91 | 92 | isText(obj: fabric.Object) { 93 | return obj.type && ['i-text', 'text', 'textbox'].includes(obj.type); 94 | } 95 | } 96 | 97 | export default EditorGroup; 98 | -------------------------------------------------------------------------------- /src/components/fontTmpl.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 95 | 96 | 103 | -------------------------------------------------------------------------------- /src/components/setSize.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 46 | 47 | 92 | 93 | 104 | -------------------------------------------------------------------------------- /README-en.md: -------------------------------------------------------------------------------- 1 | English| [中文](https://github.com/nihaojob/vue-fabric-editor/blob/main/README.md) 2 | 3 | Documents are translated using software,You are welcome to submit for inspection. 4 | 5 | # vue-fabric-editor 6 | 7 | [Demo](https://nihaojob.github.io/vue-fabric-editor/) fabric.js and Vue based image editor, can customize fonts, materials, design templates. 8 | 9 |

10 | 11 | ## Existing function 12 | 13 | - Import JSON file 14 | - Save it as a PNG, SVG, or JSON file 15 | - Insert SVG, image files 16 | - Multi-element horizontal and vertical alignment 17 | - Combine/Split Combine 18 | - Layer and order adjustments 19 | - undo/redo 20 | - Background property setting 21 | - Appearance Properties/Font Properties/Stroke/Shadow 22 | - custom font 23 | - Custom template material 24 | - i18n 25 | - Auxiliary line 26 | 27 | ## Use 28 | 29 | ### Startup 30 | 31 | ``` 32 | yarn install 33 | yarn serve 34 | ``` 35 | 36 | ### Custom font 37 | 38 | The font-related files are in `src/assets/fonts`, put the font files in the directory, and update the newly added font name to the `font.css` and `font.js` files. 39 | 40 | ```js 41 | // font.js 42 | const cnList = [ 43 | { 44 | name: '汉体', 45 | fontFamily: '汉体', 46 | }, 47 | { 48 | name: '华康金刚黑', 49 | fontFamily: '华康金刚黑', 50 | }, 51 | ]; 52 | 53 | const enList = []; 54 | export default [...cnList, ...enList]; 55 | ``` 56 | 57 | ```css 58 | /* font.css */ 59 | @font-face { 60 | font-family: '汉体'; 61 | src: url('./cn/汉体.ttf'); 62 | } 63 | 64 | @font-face { 65 | font-family: '华康金刚黑'; 66 | src: url('./cn/华康金刚黑.ttf'); 67 | } 68 | ``` 69 | 70 | ### Custom template 71 | 72 | The entry of the custom template is in the `src/components/importTmpl.vue` component, and the template image and JSON file can be placed in the `public/template` file, and the data can be displayed in the component. 73 | 74 | ## Contribution Guidelines 75 | 76 | This is a design editor project that I am using. There are many similar paid editors on the market. As a developer, I still hope to find an editor that can be easily extended and customized. If you also have needs, welcome to join us maintain. 77 | 78 | Development introduction:[使用 fabric.js 快速开发一个图片编辑器](https://juejin.cn/post/7155040639497797645) 79 | 80 | ## plan 81 | 82 | ### Possible New Features 83 | 84 | - [ ] svgIcon summary 85 | - [ ] Heading Style List Template 86 | - [ ] gradient configuration 87 | - [x] copy paste shortcut 88 | - [ ] Drag mode, zoom in and zoom out 89 | - [ ] Canvas size saving 90 | - [ ] Replace pictures, load url pictures 91 | - [x] zoom 92 | - [x] triangles, arrows, lines 93 | - [ ] Tile background, Isometric background 94 | - [ ] preview 95 | - [ ] stroke strokeDashArray 96 | - [x] draw lines 97 | 98 | ## License 99 | 100 | Licensed under the MIT License. 101 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Description: 3 | * @version: 4 | * @Author: June 5 | * @Date: 2023-04-24 00:25:39 6 | * @LastEditors: June 7 | * @LastEditTime: 2023-05-22 23:44:10 8 | */ 9 | import { defineConfig, loadEnv } from 'vite'; 10 | import vue from '@vitejs/plugin-vue'; 11 | import { createHtmlPlugin } from 'vite-plugin-html'; 12 | import vueJsx from '@vitejs/plugin-vue-jsx'; 13 | const autoprefixer = require('autoprefixer'); 14 | const path = require('path'); 15 | import eslintPlugin from 'vite-plugin-eslint'; //导入包 16 | import vueSetupExtend from 'vite-plugin-vue-setup-extend-plus'; 17 | import autoImports from 'unplugin-auto-import/vite'; 18 | 19 | const config = ({ mode }) => { 20 | const isProd = mode === 'production'; 21 | const envPrefix = 'APP_'; 22 | const { APP_TITLE = '' } = loadEnv(mode, process.cwd(), envPrefix); 23 | return { 24 | base: isProd ? '/dm-editor/' : '/', 25 | plugins: [ 26 | vue(), 27 | autoImports({ 28 | imports: ['vue'], 29 | eslintrc: { 30 | enabled: true, 31 | }, 32 | }), 33 | vueSetupExtend(), 34 | // 增加下面的配置项,这样在运行时就能检查eslint规范 35 | eslintPlugin({ 36 | include: ['src/**/*.js', 'src/**/*.vue', 'src/*.js', 'src/*.vue'], 37 | }), 38 | vueJsx({ 39 | // options are passed on to @vue/babel-plugin-jsx 40 | }), 41 | createHtmlPlugin({ 42 | minify: isProd, 43 | inject: { 44 | data: { 45 | title: APP_TITLE, 46 | }, 47 | }, 48 | }), 49 | ], 50 | build: { 51 | target: 'es2015', 52 | outDir: path.resolve(__dirname, 'dist'), 53 | assetsDir: 'assets', 54 | assetsInlineLimit: 8192, 55 | // sourcemap: !isProd, 56 | emptyOutDir: true, 57 | rollupOptions: { 58 | input: path.resolve(__dirname, 'index.html'), 59 | output: { 60 | chunkFileNames: 'js/[name].[hash].js', 61 | entryFileNames: 'js/[name].[hash].js', 62 | }, 63 | }, 64 | }, 65 | envPrefix, 66 | resolve: { 67 | alias: [ 68 | { find: /^@\//, replacement: path.resolve(__dirname, 'src') + '/' }, 69 | { find: /^~/, replacement: '' }, 70 | ], 71 | extensions: ['.ts', '.tsx', '.js', '.mjs', '.vue', '.json', '.less', '.css'], 72 | }, 73 | css: { 74 | postcss: { 75 | plugins: [autoprefixer], 76 | }, 77 | preprocessorOptions: { 78 | less: { 79 | javascriptEnabled: true, 80 | additionalData: `@import "${path.resolve(__dirname, 'src/styles/variable.less')}";`, 81 | }, 82 | }, 83 | }, 84 | server: { 85 | port: 3000, 86 | open: true, 87 | proxy: { 88 | '/fontFile': { 89 | target: 'https://github.com/', 90 | changeOrigin: true, 91 | rewrite: (path) => path.replace(/^\/fontFile/, ''), 92 | }, 93 | }, 94 | }, 95 | preview: { 96 | port: 5000, 97 | }, 98 | }; 99 | }; 100 | 101 | export default defineConfig(config); 102 | -------------------------------------------------------------------------------- /src/components/bgBar.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 73 | 74 | 75 | 113 | -------------------------------------------------------------------------------- /src/core/initializeLineDrawing.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable radix */ 2 | /* eslint-disable no-mixed-operators */ 3 | /* 4 | * @Author: 秦少卫 5 | * @Date: 2023-01-06 23:40:09 6 | * @LastEditors: 秦少卫 7 | * @LastEditTime: 2023-04-18 09:17:58 8 | * @Description: 线条绘制 9 | */ 10 | 11 | import { v4 as uuid } from 'uuid'; 12 | import { fabric } from 'fabric'; 13 | import Arrow from '@/core/objects/Arrow'; 14 | 15 | function initializeLineDrawing(canvas, defaultPosition) { 16 | let isDrawingLine = false; 17 | let isDrawingLineMode = false; 18 | let isArrow = false; 19 | let lineToDraw; 20 | let pointer; 21 | let pointerPoints; 22 | 23 | canvas.on('mouse:down', (o) => { 24 | if (!isDrawingLineMode) return; 25 | canvas.discardActiveObject(); 26 | canvas.getObjects().forEach((obj) => { 27 | obj.selectable = false; 28 | obj.hasControls = false; 29 | }); 30 | canvas.requestRenderAll(); 31 | isDrawingLine = true; 32 | pointer = canvas.getPointer(o.e); 33 | pointerPoints = [pointer.x, pointer.y, pointer.x, pointer.y]; 34 | 35 | const NodeHandler = isArrow ? Arrow : fabric.Line; 36 | lineToDraw = new NodeHandler(pointerPoints, { 37 | strokeWidth: 2, 38 | stroke: '#000000', 39 | ...defaultPosition, 40 | id: uuid(), 41 | }); 42 | 43 | lineToDraw.selectable = false; 44 | lineToDraw.evented = false; 45 | lineToDraw.strokeUniform = true; 46 | canvas.add(lineToDraw); 47 | }); 48 | 49 | canvas.on('mouse:move', (o) => { 50 | if (!isDrawingLine) return; 51 | canvas.discardActiveObject(); 52 | const activeObject = canvas.getActiveObject(); 53 | if (activeObject) return; 54 | pointer = canvas.getPointer(o.e); 55 | 56 | if (o.e.shiftKey) { 57 | // calc angle 58 | const startX = pointerPoints[0]; 59 | const startY = pointerPoints[1]; 60 | const x2 = pointer.x - startX; 61 | const y2 = pointer.y - startY; 62 | const r = Math.sqrt(x2 * x2 + y2 * y2); 63 | let angle = (Math.atan2(y2, x2) / Math.PI) * 180; 64 | angle = parseInt(((angle + 7.5) % 360) / 15) * 15; 65 | 66 | const cosx = r * Math.cos((angle * Math.PI) / 180); 67 | const sinx = r * Math.sin((angle * Math.PI) / 180); 68 | 69 | lineToDraw.set({ 70 | x2: cosx + startX, 71 | y2: sinx + startY, 72 | }); 73 | } else { 74 | lineToDraw.set({ 75 | x2: pointer.x, 76 | y2: pointer.y, 77 | }); 78 | } 79 | 80 | canvas.renderAll(); 81 | }); 82 | 83 | canvas.on('mouse:up', () => { 84 | if (!isDrawingLine) return; 85 | lineToDraw.setCoords(); 86 | isDrawingLine = false; 87 | canvas.discardActiveObject(); 88 | }); 89 | 90 | function endRest() { 91 | canvas.getObjects().forEach((obj) => { 92 | if (obj.id !== 'workspace') { 93 | obj.selectable = true; 94 | obj.hasControls = true; 95 | } 96 | }); 97 | } 98 | 99 | return { 100 | setArrow(params) { 101 | isArrow = params; 102 | }, 103 | setMode(params) { 104 | isDrawingLineMode = params; 105 | if (!isDrawingLineMode) { 106 | endRest(); 107 | } 108 | }, 109 | }; 110 | } 111 | 112 | export default initializeLineDrawing; 113 | -------------------------------------------------------------------------------- /src/core/EditorGroupText.ts: -------------------------------------------------------------------------------- 1 | /* 2 | /* 3 | * @Author: 秦少卫 4 | * @Date: 2023-04-20 02:15:09 5 | * @LastEditors: 秦少卫 6 | * @LastEditTime: 2023-04-27 23:07:25 7 | * @Description: 编辑组内文字 8 | */ 9 | 10 | import { fabric } from 'fabric'; 11 | import { v4 as uuid } from 'uuid'; 12 | 13 | class EditorGroupText { 14 | canvas: fabric.Canvas; 15 | isDown: boolean; 16 | constructor(canvas: fabric.Canvas) { 17 | this.canvas = canvas; 18 | this._init(); 19 | this.isDown = false; 20 | } 21 | 22 | // 组内文本输入 23 | _init() { 24 | this.canvas.on('mouse:down', (opt) => { 25 | this.isDown = true; 26 | if (opt.target && opt.target.type === 'group') { 27 | const textObject = this._getGroupTextObj(opt) as fabric.IText; 28 | if (textObject) { 29 | this._bedingEditingEvent(textObject, opt); 30 | this.canvas.setActiveObject(textObject); 31 | textObject.enterEditing(); 32 | } else { 33 | this.canvas.setActiveObject(opt.target); 34 | } 35 | } 36 | }); 37 | 38 | this.canvas.on('mouse:up', () => { 39 | this.isDown = false; 40 | }); 41 | 42 | this.canvas.on('mouse:move', (opt) => { 43 | if (this.isDown && opt.target && opt.target.type === 'group') { 44 | const textObject = this._getGroupTextObj(opt); 45 | if (textObject) { 46 | // todo bug 文字编辑结束后,点击组内其他元素可单独拖动 47 | } 48 | } 49 | }); 50 | } 51 | 52 | // 获取点击区域内的组内文字元素 53 | _getGroupTextObj(opt: fabric.IEvent) { 54 | const pointer = this.canvas.getPointer(opt.e, true); 55 | const clickObj = this.canvas._searchPossibleTargets(opt.target?._objects, pointer); 56 | if (clickObj && this.isText(clickObj)) { 57 | return clickObj; 58 | } 59 | return false; 60 | } 61 | 62 | // 绑定编辑取消事件 63 | _bedingEditingEvent(textObject: fabric.IText, opt: fabric.IEvent) { 64 | if (!opt.target) return; 65 | const left = opt.target.left; 66 | const top = opt.target.top; 67 | const ids = this._unGroup() || []; 68 | 69 | const resetGroup = () => { 70 | const groupArr = this.canvas.getObjects().filter((item) => item.id && ids.includes(item.id)); 71 | // 删除元素 72 | groupArr.forEach((item) => this.canvas.remove(item)); 73 | 74 | // 生成新组 75 | const group = new fabric.Group([...groupArr]); 76 | group.set('left', left); 77 | group.set('top', top); 78 | group.set('id', uuid()); 79 | textObject.off('editing:exited', resetGroup); 80 | this.canvas.add(group); 81 | this.canvas.discardActiveObject().renderAll(); 82 | }; 83 | // 绑定取消事件 84 | textObject.on('editing:exited', resetGroup); 85 | } 86 | 87 | // 拆分组合并返回ID 88 | _unGroup() { 89 | const ids: string[] = []; 90 | const activeObj = this.canvas.getActiveObject() as fabric.Group; 91 | if (!activeObj) return; 92 | activeObj.toActiveSelection(); 93 | activeObj.getObjects().forEach((item) => { 94 | const id = uuid(); 95 | ids.push(id); 96 | item.set('id', id); 97 | }); 98 | return ids; 99 | } 100 | 101 | isText(obj: fabric.Object) { 102 | return obj.type && ['i-text', 'text', 'textbox'].includes(obj.type); 103 | } 104 | } 105 | 106 | export default EditorGroupText; 107 | -------------------------------------------------------------------------------- /src/components/history.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 30 | 31 | 114 | 115 | 130 | 131 | 136 | -------------------------------------------------------------------------------- /src/components/flip.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 68 | 69 | 84 | 85 | 102 | -------------------------------------------------------------------------------- /src/core/initControlsRotate.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2023-01-31 12:06:00 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2023-03-08 23:52:42 6 | * @Description: 旋转 7 | */ 8 | 9 | import { fabric } from 'fabric'; 10 | 11 | // 定义旋转光标样式,根据转动角度设定光标旋转 12 | function rotateIcon(angle: number) { 13 | return `url("data:image/svg+xml,%3Csvg height='18' width='18' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: black;'%3E%3Cg fill='none' transform='rotate(${angle} 16 16)'%3E%3Cpath d='M22.4484 0L32 9.57891L22.4484 19.1478V13.1032C17.6121 13.8563 13.7935 17.6618 13.0479 22.4914H19.2141L9.60201 32.01L0 22.4813H6.54912C7.36524 14.1073 14.0453 7.44023 22.4484 6.61688V0Z' fill='white'/%3E%3Cpath d='M24.0605 3.89587L29.7229 9.57896L24.0605 15.252V11.3562C17.0479 11.4365 11.3753 17.0895 11.3048 24.0879H15.3048L9.60201 29.7308L3.90932 24.0879H8.0806C8.14106 15.3223 15.2645 8.22345 24.0605 8.14313V3.89587Z' fill='black'/%3E%3C/g%3E%3C/svg%3E ") 12 12,crosshair`; 14 | } 15 | 16 | function initControlsRotate(canvas: fabric.Canvas) { 17 | // 添加旋转控制响应区域 18 | fabric.Object.prototype.controls.mtr = new fabric.Control({ 19 | x: -0.5, 20 | y: -0.5, 21 | offsetY: -10, 22 | offsetX: -10, 23 | rotate: 20, 24 | actionName: 'rotate', 25 | actionHandler: fabric.controlsUtils.rotationWithSnapping, 26 | render: () => '', 27 | }); 28 | // ↖左上 29 | fabric.Object.prototype.controls.mtr2 = new fabric.Control({ 30 | x: 0.5, 31 | y: -0.5, 32 | offsetY: -10, 33 | offsetX: 10, 34 | rotate: 20, 35 | actionName: 'rotate', 36 | actionHandler: fabric.controlsUtils.rotationWithSnapping, 37 | render: () => '', 38 | }); // ↗右上 39 | fabric.Object.prototype.controls.mtr3 = new fabric.Control({ 40 | x: 0.5, 41 | y: 0.5, 42 | offsetY: 10, 43 | offsetX: 10, 44 | rotate: 20, 45 | actionName: 'rotate', 46 | actionHandler: fabric.controlsUtils.rotationWithSnapping, 47 | render: () => '', 48 | }); // ↘右下 49 | fabric.Object.prototype.controls.mtr4 = new fabric.Control({ 50 | x: -0.5, 51 | y: 0.5, 52 | offsetY: 10, 53 | offsetX: -10, 54 | rotate: 20, 55 | actionName: 'rotate', 56 | actionHandler: fabric.controlsUtils.rotationWithSnapping, 57 | render: () => '', 58 | }); // ↙左下 59 | 60 | // 渲染时,执行 61 | canvas.on('after:render', () => { 62 | const activeObj = canvas.getActiveObject(); 63 | const angle = activeObj?.angle?.toFixed(2); 64 | if (angle !== undefined) { 65 | fabric.Object.prototype.controls.mtr.cursorStyle = rotateIcon(Number(angle)); 66 | fabric.Object.prototype.controls.mtr2.cursorStyle = rotateIcon(Number(angle) + 90); 67 | fabric.Object.prototype.controls.mtr3.cursorStyle = rotateIcon(Number(angle) + 180); 68 | fabric.Object.prototype.controls.mtr4.cursorStyle = rotateIcon(Number(angle) + 270); 69 | } 70 | }); 71 | 72 | // 旋转时,实时更新旋转控制图标 73 | canvas.on('object:rotating', (event) => { 74 | const body = canvas.lowerCanvasEl.nextSibling as HTMLElement; 75 | const angle = canvas.getActiveObject()?.angle?.toFixed(2); 76 | if (angle === undefined) return; 77 | switch (event.transform?.corner) { 78 | case 'mtr': 79 | body.style.cursor = rotateIcon(Number(angle)); 80 | break; 81 | case 'mtr2': 82 | body.style.cursor = rotateIcon(Number(angle) + 90); 83 | break; 84 | case 'mtr3': 85 | body.style.cursor = rotateIcon(Number(angle) + 180); 86 | break; 87 | case 'mtr4': 88 | body.style.cursor = rotateIcon(Number(angle) + 270); 89 | break; 90 | default: 91 | break; 92 | } // 设置四角旋转光标 93 | }); 94 | } 95 | 96 | export default initControlsRotate; 97 | -------------------------------------------------------------------------------- /src/language/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "templates": "Modelos", 3 | "elements": "Elementos", 4 | "background": "Fundo", 5 | "size": "Tamanho", 6 | "width": "Largura", 7 | "height": "Altura", 8 | "grid": "Grid", 9 | "common_elements": "Elementos comuns", 10 | "draw_elements": "Elementos arrastre", 11 | "quick_navigation": "Navegação rápida", 12 | "color_macthing": "Seleção de cor", 13 | "background_texture": "Textura de fundo", 14 | "material": "Material", 15 | "picture": "Imagem", 16 | "everything_is_fine": "Está tudo bem", 17 | "everything_goes_well": "Tudo vai bem", 18 | "cartoon": "Cartoon", 19 | "default_size": "Tamanho padrão", 20 | "preview":"Antevisão", 21 | "empty": "Vazio", 22 | "keep": "Salvar", 23 | "copy_to_clipboard": "Copiar para a área de transferência", 24 | "save_as_picture": "Salvar como imagem", 25 | "save_as_svg": "Salvar como SVG", 26 | "save_as_json": "Salvar como JSON", 27 | "layers": "Camadas", 28 | "title_template": "Sem título", 29 | "insert_svg": "Inserir SVG", 30 | "select_svg": "Selecionar arquivo SVG", 31 | "please_choose": "Por favor, escolha", 32 | "string": "String", 33 | "file": "Arquivo", 34 | "import_files": "Importar arquivos", 35 | "select_json": "Selecione o arquivo JSON", 36 | "insert": "Inserir", 37 | "insert_picture": "Inserir imagem", 38 | "select_image": "Selecionar arquivo de imagem", 39 | "insertFile": { 40 | "remarks": "插入文件", 41 | "insert": "insert", 42 | "insert_picture": "Insert picture", 43 | "insert_SVG": "Insert SVG", 44 | "insert_SVGStr": "Insert SVG String", 45 | "insert_SVGStr_placeholder": "Please enter SVG String", 46 | "modal_tittle": "Please enter" 47 | }, 48 | "upload_background": "Carregar plano de fundo", 49 | "mouseMenu": { 50 | "layer": "Gestão de camadas", 51 | "copy": "Copiar", 52 | "delete": "Eliminar", 53 | "group": "combinación", 54 | "unGroup": "divida", 55 | "up": "up", 56 | "down": "down", 57 | "upTop": "Traer al frente", 58 | "downTop": "Enviar a volver", 59 | "center": "centro" 60 | }, 61 | "alert": { 62 | "loading_fonts": "Carregando fontes, aguarde...", 63 | "loading_fonts_failed": "Falha ao carregar as fontes, tente novamente", 64 | "loading_data": "Carregando dados...", 65 | "select_image": "Por favor, selecione uma imagem de plano de fundo", 66 | "select_file": "Selecione um arquivo", 67 | "copied_sucessful": "复制成功", 68 | "fail_copy": "复制失败" 69 | }, 70 | "fruits": "Frutas de desenhos animados", 71 | "sports": "Esportes", 72 | "seasons": "Outono", 73 | "eletronics": "Computador", 74 | "clothes": "Roupas", 75 | "flags": "Bandeiras", 76 | "threes": "Árvores", 77 | "food": "Comida", 78 | "medals": "Medalhas", 79 | "business": "Negócios", 80 | "activity": "Atividade", 81 | "vintage": "vintage", 82 | "animals": "animais", 83 | "hand_painted": "pintado à mão", 84 | "scenary_x": "Cena {number}", 85 | "color": "Cor", 86 | "red_book_vertical": "Red Book - V", 87 | "red_book_horizontal": "Red Book - H", 88 | "phone_wallpaper": "Phone Wallpaper", 89 | "attributes": { 90 | "id": "ID", 91 | "font": "Fonte", 92 | "align": "Alinhamento", 93 | "bold": "Negrito:", 94 | "italic": "Italico:", 95 | "underline": "Underline:", 96 | "stroke": "Stroke:", 97 | "swipe_up": "Swipe UP:", 98 | "line_height": "Line height", 99 | "char_spacing": "Espaço Char.", 100 | "exterior": "Exterior", 101 | "angle": "Ângulo", 102 | "left": "Esq", 103 | "top": "Topo", 104 | "opacity": "Transparência", 105 | "shadow": "Sombra", 106 | "blur": "Blur", 107 | "offset_x": "X", 108 | "offset_y": "Y", 109 | "picture_filter": "Filtro" 110 | } 111 | } -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | export const RGB2Hex = (r: number, g: number, b: number) => { 2 | let _r = Math.round(r).toString(16); 3 | let _g = Math.round(g).toString(16); 4 | let _b = Math.round(b).toString(16); 5 | 6 | if (_r.length === 1) _r = '0' + _r; 7 | if (_g.length === 1) _g = '0' + _g; 8 | if (_b.length === 1) _b = '0' + _b; 9 | 10 | return '#' + _r + _g + _b; 11 | }; 12 | 13 | export const RGBA2HexA = (r: number, g: number, b: number, a = 1) => { 14 | const hex = RGB2Hex(r, g, b); 15 | 16 | let _a = Math.round((a as number) * 255).toString(16); 17 | if (_a.length === 1) _a = '0' + _a; 18 | 19 | return hex + _a; 20 | }; 21 | 22 | export const RGB2HSL = (r: number, g: number, b: number) => { 23 | r /= 255; 24 | g /= 255; 25 | b /= 255; 26 | 27 | const minVal = Math.min(r, g, b); 28 | const maxVal = Math.max(r, g, b); 29 | const delta = maxVal - minVal; 30 | 31 | let h = 0; 32 | let s = 0; 33 | const l = maxVal; 34 | if (delta === 0) { 35 | h = s = 0; 36 | } else { 37 | s = delta / maxVal; 38 | const dr = ((maxVal - r) / 6 + delta / 2) / delta; 39 | const dg = ((maxVal - g) / 6 + delta / 2) / delta; 40 | const db = ((maxVal - b) / 6 + delta / 2) / delta; 41 | 42 | if (r === maxVal) { 43 | h = db - dg; 44 | } else if (g === maxVal) { 45 | h = 1 / 3 + dr - db; 46 | } else if (b === maxVal) { 47 | h = 2 / 3 + dg - dr; 48 | } 49 | 50 | if (h < 0) { 51 | h += 1; 52 | } else if (h > 1) { 53 | h -= 1; 54 | } 55 | } 56 | 57 | return [h * 360, s * 100, l * 100]; 58 | }; 59 | 60 | export const RGBA2HSLA = (r: number, g: number, b: number, a = 1) => [ 61 | ...RGB2HSL(r, g, b), 62 | a, 63 | ]; 64 | 65 | export function HSL2RGB(h: number, s: number, l: number) { 66 | h = (h / 360) * 6; 67 | s /= 100; 68 | l /= 100; 69 | 70 | const i = Math.floor(h); 71 | 72 | const f = h - i; 73 | const p = l * (1 - s); 74 | const q = l * (1 - f * s); 75 | const t = l * (1 - (1 - f) * s); 76 | 77 | const mod = i % 6; 78 | const r = [l, q, p, p, t, l][mod]; 79 | const g = [t, l, l, q, p, p][mod]; 80 | const b = [p, p, t, l, l, q][mod]; 81 | 82 | return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 83 | } 84 | 85 | export const HSLA2RGBA = (h: number, s: number, l: number, a = 1) => [ 86 | ...HSL2RGB(h, s, l), 87 | a, 88 | ]; 89 | 90 | export const HSL2Hex = (h: number, s: number, l: number) => { 91 | const [r, g, b] = HSL2RGB(h, s, l); 92 | return RGB2Hex(r, g, b); 93 | }; 94 | 95 | export const HSLA2HexA = (h: number, s: number, l: number, a = 1) => { 96 | const hex = HSL2Hex(h, s, l); 97 | return `${hex}${a === 0 ? '00' : Math.round(a * 255).toString(16)}`; 98 | }; 99 | 100 | export const hex2RGB = (hex: string) => { 101 | hex = hex.slice(0, 7); 102 | 103 | let r = 0; 104 | let g = 0; 105 | let b = 0; 106 | 107 | if (hex.length == 4) { 108 | // 3 digits 109 | r = +('0x' + hex[1] + hex[1]); 110 | g = +('0x' + hex[2] + hex[2]); 111 | b = +('0x' + hex[3] + hex[3]); 112 | } else if (hex.length == 7) { 113 | // 6 digits 114 | r = +('0x' + hex[1] + hex[2]); 115 | g = +('0x' + hex[3] + hex[4]); 116 | b = +('0x' + hex[5] + hex[6]); 117 | } 118 | 119 | return [r, g, b]; 120 | }; 121 | 122 | export const hexA2RGBA = (hexA: string) => { 123 | const rgb = hex2RGB(hexA); 124 | const a = +('0x' + hexA[7] + hexA[8]); 125 | return [...rgb, +(a / 255).toFixed(2)]; 126 | }; 127 | 128 | export const hex2HSL = (hex: string) => { 129 | const [r, g, b] = hex2RGB(hex); 130 | return RGB2HSL(r, g, b); 131 | }; 132 | 133 | export const hexA2HSLA = (hexA: string) => { 134 | const [r, g, b, a] = hexA2RGBA(hexA); 135 | return RGBA2HSLA(r, g, b, a); 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/centerAlign.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 79 | 80 | 96 | 116 | -------------------------------------------------------------------------------- /src/components/importTmpl.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 120 | 121 | 128 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2022-09-05 22:21:55 4 | * @LastEditors: 秦少卫 5 | * @LastEditTime: 2023-04-06 22:44:49 6 | * @Description: 工具文件 7 | */ 8 | 9 | import FontFaceObserver from 'fontfaceobserver'; 10 | import { useClipboard, useFileDialog, useBase64 } from '@vueuse/core'; 11 | import { ElMessage } from 'element-plus'; 12 | 13 | interface Font { 14 | type: string; 15 | fontFamily: string; 16 | } 17 | 18 | /** 19 | * @description: 图片文件转字符串 20 | * @param {Blob|File} file 文件 21 | * @return {String} 22 | */ 23 | export function getImgStr(file: File | Blob): Promise { 24 | return useBase64(file).promise.value; 25 | } 26 | 27 | /** 28 | * @description: 根据json模板下载字体文件 29 | * @param {String} str 30 | * @return {Promise} 31 | */ 32 | export function downFontByJSON(str: string) { 33 | const skipFonts = ['arial', 'Microsoft YaHei']; 34 | const fontFamilies: string[] = JSON.parse(str) 35 | .objects.filter( 36 | (item: Font) => 37 | // 为text 并且不为包含字体 38 | // eslint-disable-next-line implicit-arrow-linebreak 39 | item.type.includes('text') && !skipFonts.includes(item.fontFamily) 40 | ) 41 | .map((item: Font) => item.fontFamily); 42 | const fontFamiliesAll = fontFamilies.map((fontName) => { 43 | const font = new FontFaceObserver(fontName); 44 | return font.load(null, 150000); 45 | }); 46 | return Promise.all(fontFamiliesAll); 47 | } 48 | 49 | /** 50 | * @description: 选择文件 51 | * @param {Object} options accept = '', capture = '', multiple = false 52 | * @return {Promise} 53 | */ 54 | export function selectFiles(options: { 55 | accept?: string; 56 | capture?: string; 57 | multiple?: boolean; 58 | }): Promise { 59 | return new Promise((resolve) => { 60 | const { onChange, open } = useFileDialog(options); 61 | onChange((files) => { 62 | resolve(files); 63 | }); 64 | open(); 65 | }); 66 | } 67 | 68 | /** 69 | * @description: 前端下载文件 70 | * @param {String} fileStr 71 | * @param fileName 72 | */ 73 | export function downFile(fileStr: string, fileName: string) { 74 | const anchorEl = document.createElement('a'); 75 | anchorEl.href = fileStr; 76 | anchorEl.download = fileName; 77 | document.body.appendChild(anchorEl); // required for firefox 78 | anchorEl.click(); 79 | anchorEl.remove(); 80 | } 81 | 82 | /** 83 | * @description: 创建图片元素 84 | * @param {String} str 图片地址或者base64图片 85 | * @return {Promise} element 图片元素 86 | */ 87 | export function insertImgFile(str: string) { 88 | return new Promise((resolve) => { 89 | const imgEl = document.createElement('img'); 90 | imgEl.src = str; 91 | // 插入页面 92 | document.body.appendChild(imgEl); 93 | imgEl.onload = () => { 94 | resolve(imgEl); 95 | }; 96 | }); 97 | } 98 | 99 | /** 100 | * Copying text to the clipboard 101 | * @param source Copy source 102 | * @param options Copy options 103 | * @returns Promise that resolves when the text is copied successfully, or rejects when the copy fails. 104 | */ 105 | export const clipboardText = async ( 106 | source: string, 107 | options?: Parameters[0] 108 | ) => { 109 | try { 110 | await useClipboard({ source, ...options }).copy(); 111 | ElMessage.success('复制成功'); 112 | } catch (error) { 113 | ElMessage.error('复制失败'); 114 | throw error; 115 | } 116 | }; 117 | 118 | /** 119 | * 获取文件后缀 120 | * @param file 文件 121 | */ 122 | export function getFileExt(file: File | Blob) { 123 | let fileExtension = ''; 124 | if (file.name.lastIndexOf('.') > -1) { 125 | fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1); 126 | } 127 | return fileExtension; 128 | } 129 | 130 | /** 131 | * 判断文件类型是否在列表内 132 | * @param file 文件 133 | * @param fileTypes 文件类型数组 134 | */ 135 | export function checkFileExt(file: File | Blob, fileTypes: any | []) { 136 | const ext = getFileExt(file); 137 | const isTypeOk = fileTypes.some((type: string) => { 138 | if (file.type.indexOf(type) > -1) return true; 139 | if (ext && ext.indexOf(type) > -1) return true; 140 | return false; 141 | }); 142 | return isTypeOk; 143 | } 144 | -------------------------------------------------------------------------------- /src/core/ruler/guideline.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { fabric } from 'fabric'; 4 | 5 | export function setupGuideLine() { 6 | if (fabric.GuideLine) { 7 | return; 8 | } 9 | 10 | fabric.GuideLine = fabric.util.createClass(fabric.Line, { 11 | type: 'GuideLine', 12 | selectable: false, 13 | hasControls: false, 14 | hasBorders: false, 15 | stroke: '#4bec13', 16 | originX: 'center', 17 | originY: 'center', 18 | padding: 4, // 填充,让辅助线选择范围更大,方便选中 19 | globalCompositeOperation: 'difference', 20 | axis: 'horizontal', 21 | // excludeFromExport: true, 22 | 23 | initialize(points, options) { 24 | const isHorizontal = options.axis === 'horizontal'; 25 | // 指针 26 | this.hoverCursor = isHorizontal ? 'ns-resize' : 'ew-resize'; 27 | // 设置新的点 28 | const newPoints = isHorizontal 29 | ? [-999999, points, 999999, points] 30 | : [points, -999999, points, 999999]; 31 | // 锁定移动 32 | options[isHorizontal ? 'lockMovementX' : 'lockMovementY'] = true; 33 | // 调用父类初始化 34 | this.callSuper('initialize', newPoints, options); 35 | 36 | // 绑定事件 37 | this.on('mousedown:before', (e) => { 38 | if (this.activeOn === 'down') { 39 | // 设置selectable:false后激活对象才能进行移动 40 | this.canvas.setActiveObject(this, e.e); 41 | } 42 | }); 43 | 44 | this.on('moving', (e) => { 45 | if (this.canvas.ruler.options.enabled && this.isPointOnRuler(e.e)) { 46 | this.moveCursor = 'not-allowed'; 47 | } else { 48 | this.moveCursor = this.isHorizontal() ? 'ns-resize' : 'ew-resize'; 49 | } 50 | this.canvas.fire('guideline:moving', { 51 | target: this, 52 | e: e.e, 53 | }); 54 | }); 55 | 56 | this.on('mouseup', (e) => { 57 | // 移动到标尺上,移除辅助线 58 | if (this.canvas.ruler.options.enabled && this.isPointOnRuler(e.e)) { 59 | // console.log('移除辅助线', this); 60 | this.canvas.remove(this); 61 | return; 62 | } 63 | this.moveCursor = this.isHorizontal() ? 'ns-resize' : 'ew-resize'; 64 | this.canvas.fire('guideline:mouseup', { 65 | target: this, 66 | e: e.e, 67 | }); 68 | }); 69 | 70 | this.on('removed', () => { 71 | this.off('removed'); 72 | this.off('mousedown:before'); 73 | this.off('moving'); 74 | this.off('mouseup'); 75 | }); 76 | }, 77 | 78 | getBoundingRect(absolute, calculate) { 79 | this.bringToFront(); 80 | 81 | const isHorizontal = this.isHorizontal(); 82 | const rect = this.callSuper('getBoundingRect', absolute, calculate); 83 | rect[isHorizontal ? 'top' : 'left'] += rect[isHorizontal ? 'height' : 'width'] / 2; 84 | rect[isHorizontal ? 'height' : 'width'] = 0; 85 | return rect; 86 | }, 87 | 88 | isPointOnRuler(e) { 89 | const isHorizontal = this.isHorizontal(); 90 | const hoveredRuler = this.canvas.ruler.isPointOnRuler(new fabric.Point(e.offsetX, e.offsetY)); 91 | if ( 92 | (isHorizontal && hoveredRuler === 'horizontal') || 93 | (!isHorizontal && hoveredRuler === 'vertical') 94 | ) { 95 | return hoveredRuler; 96 | } 97 | return false; 98 | }, 99 | 100 | isHorizontal() { 101 | return this.height === 0; 102 | }, 103 | } as fabric.IGuideLineClassOptions); 104 | 105 | fabric.GuideLine.fromObject = function (object, callback) { 106 | const clone = fabric.util.object.clone as (object: any, deep: boolean) => any; 107 | 108 | function _callback(instance: any) { 109 | delete instance.xy; 110 | callback && callback(instance); 111 | } 112 | 113 | const options = clone(object, true); 114 | const isHorizontal = options.height === 0; 115 | 116 | options.xy = isHorizontal ? options.y1 : options.x1; 117 | options.axis = isHorizontal ? 'horizontal' : 'vertical'; 118 | 119 | fabric.Object._fromObject(options.type, options, _callback, 'xy'); 120 | }; 121 | } 122 | 123 | export default fabric.GuideLine; 124 | -------------------------------------------------------------------------------- /src/language/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "templates": "模板", 3 | "elements": "元素", 4 | "background": "背景", 5 | "size": "尺寸", 6 | "width": "宽度", 7 | "height": "高度", 8 | "grid": "网格", 9 | "common_elements": "基础要素", 10 | "draw_elements": "绘制元素", 11 | "quick_navigation": "快捷导航", 12 | "color_macthing": "配色", 13 | "background_texture": "背景纹理", 14 | "material": "素材", 15 | "picture": "图片", 16 | "everything_is_fine": "万事大吉", 17 | "everything_goes_well": "诸事顺遂", 18 | "cartoon": "卡通", 19 | "default_size": "预设尺寸", 20 | "preview": "预览", 21 | "empty": "清空", 22 | "keep": "保存", 23 | "copy_to_clipboard": "复制到剪切板", 24 | "save_as_picture": "保存为图片", 25 | "save_as_svg": "保存为SVG", 26 | "save_as_json": "保存为JSON", 27 | "layers": "图层", 28 | "title_template": "标题模板", 29 | "insert_svg": "插入SVG元素", 30 | "select_svg": "选择SVG文件", 31 | "please_choose": "请选择", 32 | "string": "字符串", 33 | "file": "文件", 34 | "import_files": "导入文件", 35 | "select_json": "选择JSON文件", 36 | "repleaceImg": "替换图片", 37 | "filters": { 38 | "simple": "简单滤镜", 39 | "complex": "复杂滤镜", 40 | "Invert": "反向", 41 | "Sepia": "乌黑", 42 | "BlackWhite": "黑白", 43 | "Brownie": "巧克力", 44 | "Vintage": "复古", 45 | "Kodachrome": "胶片", 46 | "technicolor": "鲜艳", 47 | "Polaroid": "高光", 48 | "Grayscale": "灰度", 49 | "GrayscaleList": { 50 | "average": "一般", 51 | "lightness": "中度", 52 | "luminosity": "明亮" 53 | }, 54 | "RemoveColor": "去除颜色", 55 | "Brightness": "亮度", 56 | "Contrast": "对比度", 57 | "Saturation": "饱和度", 58 | "Vibrance": "振动", 59 | "HueRotation": "色调", 60 | "Noise": "噪音", 61 | "Pixelate": "像素化", 62 | "Blur": "模糊", 63 | "Gamma": "调整" 64 | }, 65 | "quick": { 66 | "del": "删除", 67 | "copy": "复制", 68 | "lock": "锁定" 69 | }, 70 | "flip": { 71 | "x": "水平翻转", 72 | "y": "垂直翻转" 73 | }, 74 | "center_align": { 75 | "centerX": "水平居中", 76 | "center": "水平垂直居中", 77 | "centerY": "垂直居中" 78 | }, 79 | "group_align": { 80 | "left": "左对齐", 81 | "centerX": "水平居中对齐", 82 | "right": "右对齐", 83 | "top": "上对齐", 84 | "bottom": "下对齐", 85 | "centerY": "垂直居中对齐", 86 | "averageX": "水平分布", 87 | "averageY": "垂直分布" 88 | }, 89 | "insertFile": { 90 | "insert": "插入", 91 | "insert_picture": "插入图片", 92 | "insert_SVG": "插入SVG元素", 93 | "insert_SVGStr": "插入SVG字符", 94 | "insert_SVGStr_placeholder": "请输入SVG字符串", 95 | "modal_tittle": "请输入" 96 | }, 97 | "history": { 98 | "revocation": "撤销", 99 | "redo": "重做" 100 | }, 101 | "upload_background": "上传背景", 102 | "mouseMenu": { 103 | "layer": "图层管理", 104 | "copy": "复制", 105 | "delete": "删除", 106 | "group": "组合", 107 | "unGroup": "取消组合", 108 | "up": "上移一层", 109 | "down": "下移一层", 110 | "upTop": "移到顶部", 111 | "downTop": "移到底部", 112 | "center": "水平垂直居中" 113 | }, 114 | "alert": { 115 | "loading_fonts": "正在加载字体,您耐心等候...", 116 | "loading_fonts_failed": "字体加载失败,请重试", 117 | "loading_data": "加载数据中...", 118 | "select_image": "请选择背景图片", 119 | "select_file": "请选择文件", 120 | "copied_sucessful": "复制成功", 121 | "fail_copy": "复制失败" 122 | }, 123 | "fruits": "卡通水果", 124 | "sports": "体育", 125 | "seasons": "秋天", 126 | "eletronics": "计算机", 127 | "clothes": "服饰", 128 | "flags": "旗子", 129 | "threes": "树木", 130 | "food": "食物", 131 | "medals": "奖牌", 132 | "business": "商务", 133 | "activity": "活动", 134 | "vintage": "复古", 135 | "animals": "动物", 136 | "hand_painted": "手绘", 137 | "scenary_x": "方案 {number}", 138 | "color": "颜色", 139 | "red_book_vertical": "红书竖版", 140 | "red_book_horizontal": "红书横版", 141 | "phone_wallpaper": "手机壁纸", 142 | "attributes": { 143 | "id": "标识", 144 | "font": "字体", 145 | "align": "对齐", 146 | "bold": "加粗:", 147 | "italic": "斜体:", 148 | "underline": "下划:", 149 | "stroke": "边框", 150 | "swipe_up": "上划:", 151 | "line_height": "行高", 152 | "char_spacing": "间距", 153 | "exterior": "外观", 154 | "angle": "旋转", 155 | "left": "X轴", 156 | "top": "Y轴", 157 | "opacity": "透明", 158 | "shadow": "阴影", 159 | "blur": "模糊", 160 | "offset_x": "X轴", 161 | "offset_y": "Y轴" 162 | }, 163 | "tip": "提示", 164 | "clearTip": "确定要清空吗?", 165 | "ok": "确认", 166 | "cancel": "取消" 167 | } -------------------------------------------------------------------------------- /src/components/group.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 52 | 53 | 72 | 90 | -------------------------------------------------------------------------------- /src/components/zoom.vue: -------------------------------------------------------------------------------- 1 | 8 | 62 | 63 | 99 | 106 | -------------------------------------------------------------------------------- /src/components/save.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 35 | 36 | 138 | 139 | 145 | -------------------------------------------------------------------------------- /src/views/home/index.module.less: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 50%; 3 | margin: 20px; 4 | } 5 | 6 | .logo { 7 | width: 30px; 8 | height: 30px; 9 | display: inline-block; 10 | margin-right: 10px; 11 | text-align: center; 12 | vertical-align: middle; 13 | .ivu-icon { 14 | vertical-align: super; 15 | } 16 | } 17 | 18 | // 属性面板样式 19 | :deep(.attr-item) { 20 | position: relative; 21 | margin-bottom: 12px; 22 | height: 40px; 23 | padding: 0 10px; 24 | background: #f6f7f9; 25 | border: none; 26 | border-radius: 4px; 27 | display: flex; 28 | align-items: center; 29 | .ivu-tooltip { 30 | text-align: center; 31 | flex: 1; 32 | } 33 | } 34 | 35 | .ivu-menu-vertical .menu-item { 36 | text-align: center; 37 | padding: 10px 2px; 38 | box-sizing: border-box; 39 | font-size: 12px; 40 | 41 | & > i { 42 | margin: 0; 43 | } 44 | } 45 | 46 | :deep(.ivu-layout-header) { 47 | --height: 45px; 48 | padding: 0 10px; 49 | border-bottom: 1px solid #eef2f8; 50 | background: #fff; 51 | height: var(--height); 52 | line-height: var(--height); 53 | } 54 | 55 | .home, 56 | .ivu-layout { 57 | height: 100vh; 58 | } 59 | 60 | .icon { 61 | display: block; 62 | } 63 | 64 | .canvas-box { 65 | position: relative; 66 | } 67 | 68 | .inside-shadow { 69 | position: absolute; 70 | width: 100%; 71 | height: 100%; 72 | box-shadow: inset 15px 5px blue; 73 | box-shadow: inset 0 0 9px 2px #0000001f; 74 | z-index: 2; 75 | pointer-events: none; 76 | } 77 | 78 | #canvas { 79 | width: 300px; 80 | height: 300px; 81 | margin: 0 auto; 82 | // background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAHUlEQVQ4jWNgYGAQIYAJglEDhoUBg9+FowbQ2gAARjwKARjtnN8AAAAASUVORK5CYII="); 83 | // background-size: 30px 30px; 84 | } 85 | 86 | #workspace { 87 | overflow: hidden; 88 | } 89 | 90 | .content { 91 | flex: 1; 92 | width: 220px; 93 | padding: 10px; 94 | padding-top: 0; 95 | height: 100%; 96 | overflow-y: auto; 97 | } 98 | 99 | .ivu-menu-light.ivu-menu-vertical .ivu-menu-item-active:not(.ivu-menu-submenu) { 100 | background: none; 101 | } 102 | // 标尺与网格背景 103 | .switch { 104 | margin-right: 10px; 105 | } 106 | .design-stage-point { 107 | --offsetX: 0px; 108 | --offsetY: 0px; 109 | --size: 20px; 110 | background-size: var(--size) var(--size); 111 | background-image: radial-gradient(circle, #2f3542 1px, rgba(0, 0, 0, 0) 1px); 112 | background-position: var(--offsetX) var(--offsetY); 113 | } 114 | 115 | .design-stage-grid { 116 | // dom.style.setProperty('--offsetX', `${point.x + e.clientX}px`) 通过修改 偏移量 可实现跟随鼠标效果 --size 则为间距 117 | // dom.style.setProperty('--offsetY', `${point.y + e.clientY}px`) 118 | --offsetX: 0px; 119 | --offsetY: 0px; 120 | --size: 16px; 121 | --color: #dedcdc; 122 | background-image: linear-gradient( 123 | 45deg, 124 | var(--color) 25%, 125 | transparent 0, 126 | transparent 75%, 127 | var(--color) 0 128 | ), 129 | linear-gradient(45deg, var(--color) 25%, transparent 0, transparent 75%, var(--color) 0); 130 | background-position: var(--offsetX) var(--offsetY), 131 | calc(var(--size) + var(--offsetX)) calc(var(--size) + var(--offsetY)); 132 | background-size: calc(var(--size) * 2) calc(var(--size) * 2); 133 | } 134 | 135 | .coordinates-bar { 136 | --ruler-size: 16px; 137 | --ruler-c: #808080; 138 | --rule4-bg-c: #252525; 139 | --ruler-bdw: 1px; 140 | --ruler-h: 8px; 141 | --ruler-space: 5px; 142 | --ruler-tall-h: 16px; 143 | --ruler-tall-space: 15px; 144 | position: absolute; 145 | z-index: 2; 146 | background-color: var(--rule4-bg-c); 147 | } 148 | .coordinates-bar-top { 149 | cursor: row-resize; 150 | top: 0; 151 | left: 0; 152 | height: var(--ruler-size); 153 | width: 100%; 154 | background-image: linear-gradient(90deg, var(--ruler-c) 0 var(--ruler-bdw), transparent 0), 155 | linear-gradient(90deg, var(--ruler-c) 0 var(--ruler-bdw), transparent 0); 156 | background-repeat: repeat-x; 157 | background-size: var(--ruler-space) var(--ruler-h), var(--ruler-tall-space) var(--ruler-tall-h); 158 | background-position: bottom; 159 | } 160 | .coordinates-bar-left { 161 | cursor: col-resize; 162 | top: var(--ruler-size); 163 | width: var(--ruler-size); 164 | height: 100%; 165 | left: 0; 166 | background-image: linear-gradient(0deg, var(--ruler-c) 0 var(--ruler-bdw), transparent 0), 167 | linear-gradient(0deg, var(--ruler-c) 0 var(--ruler-bdw), transparent 0); 168 | background-repeat: repeat-y; 169 | background-size: var(--ruler-h) var(--ruler-space), var(--ruler-tall-h) var(--ruler-tall-space); 170 | background-position: right; 171 | } 172 | -------------------------------------------------------------------------------- /src/core/ruler/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Rect } from './ruler'; 2 | import { fabric } from 'fabric'; 3 | 4 | /** 5 | * 计算尺子间距 6 | * @param zoom 缩放比例 7 | * @returns 返回计算出的尺子间距 8 | */ 9 | const getGap = (zoom: number) => { 10 | const zooms = [0.02, 0.03, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 18]; 11 | const gaps = [5000, 2500, 1000, 500, 250, 100, 50, 25, 10, 5, 2]; 12 | 13 | let i = 0; 14 | while (i < zooms.length && zooms[i] < zoom) { 15 | i++; 16 | } 17 | 18 | return gaps[i - 1] || 5000; 19 | }; 20 | 21 | /** 22 | * 线段合并 23 | * @param rect Rect数组 24 | * @param isHorizontal 25 | * @returns 合并后的Rect数组 26 | */ 27 | const mergeLines = (rect: Rect[], isHorizontal: boolean) => { 28 | const axis = isHorizontal ? 'left' : 'top'; 29 | const length = isHorizontal ? 'width' : 'height'; 30 | // 先按照 axis 的大小排序 31 | rect.sort((a, b) => a[axis] - b[axis]); 32 | const mergedLines = []; 33 | let currentLine = Object.assign({}, rect[0]); 34 | for (const item of rect) { 35 | const line = Object.assign({}, item); 36 | if (currentLine[axis] + currentLine[length] >= line[axis]) { 37 | // 当前线段和下一个线段相交,合并宽度 38 | currentLine[length] = 39 | Math.max(currentLine[axis] + currentLine[length], line[axis] + line[length]) - 40 | currentLine[axis]; 41 | } else { 42 | // 当前线段和下一个线段不相交,将当前线段加入结果数组中,并更新当前线段为下一个线段 43 | mergedLines.push(currentLine); 44 | currentLine = Object.assign({}, line); 45 | } 46 | } 47 | // 加入数组 48 | mergedLines.push(currentLine); 49 | return mergedLines; 50 | }; 51 | 52 | const darwLine = ( 53 | ctx: CanvasRenderingContext2D, 54 | options: { 55 | left: number; 56 | top: number; 57 | width: number; 58 | height: number; 59 | stroke?: string | CanvasGradient | CanvasPattern; 60 | lineWidth?: number; 61 | } 62 | ) => { 63 | ctx.save(); 64 | const { left, top, width, height, stroke, lineWidth } = options; 65 | ctx.beginPath(); 66 | stroke && (ctx.strokeStyle = stroke); 67 | ctx.lineWidth = lineWidth ?? 1; 68 | ctx.moveTo(left, top); 69 | ctx.lineTo(left + width, top + height); 70 | ctx.stroke(); 71 | ctx.restore(); 72 | }; 73 | 74 | const darwText = ( 75 | ctx: CanvasRenderingContext2D, 76 | options: { 77 | left: number; 78 | top: number; 79 | text: string; 80 | fill?: string | CanvasGradient | CanvasPattern; 81 | align?: CanvasTextAlign; 82 | angle?: number; 83 | fontSize?: number; 84 | } 85 | ) => { 86 | ctx.save(); 87 | const { left, top, text, fill, align, angle, fontSize } = options; 88 | fill && (ctx.fillStyle = fill); 89 | ctx.textAlign = align ?? 'left'; 90 | ctx.textBaseline = 'top'; 91 | ctx.font = `${fontSize ?? 10}px sans-serif`; 92 | if (angle) { 93 | ctx.translate(left, top); 94 | ctx.rotate((Math.PI / 180) * angle); 95 | ctx.translate(-left, -top); 96 | } 97 | ctx.fillText(text, left, top); 98 | ctx.restore(); 99 | }; 100 | 101 | const darwRect = ( 102 | ctx: CanvasRenderingContext2D, 103 | options: { 104 | left: number; 105 | top: number; 106 | width: number; 107 | height: number; 108 | fill?: string | CanvasGradient | CanvasPattern; 109 | stroke?: string; 110 | strokeWidth?: number; 111 | } 112 | ) => { 113 | ctx.save(); 114 | const { left, top, width, height, fill, stroke, strokeWidth } = options; 115 | ctx.beginPath(); 116 | fill && (ctx.fillStyle = fill); 117 | ctx.rect(left, top, width, height); 118 | ctx.fill(); 119 | if (stroke) { 120 | ctx.strokeStyle = stroke; 121 | ctx.lineWidth = strokeWidth ?? 1; 122 | ctx.stroke(); 123 | } 124 | ctx.restore(); 125 | }; 126 | 127 | const drawMask = ( 128 | ctx: CanvasRenderingContext2D, 129 | options: { 130 | isHorizontal: boolean; 131 | left: number; 132 | top: number; 133 | width: number; 134 | height: number; 135 | backgroundColor: string; 136 | } 137 | ) => { 138 | ctx.save(); 139 | const { isHorizontal, left, top, width, height, backgroundColor } = options; 140 | // 创建一个线性渐变对象 141 | const gradient = isHorizontal 142 | ? ctx.createLinearGradient(left, height / 2, left + width, height / 2) 143 | : ctx.createLinearGradient(width / 2, top, width / 2, height + top); 144 | const transparentColor = new fabric.Color(backgroundColor); 145 | transparentColor.setAlpha(0); 146 | gradient.addColorStop(0, transparentColor.toRgba()); 147 | gradient.addColorStop(0.33, backgroundColor); 148 | gradient.addColorStop(0.67, backgroundColor); 149 | gradient.addColorStop(1, transparentColor.toRgba()); 150 | darwRect(ctx, { 151 | left, 152 | top, 153 | width, 154 | height, 155 | fill: gradient, 156 | }); 157 | ctx.restore(); 158 | }; 159 | 160 | export { getGap, mergeLines, darwRect, darwText, darwLine, drawMask }; 161 | -------------------------------------------------------------------------------- /src/language/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "templates": "Templates", 3 | "elements": "Elements", 4 | "background": "Background", 5 | "size": "Size", 6 | "width": "Width", 7 | "height": "Height", 8 | "grid": "Grid", 9 | "common_elements": "Common Elements", 10 | "draw_elements": "Draw Elements", 11 | "quick_navigation": "Quick Navigation", 12 | "color_macthing": "Macthing", 13 | "background_texture": "Background Texture", 14 | "material": "Material", 15 | "picture": "picture", 16 | "everything_is_fine": "Everything is fine", 17 | "everything_goes_well": "Everything goes well", 18 | "cartoon": "Cartoon", 19 | "default_size": "Default Size", 20 | "preview": "Preview", 21 | "empty": "Empty", 22 | "keep": "Save", 23 | "copy_to_clipboard": "Copy to clipboard", 24 | "save_as_picture": "Save as picture", 25 | "save_as_svg": "Save as SVG", 26 | "save_as_json": "Save as JSON", 27 | "layers": "Layers", 28 | "title_template": "Title Template", 29 | "insert_svg": "Insert SVG", 30 | "select_svg": "Select SVG", 31 | "please_choose": "Please choose", 32 | "string": "string", 33 | "file": "file", 34 | "import_files": "Import files", 35 | "select_json": "Select JSON", 36 | "repleaceImg": "repleace Image", 37 | "filters": { 38 | "simple": "Simple Filter", 39 | "complex": "Complex Filter", 40 | "Invert": "Invert", 41 | "Sepia": "Sepia", 42 | "BlackWhite": "BlackWhite", 43 | "Brownie": "Brownie", 44 | "Vintage": "Vintage", 45 | "Kodachrome": "Kodachrome", 46 | "technicolor": "technicolor", 47 | "Polaroid": "Polaroid", 48 | "Grayscale": "Grayscale", 49 | "GrayscaleList": { 50 | "average": "average", 51 | "lightness": "lightness", 52 | "luminosity": "luminosity" 53 | }, 54 | "RemoveColor": "RemoveColor", 55 | "Brightness": "Brightness", 56 | "Contrast": "Contrast", 57 | "Saturation": "Saturation", 58 | "Vibrance": "Vibrance", 59 | "HueRotation": "HueRotation", 60 | "Noise": "Noise", 61 | "Pixelate": "Pixelate", 62 | "Blur": "Blur", 63 | "Gamma": "Gamma" 64 | }, 65 | "flip": { 66 | "x": "flip x", 67 | "y": "flip y" 68 | }, 69 | "center_align": { 70 | "centerX": "centerX", 71 | "center": "center", 72 | "centerY": "centerY" 73 | }, 74 | "group_align": { 75 | "left": "left", 76 | "centerX": "centerX", 77 | "right": "right", 78 | "top": "top", 79 | "bottom": "bottom", 80 | "centerY": "centerY", 81 | "averageX": "averageX", 82 | "averageY": "averageY" 83 | }, 84 | "insert": "Insert", 85 | "insertFile": { 86 | "insert": "insert", 87 | "insert_picture": "Insert picture", 88 | "insert_SVG": "Insert SVG", 89 | "insert_SVGStr": "Insert SVG String", 90 | "insert_SVGStr_placeholder": "Please enter SVG String", 91 | "modal_tittle": "Please enter" 92 | }, 93 | "history": { 94 | "revocation": "revocation", 95 | "redo": "redo" 96 | }, 97 | "select_image": "Select image", 98 | "upload_background": "Upload background", 99 | "mouseMenu": { 100 | "layer": "LayerManage", 101 | "copy": "Copy", 102 | "delete": "Delete", 103 | "group": "group", 104 | "unGroup": "Split", 105 | "up": "up", 106 | "down": "down", 107 | "upTop": "BringToFront", 108 | "downTop": "SendToBack", 109 | "center": "Center" 110 | }, 111 | "alert": { 112 | "loading_fonts": "Loading fonts, please wait...", 113 | "loading_fonts_failed": "Fonts failed to load, please try again", 114 | "loading_data": "Loading data...", 115 | "select_image": "Please select a background image", 116 | "select_file": "Select a file", 117 | "copied_sucessful": "复制成功", 118 | "fail_copy": "复制失败" 119 | }, 120 | "fruits": "Fruits", 121 | "sports": "Sports", 122 | "seasons": "Autumn", 123 | "eletronics": "Computer", 124 | "clothes": "Clothes", 125 | "flags": "Flags", 126 | "threes": "trees", 127 | "food": "food", 128 | "medals": "Medals", 129 | "business": "Business", 130 | "activity": "Activity", 131 | "vintage": "vintage", 132 | "animals": "animals", 133 | "hand_painted": "hand painted", 134 | "scenary_x": "Scenary {number}", 135 | "color": "Color", 136 | "red_book_vertical": "Red Book - V", 137 | "red_book_horizontal": "Red Book - H", 138 | "phone_wallpaper": "Phone Wallpaper", 139 | "attributes": { 140 | "id": "ID", 141 | "font": "Font", 142 | "align": "Align", 143 | "bold": "Bold:", 144 | "italic": "Italic:", 145 | "underline": "Underline:", 146 | "stroke": "Stroke:", 147 | "swipe_up": "Swipe UP:", 148 | "line_height": "Line height", 149 | "char_spacing": "Spacing", 150 | "exterior": "Exterior", 151 | "angle": "Angle", 152 | "left": "Left", 153 | "top": "Top", 154 | "opacity": "Opacity", 155 | "shadow": "Shadow", 156 | "blur": "Blur", 157 | "offset_x": "X", 158 | "offset_y": "Y" 159 | }, 160 | "tip": "tip", 161 | "clearTip": "Are you sure you want to clear it?", 162 | "ok": "ok", 163 | "cancel": "cancel" 164 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | nihaojob@163.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2023-02-03 23:29:34 4 | * @LastEditors: bamzc 5 | * @LastEditTime: 2023-06-02 16:55:29 6 | * @Description: 核心入口文件 7 | */ 8 | import EventEmitter from 'events'; 9 | // import { fabric } from 'fabric'; 10 | import { v4 as uuid } from 'uuid'; 11 | 12 | // 对齐辅助线 13 | import initAligningGuidelines from '@/core/initAligningGuidelines'; 14 | import initControlsRotate from '@/core/initControlsRotate'; 15 | import InitCenterAlign from '@/core/initCenterAlign'; 16 | import initHotkeys from '@/core/initHotKeys'; 17 | import initControls from '@/core/initControls'; 18 | import initRuler from '@/core/ruler'; 19 | import EditorGroupText from '@/core/EditorGroupText'; 20 | import EditorGroup from '@/core/EditorGroup'; 21 | import type CanvasRuler from '@/core/ruler/ruler'; 22 | import type EditorWorkspace from '@/core/EditorWorkspace'; 23 | 24 | import type { fabric } from 'fabric'; 25 | 26 | class Editor extends EventEmitter { 27 | canvas: fabric.Canvas; 28 | editorWorkspace: EditorWorkspace | null; 29 | centerAlign: InitCenterAlign; 30 | ruler: CanvasRuler; 31 | constructor(canvas: fabric.Canvas) { 32 | super(); 33 | 34 | this.canvas = canvas; 35 | this.editorWorkspace = null; 36 | 37 | initAligningGuidelines(canvas); 38 | initHotkeys(this, canvas); 39 | initControls(canvas); 40 | initControlsRotate(canvas); 41 | // new EditorGroupText(canvas); 42 | new EditorGroup(canvas); 43 | this.centerAlign = new InitCenterAlign(canvas); 44 | this.ruler = initRuler(canvas); 45 | } 46 | 47 | pasteObj(cloned: fabric.Object) { 48 | const canvas = this.canvas; 49 | // 间距设置 50 | const grid = 10; 51 | // 再次进行克隆,处理选择多个对象的情况 52 | cloned.clone((clonedObj: fabric.Object) => { 53 | canvas.discardActiveObject(); 54 | if (clonedObj.left === undefined || clonedObj.top === undefined) return; 55 | // 设置位置信息 56 | clonedObj.set({ 57 | left: clonedObj.left + grid, 58 | top: clonedObj.top + grid, 59 | evented: true, 60 | id: uuid(), 61 | }); 62 | if (clonedObj.type === 'activeSelection') { 63 | // 将克隆的画布重新赋值 64 | clonedObj.canvas = canvas; 65 | // eslint-disable-next-line 66 | clonedObj.forEachObject((obj: fabric.Object) => { 67 | obj.id = uuid(); 68 | canvas.add(obj); 69 | }); 70 | // 解决不可选择问题 71 | clonedObj.setCoords(); 72 | } else { 73 | canvas.add(clonedObj); 74 | } 75 | if (cloned.left === undefined || cloned.top === undefined) return; 76 | cloned.top += grid; 77 | cloned.left += grid; 78 | cloned.id = uuid(); 79 | canvas.setActiveObject(clonedObj); 80 | canvas.requestRenderAll(); 81 | }); 82 | } 83 | 84 | copyObj(copy: (c: fabric.Object) => void) { 85 | const activeObject = this.canvas.getActiveObject(); 86 | activeObject?.clone((cloned: fabric.Object) => { 87 | copy(cloned); 88 | }); 89 | } 90 | 91 | clone() { 92 | this.copyObj((cloned: fabric.Object) => { 93 | this.pasteObj(cloned); 94 | }); 95 | } 96 | 97 | // 拆分组 98 | unGroup() { 99 | const activeObject = this.canvas.getActiveObject() as fabric.Group; 100 | if (!activeObject) return; 101 | // 先获取当前选中的对象,然后打散 102 | activeObject.toActiveSelection(); 103 | activeObject.getObjects().forEach((item: fabric.Object) => { 104 | item.set('id', uuid()); 105 | }); 106 | this.canvas.discardActiveObject().renderAll(); 107 | } 108 | 109 | group() { 110 | // 组合元素 111 | const activeObj = this.canvas.getActiveObject() as fabric.ActiveSelection; 112 | if (!activeObj) return; 113 | const activegroup = activeObj.toGroup(); 114 | const objectsInGroup = activegroup.getObjects(); 115 | activegroup.clone((newgroup: fabric.Group) => { 116 | newgroup.set('id', uuid()); 117 | this.canvas.remove(activegroup); 118 | objectsInGroup.forEach((object) => { 119 | this.canvas.remove(object); 120 | }); 121 | this.canvas.add(newgroup); 122 | this.canvas.setActiveObject(newgroup); 123 | }); 124 | } 125 | 126 | up() { 127 | const actives = this.canvas.getActiveObjects(); 128 | if (actives && actives.length === 1) { 129 | const activeObject = this.canvas.getActiveObjects()[0]; 130 | activeObject && activeObject.bringForward(); 131 | this.canvas.renderAll(); 132 | this._workspaceSendToBack(); 133 | } 134 | } 135 | 136 | upTop() { 137 | const actives = this.canvas.getActiveObjects(); 138 | if (actives && actives.length === 1) { 139 | const activeObject = this.canvas.getActiveObjects()[0]; 140 | activeObject && activeObject.bringToFront(); 141 | this.canvas.renderAll(); 142 | this._workspaceSendToBack(); 143 | } 144 | } 145 | 146 | down() { 147 | const actives = this.canvas.getActiveObjects(); 148 | if (actives && actives.length === 1) { 149 | const activeObject = this.canvas.getActiveObjects()[0]; 150 | activeObject && activeObject.sendBackwards(); 151 | this.canvas.renderAll(); 152 | this._workspaceSendToBack(); 153 | } 154 | } 155 | 156 | downTop() { 157 | const actives = this.canvas.getActiveObjects(); 158 | if (actives && actives.length === 1) { 159 | const activeObject = this.canvas.getActiveObjects()[0]; 160 | activeObject && activeObject.sendToBack(); 161 | this.canvas.renderAll(); 162 | this._workspaceSendToBack(); 163 | } 164 | } 165 | 166 | getWorkspace() { 167 | return this.canvas.getObjects().find((item) => item.id === 'workspace'); 168 | } 169 | 170 | _workspaceSendToBack() { 171 | const workspace = this.getWorkspace(); 172 | workspace && workspace.sendToBack(); 173 | } 174 | 175 | getJson() { 176 | return this.canvas.toJSON(['id', 'gradientAngle', 'selectable', 'hasControls']); 177 | } 178 | 179 | /** 180 | * @description: 拖拽添加到画布 181 | * @param {Event} event 182 | * @param {Object} item 183 | */ 184 | dragAddItem(event: DragEvent, item: fabric.Object) { 185 | const { left, top } = this.canvas.getSelectionElement().getBoundingClientRect(); 186 | if (event.x < left || event.y < top || item.width === undefined) return; 187 | 188 | const point = { 189 | x: event.x - left, 190 | y: event.y - top, 191 | }; 192 | const pointerVpt = this.canvas.restorePointerVpt(point); 193 | item.left = pointerVpt.x - item.width / 2; 194 | item.top = pointerVpt.y; 195 | this.canvas.add(item); 196 | this.canvas.requestRenderAll(); 197 | } 198 | } 199 | 200 | export default Editor; 201 | -------------------------------------------------------------------------------- /src/components/contextMenu/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 229 | 230 | 272 | -------------------------------------------------------------------------------- /src/components/importFile.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 57 | 58 | 225 | 226 | 231 | -------------------------------------------------------------------------------- /src/components/color.vue: -------------------------------------------------------------------------------- 1 | 8 | 41 | 42 | 235 | 236 | 269 | -------------------------------------------------------------------------------- /src/utils/psd.ts: -------------------------------------------------------------------------------- 1 | // import { CLOUD_TYPE, WRITING_MODE } from '@/constants'; 2 | import * as colorer from './color'; 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore 5 | import PSD from 'psd.js'; 6 | const workspace = { 7 | type: 'rect', 8 | version: '5.3.0', 9 | originX: 'left', 10 | originY: 'top', 11 | left: -22, 12 | top: -233.5, 13 | width: 900, 14 | height: 1200, 15 | fill: '#ffffff', 16 | stroke: null, 17 | strokeWidth: 1, 18 | strokeDashArray: null, 19 | strokeLineCap: 'butt', 20 | strokeDashOffset: 0, 21 | strokeLineJoin: 'miter', 22 | strokeUniform: false, 23 | strokeMiterLimit: 4, 24 | scaleX: 1, 25 | scaleY: 1, 26 | angle: 0, 27 | flipY: false, 28 | flipX: false, 29 | opacity: 1, 30 | shadow: null, 31 | visible: true, 32 | backgroundColor: '', 33 | fillRule: 'nonzero', 34 | paintFirst: 'fill', 35 | globalCompositeOperation: 'source-over', 36 | skewX: 0, 37 | skewY: 0, 38 | rx: 0, 39 | ry: 0, 40 | id: 'workspace', 41 | }; 42 | export async function parsePSDFromURL(url: string) { 43 | return await PSD.fromURL(url); 44 | } 45 | 46 | function toRGBAColor(data: number[]) { 47 | const [r, g, b] = data; 48 | let [, , , a] = data; 49 | if (a > 1) { 50 | a = a / 255; 51 | } 52 | return [r, g, b, a] as const; 53 | } 54 | 55 | function toRGBAColor1(data: number[]) { 56 | const [a, b, g, r] = data; 57 | return [r * 255, g * 255, b * 255, a] as const; 58 | } 59 | 60 | const STROKE_TYPE = { 61 | InsF: 'inner', 62 | CtrF: 'center', 63 | OutF: 'outer', 64 | }; 65 | 66 | function calcTransform({ xx, xy }: { xx: number; xy: number }): { 67 | scale: number; 68 | angle: number; 69 | } { 70 | /** 71 | * xx yx tx 72 | * xy yy ty 73 | * 0 0 1 74 | */ 75 | const scale = Math.sqrt(xx * xx + xy * xy); 76 | const angle = (Math.asin(xy / scale) * 180) / Math.PI; 77 | return { scale, angle }; 78 | } 79 | 80 | function toTransformMatrix({ 81 | xx, 82 | xy, 83 | yx, 84 | yy, 85 | tx, 86 | ty 87 | }: { 88 | xx: number; 89 | xy: number; 90 | yx: number; 91 | yy: number; 92 | tx: number; 93 | ty: number; 94 | }) { 95 | return { a: xx, b: xy, c: yx, d: yy, tx, ty }; 96 | } 97 | 98 | function toCloudTextConfig(data: any, layer: any) { 99 | // console.info('toCloudTextConfig', layer); 100 | 101 | const { objectEffects, typeTool } = layer.adjustments; 102 | const { StyleRun } = typeTool.engineData.EngineDict; 103 | 104 | let point = 0; 105 | const texts = StyleRun.RunArray.map((text: any, index: number) => { 106 | const length = StyleRun.RunLengthArray[index]; 107 | const props: { text: string; fontSize: number; color?: string } = { 108 | text: data.text.value.substr(point, length), 109 | fontSize: text.StyleSheet.StyleSheetData.FontSize, 110 | }; 111 | const { FillColor } = text.StyleSheet.StyleSheetData; 112 | if (FillColor) { 113 | props.color = colorer.RGBA2HexA(...toRGBAColor1(FillColor.Values)); 114 | } 115 | point += length; 116 | return props; 117 | }); 118 | 119 | const strokes = []; 120 | const shadows = []; 121 | if (objectEffects) { 122 | const { FrFX, DrSh } = objectEffects.data; 123 | 124 | if (FrFX) { 125 | strokes.push({ 126 | type: Reflect.get(STROKE_TYPE, FrFX.Styl.value), 127 | width: FrFX['Sz '].value, 128 | color: [ 129 | FrFX['Clr ']['Rd '], 130 | FrFX['Clr ']['Grn '], 131 | FrFX['Clr ']['Bl '], 132 | FrFX.Opct.value / 100, 133 | ], 134 | }); 135 | } 136 | 137 | if (DrSh) { 138 | shadows.push({ 139 | color: [ 140 | DrSh['Clr ']['Rd '], 141 | DrSh['Clr ']['Grn '], 142 | DrSh['Clr ']['Bl '], 143 | DrSh.Opct.value / 100, 144 | ], 145 | distance: DrSh.Dstn.value, 146 | blur: DrSh.blur.value, 147 | angle: DrSh.lagl.value, 148 | }); 149 | } 150 | } 151 | 152 | const { angle } = calcTransform(typeTool.transform); 153 | 154 | return { 155 | type: 'text', 156 | name: data.name, 157 | width: data.width, 158 | height: data.height, 159 | top: data.top, 160 | left: data.left, 161 | text: data.text.value, 162 | opacity: data.opacity, 163 | textAlign: data.text.font.alignment[0], 164 | // fontFamily: typeTool 165 | // .fonts() 166 | // .map((font: string) => font.slice(1).replace('\u0000', '')), 167 | fontFamily: typeTool.fonts()[0].slice(1).replace('\u0000', ''), 168 | fontSize: data.text.font.sizes[0], 169 | color: colorer.RGBA2HexA(...toRGBAColor(data.text.font.colors[0])), 170 | // textDecoration: StyleRun.RunArray[0].StyleSheet.StyleSheetData.Underline 171 | // ? 'underline' 172 | // : '', 173 | // writingMode: 174 | // typeTool.obj.textData.Ornt.value === 'Hrzn' 175 | // ? WRITING_MODE.h 176 | // : WRITING_MODE.v, 177 | fontWeight: '', 178 | fontStyle: '', 179 | texts, 180 | strokes, 181 | shadows, 182 | angle, 183 | transform: toTransformMatrix(typeTool.transform), 184 | }; 185 | } 186 | 187 | function toCloudImageConfig(data: any, layer: any) { 188 | // const { type, b64 } = splitBase64(layer.image.toBase64()); 189 | // const src = URL.createObjectURL(b64toBlob(b64, type)); 190 | console.log('data=', data); 191 | return { 192 | type: 'image', 193 | src: layer.image.toBase64(), 194 | name: data.name, 195 | width: data.width, 196 | height: data.height, 197 | top: data.top, 198 | left: data.left, 199 | opacity: data.opacity, 200 | }; 201 | } 202 | 203 | function toCloud(data: any, layer: any) { 204 | if (layer.typeTool) { 205 | return toCloudTextConfig(data, layer); 206 | } else { 207 | return toCloudImageConfig(data, layer); 208 | } 209 | } 210 | 211 | export async function convertPSD2Sky(psd: any) { 212 | const { children, document: doc } = psd.tree().export(); 213 | 214 | const findLayer = (data: any) => { 215 | return psd.layers.find( 216 | (layer: any) => 217 | layer.name === data.name && 218 | layer.top === data.top && 219 | layer.right === data.right && 220 | layer.bottom === data.bottom && 221 | layer.left === data.left && 222 | layer.width === data.width && 223 | layer.height === data.height 224 | ); 225 | }; 226 | 227 | // eslint-ignore-next-line 228 | // console.info('children, document', psd, children, doc); 229 | 230 | const background = { 231 | image: '', 232 | src: '', 233 | type: '', 234 | fill: 'rgb(0,0,0)', 235 | width: doc.width, 236 | height: doc.height, 237 | }; 238 | const [bgData] = children.slice(-1); 239 | if (['Background', 'background', '背景'].includes(bgData.name)) { 240 | const layer = findLayer(bgData); 241 | const image = toCloudImageConfig(bgData, layer); 242 | background.src = image.src; 243 | background.type = 'image'; 244 | children.pop(); 245 | } 246 | 247 | const sky = { 248 | background, 249 | width: doc.width, 250 | height: doc.height, 251 | objects: [], 252 | }; 253 | 254 | const process = (children: any) => { 255 | children.forEach((item: any) => { 256 | if (item.type === 'group' && Array.isArray(item.children)) { 257 | return process(item.children); 258 | } 259 | const layer = findLayer(item); 260 | if (!layer) return; 261 | 262 | const cloud = toCloud(item, layer); 263 | sky.objects.unshift(cloud as never); 264 | }); 265 | }; 266 | 267 | process(children); 268 | sky.objects.unshift(background as never); 269 | // sky.objects.unshift(workspace as never); 270 | return sky; 271 | } 272 | 273 | export async function processPSD2Sky(file: File) { 274 | const url = URL.createObjectURL(file); 275 | const psd = await parsePSDFromURL(url); 276 | 277 | URL.revokeObjectURL(url); 278 | 279 | return convertPSD2Sky(psd); 280 | } 281 | -------------------------------------------------------------------------------- /src/core/EditorWorkspace.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | /* eslint-disable no-param-reassign */ 3 | /* eslint-disable no-underscore-dangle */ 4 | /* 5 | * @Author: 秦少卫 6 | * @Date: 2023-02-03 21:50:10 7 | * @LastEditors: 秦少卫 8 | * @LastEditTime: 2023-05-21 19:07:05 9 | * @Description: 工作区初始化 10 | */ 11 | 12 | import { fabric } from 'fabric'; 13 | import { throttle } from 'lodash-es'; 14 | 15 | declare type EditorWorkspaceOption = { width: number; height: number }; 16 | declare type ExtCanvas = fabric.Canvas & { 17 | isDragging: boolean; 18 | lastPosX: number; 19 | lastPosY: number; 20 | }; 21 | 22 | class EditorWorkspace { 23 | canvas: fabric.Canvas; 24 | workspaceEl: HTMLElement; 25 | workspace: fabric.Rect | null; 26 | option: EditorWorkspaceOption; 27 | // width: number; 28 | // height: number; 29 | dragMode: boolean; 30 | constructor(canvas: fabric.Canvas, option: EditorWorkspaceOption) { 31 | this.canvas = canvas; 32 | const workspaceEl = document.querySelector('#workspace') as HTMLElement; 33 | if (!workspaceEl) { 34 | throw new Error('element #workspace is missing, plz check!'); 35 | } 36 | this.workspaceEl = workspaceEl; 37 | this.workspace = null; 38 | this.option = option; 39 | this.dragMode = false; 40 | this._initBackground(); 41 | this._initWorkspace(); 42 | this._initResizeObserve(); 43 | this._initDring(); 44 | } 45 | 46 | // 初始化背景 47 | _initBackground() { 48 | this.canvas.setBackgroundColor('#F1F1F1', this.canvas.renderAll.bind(this.canvas)); 49 | this.canvas.backgroundImage = ''; 50 | this.canvas.setWidth(this.workspaceEl.offsetWidth); 51 | this.canvas.setHeight(this.workspaceEl.offsetHeight); 52 | } 53 | 54 | // 初始化画布 55 | _initWorkspace() { 56 | const { width, height } = this.option; 57 | const workspace = new fabric.Rect({ 58 | fill: '#ffffff', 59 | width, 60 | height, 61 | id: 'workspace', 62 | }); 63 | workspace.set('selectable', false); 64 | workspace.set('hasControls', false); 65 | workspace.hoverCursor = 'default'; 66 | this.canvas.add(workspace); 67 | this.canvas.renderAll(); 68 | 69 | this.workspace = workspace; 70 | this.auto(); 71 | } 72 | 73 | /** 74 | * 设置画布中心到指定对象中心点上 75 | * @param {Object} obj 指定的对象 76 | */ 77 | setCenterFromObject(obj: fabric.Rect) { 78 | const { canvas } = this; 79 | const objCenter = obj.getCenterPoint(); 80 | const viewportTransform = canvas.viewportTransform; 81 | if (canvas.width === undefined || canvas.height === undefined || !viewportTransform) return; 82 | viewportTransform[4] = canvas.width / 2 - objCenter.x * viewportTransform[0]; 83 | viewportTransform[5] = canvas.height / 2 - objCenter.y * viewportTransform[3]; 84 | canvas.setViewportTransform(viewportTransform); 85 | canvas.renderAll(); 86 | } 87 | 88 | // 初始化监听器 89 | _initResizeObserve() { 90 | const resizeObserver = new ResizeObserver( 91 | throttle(() => { 92 | this.auto(); 93 | }, 50) 94 | ); 95 | resizeObserver.observe(this.workspaceEl); 96 | } 97 | 98 | setSize(width: number, height: number) { 99 | this._initBackground(); 100 | this.option.width = width; 101 | this.option.height = height; 102 | // 重新设置workspace 103 | this.workspace = this.canvas 104 | .getObjects() 105 | .find((item) => item.id === 'workspace') as fabric.Rect; 106 | this.workspace.set('width', width); 107 | this.workspace.set('height', height); 108 | this.auto(); 109 | } 110 | 111 | setZoomAuto(scale: number, cb?: (left?: number, top?: number) => void) { 112 | const { workspaceEl } = this; 113 | const width = workspaceEl.offsetWidth; 114 | const height = workspaceEl.offsetHeight; 115 | this.canvas.setWidth(width); 116 | this.canvas.setHeight(height); 117 | const center = this.canvas.getCenter(); 118 | this.canvas.setViewportTransform(fabric.iMatrix.concat()); 119 | this.canvas.zoomToPoint(new fabric.Point(center.left, center.top), scale); 120 | if (!this.workspace) return; 121 | this.setCenterFromObject(this.workspace); 122 | 123 | // 超出画布不展示 124 | this.workspace.clone((cloned: fabric.Rect) => { 125 | this.canvas.clipPath = cloned; 126 | this.canvas.requestRenderAll(); 127 | }); 128 | if (cb) cb(this.workspace.left, this.workspace.top); 129 | } 130 | 131 | _getScale() { 132 | const viewPortWidth = this.workspaceEl.offsetWidth; 133 | const viewPortHeight = this.workspaceEl.offsetHeight; 134 | // 按照宽度 135 | if (viewPortWidth / viewPortHeight < this.option.width / this.option.height) { 136 | return viewPortWidth / this.option.width; 137 | } // 按照宽度缩放 138 | return viewPortHeight / this.option.height; 139 | } 140 | 141 | // 放大 142 | big() { 143 | let zoomRatio = this.canvas.getZoom(); 144 | zoomRatio += 0.05; 145 | const center = this.canvas.getCenter(); 146 | this.canvas.zoomToPoint(new fabric.Point(center.left, center.top), zoomRatio); 147 | } 148 | 149 | // 缩小 150 | small() { 151 | let zoomRatio = this.canvas.getZoom(); 152 | zoomRatio -= 0.05; 153 | const center = this.canvas.getCenter(); 154 | this.canvas.zoomToPoint( 155 | new fabric.Point(center.left, center.top), 156 | zoomRatio < 0 ? 0.01 : zoomRatio 157 | ); 158 | } 159 | 160 | // 自动缩放 161 | auto() { 162 | const scale = this._getScale(); 163 | this.setZoomAuto(scale - 0.08); 164 | } 165 | 166 | // 1:1 放大 167 | one() { 168 | this.setZoomAuto(0.8 - 0.08); 169 | this.canvas.requestRenderAll(); 170 | } 171 | 172 | // 开始拖拽 173 | startDring() { 174 | this.dragMode = true; 175 | this.canvas.defaultCursor = 'grab'; 176 | } 177 | endDring() { 178 | this.dragMode = false; 179 | this.canvas.defaultCursor = 'default'; 180 | } 181 | 182 | // 拖拽模式 183 | _initDring() { 184 | const This = this; 185 | this.canvas.on('mouse:down', function (this: ExtCanvas, opt) { 186 | const evt = opt.e; 187 | if (evt.altKey || This.dragMode) { 188 | This.canvas.defaultCursor = 'grabbing'; 189 | This.canvas.discardActiveObject(); 190 | This._setDring(); 191 | this.selection = false; 192 | this.isDragging = true; 193 | this.lastPosX = evt.clientX; 194 | this.lastPosY = evt.clientY; 195 | this.requestRenderAll(); 196 | } 197 | }); 198 | 199 | this.canvas.on('mouse:move', function (this: ExtCanvas, opt) { 200 | if (this.isDragging) { 201 | This.canvas.discardActiveObject(); 202 | This.canvas.defaultCursor = 'grabbing'; 203 | const { e } = opt; 204 | if (!this.viewportTransform) return; 205 | const vpt = this.viewportTransform; 206 | vpt[4] += e.clientX - this.lastPosX; 207 | vpt[5] += e.clientY - this.lastPosY; 208 | this.lastPosX = e.clientX; 209 | this.lastPosY = e.clientY; 210 | this.requestRenderAll(); 211 | } 212 | }); 213 | 214 | this.canvas.on('mouse:up', function (this: ExtCanvas) { 215 | if (!this.viewportTransform) return; 216 | this.setViewportTransform(this.viewportTransform); 217 | this.isDragging = false; 218 | this.selection = true; 219 | this.getObjects().forEach((obj) => { 220 | if (obj.id !== 'workspace' && obj.hasControls) { 221 | obj.selectable = true; 222 | } 223 | }); 224 | this.requestRenderAll(); 225 | This.canvas.defaultCursor = 'default'; 226 | }); 227 | 228 | this.canvas.on('mouse:wheel', function (this: fabric.Canvas, opt) { 229 | const delta = opt.e.deltaY; 230 | let zoom = this.getZoom(); 231 | zoom *= 0.999 ** delta; 232 | if (zoom > 20) zoom = 20; 233 | if (zoom < 0.01) zoom = 0.01; 234 | const center = this.getCenter(); 235 | this.zoomToPoint(new fabric.Point(center.left, center.top), zoom); 236 | opt.e.preventDefault(); 237 | opt.e.stopPropagation(); 238 | }); 239 | } 240 | 241 | _setDring() { 242 | this.canvas.selection = false; 243 | this.canvas.defaultCursor = 'grab'; 244 | this.canvas.getObjects().forEach((obj) => { 245 | obj.selectable = false; 246 | }); 247 | this.canvas.renderAll(); 248 | this.canvas.requestRenderAll(); 249 | } 250 | } 251 | 252 | export default EditorWorkspace; 253 | -------------------------------------------------------------------------------- /src/core/initControls.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 秦少卫 3 | * @Date: 2023-01-09 22:49:02 4 | * @LastEditors: 白召策 5 | * @LastEditTime: 2023-05-30 18:23:56 6 | * @Description: 控制条样式 7 | */ 8 | 9 | import { fabric } from 'fabric'; 10 | import verticalImg from '@/assets/editor/middlecontrol.svg'; 11 | import horizontalImg from '@/assets/editor/middlecontrolhoz.svg'; 12 | import edgeImg from '@/assets/editor/edgecontrol.svg'; 13 | import rotateImg from '@/assets/editor/rotateicon.svg'; 14 | 15 | /** 16 | * 实际场景: 在进行某个对象缩放的时候,由于fabricjs默认精度使用的是toFixed(2)。 17 | * 此处为了缩放的精度更准确一些,因此将NUM_FRACTION_DIGITS默认值改为4,即toFixed(4). 18 | */ 19 | fabric.Object.NUM_FRACTION_DIGITS = 4; 20 | 21 | function drawImg( 22 | ctx: CanvasRenderingContext2D, 23 | left: number, 24 | top: number, 25 | img: HTMLImageElement, 26 | wSize: number, 27 | hSize: number, 28 | angle: number | undefined 29 | ) { 30 | if (angle === undefined) return; 31 | ctx.save(); 32 | ctx.translate(left, top); 33 | ctx.rotate(fabric.util.degreesToRadians(angle)); 34 | ctx.drawImage(img, -wSize / 2, -hSize / 2, wSize, hSize); 35 | ctx.restore(); 36 | } 37 | 38 | // 中间横杠 39 | function intervalControl() { 40 | const verticalImgIcon = document.createElement('img'); 41 | verticalImgIcon.src = verticalImg; 42 | 43 | const horizontalImgIcon = document.createElement('img'); 44 | horizontalImgIcon.src = horizontalImg; 45 | 46 | function renderIcon( 47 | ctx: CanvasRenderingContext2D, 48 | left: number, 49 | top: number, 50 | styleOverride: any, 51 | fabricObject: fabric.Object 52 | ) { 53 | drawImg(ctx, left, top, verticalImgIcon, 20, 25, fabricObject.angle); 54 | } 55 | 56 | function renderIconHoz( 57 | ctx: CanvasRenderingContext2D, 58 | left: number, 59 | top: number, 60 | styleOverride: any, 61 | fabricObject: fabric.Object 62 | ) { 63 | drawImg(ctx, left, top, horizontalImgIcon, 25, 20, fabricObject.angle); 64 | } 65 | // 中间横杠 66 | fabric.Object.prototype.controls.ml = new fabric.Control({ 67 | x: -0.5, 68 | y: 0, 69 | offsetX: -1, 70 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 71 | actionHandler: fabric.controlsUtils.scalingXOrSkewingY, 72 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 73 | render: renderIcon, 74 | }); 75 | 76 | fabric.Object.prototype.controls.mr = new fabric.Control({ 77 | x: 0.5, 78 | y: 0, 79 | offsetX: 1, 80 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 81 | actionHandler: fabric.controlsUtils.scalingXOrSkewingY, 82 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 83 | render: renderIcon, 84 | }); 85 | 86 | fabric.Object.prototype.controls.mb = new fabric.Control({ 87 | x: 0, 88 | y: 0.5, 89 | offsetY: 1, 90 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 91 | actionHandler: fabric.controlsUtils.scalingYOrSkewingX, 92 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 93 | render: renderIconHoz, 94 | }); 95 | 96 | fabric.Object.prototype.controls.mt = new fabric.Control({ 97 | x: 0, 98 | y: -0.5, 99 | offsetY: -1, 100 | cursorStyleHandler: fabric.controlsUtils.scaleSkewCursorStyleHandler, 101 | actionHandler: fabric.controlsUtils.scalingYOrSkewingX, 102 | getActionName: fabric.controlsUtils.scaleOrSkewActionName, 103 | render: renderIconHoz, 104 | }); 105 | } 106 | 107 | // 顶点 108 | function peakControl() { 109 | const img = document.createElement('img'); 110 | img.src = edgeImg; 111 | 112 | function renderIconEdge( 113 | ctx: CanvasRenderingContext2D, 114 | left: number, 115 | top: number, 116 | styleOverride: any, 117 | fabricObject: fabric.Object 118 | ) { 119 | drawImg(ctx, left, top, img, 25, 25, fabricObject.angle); 120 | } 121 | // 四角图标 122 | fabric.Object.prototype.controls.tl = new fabric.Control({ 123 | x: -0.5, 124 | y: -0.5, 125 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 126 | actionHandler: fabric.controlsUtils.scalingEqually, 127 | render: renderIconEdge, 128 | }); 129 | fabric.Object.prototype.controls.bl = new fabric.Control({ 130 | x: -0.5, 131 | y: 0.5, 132 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 133 | actionHandler: fabric.controlsUtils.scalingEqually, 134 | render: renderIconEdge, 135 | }); 136 | fabric.Object.prototype.controls.tr = new fabric.Control({ 137 | x: 0.5, 138 | y: -0.5, 139 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 140 | actionHandler: fabric.controlsUtils.scalingEqually, 141 | render: renderIconEdge, 142 | }); 143 | fabric.Object.prototype.controls.br = new fabric.Control({ 144 | x: 0.5, 145 | y: 0.5, 146 | cursorStyleHandler: fabric.controlsUtils.scaleCursorStyleHandler, 147 | actionHandler: fabric.controlsUtils.scalingEqually, 148 | render: renderIconEdge, 149 | }); 150 | } 151 | // 删除 152 | function deleteControl(canvas: fabric.Canvas) { 153 | const deleteIcon = 154 | "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Ebene_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='595.275px' height='595.275px' viewBox='200 215 230 470' xml:space='preserve'%3E%3Ccircle style='fill:%23F44336;' cx='299.76' cy='439.067' r='218.516'/%3E%3Cg%3E%3Crect x='267.162' y='307.978' transform='matrix(0.7071 -0.7071 0.7071 0.7071 -222.6202 340.6915)' style='fill:white;' width='65.545' height='262.18'/%3E%3Crect x='266.988' y='308.153' transform='matrix(0.7071 0.7071 -0.7071 0.7071 398.3889 -83.3116)' style='fill:white;' width='65.544' height='262.179'/%3E%3C/g%3E%3C/svg%3E"; 155 | const delImg = document.createElement('img'); 156 | delImg.src = deleteIcon; 157 | 158 | function renderDelIcon( 159 | ctx: CanvasRenderingContext2D, 160 | left: number, 161 | top: number, 162 | styleOverride: any, 163 | fabricObject: fabric.Object 164 | ) { 165 | drawImg(ctx, left, top, delImg, 24, 24, fabricObject.angle); 166 | } 167 | 168 | // 删除选中元素 169 | function deleteObject(mouseEvent: MouseEvent, target: fabric.Transform) { 170 | if (target.action === 'rotate') return true; 171 | const activeObject = canvas.getActiveObjects(); 172 | if (activeObject) { 173 | activeObject.map((item) => canvas.remove(item)); 174 | canvas.requestRenderAll(); 175 | canvas.discardActiveObject(); 176 | } 177 | return true; 178 | } 179 | 180 | // 删除图标 181 | fabric.Object.prototype.controls.deleteControl = new fabric.Control({ 182 | x: 0.5, 183 | y: -0.5, 184 | offsetY: -16, 185 | offsetX: 16, 186 | cursorStyle: 'pointer', 187 | mouseUpHandler: deleteObject, 188 | render: renderDelIcon, 189 | // cornerSize: 24, 190 | }); 191 | } 192 | 193 | // 旋转 194 | function rotationControl() { 195 | const img = document.createElement('img'); 196 | img.src = rotateImg; 197 | function renderIconRotate( 198 | ctx: CanvasRenderingContext2D, 199 | left: number, 200 | top: number, 201 | styleOverride: any, 202 | fabricObject: fabric.Object 203 | ) { 204 | drawImg(ctx, left, top, img, 40, 40, fabricObject.angle); 205 | } 206 | // 旋转图标 207 | fabric.Object.prototype.controls.mtr = new fabric.Control({ 208 | x: 0, 209 | y: 0.5, 210 | cursorStyleHandler: fabric.controlsUtils.rotationStyleHandler, 211 | actionHandler: fabric.controlsUtils.rotationWithSnapping, 212 | offsetY: 30, 213 | // withConnecton: false, 214 | actionName: 'rotate', 215 | render: renderIconRotate, 216 | }); 217 | } 218 | 219 | function initControls(canvas: fabric.Canvas) { 220 | // 删除图标 221 | deleteControl(canvas); 222 | // 顶点图标 223 | peakControl(); 224 | // 中间横杠图标 225 | intervalControl(); 226 | // 旋转图标 227 | rotationControl(); 228 | 229 | // 选中样式 230 | fabric.Object.prototype.set({ 231 | transparentCorners: false, 232 | borderColor: '#51B9F9', 233 | cornerColor: '#FFF', 234 | borderScaleFactor: 2.5, 235 | cornerStyle: 'circle', 236 | cornerStrokeColor: '#0E98FC', 237 | borderOpacityWhenMoving: 1, 238 | }); 239 | // textbox保持一致 240 | // fabric.Textbox.prototype.controls = fabric.Object.prototype.controls; 241 | } 242 | 243 | export default initControls; 244 | --------------------------------------------------------------------------------