├── .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 | ![kaitu](/assets/index_demo.png) 6 | 7 |

8 | Version 9 | 10 | License: MIT 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 | ![kaitu](/assets/index_demo.png) 6 | 7 |

8 | Version 9 | 10 | License: MIT 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 | ![kaitu](/assets/index_demo.png) 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 | ![kaitu](/assets/rect_demo.png) 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 | ![kaitu](/assets/editor_canvas.png) 72 | 73 | ### 2.1 模板 74 | 点击模板,即可使用该模板,模板保存在本地,用户可以新增模板和删除模板,使用`localforage`做本地离线数据处理 75 | ![kaitu](/assets/editor_temp.png) 76 | 77 | ### 2.2 组件 78 | 组件 主要有如文本,图片,直线,矩形,圆形,三角形,箭头,也根据自己扩展更多的基本图元,在组件tab页面,拖拽任意一个元素到 `画布`,即可添加到`画布上`, 79 | ![kaitu](/assets/editor_com.png) 80 | 81 | ### 3.1 设置 82 | 设置画布的大小和颜色,和渐变色,大家可以自由扩展,添加更多的颜色 83 | ![kaitu](/assets/editor_set.png) 84 | 85 | ### 3.2 属性 86 | 属性编辑主要是用来对图形属性进行配置的,比如填充颜色,描边颜色,描边宽度,目前我主要定义了这3个属性,大家也可以基于此继续扩展更多的可编辑属性,例如 width/height/top/left/....等等 87 | ![kaitu](/assets/editor_attr.png) 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 | 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 | 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 | --------------------------------------------------------------------------------