使用fabric从零开始打造互动白板(一)

lxf2023-04-15 16:50:01

最近由于公司业务需要,原本使用第三方的互动白板功能,准备自行实现。一方面为了节省开支,另一方面自行实现定制化也更强一些,能够和现有业务更好的结合,用以满足第三方互动白板无法实现的一些功能。

一、功能整理

既然需求明确了,于是就开始着手整理白板所需的功能。由于我们的直播课都是大班课,只需要讲师在白板上操作,用户端只用来展示,也就不需要太多复杂的功能,结合几个第三方互动白板,归纳整理出了如下几个需要实现的功能点:

  • 自由画笔
  • 文字书写
  • 橡皮擦
  • 画三角、圆形、矩形
  • 画直线和箭头
  • 清空画布
  • 撤销重做
  • 画布缩放
  • 插入PPT图片及切换控制

二、技术选择

观察了现有的互动白板,都是在Canvas进行操作,为了节约开发时间于是找到了fabric这个库,这个库本身就实现了不少功能,可以大大的减少开发时间。

结合我熟悉的技术栈,最终选定了使用Vite+Vue3+TypeScript进行demo版本的构建。

相关代码放在github上,链接地址:使用vite+typescript+fabric创建的互动白板项目

三、页面结构

参考其他白板的布局进行了页面结构的搭建。白板使用一个容器进行包裹,左侧是工具区域,包括画笔、橡皮擦、画线、清空画布等各种绘制工具;左下角是撤销、重做和画布缩放控制区域;右上角是插入PPT文件控制区域;右下角是PPT控制区域。最后提供了一个容器进行的白板预览。

效果图如下:

使用fabric从零开始打造互动白板(一)

页面结构代码如下:

<template>
  <div>
    <div class="canvas-wrap">
      <div class="tool-box-out">
        <ToolBox></ToolBox>
      </div>
      <div class="redo-undo-box">
          <RedoUndo></RedoUndo>
      </div>
      <div class="zoom-controller-box">
        <ZoomController></ZoomController>
      </div>
      <div class="room-controller-box" v-show="!isPreviewShow">
        <div class="page-controller-mid-box">
          <div className="page-preview-cell" @click="insertPPT">
              <img style="width: 28px" :src="folder" alt="文件"/>
          </div>
        </div>
      </div>
      <div class="page-controller-box" v-show="isShowPPTControl">
          <div className="page-controller-mid-box">
              <PageController></PageController>
              <div className="page-preview-cell" @click="handlePreviewState(true)">
                  <img :src="pages" alt="PPT预览"/>
              </div>
          </div>
      </div>
      <div class="preview-controller-box" v-show="isShowPPTControl&&isPreviewShow">
        <PreviewController @handlePreviewState="handlePreviewState"></PreviewController>
      </div>
      <canvas id="canvas" width="800" height="450"></canvas>
    </div>
    <div class="canvas-wrap">
      <canvas id="canvas2" width="800" height="450"></canvas>
    </div>
  </div>
</template>

四、初始化白板

为了方便后续使用,这里对fabric进行封装,后续拓展也能更加灵活。相关代码如下:

import { fabric } from "fabric";

class FabricCanvas {
    constructor(canvasId: string) {
    
    // 初始化画布,默认可绘制
    this.canvas = new fabric.Canvas(canvasId, {
      isDrawingMode: true,
      selection: false,
      includeDefaultValues: false, // 转换成json对象,不包含默认值
    });
  }
}

使用示例:

const canvas = new FabricCanvas('canvas');

五、工具栏相关功能实现

页面框架搭建完成之后,就开始各种功能的开发。这里将fabric封装成一个类,所有需要实现的方法在类中实现,方便后续灵活调用。

选择

选择功能实现比较简单,只需要关闭绘制模式,然后将画布设置可选中。相关代码如下:

this.canvas.isDrawingMode = false;
this.canvas.selection = true;
// 设置鼠标光标
this.canvas.defaultCursor = 'auto';

自由画笔

fabric提供了各种丰富的笔刷,实现自由绘制这里调用了PencilBrush类,并将isDrawingMode设置为true即可。相关代码如下:

  public drawFreeDraw() {
    this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas);
    this.canvas.freeDrawingBrush.color = '#ff0000'
    this.canvas.freeDrawingBrush.width = 5
    this.canvas.freeDrawingCursor = 'default'
    this.canvas.isDrawingMode = true;
  }

使用示例:

const canvas = new FabricCanvas('canvas');
// 自由画笔
canvas.drawFreeDraw();

文字书写

文字输入使用fabric提供的IText方法,实现文字编辑和修改,并且让输入框自动获取焦点,方便输入。相关代码如下:

  public drawText(text: string, options?: ITextOptions): void {
    const textObj = new fabric.IText(text, {
        editingBorderColor: '#ff0000',
        padding: 5, 
        ...options 
    });
    this.canvas.add(textObj);
    this.canvas.defaultCursor = 'text'
    this.currentShape = textObj;
    // 文本打开编辑模式
    textObj.enterEditing();
    // 文本编辑框获取焦点
    textObj.hiddenTextarea.focus()
    this.setActiveObject(textObj);
  }

使用示例:

const canvas = new FabricCanvas('canvas');
// 绘制文本
canvas.drawText('Hello World!', { left: 50, top: 250, fontSize: 24, fill: 'red' })

橡皮擦

fabric内置了EraserBrush用来实现橡皮擦功能,不过默认构建排除了该功能,避免库文件过大。如需使用,需要进入node_modules/fabric目录执行下面的命令重新构建:

node build.js modules=ALL exclude=gestures,accessors requirejs minifier=uglifyjs

构建完成之后就可以使用EraserBrush来实现橡皮擦功能了,相关代码如下:

public eraser(options?: any): void {
    this.canvas.freeDrawingBrush = new fabric.EraserBrush(this.canvas, options);
    this.canvas.freeDrawingBrush.width = 10
    this.canvas.freeDrawingCursor = 'default'
    this.canvas.isDrawingMode = true;
}

使用示例:

const canvas = new FabricCanvas('canvas');
// 橡皮擦
canvas.erase({ width: 10 });

画三角、圆形、矩形

画三角形、圆形、矩形方法相似,直接调用fabric封装的对应方法即可。

这里以绘制矩形为例,相关代码实现如下:

public drawRect(options: IRectOptions): void {
    const rect = new fabric.Rect({ ...this.options, ...options });
    this.canvas.add(rect);
    this.currentShape = rect;
    this.canvas.defaultCursor = 'crosshair'
 }

使用示例:

const canvas = new FabricCanvas('canvas');
// 绘制矩形
canvas.drawRect({ left: 50, top: 150, width: 100, height: 50, fill: 'green', stroke: 'black' });

画直线和箭头

画直线功能fabric直接内置了,调用对应的方法即可,这里重点讲画箭头。画箭头的本质是在直线的一端加上一个三角形,根据起始点使用三角函数计算好三角形的方向,这样就组合成一个箭头了。这里搬运网友的画箭头方法,直接对画直线功能进行拓展,并封装成fabric中的功能模块,方便后续调用。相关代码如下:

import { fabric } from 'fabric';

fabric.Arrow = fabric.util.createClass(fabric.Line, {
  type: 'arrow',
  superType: 'drawing',
  initialize(points: number[], options: any) {
    if (!points) {
      const { x1, x2, y1, y2 } = options;
      points = [x1, y1, x2, y2];
    }
    options = options || {};
    this.callSuper('initialize', points, options);
  },
  _render(ctx: any) {
    this.callSuper('_render', ctx);
    ctx.save();
    const xDiff = this.x2 - this.x1;
    const yDiff = this.y2 - this.y1;
    const angle = Math.atan2(yDiff, xDiff);
    ctx.translate((this.x2 - this.x1) / 2, (this.y2 - this.y1) / 2);
    ctx.rotate(angle);
    ctx.beginPath();
    // Move 5px in front of line to start the arrow so it does not have the square line end showing in front (0,0)
    ctx.moveTo(5, 0);
    ctx.lineTo(-5, 5);
    ctx.lineTo(-5, -5);
    ctx.closePath();
    ctx.fillStyle = this.stroke;
    ctx.fill();
    ctx.restore();
  },
});

fabric.Arrow.fromObject = (options: any, callback: any) => {
  const { x1, x2, y1, y2 } = options;
  return callback(new fabric.Arrow([x1, y1, x2, y2], options));
};

export default fabric.Arrow;

封装好的代码,直接导入调用即可。相关代码如下:

import Arrow from "./objects/Arrow";
// 绘制箭头
public drawArrow(x1: number, y1: number, x2: number, y2: number, options?: ILineOptions) {
    const arrow = new Arrow([x1, y1, x2, y2], { ...this.options, ...options });
    this.canvas.add(arrow);
    this.currentShape = arrow;
    this.canvas.defaultCursor = 'crosshair'
}

使用示例:

const canvas = new FabricCanvas('canvas');
// 绘制矩形
canvas.drawArrow(10, 50, 100, 50, { stroke: 'blue', strokeWidth: 2 })

通过鼠标绘制图形

实际使用白板过程中,上面这些图形、线条的绘制都是通过鼠标拖动进行,这样更加灵活一些。

