├── .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 |
2 |
3 |
4 |
5 |
6 |
7 |
31 |
--------------------------------------------------------------------------------
/src/components/clone.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
33 |
--------------------------------------------------------------------------------
/src/assets/editor/edgecontrol.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/src/assets/editor/middlecontrol.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/src/assets/editor/middlecontrolhoz.svg:
--------------------------------------------------------------------------------
1 |
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 |
10 |
11 |
12 | {{ lang }}
13 |
14 |
15 |
16 |
17 |
18 | {{ lang.langName }}
19 |
20 |
21 |
22 |
23 |
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 |
10 |
11 | {{ $t('preview') }}
12 |
13 | {
18 | showViewer = false;
19 | url = '';
20 | }
21 | "
22 | :url-list="[url]"
23 | />
24 |
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 |
10 |
11 | {{ $t('import_files') }}
12 |
13 |
14 |
15 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/assets/editor/rotateicon.svg:
--------------------------------------------------------------------------------
1 |
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 |
2 |
3 |
36 |
37 |
38 |
39 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/src/components/dragMode.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
21 |
22 |
23 |
24 |
75 |
82 |
--------------------------------------------------------------------------------
/src/components/replaceImg.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | {{ $t('repleaceImg') }}
12 |
13 |
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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
10 |
32 |
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 |
10 |
11 |
{{ $t('title_template') }}
12 |
18 |
24 |
25 |
26 |
27 |
28 |
95 |
96 |
103 |
--------------------------------------------------------------------------------
/src/components/setSize.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | {{ $t('size') }}
12 |
13 |
14 |
21 |
22 |
23 |
30 |
31 |
32 | {{ $t('default_size') }}
33 |
34 |
41 | {{ item.label }}:{{ item.width }}x{{ item.height }}
42 |
43 |
44 |
45 |
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 |
2 |
3 |
{{ $t('color') }}
4 |
5 |
6 |
7 |
8 |
9 |
{{ $t('color_macthing') }}
10 |
11 |
12 |
13 | {{ item.label }}:
14 |
20 |
21 |
22 |
23 |
24 |
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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {{ useDateFormat(history[0].timestamp, 'HH:mm:ss').value }}
27 |
28 |
29 |
30 |
31 |
114 |
115 |
130 |
131 |
136 |
--------------------------------------------------------------------------------
/src/components/flip.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
37 |
38 |
39 |
40 |
41 |
64 |
65 |
66 |
67 |
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 |
10 |
11 |
12 |
13 |
14 |
29 |
30 |
31 |
32 |
33 |
34 |
55 |
56 |
57 |
58 |
59 |
60 |
75 |
76 |
77 |
78 |
79 |
80 |
96 |
116 |
--------------------------------------------------------------------------------
/src/components/importTmpl.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
{{ $t('title_template') }}
12 |
18 |
24 |
25 |
26 |
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 |
10 |
11 |
12 |
13 |
14 |
29 | 组合
30 |
31 |
32 |
33 |
48 | 拆分组合
49 |
50 |
51 |
52 |
53 |
72 |
90 |
--------------------------------------------------------------------------------
/src/components/zoom.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
33 |
34 |
35 |
56 |
57 | 1:1
58 |
59 |
60 |
61 |
62 |
63 |
99 |
106 |
--------------------------------------------------------------------------------
/src/components/save.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 | {{ $t('empty') }}
17 |
18 |
19 |
20 | {{ $t('keep') }}
21 |
22 |
23 |
24 |
25 | {{ $t('copy_to_clipboard') }}
26 | {{ $t('save_as_picture') }}
27 | {{ $t('save_as_svg') }}
28 | {{ $t('save_as_json') }}
29 |
30 |
31 |
32 |
33 |
34 |
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 |
2 |
15 |
16 |
17 |
229 |
230 |
272 |
--------------------------------------------------------------------------------
/src/components/importFile.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | {{ $t('insertFile.insert') }}
14 |
15 |
16 |
17 |
18 |
19 |
20 | {{ $t('insertFile.insert_picture') }}
21 |
22 |
23 | {{ $t('insertFile.insert_SVG') }}
24 |
25 |
26 | {{ $t('insertFile.insert_SVGStr') }}
27 |
28 |
29 |
30 |
31 |
32 |
42 |
48 |
49 |
53 |
54 |
55 |
56 |
57 |
58 |
225 |
226 |
231 |
--------------------------------------------------------------------------------
/src/components/color.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
36 |
37 |
38 |
39 |
40 |
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 |
--------------------------------------------------------------------------------