前言

经过将近四个月的开发与测试,站酷海洛的图片编辑器终于发布上线了!?? 编辑器和图库的整合,使得设计变得更加容易了。项目的初心也很明确,回馈给社区一份好的设计工具,提高设计圈的创造力。 目前的版本有裁剪、文本、滤镜三种功能,后期还会继续迭代,用来增强用户体验和丰富功能。

概要

整个项目是围绕React + Fabric.js来构建的,此外还使用了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() 实际上调用的是initialize

export { instance }
复制代码

src/editor.js

import { instance } from 'lib/fabric'

// ...

componentDidMount() {
  instance.initialize(this.canvas, {
    preserveObjectStacking: true
  })
}

render() {
  return (
    <canvas ref={ref => { this.canvas = ref }} />
  )
}

复制代码

如此,便可以在项目内部任何地方引用了。

搭配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"]

其他实例的添加方法也是类似,主要区别在于实例的配置项,具体细节可以去官方文档查阅。

那么用户操作的状态如何保存呢?换句话说有没有办法可以把画布的内容序列化成一个对象?

serialization正好符合要求。序列化之后的画布反映了当前画布包含哪些内容。只要每次更新画布后都调用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格式的下载。下载流程如下图所示,省略了业务逻辑和相关权限校验


获取原始图片链接并下载到本地后,如果用户选择的尺寸与原尺寸不一致,需要对其裁剪,用到的是pica,裁剪之后放入原生的canvas元素,目的是为了替换画布中原有的canvas元素,然后再应用滤镜(因为图片版权的原因,用户编辑的图片都是带水印的小图,只有氪金用户才能使用下载功能),这样一来,处理的就是刚刚剪裁后的原图。再然后,把处理过的Canvas转换成Blob对象,此时还需要blueimp-canvas-to-blob来兼容一部分浏览器不支持canvas.toBlob的情况。

最后通过window.URL.createObjectURL(blob)得到一个新的URL对象并赋值给动态创建的a标签,即可完成下载。

const objectURL = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.download = fileName
a.setAttribute('style', 'display: none;')
a.href = objectURL
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
复制代码

技术栈

React + React-Router + Redux + Immutable + Reselect + Fabric.js

结语

体验地址:站酷海洛

作者:cuining