使用fabric.js简简单单实现一个画板

韦业
2023-12-01

什么是fabric

fabric是一个功能强大的JavaScript库,运行在HTML5 canvas上。fabric为canvas提供了一个交互式对象模型,以及一个svg-to-canvas解析器。

与canvas的区别

来一个简单的例子来说明一下fabric与canvas的区别,假设我们想在一个画布上画一个红色的矩形:

<canvas id="c"></canvas>
// 原生 canvas api

// 有一个id是c的canvas元素
var canvasEl = document.getElementById('c');

// 获取2d位图模型
var ctx = canvasEl.getContext('2d');

// 设置填充颜色
ctx.fillStyle = 'red';

// 创建一个坐标100,190,尺寸是20,20的矩形
ctx.fillRect(100, 100, 20, 20);

// 使用fabric实现

// 用原生canvas元素创建一个fabric实例
var canvas = new fabric.Canvas('c');

// 创建一个矩形对象
var rect = new fabric.Rect({
  left: 100,
  top: 100,
  fill: 'red',
  width: 20,
  height: 20
});

// 将矩形添加到canvas画布上
canvas.add(rect);

使用fabric做一个画板

因为篇幅有限,这里不详细介绍fabric的全部api,大家可以上

fabric官网

学习,画板完整实现可参考

fabric-drawing-board仓库

画板功能分解

一个画板包含以下功能

  • 画笔
  • 线条
  • 矩形
  • 圆形
  • 文本
  • 橡皮擦
  • 移动
  • 缩放
  • 撤销
  • 还原
  • 清空
  • 导出

画板功能实现

下面按照fabric针对不同功能的实现方式进行分类说明

初始化

this.canvas = new fabric.Canvas('canvasId')

画笔

fabric封装好了画笔功能,我们在使用的时候对画笔进行一些配置即可使用

// 开启画布自由绘画模式
this.canvas.isDrawingMode = true;
// 设置自由绘画模式画笔类型为 铅笔类型
this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas);
// 设置自由绘画模式 画笔颜色与画笔线条大小
this.canvas.freeDrawingBrush.color = ”#000000“;
this.canvas.freeDrawingBrush.width = 4;

橡皮擦

橡皮擦与画笔在使用上基本相同,都是用户使用鼠标进行自由绘画或擦除,所以使用的都是freeDrawingBrush这个api。需要注意的是fabric.js基础库是没有提供橡皮擦模块,我们需要额外引入eraser_brush.mixin.js这个文件。

import { fabric } from 'fabric'
// 启用橡皮擦功能需要额外引入 eraser_brush_mixin.js
import '@/libs/eraser_brush.mixin.js'
...
// 启用自由绘画模式
this.canvas.isDrawingMode = true
// 自由绘画模式 画笔类型设置为 橡皮擦对象
this.canvas.freeDrawingBrush = new fabric.EraserBrush(this.canvas)
// 设置橡皮擦大小
this.canvas.freeDrawingBrush.width = 4

开发橡皮擦功能遇到的坑:

// 注意,引入 eraser_brush.mixin.js文件后,
// 修改画布背景色如果使用 this.canvas.setBackgroundColor('#xxxxxx') 会报错
// 正确的修改画布背景色方法为:
this.canvas.setBackgroundColor('#xxxxxx', undefined, { erasable: false })

线条,矩形,圆形

用鼠标在画布上绘制这三种图形,我们需要给画布监听鼠标两类事件

  • 监听鼠标按下事件:当前坐标设置为起点坐标
  • 监听鼠标移动事件:实时记录当前坐标并将当前坐标设置为终点坐标,根据起点终点坐标动态绘制图形
 // 监听鼠标按下事件
