前言
经过将近四个月的开发与测试,站酷海洛的图片编辑器终于发布上线了!?? 编辑器和图库的整合,使得设计变得更加容易了。项目的初心也很明确,回馈给社区一份好的设计工具,提高设计圈的创造力。 目前的版本有裁剪、文本、滤镜三种功能,后期还会继续迭代,用来增强用户体验和丰富功能。
概要
整个项目是围绕React + 来构建的,此外还使用了Redux来接管状态管理,用来解决多交互的应用场景。同时配套的还有Immutable +Reselect,用来提升整个项目的性能。
Fabric是一个强大的图形处理库,是在Canvas的基础上封装的,它简化了实现各种图形的难度,同时扩展了事件系统、滤镜、拖跩、缩放、SVG解析、动画等功能,支持IE10及以上的浏览器。整体压缩后的文件大小为270KB左右,官方还提供了()功能,可以选择过滤一部分功能来减小文件体积。
它的整体结构如下:
画布作为容器,所有的2D图形及组合效果都可以填充到画布上面,工具类则提供了少量的公共函数。
按照官方文档,实例化一个画布需要这样(下文中的画布都代表实例化后的对象)
const instance = new fabric.Canvas('c')复制代码
因为React的组件化形式,我们需要等到对应组件渲染完毕后才能实例化,这就限制了画布的作用域,导致无法在其他地方填充2D图形。如果想对内部全局可用,需要稍微改动一下实例化的方式
大致的代码如下:
lib/fabric.js
import fabric from 'fabric'const instance = new fabric.Canvas() // new Canvas() 实际上调用的是initializeexport { instance }复制代码
src/editor.js
import { instance } from 'lib/fabric'// ...componentDidMount() { instance.initialize(this.canvas, { preserveObjectStacking: true })}render() { return (
如此,便可以在项目内部任何地方引用了。
搭配React
要丰富画布的内容,需要调用instance.add 来添加其他实例,比如
const text = new fabric.Text('hello world', { fontSize: 24 })instance.add(text)复制代码
当然,这是最基本的一种。如果要更改字体、颜色、描边、阴影等等,都可以通过可选的options来设置,目前支持的属性有["stroke"
, "strokeWidth"
, "fill"
, "fontFamily"
, "fontSize"
, "fontWeight"
, "fontStyle"
, "underline"
, "overline"
, "linethrough"
, "textBackgroundColor"
]
其他实例的添加方法也是类似,主要区别在于实例的配置项,具体细节可以去官方文档查阅。
那么用户操作的状态如何保存呢?换句话说有没有办法可以把画布的内容序列化成一个对象?
正好符合要求。序列化之后的画布反映了当前画布包含哪些内容。只要每次更新画布后都调用toObject,将数据更新到store中即可,基于此,撤销重做、自动保存都能实现了。
滤镜
CanvasRenderingContext2D.getImageData() 返回一个ImageData对象,用来描述Canvas区域隐含的像素数据。它的data属性描述了一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。Fabric内置的是基于来实现的。具体来说就是每一个滤镜对应一个 4*5 的矩阵,对于当前像素区域内的RGBA,应用矩阵算法后会得到新的R’G’B’A’。如此一来,再调用putImageData重新填充回Canvas之后,就可以看到应用滤镜后的效果了。
历史记录
对于用户的一些特定操作,我们通过保存其历史记录来实现撤销与重做。上文中已经介绍了画布是可以序列化的,因此撤销重做也是可以实现的。社区已经有比较成熟的redux-undo,其本质也是一个reducer。虽然它也有过滤功能,可以指定某些特定action,但是他会影响最终的store结构,这对于使用了Reselect库的项目来说,是很不爽的一件事情。而且每次触发action都会进行判断,然后在分发给下层的reducer,性能上也会有一定损失。出于以上两点,我们自己内部实现了一个undo类,在不影响store结构的前提下,它可以指定记录store中关键key值的变化。
大致情况就是首先定义两个栈来存放撤销与重做的内容,snapshotFields则用来存储需要记录变动的key值(比如store中的doc.fabric.data)
import { Stack, Map, List, fromJS, is } from 'immutable'class Snapshot { undoStack = Stack([]) redoStack = Stack([]) snapshotFields = List([]) takeSnapshot() { // 取出当前的store const snapshot = this.getSnapshot() // 与undoStack栈顶的store做比较,如果不同则放入栈中 const isEqual = is(this.undoStack.peek(), snapshot) if (!isEqual) { this.getRedoLength() > 0 && this.resetRedoStack() this.undoStack = this.undoStack.push(snapshot) } } getSnapshot() { // 返回store,此处的store是经过过滤的,也就是只有snapshotFields中的字段才会返回 } loadSnapshot(state) { /** * 伪代码如下 * 1. 遍历snapshotFields * 2. 取出state中对应Field的值 * 3. 更新store中对应的值 */ } includeKeyPathInSnapshots(e) { this.snapshotFields = this.snapshotFields.push( Array.isArray(e) ? fromJS(e) : e ) } undo() { if (this.undoStack.size > 1) { const snapshot = this.getSnapshot() this.redoStack = this.redoStack.push(snapshot) this.undoStack = this.undoStack.pop() const peeked = this.undoStack.peek() this.loadSnapshot(peeked) } } redo() { if (this.redoStack.size > 0) { const peeked = this.redoStack.peek() this.undoStack = this.undoStack.push(peeked) this.redoStack = this.redoStack.pop() this.loadSnapshot(peeked) } } // ...}复制代码
有了Snapshot,实例化之后就可以通过includeKeyPathInSnapshots来指定需要记录哪个key值了。takeSnapshot方法可以放在画布更新后的回调中去用来记录每次的画布数据,undo与redo则可以绑定到对应的组件Click事件中,整个撤销与重做大致就完成了。
下载
目前支持png、jpg格式的下载。下载流程如下图所示,省略了业务逻辑和相关权限校验
获取原始图片链接并下载到本地后,如果用户选择的尺寸与原尺寸不一致,需要对其裁剪,用到的是,裁剪之后放入原生的canvas元素,目的是为了替换画布中原有的canvas元素,然后再应用滤镜(因为图片版权的原因,用户编辑的图片都是带水印的小图,只有氪金用户才能使用下载功能),这样一来,处理的就是刚刚剪裁后的原图。再然后,把处理过的Canvas转换成Blob对象,此时还需要来兼容一部分浏览器不支持canvas.toBlob的情况。
最后通过window.URL.createObjectURL(blob)得到一个新的URL对象并赋值给动态创建的a标签,即可完成下载。
const objectURL = window.URL.createObjectURL(blob)const a = document.createElement('a')a.download = fileNamea.setAttribute('style', 'display: none;')a.href = objectURLdocument.body.appendChild(a)a.click()document.body.removeChild(a)复制代码
技术栈
React + React-Router + Redux + Immutable + Reselect + Fabric.js
结语
体验地址:
作者: