├── .editorconfig
├── .eslintignore
├── .gitignore
├── LICENSE
├── README.md
├── README_en.md
├── assets
├── editor_attr.png
├── editor_canvas.png
├── editor_com.png
├── editor_set.png
├── editor_temp.png
├── index_demo.png
└── rect_demo.png
├── babel.config.js
├── index.md
├── jsconfig.json
├── moc.sh
├── mod.sh
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
└── static
│ └── img
│ ├── 1.jpg
│ └── 2.jpg
├── src
├── App.vue
├── assets
│ ├── iconfont
│ │ ├── iconfont.css
│ │ ├── iconfont.js
│ │ ├── iconfont.json
│ │ ├── iconfont.ttf
│ │ ├── iconfont.woff
│ │ └── iconfont.woff2
│ ├── logo.png
│ └── style
│ │ ├── editor.scss
│ │ └── index.scss
├── main.js
├── utils
│ ├── aligning_guidelines.js
│ ├── arrow.js
│ └── background.js
└── views
│ ├── index.js
│ └── ktEditor.vue
└── vue.config.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{js,jsx,ts,tsx,vue}]
2 | indent_style = space
3 | indent_size = 2
4 | trim_trailing_whitespace = true
5 | insert_final_newline = true
6 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | iconfont.js
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 | .history/
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 kai-tu.cn soft
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to kaitu-image-editor
2 |
3 | 🌈🐴🐂🐱🐶🐷🌈
4 |
5 | 
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | 简体中文 | [English](README_en.md)
15 |
16 | > 一款轻量级且可扩展的图片编辑器
17 | kaitu-image-editor 是基于Fabric.js实现的在线可视化图片编辑器
18 |
19 | ### ✨ [Demo](http://kai-tu-cn.github.io/kaitu-image-editor)
20 |
21 |
22 | ## 开发
23 | ```bash
24 | $ git clone https://github.com/kai-tu-cn/kaitu-image-editor
25 | $ cd kaitu-image-editor
26 | $ yarn install
27 | $ yarn run serve
28 | ```
29 |
30 | ## Author
31 |
32 | 👤 **开图软件**
33 |
34 | * Website: [网站](http://139.9.84.71/)
35 | * Github: [Github](https://github.com/kai-tu-cn)
36 |
37 | ## Show your support
38 |
39 | Give a ⭐️ if this project helped you!
40 |
41 | ## LICENSE
42 | MIT 2.0
43 |
44 | ## 技术反馈和交流群
45 | 微信:kai-tu_cn
46 |
--------------------------------------------------------------------------------
/README_en.md:
--------------------------------------------------------------------------------
1 | # Welcome to kaitu-image-editor
2 |
3 | 🌈🐴🐂🐱🐶🐷🌈
4 |
5 | 
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | [简体中文](README.md) | English
15 |
16 | > A lightweight and scalable picture editor.
17 | kaitu-image-editor is an online image editor application based on Fabric.js
18 |
19 | ### ✨ [Demo](http://kai-tu-cn.github.io/kaitu-image-editor)
20 |
21 |
22 | ## Development
23 | ```bash
24 | $ git clone https://github.com/kai-tu-cn/kaitu-image-editor
25 | $ cd kaitu-image-editor
26 | $ yarn install
27 | $ yarn run serve
28 | ```
29 |
30 | ## Author
31 |
32 | 👤 **kaitu-soft**
33 |
34 | * Website: [Website](http://139.9.84.71/)
35 | * Github: [Github](https://github.com/kai-tu-cn)
36 |
37 | ## Show your support
38 |
39 | Give a ⭐️ if this project helped you!
40 |
41 | ## LICENSE
42 | MIT 2.0
43 |
44 | ## Technical feedback and communication
45 | wechat:kai-tu_cn
46 |
--------------------------------------------------------------------------------
/assets/editor_attr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/editor_attr.png
--------------------------------------------------------------------------------
/assets/editor_canvas.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/editor_canvas.png
--------------------------------------------------------------------------------
/assets/editor_com.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/editor_com.png
--------------------------------------------------------------------------------
/assets/editor_set.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/editor_set.png
--------------------------------------------------------------------------------
/assets/editor_temp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/editor_temp.png
--------------------------------------------------------------------------------
/assets/index_demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/index_demo.png
--------------------------------------------------------------------------------
/assets/rect_demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/assets/rect_demo.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/index.md:
--------------------------------------------------------------------------------
1 | # 基于Vue+Fabric实现一个h5可视化图片编辑器
2 |
3 | 背景介绍
4 |
5 | 为了提高企业研发效能和对客户需求的快速响应,现在很多企业都在着手数字化转型,不仅仅是大厂(阿里,字节,腾讯,百度)在做低代码可视化这一块,很多中小企业也在做,拥有可视化低代码相关技术背景的程序员也越来受重视.
6 | 大家现在跟我一起做一个图片编辑器.
7 |
8 | 预览界面
9 | 
10 |
11 | Github地址: [传送门](https://github.com/kai-tu-cn/kaitu-image-editor)
12 |
13 | 项目搭建和技术选型
14 | - vue 前端主流框架(react,vue,angular)之一
15 | - element-ui vue组件库
16 | - fabric canvas操作组件库
17 | - file-saver 文件下载
18 | - localforage 本地离线存储
19 | - nanoid uuid生成器
20 |
21 | 我们先安装`fabric`
22 |
23 | ```bash
24 | yarn add fabric
25 | ```
26 |
27 | ```js
28 |
29 |
30 | import { fabric } from "fabric";
31 | export default {
32 |
33 | }
34 | mounted () {
35 | this.initEditor()
36 | },
37 | methods: {
38 | initEditor() {
39 | const canvas = new fabric.Canvas('c')
40 | const shape = new fabric.Rect({
41 | width : 60,
42 | height : 60,
43 | fill : '#1ab394',
44 | left: 30,
45 | top: 30
46 | })
47 | canvas.add(shape);
48 | }
49 | }
50 | ```
51 | 这样我们就创建好了一个画布,并在画布中插入了一个矩形,如下
52 |
53 | 
54 |
55 | 想要了解fabric.js,请前往 [fabric](http://fabricjs.com/docs/)
56 |
57 | ## 编辑器设计
58 |
59 | 页面布局
60 | - 顶部 工具栏
61 | - 左侧
62 | - 模板
63 | - 组件 可拖拽的组件
64 | - 中间
65 | - 编辑区
66 | - 右侧
67 | - 图纸设置/操作
68 | - 组件属性设置
69 | ### 1.画布
70 | 画布中间有一块编辑区,拖拽组件到该区域,就完成添加元素,拖拽到编辑区之外的,元素变成不可见,这里利用了`canvas`的 `globalCompositeOperation: source-atop` 目标图形和源图形内容重叠的部分的目标图形会被绘制
71 | 
72 |
73 | ### 2.1 模板
74 | 点击模板,即可使用该模板,模板保存在本地,用户可以新增模板和删除模板,使用`localforage`做本地离线数据处理
75 | 
76 |
77 | ### 2.2 组件
78 | 组件 主要有如文本,图片,直线,矩形,圆形,三角形,箭头,也根据自己扩展更多的基本图元,在组件tab页面,拖拽任意一个元素到 `画布`,即可添加到`画布上`,
79 | 
80 |
81 | ### 3.1 设置
82 | 设置画布的大小和颜色,和渐变色,大家可以自由扩展,添加更多的颜色
83 | 
84 |
85 | ### 3.2 属性
86 | 属性编辑主要是用来对图形属性进行配置的,比如填充颜色,描边颜色,描边宽度,目前我主要定义了这3个属性,大家也可以基于此继续扩展更多的可编辑属性,例如 width/height/top/left/....等等
87 | 
88 |
89 | ### 下载图片功能实现
90 | 我们想要保存的不是整个画布,不能使用`canvas.toDataURL(''image/png)`,
91 | 我们先获取到原有的画布数据,再使用`fabric`的静态画布`StaticCanvas`,创建一个不可见的`画布`,把原有的数据一一添加到 新的不可见`画布`中去,
92 |
93 | ```js
94 | disposeImage() {
95 | //处理画布数据并生成图片
96 | const self = this
97 | const canvas = new fabric.StaticCanvas(null, {
98 | width: self.setting.width,
99 | height: self.setting.height,
100 | })
101 | _fabricObj.getObjects().forEach(e => {
102 | canvas.add(e)
103 | })
104 | canvas.renderAll()
105 | self.canvasFitView(canvas)
106 | return canvas.toDataURL('image/png')
107 | },
108 | canvasFitView(canvas) {
109 | // 组件缩放到整个画布
110 | var objects = canvas.getObjects();
111 | if (!objects.length) return
112 | canvas.setZoom(1);
113 | canvas.absolutePan({ x: 0, y: 0 });
114 | if (objects.length > 0) {
115 | var rect = objects[0].getBoundingRect();
116 | var minX = rect.left;
117 | var minY = rect.top;
118 | var maxX = rect.left + rect.width;
119 | var maxY = rect.top + rect.height;
120 | for (var i = 1; i < objects.length; i++) {
121 | rect = objects[i].getBoundingRect();
122 | minX = Math.min(minX, rect.left);
123 | minY = Math.min(minY, rect.top);
124 | maxX = Math.max(maxX, rect.left + rect.width);
125 | maxY = Math.max(maxY, rect.top + rect.height);
126 | }
127 | }
128 | var panX = (maxX - minX - canvas.width) / 2 + minX;
129 | var panY = (maxY - minY - canvas.height) / 2 + minY;
130 | canvas.absolutePan({ x: panX, y: panY });
131 | var zoom = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY));
132 | zoom = Math.min(5, zoom)
133 | zoom = Math.max(0.2, zoom)
134 | var zoomPoint = new fabric.Point(canvas.width / 2, canvas.height / 2);
135 | canvas.zoomToPoint(zoomPoint, zoom);
136 | },
137 | ```
138 |
139 |
140 |
141 | ### 模版保存实现
142 |
143 | 在设计图片编辑器的过程中我们也要考虑保存用户的资产,比如做的比较好的图片可以保存为模版,以便下次复用.
144 | fabric 提供了序列化画布的方法 toDatalessJSON(),我们在保存模版的时候只要把序列化后的 json 和图片一起保存即可,这里方便处理数据,使用了 localforage,
145 |
146 | 保存模版的具体实现如下
147 |
148 | ```js
149 | const self = this
150 | canvas.discardActiveObject()
151 | canvas.renderAll();
152 | const json = canvas.toDatalessJSON()
153 | const img = self.disposeImage()// 处理图片,特别说明
154 | const id = nanoid(6)
155 | localforage.getItem('kt_temps').then(res => {
156 | const temps = JSON.parse(res || '{}')
157 | temps[id] = {json, title: self.tempTitle, img};
158 | localforage.setItem('kt_temps', JSON.stringify(temps)).then(res => {
159 | //再次获取模板列表
160 | })
161 | })
162 | ```
163 |
164 | ### 引用模版功能实现
165 |
166 | 引用模版的本质是反序列化 Json Schema,所以我们可以直接将模板的 json 直接加载到画布
167 | ```js
168 | canvas.clear();
169 | canvas.loadFromJSON(json, canvas.renderAll.bind(canvas))
170 | ```
171 | ### 导出Json
172 | 调用 fabric.js 的 toDatalessJSON 和FileSaver.js
173 | ```js
174 | canvas.discardActiveObject() //先取消选中
175 | canvas.renderAll();
176 | const json = canvas.toDatalessJSON()
177 | let content = JSON.stringify(json)
178 | FileSaver.saveAs(
179 | new Blob([content], { type: 'text/plain;charset=utf-8' }),
180 | `导出.json`
181 | );
182 | ```
183 |
184 | ### 导入Json
185 | 选择本地json,再一次调用 反序列化 Json Schema, 把 json 直接加载到画布
186 | ```js
187 | const input = document.createElement('input');
188 | input.type = 'file';
189 | input.accept = 'application/json'
190 | input.onchange = (event) => {
191 | const elem = event.target;
192 | if (elem.files && elem.files[0]) {
193 | const file = elem.files[0]
194 | const reader = new FileReader();
195 | reader.onloadend = (e) => {
196 | canvas.clear();
197 | canvas.loadFromJSON(e.target.result, canvas.renderAll.bind(canvas))
198 | };
199 | reader.readAsText(file, 'utf-8');
200 | }
201 | };
202 | input.click();
203 | ```
204 | 这款图片编辑器我已经在 github 开源了,大家可以基于本项目进行二次开发,开发出更强大的图片编辑器,对于图片编辑器的后期规划,我确定了几个可行的方向,如果大家感兴趣也可以联系我参与到项目中来。
205 |
206 | ### 后期规划
207 | - [x] 撤销重做
208 | - [x] 键盘快捷键
209 | - [x] 丰富图形组件库
210 | - [x] 图片滤镜配置
211 | - [x] 模块化界面
212 |
213 | 如果大家对可视化搭建或者低代码感兴趣,可以在评论区交流你的想法和心得.
214 |
215 | github: kaitu-image-editor | 轻量级且可扩展的图片/图形编辑器解决方案
216 | 首发:掘金技术社区
217 | 作者:南极熊的鼻涕
218 | Github地址: [传送门](https://github.com/kai-tu-cn/kaitu-image-editor)
219 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "esnext",
5 | "baseUrl": "./",
6 | "moduleResolution": "node",
7 | "paths": {
8 | "@/*": [
9 | "src/*"
10 | ]
11 | },
12 | "lib": [
13 | "esnext",
14 | "dom",
15 | "dom.iterable",
16 | "scripthost"
17 | ]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/moc.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #更改提交中所有邮箱为OLD_EMAIL或用户名为OLD_NAME的为新的用户名和新的邮箱,注释部分的可以变更邮箱
3 |
4 | # CORRECT_EMAIL="your-correct-email@example.com"
5 | # OLD_EMAIL="yo.com"
6 | # export GIT_COMMITTER_EMAIL="$CORRECT_EMAIL"
7 | # export GIT_AUTHOR_EMAIL="$CORRECT_EMAIL"
8 | git filter-branch --env-filter '
9 | OLD_NAME="WB0000001"
10 | CORRECT_NAME="gaowj"
11 |
12 | if [ "$GIT_COMMITTER_NAME" = "$OLD_NAME" ]
13 | then
14 | export GIT_COMMITTER_NAME="$CORRECT_NAME"
15 | fi
16 | if [ "$GIT_AUTHOR_NAME" = "$OLD_NAME" ]
17 | then
18 | export GIT_AUTHOR_NAME="$CORRECT_NAME"
19 | fi
20 | ' -f --tag-name-filter cat -- --branches --tags #-f为强行覆盖
21 | #取消下面的#注释,将自动强行推送所有修改到主分支
22 | #git push origin master --force
--------------------------------------------------------------------------------
/mod.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | git filter-branch --env-filter '
4 |
5 | an="$GIT_AUTHOR_NAME"
6 | am="$GIT_AUTHOR_EMAIL"
7 | cn="$GIT_COMMITTER_NAME"
8 | cm="$GIT_COMMITTER_EMAIL"
9 |
10 | if [ "$GIT_COMMITTER_NAME" = "rolitter" ]
11 | then
12 | cn="sco1314"
13 | an="sco1314"
14 | fi
15 |
16 | if [ "$GIT_COMMITTER_EMAIL" = "rolitter@gmail.com" ]
17 | then
18 | cm="2421462516@qq.com"
19 | am="2421462516@qq.com"
20 | fi
21 |
22 | export GIT_AUTHOR_NAME="$an"
23 | export GIT_AUTHOR_EMAIL="$am"
24 | export GIT_COMMITTER_NAME="$cn"
25 | export GIT_COMMITTER_EMAIL="$cm"
26 |
27 | ' HEAD
28 |
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kaitu-image-editor",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint"
9 | },
10 | "dependencies": {
11 | "core-js": "^3.8.3",
12 | "element-ui": "^2.15.9",
13 | "fabric": "^5.2.1",
14 | "file-saver": "^2.0.5",
15 | "localforage": "^1.10.0",
16 | "nanoid": "^4.0.0",
17 | "vue": "^2.6.14",
18 | "vue-github-buttons": "^3.1.0",
19 | "vue-router": "^3.5.1"
20 | },
21 | "devDependencies": {
22 | "@babel/core": "^7.12.16",
23 | "@babel/eslint-parser": "^7.12.16",
24 | "@vue/cli-plugin-babel": "~5.0.0",
25 | "@vue/cli-plugin-eslint": "~5.0.0",
26 | "@vue/cli-plugin-router": "~5.0.0",
27 | "@vue/cli-plugin-vuex": "~5.0.0",
28 | "@vue/cli-service": "~5.0.0",
29 | "@vue/eslint-config-standard": "^6.1.0",
30 | "eslint": "^7.32.0",
31 | "eslint-plugin-import": "^2.25.3",
32 | "eslint-plugin-node": "^11.1.0",
33 | "eslint-plugin-promise": "^5.1.0",
34 | "eslint-plugin-vue": "^8.0.3",
35 | "sass": "^1.32.7",
36 | "sass-loader": "^12.0.0",
37 | "vue-template-compiler": "^2.6.14"
38 | },
39 | "eslintConfig": {
40 | "root": true,
41 | "env": {
42 | "node": true
43 | },
44 | "extends": [
45 | "plugin:vue/essential",
46 | "@vue/standard"
47 | ],
48 | "parserOptions": {
49 | "parser": "@babel/eslint-parser"
50 | },
51 | "rules": {}
52 | },
53 | "browserslist": [
54 | "> 1%",
55 | "last 2 versions",
56 | "not dead"
57 | ]
58 | }
59 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= htmlWebpackPlugin.options.title %>
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/public/static/img/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/public/static/img/1.jpg
--------------------------------------------------------------------------------
/public/static/img/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/public/static/img/2.jpg
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "iconfont"; /* Project id 3472194 */
3 | /* Color fonts */
4 | src:
5 | url('iconfont.woff2?t=1655460110529') format('woff2'),
6 | url('iconfont.woff?t=1655460110529') format('woff'),
7 | url('iconfont.ttf?t=1655460110529') format('truetype');
8 | }
9 |
10 | .iconfont {
11 | font-family: "iconfont" !important;
12 | font-size: 16px;
13 | font-style: normal;
14 | -webkit-font-smoothing: antialiased;
15 | -moz-osx-font-smoothing: grayscale;
16 | }
17 |
18 | .icon-xingzhuang-sanjiaoxing:before {
19 | content: "\eb99";
20 | }
21 |
22 | .icon-circle:before {
23 | content: "\e601";
24 | }
25 |
26 | .icon-juxing:before {
27 | content: "\e652";
28 | }
29 |
30 | .icon-zhixian:before {
31 | content: "\e653";
32 | }
33 |
34 | .icon-jiantou:before {
35 | content: "\e654";
36 | }
37 |
38 | .icon-rect:before {
39 | content: "\e706";
40 | }
41 |
42 | .icon-wenben:before {
43 | content: "\e605";
44 | }
45 |
46 | .icon-line:before {
47 | content: "\e7cf";
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.js:
--------------------------------------------------------------------------------
1 | !function(t){var e,n,o,c,i,a='',d=(d=document.getElementsByTagName("script"))[d.length-1].getAttribute("data-injectcss"),l=function(t,e){e.parentNode.insertBefore(t,e)};if(d&&!t.__iconfont__svg__cssinject__){t.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(t){console&&console.log(t)}}function s(){i||(i=!0,o())}function h(){try{c.documentElement.doScroll("left")}catch(t){return void setTimeout(h,50)}s()}e=function(){var t,e=document.createElement("div");e.innerHTML=a,a=null,(e=e.getElementsByTagName("svg")[0])&&(e.setAttribute("aria-hidden","true"),e.style.position="absolute",e.style.width=0,e.style.height=0,e.style.overflow="hidden",e=e,(t=document.body).firstChild?l(e,t.firstChild):t.appendChild(e))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(e,0):(n=function(){document.removeEventListener("DOMContentLoaded",n,!1),e()},document.addEventListener("DOMContentLoaded",n,!1)):document.attachEvent&&(o=e,c=t.document,i=!1,h(),c.onreadystatechange=function(){"complete"==c.readyState&&(c.onreadystatechange=null,s())})}(window);
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "3472194",
3 | "name": "图片编辑器",
4 | "font_family": "iconfont",
5 | "css_prefix_text": "icon-",
6 | "description": "",
7 | "glyphs": [
8 | {
9 | "icon_id": "4354256",
10 | "name": "形状-三角形",
11 | "font_class": "xingzhuang-sanjiaoxing",
12 | "unicode": "eb99",
13 | "unicode_decimal": 60313
14 | },
15 | {
16 | "icon_id": "6586753",
17 | "name": "圆形",
18 | "font_class": "circle",
19 | "unicode": "e601",
20 | "unicode_decimal": 58881
21 | },
22 | {
23 | "icon_id": "12550614",
24 | "name": "矩形",
25 | "font_class": "juxing",
26 | "unicode": "e652",
27 | "unicode_decimal": 58962
28 | },
29 | {
30 | "icon_id": "12550615",
31 | "name": "直线",
32 | "font_class": "zhixian",
33 | "unicode": "e653",
34 | "unicode_decimal": 58963
35 | },
36 | {
37 | "icon_id": "12550616",
38 | "name": "箭头",
39 | "font_class": "jiantou",
40 | "unicode": "e654",
41 | "unicode_decimal": 58964
42 | },
43 | {
44 | "icon_id": "17692235",
45 | "name": "矩形",
46 | "font_class": "rect",
47 | "unicode": "e706",
48 | "unicode_decimal": 59142
49 | },
50 | {
51 | "icon_id": "20360858",
52 | "name": "符号-文本",
53 | "font_class": "wenben",
54 | "unicode": "e605",
55 | "unicode_decimal": 58885
56 | },
57 | {
58 | "icon_id": "21024744",
59 | "name": "线条",
60 | "font_class": "line",
61 | "unicode": "e7cf",
62 | "unicode_decimal": 59343
63 | }
64 | ]
65 | }
66 |
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/src/assets/iconfont/iconfont.ttf
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/src/assets/iconfont/iconfont.woff
--------------------------------------------------------------------------------
/src/assets/iconfont/iconfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/src/assets/iconfont/iconfont.woff2
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kai-tu-cn/kaitu-image-editor/7a3e74ef01f6820912539870c4e4e0e060163801/src/assets/logo.png
--------------------------------------------------------------------------------
/src/assets/style/editor.scss:
--------------------------------------------------------------------------------
1 | .ui-editor{
2 | height: 100%;
3 | width: 100%;
4 | position: relative;
5 | display: flex;
6 | .ui-block{
7 | width: 100%;
8 | }
9 | .ui-margin-top{
10 | margin-top: 20px;
11 | }
12 | .ui-editor_formwork{
13 | width: 260px;
14 | height: 100%;
15 | .el-tabs{
16 | height: 100%;
17 | .el-tabs__content{
18 | height: calc(100% - 40px);
19 | padding: 0;
20 | .el-tab-pane{
21 | height: 100%;
22 | overflow: hidden;
23 | }
24 | }
25 | }
26 | .ui-editor_formwork_template{
27 | list-style: none;
28 | margin: 0;
29 | padding: 5px;
30 | height: 100%;
31 | overflow-y: auto;
32 | li{
33 | position: relative;
34 | height: 160px;
35 | cursor: pointer;
36 | &:hover{
37 | outline: 1px solid #409EFF;
38 | .ui-editor_delete{
39 | display: block;
40 | }
41 | }
42 | &+li{
43 | margin-top: 10px;
44 | border-top: 1px solid #DCDFE6;
45 | }
46 | img{
47 | height: 140px;
48 | width: 100%;
49 | }
50 | div{
51 | height: 20px;
52 | text-align: center;
53 | font-size: 13px;
54 | }
55 | .ui-editor_delete{
56 | display: none;
57 | position: absolute;
58 | right: 5px;
59 | top: 5px;
60 | color: red;
61 | }
62 | }
63 | }
64 | .ui-editor_formwork_material{
65 | list-style: none;
66 | margin: 0;
67 | padding: 5px;
68 | height: 100%;
69 | overflow-y: auto;
70 | li{
71 | float: left;
72 | width: 70px;
73 | cursor: pointer;
74 | margin: 5px;
75 | text-align: center;
76 | &:hover{
77 | outline: 1px solid #409EFF;
78 | }
79 | img{
80 | height: 70px;
81 | width: 100%;
82 | }
83 | .icon{
84 | font-size: 50px;
85 | }
86 | div{
87 | height: 20px;
88 | text-align: center;
89 | font-size: 13px;
90 | }
91 | }
92 | }
93 | }
94 | .ui-editor_workspace{
95 | flex: 1;
96 | width: 100%;
97 | height: 100%;
98 | overflow: auto;
99 | background: #ccc;
100 | .ui-editor_container{
101 | width: 100%;
102 | height: 100%;
103 | }
104 | .ui-editor_menu{
105 | position: fixed;
106 | z-index: 129;
107 | width: 140px;
108 | background-color: rgb(255, 255, 255);
109 | border-radius: 4px;
110 | box-shadow: rgb(0 0 0 / 10%) 0.5px 2px 7px;
111 | display: none;
112 | ul{
113 | list-style: none;
114 | padding: 5px 0;
115 | margin: 0;
116 | li{
117 | padding: 5px 20px;
118 | height: 32px;
119 | display: flex;
120 | align-items: center;
121 | user-select: none;
122 | font-size: 14px;
123 | &:hover{
124 | background-color: #ececff;
125 | }
126 | }
127 | }
128 | }
129 | }
130 | .ui-editor_property{
131 | width: 240px;
132 | background: #fff;
133 | .el-tabs{
134 | height: 100%;
135 | .el-tabs__content{
136 | height: calc(100% - 40px);
137 | padding: 10px;
138 | }
139 | }
140 | .ui-editor_gradient{
141 | display: flex;
142 | flex-wrap: wrap;
143 | .ui-editor_gradient_item{
144 | position: relative;
145 | width: 32px;
146 | height: 32px;
147 | border-radius: 4px;
148 | cursor: pointer;
149 | margin: 5px;
150 | }
151 | }
152 | }
153 | }
--------------------------------------------------------------------------------
/src/assets/style/index.scss:
--------------------------------------------------------------------------------
1 | body, html{
2 | height: 100%;
3 | width: 100%;
4 | margin: 0;
5 | padding: 0;
6 | }
7 | *{
8 | box-sizing: border-box;
9 | }
10 | ::-webkit-scrollbar {
11 | width: 7px;
12 | height: 7px;
13 | background-color: rgba(0, 0, 0, 0);
14 | }
15 | /*定义滚动条轨道 内阴影+圆角*/
16 | ::-webkit-scrollbar-track {
17 | box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
18 | border-radius: 5px;
19 | background-color: rgba(0, 0, 0, 0);
20 | }
21 |
22 | /*定义滑块 内阴影+圆角*/
23 | ::-webkit-scrollbar-thumb {
24 | border-radius: 5px;
25 | box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.15);
26 | background-color: rgba(144, 147, 153, 0.15);
27 | }
28 | .icon{
29 | vertical-align: middle;
30 | width: 1em;
31 | height: 1em;
32 | fill: currentColor;
33 | }
34 | #app {
35 | height: 100%;
36 | font-family: Avenir, Helvetica, Arial, sans-serif;
37 | -webkit-font-smoothing: antialiased;
38 | -moz-osx-font-smoothing: grayscale;
39 | color: #2c3e50;
40 | }
41 | .ui-index{
42 | height: 100%;
43 | width: 100%;
44 | background: #f2f2f2;
45 | .ui-index_header{
46 | padding: 10px 20px;
47 | height: 50px;
48 | background-color: #fff;
49 | display: flex;
50 | .soft{
51 | display: inline-block;
52 | margin: 0 5px;
53 | background-color: #0069d9;
54 | color: #fff;
55 | border-radius: 4px;
56 | padding: 5px 10px;
57 | font-size: 14px;
58 | cursor: pointer;
59 | }
60 | .project{
61 | flex: 1 1;
62 | font-weight: 700;
63 | text-align: center;
64 | }
65 | }
66 | .ui-index_content{
67 | height: calc(100% - 50px - 24px);
68 | }
69 | .ui-index_footer{
70 | height: 24px;
71 | text-align: center;
72 | font-size: 14px;
73 | color: #ccc;
74 | cursor: pointer;
75 | }
76 | }
77 | @import "./editor.scss"
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import VueRouter from 'vue-router'
3 |
4 | import App from './App.vue'
5 |
6 | import '@/assets/iconfont/iconfont.js'
7 | import '@/assets/style/index.scss'
8 |
9 | import ElementUI from 'element-ui'
10 | import 'element-ui/lib/theme-chalk/index.css'
11 |
12 | import VueGitHubButtons from 'vue-github-buttons'
13 | import 'vue-github-buttons/dist/vue-github-buttons.css'
14 |
15 | Vue.use(VueRouter)
16 | Vue.use(ElementUI, {
17 | size: 'small'
18 | })
19 | Vue.use(VueGitHubButtons)
20 |
21 | Vue.config.productionTip = false
22 |
23 | const router = new VueRouter({
24 | base: process.env.BASE_URL,
25 | routes: [
26 | {
27 | path: '/',
28 | name: 'editor',
29 | component: () => import('@/views/ktEditor.vue')
30 | }
31 | ]
32 | })
33 |
34 | new Vue({
35 | router,
36 | render: h => h(App)
37 | }).$mount('#app')
38 |
--------------------------------------------------------------------------------
/src/utils/aligning_guidelines.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Should objects be aligned by a bounding box?
3 | * [Bug] Scaled objects sometimes can not be aligned by edges
4 | *
5 | */
6 | import { fabric } from 'fabric'
7 |
8 | export const initAligningGuidelines = (canvas) => {
9 | const ctx = canvas.getSelectionContext()
10 | const aligningLineOffset = 5
11 | const aligningLineMargin = 4
12 | const aligningLineWidth = 2
13 | const aligningLineColor = '#e056fd'
14 | let viewportTransform
15 | let zoom = 1
16 |
17 | function drawVerticalLine (coords) {
18 | drawLine(
19 | coords.x + 0.5,
20 | coords.y1 > coords.y2 ? coords.y2 : coords.y1,
21 | coords.x + 0.5,
22 | coords.y2 > coords.y1 ? coords.y2 : coords.y1)
23 | }
24 |
25 | function drawHorizontalLine (coords) {
26 | drawLine(
27 | coords.x1 > coords.x2 ? coords.x2 : coords.x1,
28 | coords.y + 0.5,
29 | coords.x2 > coords.x1 ? coords.x2 : coords.x1,
30 | coords.y + 0.5)
31 | }
32 |
33 | function drawLine (x1, y1, x2, y2) {
34 | ctx.save()
35 | ctx.lineWidth = aligningLineWidth
36 | ctx.strokeStyle = aligningLineColor
37 | ctx.beginPath()
38 | ctx.moveTo(((x1 + viewportTransform[4]) * zoom), ((y1 + viewportTransform[5]) * zoom))
39 | ctx.lineTo(((x2 + viewportTransform[4]) * zoom), ((y2 + viewportTransform[5]) * zoom))
40 | ctx.stroke()
41 | ctx.restore()
42 | }
43 |
44 | function isInRange (value1, value2) {
45 | value1 = Math.round(value1)
46 | value2 = Math.round(value2)
47 | for (let i = value1 - aligningLineMargin, len = value1 + aligningLineMargin; i <= len; i++) {
48 | if (i === value2) {
49 | return true
50 | }
51 | }
52 | return false
53 | }
54 |
55 | const verticalLines = []
56 | const horizontalLines = []
57 |
58 | canvas.on('mouse:down', function () {
59 | viewportTransform = canvas.viewportTransform
60 | zoom = canvas.getZoom()
61 | })
62 |
63 | canvas.on('object:moving', function (e) {
64 | const activeObject = e.target
65 | const canvasObjects = canvas.getObjects()
66 | const activeObjectCenter = activeObject.getCenterPoint()
67 | const activeObjectLeft = activeObjectCenter.x
68 | const activeObjectTop = activeObjectCenter.y
69 | const activeObjectBoundingRect = activeObject.getBoundingRect()
70 | const activeObjectHeight = activeObjectBoundingRect.height / viewportTransform[3]
71 | const activeObjectWidth = activeObjectBoundingRect.width / viewportTransform[0]
72 | let horizontalInTheRange = false
73 | let verticalInTheRange = false
74 | const transform = canvas._currentTransform
75 |
76 | if (!transform) return
77 |
78 | // It should be trivial to DRY this up by encapsulating (repeating) creation of x1, x2, y1, and y2 into functions,
79 | // but we're not doing it here for perf. reasons -- as this a function that's invoked on every mouse move
80 |
81 | for (let i = canvasObjects.length; i--;) {
82 | if (canvasObjects[i] === activeObject) continue
83 |
84 | const objectCenter = canvasObjects[i].getCenterPoint()
85 | const objectLeft = objectCenter.x
86 | const objectTop = objectCenter.y
87 | const objectBoundingRect = canvasObjects[i].getBoundingRect()
88 | const objectHeight = objectBoundingRect.height / viewportTransform[3]
89 | const objectWidth = objectBoundingRect.width / viewportTransform[0]
90 |
91 | // snap by the horizontal center line
92 | if (isInRange(objectLeft, activeObjectLeft)) {
93 | verticalInTheRange = true
94 | verticalLines.push({
95 | x: objectLeft,
96 | y1: (objectTop < activeObjectTop)
97 | ? (objectTop - objectHeight / 2 - aligningLineOffset)
98 | : (objectTop + objectHeight / 2 + aligningLineOffset),
99 | y2: (activeObjectTop > objectTop)
100 | ? (activeObjectTop + activeObjectHeight / 2 + aligningLineOffset)
101 | : (activeObjectTop - activeObjectHeight / 2 - aligningLineOffset)
102 | })
103 | activeObject.setPositionByOrigin(new fabric.Point(objectLeft, activeObjectTop), 'center', 'center')
104 | }
105 |
106 | // snap by the left edge
107 | if (isInRange(objectLeft - objectWidth / 2, activeObjectLeft - activeObjectWidth / 2)) {
108 | verticalInTheRange = true
109 | verticalLines.push({
110 | x: objectLeft - objectWidth / 2,
111 | y1: (objectTop < activeObjectTop)
112 | ? (objectTop - objectHeight / 2 - aligningLineOffset)
113 | : (objectTop + objectHeight / 2 + aligningLineOffset),
114 | y2: (activeObjectTop > objectTop)
115 | ? (activeObjectTop + activeObjectHeight / 2 + aligningLineOffset)
116 | : (activeObjectTop - activeObjectHeight / 2 - aligningLineOffset)
117 | })
118 | activeObject.setPositionByOrigin(new fabric.Point(objectLeft - objectWidth / 2 + activeObjectWidth / 2, activeObjectTop), 'center', 'center')
119 | }
120 |
121 | // snap by the right edge
122 | if (isInRange(objectLeft + objectWidth / 2, activeObjectLeft + activeObjectWidth / 2)) {
123 | verticalInTheRange = true
124 | verticalLines.push({
125 | x: objectLeft + objectWidth / 2,
126 | y1: (objectTop < activeObjectTop)
127 | ? (objectTop - objectHeight / 2 - aligningLineOffset)
128 | : (objectTop + objectHeight / 2 + aligningLineOffset),
129 | y2: (activeObjectTop > objectTop)
130 | ? (activeObjectTop + activeObjectHeight / 2 + aligningLineOffset)
131 | : (activeObjectTop - activeObjectHeight / 2 - aligningLineOffset)
132 | })
133 | activeObject.setPositionByOrigin(new fabric.Point(objectLeft + objectWidth / 2 - activeObjectWidth / 2, activeObjectTop), 'center', 'center')
134 | }
135 |
136 | // snap by the vertical center line
137 | if (isInRange(objectTop, activeObjectTop)) {
138 | horizontalInTheRange = true
139 | horizontalLines.push({
140 | y: objectTop,
141 | x1: (objectLeft < activeObjectLeft)
142 | ? (objectLeft - objectWidth / 2 - aligningLineOffset)
143 | : (objectLeft + objectWidth / 2 + aligningLineOffset),
144 | x2: (activeObjectLeft > objectLeft)
145 | ? (activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset)
146 | : (activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset)
147 | })
148 | activeObject.setPositionByOrigin(new fabric.Point(activeObjectLeft, objectTop), 'center', 'center')
149 | }
150 |
151 | // snap by the top edge
152 | if (isInRange(objectTop - objectHeight / 2, activeObjectTop - activeObjectHeight / 2)) {
153 | horizontalInTheRange = true
154 | horizontalLines.push({
155 | y: objectTop - objectHeight / 2,
156 | x1: (objectLeft < activeObjectLeft)
157 | ? (objectLeft - objectWidth / 2 - aligningLineOffset)
158 | : (objectLeft + objectWidth / 2 + aligningLineOffset),
159 | x2: (activeObjectLeft > objectLeft)
160 | ? (activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset)
161 | : (activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset)
162 | })
163 | activeObject.setPositionByOrigin(new fabric.Point(activeObjectLeft, objectTop - objectHeight / 2 + activeObjectHeight / 2), 'center', 'center')
164 | }
165 |
166 | // snap by the bottom edge
167 | if (isInRange(objectTop + objectHeight / 2, activeObjectTop + activeObjectHeight / 2)) {
168 | horizontalInTheRange = true
169 | horizontalLines.push({
170 | y: objectTop + objectHeight / 2,
171 | x1: (objectLeft < activeObjectLeft)
172 | ? (objectLeft - objectWidth / 2 - aligningLineOffset)
173 | : (objectLeft + objectWidth / 2 + aligningLineOffset),
174 | x2: (activeObjectLeft > objectLeft)
175 | ? (activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset)
176 | : (activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset)
177 | })
178 | activeObject.setPositionByOrigin(new fabric.Point(activeObjectLeft, objectTop + objectHeight / 2 - activeObjectHeight / 2), 'center', 'center')
179 | }
180 | }
181 |
182 | if (!horizontalInTheRange) {
183 | horizontalLines.length = 0
184 | }
185 |
186 | if (!verticalInTheRange) {
187 | verticalLines.length = 0
188 | }
189 | })
190 |
191 | canvas.on('before:render', function () {
192 | canvas.clearContext(canvas.contextTop)
193 | })
194 |
195 | canvas.on('after:render', function () {
196 | for (let i = verticalLines.length; i--;) {
197 | drawVerticalLine(verticalLines[i])
198 | }
199 | for (let j = horizontalLines.length; j--;) {
200 | drawHorizontalLine(horizontalLines[j])
201 | }
202 |
203 | verticalLines.length = horizontalLines.length = 0
204 | })
205 |
206 | canvas.on('mouse:up', function () {
207 | verticalLines.length = horizontalLines.length = 0
208 | canvas.renderAll()
209 | })
210 | }
211 |
--------------------------------------------------------------------------------
/src/utils/arrow.js:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric'
2 |
3 | const Arrow = fabric.util.createClass(fabric.Line, {
4 | type: 'arrow',
5 | superType: 'drawing',
6 | initialize (points, options) {
7 | if (!points) {
8 | const { x1, x2, y1, y2 } = options
9 | points = [x1, y1, x2, y2]
10 | }
11 | options = options || {}
12 | this.callSuper('initialize', points, options)
13 | },
14 | _render (ctx) {
15 | this.callSuper('_render', ctx)
16 | ctx.save()
17 | const xDiff = this.x2 - this.x1
18 | const yDiff = this.y2 - this.y1
19 | const angle = Math.atan2(yDiff, xDiff)
20 | ctx.translate((this.x2 - this.x1) / 2, (this.y2 - this.y1) / 2)
21 | ctx.rotate(angle)
22 | ctx.beginPath()
23 | ctx.moveTo(5, 0)
24 | ctx.lineTo(-5, 5)
25 | ctx.lineTo(-5, -5)
26 | ctx.closePath()
27 | ctx.fillStyle = this.stroke
28 | ctx.fill()
29 | ctx.restore()
30 | }
31 | })
32 |
33 | Arrow.fromObject = (options, callback) => {
34 | const { x1, x2, y1, y2 } = options
35 | return callback(new Arrow([x1, y1, x2, y2], options))
36 | }
37 |
38 | // @ts-ignore
39 | window.fabric.Arrow = Arrow
40 |
41 | export default Arrow
42 |
--------------------------------------------------------------------------------
/src/utils/background.js:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric'
2 | // @ts-ignore
3 | export class BackgroundObject extends fabric.Rect {
4 | static type = 'Background'
5 | initialize (options) {
6 | super.initialize({
7 | ...options,
8 | selectable: false,
9 | hasControls: true,
10 | hasBorders: false,
11 | lockMovementY: true,
12 | lockMovementX: true,
13 | strokeWidth: 0,
14 | evented: true,
15 | hoverCursor: 'default'
16 | })
17 | return this
18 | }
19 |
20 | toObject (propertiesToInclude) {
21 | return super.toObject(propertiesToInclude)
22 | }
23 |
24 | toJSON (propertiesToInclude) {
25 | return super.toObject(propertiesToInclude)
26 | }
27 |
28 | static fromObject (options, callback) {
29 | return callback && callback(new fabric.Background(options))
30 | }
31 | }
32 |
33 | fabric.Background = fabric.util.createClass(BackgroundObject, {
34 | type: BackgroundObject.type
35 | })
36 | fabric.Background.fromObject = BackgroundObject.fromObject
37 |
--------------------------------------------------------------------------------
/src/views/index.js:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric'
2 | import localforage from 'localforage'
3 | import { nanoid } from 'nanoid'
4 | import * as FileSaver from 'file-saver'
5 | import '@/utils/arrow'
6 | import '@/utils/background'
7 | import { initAligningGuidelines } from '@/utils/aligning_guidelines'
8 | let _fabricObj = null
9 | let background = null
10 | export default {
11 | data () {
12 | return {
13 | publicPath: process.env.NODE_ENV === 'production' ? '/kaitu-image-editor' : '/',
14 | activeName: 'material',
15 | tempTitle: '778',
16 | tempVisible: false,
17 | tempList: [],
18 | setting: {
19 | width: 600,
20 | height: 400,
21 | background: '#fff'
22 | },
23 | material: [
24 | {
25 | name: '文本',
26 | icon: '#icon-wenben',
27 | type: 'IText'
28 | },
29 | {
30 | name: '矩形',
31 | icon: '#icon-juxing',
32 | type: 'Rect'
33 | },
34 | {
35 | name: '圆形',
36 | icon: '#icon-circle',
37 | type: 'Circle'
38 | },
39 | {
40 | name: '三角形',
41 | icon: '#icon-xingzhuang-sanjiaoxing',
42 | type: 'Triangle'
43 | },
44 | {
45 | name: '线条',
46 | icon: '#icon-zhixian',
47 | type: 'Line'
48 | },
49 | {
50 | name: '箭头',
51 | icon: '#icon-jiantou',
52 | type: 'Arrow'
53 | },
54 | {
55 | name: '图片1',
56 | icon: '/static/img/1.jpg',
57 | type: 'Image'
58 | },
59 | {
60 | name: '图片2',
61 | icon: '/static/img/2.jpg',
62 | type: 'Image'
63 | }
64 | ],
65 | backgroundGradient: [
66 | {
67 | background: 'linear-gradient(0deg, rgb(142, 197, 252) 0%, rgb(224, 195, 252) 100%)',
68 | angle: 0,
69 | colorStops: [
70 | { offset: 0, color: 'rgb(142, 197, 252)' },
71 | { offset: 1, color: 'rgb(224, 195, 252)' }
72 | ]
73 | },
74 | {
75 | background: 'linear-gradient(45deg, rgb(133, 255, 189) 0%, rgb(255, 251, 125) 100%)',
76 | angle: 45,
77 | colorStops: [
78 | { offset: 0, color: 'rgb(133, 255, 189)' },
79 | { offset: 1, color: 'rgb(255, 251, 125)' }
80 | ]
81 | },
82 | {
83 | background: 'linear-gradient(43deg, #4158D0 0%, #C850C0 46%, #FFCC70 100%)',
84 | angle: 43,
85 | colorStops: [
86 | { offset: 0, color: '#4158D0' },
87 | { offset: 0.46, color: '#C850C0' },
88 | { offset: 1, color: '#FFCC70' }
89 | ]
90 | },
91 | {
92 | background: 'linear-gradient(90deg, #FF9A8B 0%, #FF6A88 55%, #FF99AC 100%)',
93 | angle: 90,
94 | colorStops: [
95 | { offset: 0, color: '#FF9A8B' },
96 | { offset: 0.55, color: '#FF6A88' },
97 | { offset: 1, color: '#FF99AC' }
98 | ]
99 | }
100 | ],
101 | menuList: [
102 | {
103 | name: '上移图层',
104 | value: 'bringForward'
105 | },
106 | {
107 | name: '下移图层',
108 | value: 'sendBackwards'
109 | },
110 | {
111 | name: '置顶',
112 | value: 'bringToFront'
113 | },
114 | {
115 | name: '置底',
116 | value: 'sendToBack'
117 | },
118 | {
119 | name: '删除',
120 | value: 'delete'
121 | }
122 | ],
123 | editObject: {},
124 | props: ['id']
125 | }
126 | },
127 | mounted () {
128 | this.initEditor()
129 | this.loadFormworkList()
130 | },
131 | methods: {
132 | initEditor () {
133 | /* eslint-disable */
134 | const self = this
135 | const $_container = self.$refs['ui-editor_container']
136 | const width = $_container.offsetWidth
137 | const height = $_container.offsetHeight
138 | _fabricObj = new fabric.Canvas('editor_canvas', {
139 | width,
140 | height,
141 | stopContextMenu: false,
142 | fireRightClick: true,
143 | enableRetinaScaling: true,
144 | preserveObjectStacking: true,
145 | selectionBorderColor: '#0069d9',
146 | selectionColor: 'rgba(64,158,255, 0.2)',
147 | })
148 | fabric.Object.prototype.set({
149 | transparentCorners: false,
150 | cornerColor: '#fff',
151 | borderColor: '#0069d9',
152 | cornerStyle: 'circle',
153 | cornerSize: 12,
154 | cornerStrokeColor: '#ccc',
155 | borderScaleFactor: 1,
156 | borderOpacityWhenMoving: 3,
157 | borderOpacity: 1,
158 | })
159 | initAligningGuidelines(_fabricObj)
160 | self.initBackground()
161 | _fabricObj.on({
162 | 'mouse:down': function(e) {
163 | self.hideMenu()
164 | },
165 | 'selection:created': (e) => {
166 | //选中,动态更新赋值
167 | self.canvasSelectObject(e)
168 | },
169 | 'selection:updated': (e) => {
170 | //选中更新
171 | self.canvasSelectObject(e)
172 | },
173 | })
174 | },
175 | initBackground() {
176 | background = new fabric.Background({
177 | width: 600,
178 | height: 400,
179 | fill: '#f6f7f9',
180 | id: 'background',
181 | })
182 | _fabricObj.add(background)
183 | background.center()
184 | },
185 | handleBackgroundSet() {
186 | const self = this
187 | const obj = {
188 | width: +self.setting.width,
189 | height: +self.setting.height,
190 | fill: self.setting.background
191 | }
192 | background.set(obj).center()
193 | _fabricObj.renderAll()
194 | },
195 | handleBackgroundGradient(item) {
196 | const angle = item.angle
197 | const colorStops = item.colorStops
198 | let anglePI = (-parseInt(angle, 10)) * (Math.PI / 180)
199 | var coords = {
200 | x1: Math.round(50 + Math.sin(anglePI) * 50) / 100,
201 | x2: Math.round(50 + Math.sin(anglePI + Math.PI) * 50) / 100,
202 | y1: Math.round(50 + Math.cos(anglePI) * 50) / 100,
203 | y2: Math.round(50 + Math.cos(anglePI + Math.PI) * 50) / 100,
204 | };
205 | let gradient = new fabric.Gradient({
206 | type: 'linear',
207 | gradientUnits: 'percentage',
208 | coords: coords,
209 | colorStops: colorStops
210 | })
211 | background.set({
212 | fill: gradient
213 | }).setCoords()
214 | _fabricObj.renderAll()
215 | },
216 | saveFormwork() {
217 | const self = this
218 | _fabricObj.discardActiveObject()
219 | _fabricObj.renderAll();
220 | const json = _fabricObj.toDatalessJSON(self.props)
221 | const img = self.disposeImage()
222 | const id = nanoid(6)
223 | localforage.getItem('kt_temps').then(res => {
224 | const temps = JSON.parse(res || '{}')
225 | temps[id] = {json, title: self.tempTitle, img};
226 | localforage.setItem('kt_temps', JSON.stringify(temps)).then(res => {
227 | self.loadFormworkList()
228 | self.closeTempDialog()
229 | })
230 | })
231 | },
232 | openFormwork() {
233 | const self = this
234 | self.tempVisible = true
235 | self.tempTitle = ''
236 | },
237 | closeTempDialog() {
238 | const self = this
239 | self.tempVisible = false
240 | },
241 | loadFormworkList() {
242 | const self = this
243 | localforage.getItem('kt_temps').then(res => {
244 | const temps = JSON.parse(res || '{}')
245 | self.tempList = Object.keys(temps).map(item => ({
246 | title: temps[item].title,
247 | id: item,
248 | img: temps[item].img,
249 | json: temps[item].json
250 | }))
251 | })
252 | },
253 | loadFormwork (item) {
254 | _fabricObj.clear();
255 | _fabricObj.loadFromJSON(item.json, _fabricObj.renderAll.bind(_fabricObj),
256 | function (o, object) {
257 | if (object.id === 'background') {
258 | background = object
259 | background.center()
260 | }
261 | })
262 | },
263 | deleteFormwork(e, index, item) {
264 | const self = this
265 | e.stopPropagation()
266 | self.tempList.splice(index, 1)
267 | const temps = {}
268 | self.tempList.forEach(e => {
269 | temps[e.id] = {
270 | json: e.json,
271 | title: e.tempTitle,
272 | img: e.img
273 | }
274 | })
275 | localforage.setItem('kt_temps', JSON.stringify(temps)).then(res => {
276 | self.loadFormworkList()
277 | })
278 | },
279 | handleDragStart(e, item) {
280 | e.dataTransfer.setData('data', JSON.stringify(item))
281 | },
282 | handleDrop(e) {
283 | const self = this
284 | e.preventDefault()
285 | e.stopPropagation()
286 | if(e.dataTransfer.getData('data')) {
287 | const data = JSON.parse(e.dataTransfer.getData('data'))
288 | self.addMaterialToEditor(e, data)
289 | }
290 | },
291 | handleDragOver(e) {
292 | e.preventDefault()
293 | e.dataTransfer.dropEffect = 'copy'
294 | },
295 | addMaterialToEditor(e, data) {
296 | const { clientX, clientY } = e
297 | const { offsetTop, offsetLeft } = document.querySelector('.ui-editor_container')
298 | const top = clientY - offsetTop
299 | const left = clientX - offsetLeft
300 | let Shape = null
301 | switch (data.type) {
302 | case 'IText':
303 | Shape = new fabric.IText('开图-图片编辑器',{
304 | id: nanoid(6),
305 | left: left - 20,
306 | top: top,
307 | fontSize: 40,
308 | fill: '#409EFF',
309 | });
310 | break;
311 | case 'Rect':
312 | Shape = new fabric.Rect({
313 | id: nanoid(6),
314 | left: left - 60,
315 | top: top - 40,
316 | width: 120,
317 | height: 80,
318 | fill: '#409EFF',
319 | });
320 | break;
321 | case 'Circle':
322 | Shape = new fabric.Circle({
323 | id: nanoid(6),
324 | left: left - 20,
325 | top: top - 20,
326 | radius: 40,
327 | fill: '#409EFF',
328 | });
329 | break;
330 | case 'Triangle':
331 | Shape = new fabric.Triangle({
332 | id: nanoid(6),
333 | left: left - 60,
334 | top: top - 60,
335 | width: 120,
336 | height: 120,
337 | fill: '#409EFF',
338 | });
339 | break;
340 | case 'Line':
341 | Shape = new fabric.Line([0, 200, 200, 0],{
342 | id: nanoid(6),
343 | left: left - 100,
344 | top: top - 100,
345 | stroke: '#409EFF',
346 | strokeWidth: 2,
347 | });
348 | break;
349 | case 'Arrow':
350 | Shape = new fabric.Arrow([0, 200, 200, 0],{
351 | id: nanoid(6),
352 | left: left - 50,
353 | top: top - 50,
354 | stroke: '#409EFF',
355 | strokeWidth: 2,
356 | });
357 | break;
358 | case 'Image':
359 | fabric.Image.fromURL(data.icon, oImg => {
360 | oImg.set({
361 | id: nanoid(8),
362 | left: left - oImg.width/2,
363 | top: top - oImg.height/2,
364 | globalCompositeOperation: 'source-atop',
365 | })
366 | _fabricObj.add(oImg)
367 | _fabricObj.setActiveObject(oImg)
368 | _fabricObj.renderAll()
369 | })
370 | return
371 | break;
372 | }
373 | Shape.set({
374 | globalCompositeOperation: 'source-atop',
375 | })
376 | _fabricObj.add(Shape)
377 | _fabricObj.setActiveObject(Shape)
378 | _fabricObj.renderAll()
379 | },
380 | hideMenu() {
381 | const self = this
382 | self.$refs['editorMenu'].style = `
383 | display: none;
384 | `
385 | },
386 | showMenu(event) {
387 | event.preventDefault();
388 | event.stopPropagation();
389 | const self = this
390 | const menu = self.$refs['editorMenu']
391 | const pointX = event.clientX
392 | const pointY = event.clientY
393 | menu.style = `
394 | display: block;
395 | left: ${pointX}px;
396 | top: ${pointY}px;
397 | `
398 | },
399 | handleMenu(item) {
400 | const key = item.value
401 | const self = this
402 | const activeObject = _fabricObj.getActiveObject()
403 | if (activeObject) {
404 | switch(key) {
405 | case 'bringForward':
406 | _fabricObj.bringForward(activeObject)
407 | break
408 | case 'bringToFront':
409 | _fabricObj.bringToFront(activeObject)
410 | break
411 | case 'sendBackwards':
412 | _fabricObj.sendBackwards(activeObject)
413 | break
414 | case 'sendToBack':
415 | _fabricObj.sendToBack(activeObject)
416 | break
417 | case 'delete':
418 | _fabricObj.getActiveObjects().forEach(obj => {
419 | _fabricObj.remove(obj)
420 | })
421 | _fabricObj.discardActiveObject()
422 | break
423 | }
424 | _fabricObj.renderAll()
425 | }
426 | self.hideMenu()
427 | },
428 | downloadImg() {
429 | const self = this
430 | _fabricObj.discardActiveObject()
431 | _fabricObj.renderAll();
432 | const img = self.disposeImage()
433 | FileSaver.saveAs(img, `开图_${nanoid(6)}.png`);
434 | },
435 | disposeImage() {
436 | const self = this
437 | const canvas = new fabric.StaticCanvas(null, {
438 | width: self.setting.width,
439 | height: self.setting.height,
440 | })
441 | _fabricObj.getObjects().forEach(e => {
442 | canvas.add(e)
443 | })
444 | canvas.renderAll()
445 | self.canvasFitView(canvas)
446 | return canvas.toDataURL('image/png')
447 | },
448 | canvasFitView(canvas) {
449 | var objects = canvas.getObjects();
450 | if (!objects.length) return
451 | canvas.setZoom(1);
452 | canvas.absolutePan({ x: 0, y: 0 });
453 | if (objects.length > 0) {
454 | var rect = objects[0].getBoundingRect();
455 | var minX = rect.left;
456 | var minY = rect.top;
457 | var maxX = rect.left + rect.width;
458 | var maxY = rect.top + rect.height;
459 | for (var i = 1; i < objects.length; i++) {
460 | rect = objects[i].getBoundingRect();
461 | minX = Math.min(minX, rect.left);
462 | minY = Math.min(minY, rect.top);
463 | maxX = Math.max(maxX, rect.left + rect.width);
464 | maxY = Math.max(maxY, rect.top + rect.height);
465 | }
466 | }
467 | var panX = (maxX - minX - canvas.width) / 2 + minX;
468 | var panY = (maxY - minY - canvas.height) / 2 + minY;
469 | canvas.absolutePan({ x: panX, y: panY });
470 | var zoom = Math.min(canvas.width / (maxX - minX), canvas.height / (maxY - minY));
471 | zoom = Math.min(5, zoom)
472 | zoom = Math.max(0.2, zoom)
473 | var zoomPoint = new fabric.Point(canvas.width / 2, canvas.height / 2);
474 | canvas.zoomToPoint(zoomPoint, zoom);
475 | },
476 | importJson() {
477 | const input = document.createElement('input');
478 | input.type = 'file';
479 | input.accept = 'application/json'
480 | input.onchange = (event) => {
481 | const elem = event.target;
482 | if (elem.files && elem.files[0]) {
483 | const file = elem.files[0]
484 | const reader = new FileReader();
485 | reader.onloadend = (e) => {
486 | _fabricObj.clear();
487 | _fabricObj.loadFromJSON(e.target.result, _fabricObj.renderAll.bind(_fabricObj),
488 | function (o, object) {
489 | if (object.id === 'background') {
490 | background = object
491 | background.center()
492 | }
493 | })
494 | };
495 | reader.readAsText(file, 'utf-8');
496 | }
497 | };
498 | input.click();
499 | },
500 | downloadJson() {
501 | const self = this
502 | _fabricObj.discardActiveObject()
503 | _fabricObj.renderAll();
504 | const json = _fabricObj.toDatalessJSON(self.props)
505 | let content = JSON.stringify(json)
506 | FileSaver.saveAs(
507 | new Blob([content], { type: 'text/plain;charset=utf-8' }),
508 | `开图_${nanoid(6)}.json`
509 | );
510 | },
511 | canvasSelectObject(e){
512 | const self = this
513 | const row = e.selected[0]
514 | self.editObject = row.toJSON(self.props)
515 | },
516 | handleChangeObject(obj) {
517 | const currentActive = _fabricObj.getActiveObject()
518 | currentActive.set(obj)
519 | _fabricObj.renderAll()
520 | },
521 | openSoft() {
522 | const url = 'http://139.9.84.71/'
523 | window.open(url,'_blank')
524 | }
525 | }
526 | }
--------------------------------------------------------------------------------
/src/views/ktEditor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
49 |
66 |
67 |
68 |
69 |
70 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
89 |
90 |
97 |
98 |
99 |
100 |
101 |
102 | 导入JSON
107 |
108 |
109 | 下载图片
113 |
114 |
115 | 保存模板
116 |
117 |
118 | 下载JSON
119 |
120 |
121 |
122 |
123 |
127 |
128 |
135 |
136 |
137 |
144 |
145 |
146 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
163 |
164 |
165 |
166 |
167 |
168 |
172 |
173 |
174 |
175 |
176 |
177 |
181 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const { defineConfig } = require('@vue/cli-service')
2 | module.exports = defineConfig({
3 | publicPath: process.env.NODE_ENV === 'production' ? '/kaitu-image-editor' : '/',
4 | transpileDependencies: true,
5 | devServer: {
6 | open: true,
7 | port: 1234,
8 | compress: true
9 | },
10 | chainWebpack: config => {
11 | config.resolve.extensions
12 | .clear()
13 | .add('.vue')
14 | .add('.js')
15 | .add('.json')
16 | config.resolve.symlinks(true)
17 | },
18 | productionSourceMap: false
19 | })
20 |
--------------------------------------------------------------------------------