this.canvas.on("mouse:down", (options) => {
    // 记录当前鼠标的起点坐标 (减去画布在 x y轴的偏移,因为画布左上角坐标不一定在浏览器的窗口左上角)
  this.mouseFrom.x = options.e.clientX - this.canvas._offset.left;
  this.mouseFrom.y = options.e.clientY - this.canvas._offset.top;
});
// 监听鼠标移动事件
this.canvas.on("mouse:move", (options) => {
  // 记录当前鼠标移动终点坐标 (减去画布在 x y轴的偏移,因为画布左上角坐标不一定在浏览器的窗口左上角)
  this.mouseTo.x = options.e.clientX - this.canvas._offset.left
  this.mouseTo.y = options.e.clientY - this.canvas._offset.top
  switch (current_action) {
	case: 'line':
		this.drawLine();
		break;
	case: 'rect':
		this.drawRect();
		break;
	case: 'circle':
		this.drawCircle();
		break;
	}
});
// 绘制直线
drawLine() {
// 根据保存的鼠标起始点坐标 创建直线对象
      let canvasObject = new fabric.Line(
        [
          this.getTransformedPosX(this.mouseFrom.x),
          this.getTransformedPosY(this.mouseFrom.y),
          this.getTransformedPosX(this.mouseTo.x),
          this.getTransformedPosY(this.mouseTo.y),
        ],
        {
          fill: this.fillColor,
          stroke: this.strokeColor,
          strokeWidth: this.lineSize,
        }
      );
	  this.canvas.add(canvasObject)
},
// 绘制矩形
drawRect() {
      // 计算矩形长宽
      let left = this.getTransformedPosX(this.mouseFrom.x);
      let top = this.getTransformedPosY(this.mouseFrom.y);
      let width = this.mouseTo.x - this.mouseFrom.x;
      let height = this.mouseTo.y - this.mouseFrom.y;
      // 创建矩形 对象
      let canvasObject = new fabric.Rect({
        left: left,
        top: top,
        width: width,
        height: height,
        stroke: this.strokeColor,
        fill: this.fillColor,
        strokeWidth: this.lineSize,
      });
      // 绘制矩形
      this.canvas.add(canvasObject)
},
// 绘制圆形
drawCircle() {
	let left = this.getTransformedPosX(this.mouseFrom.x);
	let top = this.getTransformedPosY(this.mouseFrom.y);
	 // 计算圆形半径
      let radius =
        Math.sqrt(
          (this.getTransformedPosX(this.mouseTo.x) - left) *
            (this.getTransformedPosY(this.mouseTo.x) - left) +
            (this.getTransformedPosX(this.mouseTo.y) - top) *
              (this.getTransformedPosY(this.mouseTo.y) - top)
        ) / 2;
      // 创建 原型对象
      let canvasObject = new fabric.Circle({
        left: left,
        top: top,
        stroke: this.strokeColor,
        fill: this.fillColor,
        radius: radius,
        strokeWidth: this.lineSize,
      });
	this.canvas.add(canvasObject)
}

// 因为画布会进行移动或缩放,所以鼠标在画布上的坐标需要进行相应的处理才是相对于画布的可用坐标
getTransformedPosX(x) {
	let zoom = Number(this.canvas.getZoom())
	return (x - this.canvas.viewportTransform[4]) / zoom;
},
getTransformedPosY(y) {
	let zoom = Number(this.canvas.getZoom())
	return (y - this.canvas.viewportTransform[5]) / zoom;
},

文本

绘制文本的流程如下:

  • 鼠标第一次按下:记录当前文本的坐标,并使文本为可编辑状态
  • 鼠标第二次按下:让文本对象退出可编辑状态
drawText(){
if (!this.textObject) {
        // 当前不存在绘制中的文本对象,鼠标第一次按下

        // 根据鼠标按下的起点坐标文本对象
        this.textObject = new fabric.Textbox("", {
          left: this.getTransformedPosX(this.mouseFrom.x),
          top: this.getTransformedPosY(this.mouseFrom.y),
          fontSize: this.fontSize,
          fill: this.strokeColor,
          hasControls: false,
          editable: true,
          width: 30,
          backgroundColor: "#fff",
          selectable: false,
        });
        this.canvas.add(this.textObject);
        // 文本打开编辑模式
        this.textObject.enterEditing();
        // 文本编辑框获取焦点
        this.textObject.hiddenTextarea.focus();
} else {
        // 鼠标第二次按下 将当前文本对象退出编辑模式
        this.textObject.exitEditing();
        this.textObject.set("backgroundColor", "rgba(0,0,0,0)");
        if (this.textObject.text == "") {
          this.canvas.remove(this.textObject);
        }
        this.canvas.renderAll();
        this.textObject = null;
        return;
      }
}

移动

移动功能并不是对画布进行直接移动,因为画布在上下左右四个方向无限延伸的,我们当前能看到的部分可以理解成一个视口,我们定义的canvas宽高也只是定义的视口的宽高。通过定义视口的上下左右四个坐标点来确定当前画布展示的位置。移动画布可以理解为移动视口即修改视口的左上角坐标位置,视口移动了,画布展现的区域自然发生了变化。