通过鼠标绘制图形,需要对鼠标的mouse:downmouse:movemouse:up事件进行监听,相关代码如下:

// 监听鼠标事件
this.canvas.on("mouse:down", this.onMouseDown.bind(this));
this.canvas.on("mouse:move", this.onMouseMove.bind(this));
this.canvas.on("mouse:up", this.onMouseUp.bind(this));

这里以绘制矩形为例,实现通过通过鼠标绘制一个矩形框。

  1. 当鼠标按下时,在鼠标按下的地方绘制一个宽高为0的矩形。相关代码如下:
// 是否处于绘制状态
private isDrawing = false;
// 鼠标起点坐标x
private startX = 0;
// 鼠标起点多表y
private startY = 0;

// 鼠标按下事件处理函数
private onMouseDown(event: IEvent) {
    // 如果当前有活动的元素则不进行后续绘制
    const activeObject = this.canvas.getActiveObject();
    if (!event.pointer || activeObject) return;
    
    // 切换成绘制状态
    this.isDrawing = true;
    // 记录当前坐标点
    const { x, y } = event.pointer;
    this.startX = x;
    this.startY = y;
    
    // 在当前坐标绘制一个矩形
    this.drawRect({
      left: x,
      top: y,
      width: 0,
      height: 0,
    });
}
  1. 在鼠标移动的过程中,动态的修改矩形的宽高,并实时渲染。相关代码如下:
// 鼠标移动事件处理函数
private onMouseMove(event: IEvent) {
    if (!this.isDrawing || !event.pointer || !this.currentShape) return;
    
    // 计算宽高
    const { x, y } = event.pointer;
    const width = x - this.startX;
    const height = y - this.startY;
    
    // 设置宽高
    this.currentShape.set({
      width,
      height,
    });
    
    // 更新画布
    this.canvas.renderAll();
}
  1. 当鼠标抬起后,改变绘制状态。相关代码如下:
// 鼠标抬起事件处理函数
private onMouseUp() {
    this.isDrawing = false;
    this.currentShape = null;
}

如果想要更加灵活的在各种图形和线条中自由的进行切换,并通过鼠标绘制,在提供的demo中也进行了对应的封装。 相关代码请在github中进行查看,对fabric的各种功能封装。

清空画布

清空画布直接调用画布的清除方法即可,相关代码如下:

// 清空画布
public clearCanvas() {
    this.canvas.clear();
}

不过该方法会清除画布上包含背景的所有内容,如果不想画布背景也被清除,可以遍历画布上的所有对象进行移除。相关代码如下:

// 移除所有对象
public removeAllObject() {
    this.canvas.getObjects().forEach((obj) => {
      this.canvas.remove(obj);
    });
}

六、工具栏布局 将工具栏封装成ToolBox组件,并在组件中实现各种工具的切换。

使用fabric从零开始打造互动白板(一) 组件布局代码如下:

<template>
    <div class="tool-mid-box-left">
        <div class="tool-box-cell-box-left"  v-for="item in tools" :key="item.shapeType">
            <div class="tool-box-cell"
                    @click="clickAppliance(item.shapeType)">
                <img :src="item.shapeType === currentShapType ? item.iconActive : item.icon" :alt="item.name"/>
            </div>
        </div>
        <div class="tool-box-cell-box-left">
            <div class="tool-box-cell"
                    @click="clickClear">
                <img :src="clear" alt="清屏"/>
            </div>
        </div>
    </div>
</template>

相关功能事件实现的代码如下:

const currentShapType = ref<string>("pencil")

// 设置当前工具
function clickAppliance(type: DrawingTool) {
    currentShapType.value = type;
    canvas?.value.setDrawingTool(type)
}

// 清屏事件处理
function clickClear() {
  canvas?.value.clearCanvas()
}

设置当前绘制工具

// 设置绘图工具
public setDrawingTool(tool: DrawingTool) {
    if(this.drawingTool === tool) return;
    this.canvas.isDrawingMode = false;
    this.canvas.selection = false;

    this.drawingTool = tool;
    if (tool === "pencil") {
      this.drawFreeDraw();
    } else if (tool === "eraser") {
      this.eraser();
    } else if (tool === "select") {
      this.canvas.selection = true;
      this.canvas.defaultCursor = 'auto'
    }
}

其他功能说明

为避免文章太长,撤销重做、画布缩放、插入PPT图片及切换控制等功能的实现在后续文章中介绍

如果等不及,可以直接在github上查看相关代码实现:使用vite+typescript+fabric创建的互动白板项目

六、参考资料

  • Fabric.js Javascript Canvas Library (fabricjs.com)
  • 使用fabric.js 快速开发一个图片编辑器
  • netless-io/whiteboard-demo (github.com)