Canvas对象模型库——Fabric.js简介

422 阅读9分钟

Canvas对象模型库——Fabric.js简介

1. 前言

最近在参与风火轮UI走查开发时遇到了这样的场景:
measure1.gif

要求实现一个测量器,能够在图片上绘制各种颜色的方块、并显示出方块的尺寸,方块支持添加、删除、拖动、缩放,保存后其他用户能够查看。

提取一下需求主要包括以下几点:

  • 我们需要在页面某个区域(比如图片上)绘制出一些特定的图形
  • 需要与图形有一些简单的交互(添加、删除、鼠标拖动、缩放)
  • 能够序列号、反序列化图形信息

我们如何能够实现上述功能呢?

最初我是这样考虑的:

  1. 获取图片的位置、大小信息
  2. 在图片上方创建一个与图片一样大小的div作为绘制图形容器&定位基准
  3. 创建一个数组用于保存该图片上绘制的图形信息
  4. 监听鼠标事件mousedown mousemove mouseup 创建一个div,添加到容器中,设置方块的宽高、边框颜色
  5. 给新创建的方块添加事件监听器(点击选中、缩放、拖动、删除)
  6. 将新创建的方块对象添加到数组中
  7. 提供图形信息序列化、反序列化方法

这样做有什么优缺点?

优点:

  • 原理简单
  • 可以灵活、有针对性的根据需求进行设计

缺点:

  • 图形对对象模型、事件监听、序列化&反序列化需要自己实现,较为繁琐
  • 难扩展,如果要绘制其他图形需要针对该图形重新设计对象模型
  • 生成大量dom元素

那么有没有其他方式能够帮助我们在某个区域绘制图形呢?

canvas 就能够做到!

我们可以使用canvas提供的API在画布上绘制出各种几何图形

<canvas id="c" width="300" height="300" style="border: 1px solid greenyellow"></canvas>

<script>
  const canvas = document.getElementById('c')
  const ctx = canvas.getContext('2d')
  ctx.fillStyle = 'red';
  ctx.fillRect(50, 60, 70, 80);
</script>

image.png

但如果我们需要改变canvas中的某个图形,或者与canvas绘制图形进行任何形式的互动,原生的API实现起来就显得非常繁琐。因为使用canvas相对于最初设想的方案,只是提供了API去绘制几何图形,且避免了生成大量dom,我们仍然需要实现事件监听、序列化&反序列化,并且引入了一个新的问题———画布中图形的拾取。
由于canvas不会保存绘制图形的信息,一旦绘制完成,画布中保存的是一张图片信息,在图片上点击时无法获取对应的图形信息。

很幸运,Fabric.js给我们解决了上面的所有问题!

2. Fabric.js API

创建canvas对象模型

<canvas id="c" width="300" height="300" style="border: 1px solid greenyellow"></canvas>

<script>
  const canvas = new fabric.Canvas('c');
</script>

绘制矩形

<head>
  <script src="https://unpkg.com/fabric@4.6.0/dist/fabric.min.js">	 
  </script>
</head>
<canvas id="c" width="300" height="300" style="border: 1px solid greenyellow"></canvas>

<script>
  const canvas = new fabric.Canvas('c');
  // 创建一个长方形
  const rect = new fabric.Rect({
    left: 50,
    top: 60,
    width: 70,
    height: 80,
    fill: 'red'
  })

  canvas.add(rect)
</script>

image.png

修改图形

Fabric为我们处理画布渲染和状态管理,我们只需要修改对象本身即可。

  • 定位属性(left、top)
  • 尺寸属性(width、height)
  • 渲染属性(fill、opacity、stroke、strokeWidth)
  • 缩放与旋转属性(scaleX、scaleY、angle)
  • 翻转属性(flipX、flipY、skewX、skewY)
  const canvas = new fabric.Canvas('c');
  ...
  canvas.add(rect)
  rect.set({ left: 100, top: 100})
  rect.set({ strokeWidth: 5, stroke: 'pink' });
  rect.set('angle', 45).set('skewY', 30);
  canvas.renderAll();

image.png

绘制其他图形

Fabric提供了7种基本形状:

  • 圆(fabric.Circle)
  • 椭圆(fabric.Ellipse)
  • 线(fabric.Line)
  • 多边形(fabric.Polygon)
  • 折线(fabric.Polyline)
  • 矩形(fabric.Rect)
  • 三角形(fabric.Triangle)
  const canvas = new fabric.Canvas('c');
  ...
  const circle = new fabric.Circle({
    radius: 20, fill: 'green', left: 100, top: 100
  });
  const triangle = new fabric.Triangle({
    width: 20, height: 30, fill: 'blue', left: 50, top: 50
  });

  canvas.add(circle,triangle)

image.png

绘制文本

通过实例化fabric.Text实例,我们可以像处理任何其他Fabric对象一样处理文本

  • fontSize 字体大小
  • fontWeight 字体粗细
  • underline 下划线
  • linethrough 删除线
  • overline 上划线
  • shadow 阴影
  • fontStyle 字体样式
  • fontFamily 字体
  • stroke 笔触颜色
  • strokeWidth 笔触宽度
  • textAlign 文本对齐
  const canvas = new fabric.Canvas('c');
  ...
  const text = new fabric.Text('hello world', { left: 100, top: 100 });
  canvas.add(text)
  text.set({
    fontSize: 30,
    underline: true,
    linethrough: true,
    overline: true,
    shadow: 'rgba(0,0,0,0.3) 5px 5px 5px',
    fontStyle: 'italic',
    fontFamily: 'Delicious',
    stroke: '#c3bfbf',
    strokeWidth: 3
  })
  canvas.renderAll();

image.png

互动性

在前面我们看到了Fabric允许我们通过对象模型对画布上的形状进行编程访问和操作。如果我们希望在用户级别,可以通过鼠标(触摸)的方式对画布上的图形进行操作呢?
Fabric也能做到!用户可以在画布上选择对象、拖动、缩放或旋转,甚至可以组合在一起进行操作!

QQ20220818000636.gif

事件系统

Fabric提供了一个广泛的事件系统,这些事件使我们能够察觉到并利用画布上发生的各种动作各个关键环节。
这里有一个更加完整的事件示例

Fabric的层次结构和继承

Fabric对象之间形成了非常精确的层次结构,大多数对象都从fabric.Object继承而来。fabric.Object表示一个二维形状具有left/topwidth/height等许多其他图形公有特征的实体。

这种特性可以让我们给绘制的图形添加一下共同的属性,比如风火轮测量器中实现在图形行绘制尺寸信息

function sizeRender(ctx, left, top, styleOverride, fabricObject) {
    ctx.save();
    ctx.translate(left, top);
    ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle));
    ctx.fillStyle = 'red';
    ctx.font = '20px serif';
    ctx.fillText((this.getSize(fabricObject)).toFixed(2), 0, 0);
    ctx.restore();
}

fabric.Object.prototype.controls.widthDisplay = new fabric.Control({
    x: 0,
    y: -0.5,
    offsetY: -10,
    offsetX: -20,
    cursorStyle: 'pointer',
    mouseUpHandler: null,
    render: sizeRender,
    cornerSize: 24,
    getSize: (fabricObject) => {
        const { width, scaleX } = fabricObject;

        return width * scaleX;
    },
});

fabric.Object.prototype.controls.heightDisplay = new fabric.Control({
    x: 0.5,
    y: 0,
    offsetY: 5,
    offsetX: 10,
    cursorStyle: 'pointer',
    mouseUpHandler: null,
    render: sizeRender,
    cornerSize: 24,
    getSize: (fabricObject) => {
        const { height, scaleY } = fabricObject;

        return height * scaleY;
    },
});

序列化&反序列化

如果我们创建的画布是需要有状态的,并且允许用户将画布的内容保存在服务器上,或者将内容流式传输到不同的客户端,那么就需要对画布进行序列化&反序列化。

  const canvas = new fabric.Canvas('c');
  ...
  console.log(JSON.stringify(canvas)); // 序列化
  canvas.loadFromJSON(JSON.stringify(canvas)) // 反序列化

Fabric提供了toJSON方法将canvas进行序列化,我们在调用JSON.stringify()方法时,会隐式调用传递对象的toJSON方法。由于Fabric中的canvas实例有toJSON方法等同于JSON.stringify(canvas.toJSON())

{"version":"4.6.0","objects":[{"type":"rect","version":"4.6.0","originX":"left","originY":"top","left":100,"top":100,"width":70,"height":80,"fill":"red","stroke":"pink","strokeWidth":5,"strokeDashArray":null,"strokeLineCap":"butt","strokeDashOffset":0,"strokeLineJoin":"miter","strokeUniform":false,"strokeMiterLimit":4,"scaleX":1,"scaleY":1,"angle":45,"flipX":false,"flipY":false,"opacity":1,"shadow":null,"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":30,"rx":0,"ry":0},{"type":"circle","version":"4.6.0","originX":"left","originY":"top","left":100,"top":100,"width":40,"height":40,"fill":"green","stroke":null,"strokeWidth":1,"strokeDashArray":null,"strokeLineCap":"butt","strokeDashOffset":0,"strokeLineJoin":"miter","strokeUniform":false,"strokeMiterLimit":4,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"shadow":null,"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":0,"radius":20,"startAngle":0,"endAngle":6.283185307179586},{"type":"triangle","version":"4.6.0","originX":"left","originY":"top","left":50,"top":50,"width":20,"height":30,"fill":"blue","stroke":null,"strokeWidth":1,"strokeDashArray":null,"strokeLineCap":"butt","strokeDashOffset":0,"strokeLineJoin":"miter","strokeUniform":false,"strokeMiterLimit":4,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"shadow":null,"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":0},{"type":"text","version":"4.6.0","originX":"left","originY":"top","left":100,"top":100,"width":150.81,"height":33.9,"fill":"rgb(0,0,0)","stroke":"#c3bfbf","strokeWidth":3,"strokeDashArray":null,"strokeLineCap":"butt","strokeDashOffset":0,"strokeLineJoin":"miter","strokeUniform":false,"strokeMiterLimit":4,"scaleX":1,"scaleY":1,"angle":0,"flipX":false,"flipY":false,"opacity":1,"shadow":{"color":"rgba(0,0,0,0.3)","blur":5,"offsetX":5,"offsetY":5,"affectStroke":false,"nonScaling":false},"visible":true,"backgroundColor":"","fillRule":"nonzero","paintFirst":"fill","globalCompositeOperation":"source-over","skewX":0,"skewY":0,"fontFamily":"Delicious","fontWeight":"normal","fontSize":30,"text":"hello world","underline":true,"overline":true,"linethrough":true,"textAlign":"left","fontStyle":"italic","lineHeight":1.16,"textBackgroundColor":"","charSpacing":0,"styles":{},"direction":"ltr","path":null,"pathStartOffset":0,"pathSide":"left"}]}

3. canvas的拾取

我们在前面提到,canvas不会保存绘制图形的信息,一旦绘制完成,画布中保存的是一张图片信息,在图片上点击时无法获取对应的图形信息。我们也看到了Fabric对原生的canvas API进行了封装,实现了canvas画布中对象的拾取,这是如何做到的呢?
常见的拾取方案有以下几种:

  • 通过颜色拾取图形
  • 使用Canvas内置的API拾取图形
  • 使用几何运算拾取图形
  • 混杂上面的几种方式来拾取图形

通过颜色拾取图形

  1. 创建一个与canvas同样大小的缓存canvas
  2. 在缓存的canvas上使用图形的索引作为图形的颜色绘制图形
  3. 获取缓存canvas上拾取位置的像素点,将像素点颜色转换成索引

优点:实现简单、拾取性能好
缺点:渲染开销加倍

使用内置的API进行拾取

  1. 创建一个与canvas同样大小的缓存canvas
  2. 拾取时清空缓存canvas上的图形,在缓存canvas上依次绘制图形,并调用isPointInPath(判断点是否在绘制区域中)和isPointInStroke(判断是否在绘制线上)检查点是否在图形中。如果点在图形中,该图形就是拾取的图形,停止继续绘制图形

优点:实现简单、渲染性能好
缺点:拾取性能差

使用几何运算拾取图形

  1. 依次检测每个图形。在检测时,根据图形类型使用相应的几何图形包围检测方法判断是否在图形内(如果图形绘不闭合,判断是否在线上,如果图形闭合,判断是否被包围)

不同的几何图形有不同的检测方法,对于封闭图形可以通过射线法判断,如果从点发出的射线穿过封闭图形边的数量为奇数,则点在图形内,若为偶数,则在图形外。

优点:图形检测算法较成熟、不会影响渲染性能
缺点:实现复杂、特别是一些贝塞尔曲线和非闭合曲线的检测性能比较差
image.png

<canvas id="origin" width="300" height="300"></canvas>

<script>

  // 矩形绘制
  class Rect{
    constructor(x, y, width, height, fillStyle) {
      this.x = x
      this.y = y
      this.fillStyle = fillStyle
      this.width = width
      this.height = height
    }

    path(ctx) {
      ctx.rect(this.x, this.y, this.width, this.height)
    }
    draw(ctx, fillStyle) {
      ctx.save()
      ctx.fillStyle = fillStyle || this.fillStyle
      ctx.fillRect(this.x, this.y, this.width, this.height)
      ctx.restore()
    }
  }
  // 缓存拾取
  class CachePicker {
    constructor(canvasEl) {
      const {width, height} = canvasEl
      // 创建缓存canvas
      const cacheCanvas = document.createElement('canvas')
      cacheCanvas.width = width
      cacheCanvas.height = height
      this._originCanvas = canvasEl
      this._cacheCanvas = cacheCanvas
      this._ctx = this._cacheCanvas.getContext('2d');
      // 添加到dom中,便于观察实际可不加
      canvasEl.parentNode.appendChild(cacheCanvas)
    }

    _index2Color(index) {
      return `#${index.toString(16).padStart(6, '0')}`
    }

    // 更新缓存canvas
    updateCache(shapes) {
      const {width, height} = this._originCanvas
      this._cacheCanvas.width = width
      this._cacheCanvas.height = height
      shapes.forEach((shape, index) => {
        const fillStyle = this._index2Color(index + 1)
        shape.draw(this._ctx, fillStyle)
      })
    }
    // 拾取
    pick(x, y) {
      const ctx = this._cacheCanvas.getContext('2d')
      const color = ctx.getImageData(x, y, 1, 1)
      const rgb = color.data.slice(0, 3)
      return rgb.reverse().reduce((pre, cur, curIndex) => pre + cur * Math.pow(255, curIndex), 0) - 1
    }
  }
  // api拾取
  class ApiPicker {
    constructor(canvasEl) {
      const {width, height} = canvasEl
      const cacheCanvas = document.createElement('canvas')
      cacheCanvas.width = width
      cacheCanvas.height = height
      this._originCanvas = canvasEl
      this._cacheCanvas = cacheCanvas
      this._ctx = this._cacheCanvas.getContext('2d');
      canvasEl.parentNode.appendChild(cacheCanvas)
    }
    // 拾取
    pick(shapes, x, y) {
      // 清除缓存canvas上的图形
      this._ctx.clearRect(0, 0, this._cacheCanvas.width, this._cacheCanvas.height)
      return shapes.find((shape) => {
        this._ctx.beginPath()
        shape.path(this._ctx)
        this._ctx.stroke()
        this._ctx.closePath()
        return  this._ctx.isPointInPath(x, y) || this._ctx.isPointInStroke(x, y)
      })
    }
  }

  class Canvas {
    constructor(canvasEl) {
      this._canvas = canvasEl
      this._ctx = canvasEl.getContext('2d')
      this._cachePicker = new CachePicker(canvasEl)
      this._apiPicker = new ApiPicker(canvasEl)
    }

    _shapes = []

    fillRect(x, y, width, height, fillStyle) {
      const rect = new Rect(x, y, width, height, fillStyle)
      rect.draw(this._ctx)
      this._shapes.push(rect)
      this._cachePicker.updateCache(this._shapes)
    }

    _cachePick(x, y) {
      const index = this._cachePicker.pick(x, y)
      return index >= 0 ? this._shapes[index] : undefined
    }
    _apiPick(x, y) {
      return this._apiPicker.pick(this._shapes, x, y)
    }
  }

  class Fabric {
    static Canvas(canvasEl) {
      return new Canvas(canvasEl)
    }
  }

  const canvas = document.getElementById("origin");
  const fabricCanvas = Fabric.Canvas(canvas)
  fabricCanvas.fillRect(50, 50, 100, 100, 'red')
  fabricCanvas.fillRect(100, 100, 150, 100, 'green')
  fabricCanvas.fillRect(50, 100, 100, 150, 'blue')
  // 计算相对位置
  calculateLayerXY = (layer, pageX, pageY) => {
    const layerClientRect = layer.getBoundingClientRect();
    const { top, left } = layerClientRect;
    const layerOffsetTop = top + (document.documentElement.scrollTop || window.pageYOffset || document.body.scrollTop);
    const layerOffsetLeft = left + (document.documentElement.scrollLeft || window.pageXOffset || document.body.scrollLeft);
    const layerX = pageX - layerOffsetLeft;
    const layerY = pageY - layerOffsetTop;

    return { layerX, layerY };
  }
  canvas.addEventListener('mousedown', ({pageX, pageY}) => {
    const { layerX, layerY } = calculateLayerXY(canvas, pageX, pageY)
    console.log('cachePick', fabricCanvas._cachePick(layerX, layerY)?.fillStyle);
    console.log('apiPick', fabricCanvas._apiPick(layerX, layerY)?.fillStyle);
  })
</script>

总结

  • Fabric.js对原生的Canvas API进行封装并提供了一个广泛的事件系统,弥补了原生Canvas缺失的对象模型,使用Fabric.js能够轻松的绘制、修改画布中的图形,并能够对画布进行序列化&反序列化。
  • Fabric.js对画布进行管理后,使得画布具有一定的互动性,用户可以在画布上选择对象、拖动、缩放或旋转,甚至组合在一起进行操作。
  • canvas不会保存绘制图形的信息,但我们可以通过颜色拾取、内置API拾取、几何运算拾取、混杂拾取等方式获取对应的图形信息。

参考文献

fabricjs官网
canvas拾取方案
如何判断点是否在封闭图形内