move() {
	// 获取当前画布视口移动对象
	var vpt = this.canvas.viewportTransform;
	// 通过计算鼠标的距离修改视口左上角的坐标
	vpt[4] += this.mouseTo.x - this.mouseFrom.x;
	vpt[5] += this.mouseTo.y - this.mouseFrom.y;
	// 视口坐标修改完成后对画布进行重新渲染
	this.canvas.requestRenderAll();
},

缩放

zoom(flag){
	let zoom = this.canvas.getZoom();
      if (flag > 0) {
        // 放大
        zoom *= 1.1;
      } else {
        // 缩小
        zoom *= 0.9;
      }
      // zoom 不能大于 20 不能小于0.01
      zoom = zoom > 20 ? 20 : zoom;
      zoom = zoom < 0.01 ? 0.01 : zoom;
      this.canvas.setZoom(zoom);
}

撤销,还原

我们需要维护一个数组记录用户每次操作结束后当前画的状态,以及一个索引指向数组某个位置表示画布当前的状态。通过索引的前进与后退即可实现撤销与还原功能

// 画布上添加图形或使用橡皮擦会触发 after:render 事件,我们在回调里保存当前画布状态 this.canvas.on("after:render", () => {
          // 在绘画时会频繁触发该回调,所以间隔1s记录当前状态
          if (this.recordTimer) {
            clearTimeout(this.recordTimer)
            this.recordTimer = null
          }
          this.recordTimer = setTimeout(() => {
            this.stateArr.push(JSON.stringify(this.canvas))
            this.stateIdx++
          }, 1000)
 })
 
 // 撤销 或 还原
 tapHistoryBtn(flag) {
	 let stateIdx = this.stateIdx + flag
      // 判断是否已经到了第一步操作
      if (stateIdx < 0) return;
      // 判断是否已经到了最后一步操作
      if (stateIdx >= this.stateArr.length) return;
      if (this.stateArr[stateIdx]) {
        this.canvas.loadFromJSON(this.stateArr[stateIdx])
        if (this.canvas.getObjects().length > 0) {
          this.canvas.getObjects().forEach(item => {
            item.set('selectable', false)
          })
        }
        this.stateIdx = stateIdx
      }
 }

清空

清空即删除画布中的所有图形,实现起来非常简单

clearAll(){
	// 获取画布中的所有对象
	let children = this.canvas.getObjects()
	if (children.length > 0) {
		// 移除所有对象
        	this.canvas.remove(...children)
     	 }
}

导出

canvas一般都导出为base64数据,使用fabric框架将canvas导出base64十分简单,但这里需要注意的坑有两点:

  • fabric默认导出的内容为画布当前视口内容,若画布进行了移动或放大,均不包含视口外的内容。
  • 画布移动后,原视口外的区域底色为透明状,若导出png格式,导出的内容非初始视口区域为透明色,若导出非png格式,导出的内容非初始视口区域则为黑色。
export() {
	// 因为我们需要给初始视口区域外的部分添加背景色,所以通过克隆画布去实现,不修改原画布
	this.canvas.clone(cvs => {
        //遍历所有对对象,获取最小坐标,最大坐标,根据画布中图形的最小坐标与最大坐标计算导出内容的左上角坐标以及导出内容的宽高
        let top = 0
        let left = 0
        let width = this.canvas.width
        let height = this.canvas.height

        var objects = cvs.getObjects();
        if(objects.length > 0 ){
          var rect = objects[0].getBoundingRect();
          var minX = rect.left;
          var minY = rect.top;
          var maxX = rect.left + rect.width;
          var maxY = rect.top + rect.height;
          for(var i = 1; i<objects.length; i++){
            rect = objects[i].getBoundingRect();
            minX = Math.min(minX, rect.left);
            minY= Math.min(minY, rect.top);
            maxX = Math.max(maxX, rect.left + rect.width);
            maxY= Math.max(maxY, rect.top + rect.height);
          }
			// 给上下左右添加100px的间距,这样导出的图形不会刚好贴合最边缘的图形而是有一定的间隙。
          top = minY - 100
          left = minX - 100
          width = maxX - minX + 200
          height = maxY - minY + 200
			// 给当前画布添加颜色与背景色相同的矩形,解决初始视口外区域底色为透明的问题。
          cvs.sendToBack(new fabric.Rect({
            left,
            top,
            width,
            height,
            stroke: 'rgba(0,0,0,0)',
            fill: this.bgColor,
            strokeWidth: 0
          }))
        }
		// 将画布导出成base64格式数据
        const dataURL = cvs.toDataURL({
          format: 'png',
          multiplier: cvs.getZoom(),
          left,
          top,
          width,
          height
        });
        return dataURL
      })
}
 类似资料: