基于 Fabric.js 实现简易画板

2,296 阅读8分钟

前言

最近闲来无事,打算实现一个简易画板。

本项目基于 Vue3 + Fabric.js 技术栈开发,手搓样式,图标库使用 IconPark

  • 代码段忽略图标引入,大家可以根据自身需求在官网中搜索和替换。

  • 代码段出现的 css 变量文件没有给出,大家可以根据自身需求或审美编写样式。

跳过创建项目的过程,直接进入主题。

添加元素

Index.vue 中创建一个 canvas 容器,并设置它的 id 属性。接下来,将生成的画板实例绑定到已创建的 canvas 容器上。

定义 Sketchpad 类

Sketchpad 类用于保存画板实例对象、画板的相关属性和操作方法等。

Sketchpad 类的构造函数 constructor 接受一个参数,就是用于绑定 canvas 容器的 id 标识,初步实现的代码如下:

// Sketchpad.ts
import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';

class Sketchpad {
  public instance: Canvas; // canvas 实例

  private canvaProps = {
    width: 1000, // 默认宽度
    height: 720, // 默认高度
    fill: '#f6f6f6', // 默认填充色
    zoom: 1, // 缩放比例
    backgroundVpt: false, // 背景受视图缩放影响
    isDrawingMode: false, // 禁止使用画笔涂鸦模式
    preserveObjectStacking: true, // 元素被选中时保持原有层级
  };

  constructor(cvsId: string) {
    this.instance = new fabric.Canvas(cvsId, this.canvaProps); // 创建 canvas 实例
  }
}

定义和渲染图形列表

由于可以生成、组合多种图形,我们将选项集成到 shapeList.tsx 中,便于后续管理。

// shapeList.tsx
export type ShapeType = 'Textbox' | 'Line' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'Rect' | 'Square';

export interface ShapeProps {
  type: ShapeType; // 图形类型
  element: JSX.Element; // 图形对应的图标
}

export const shapeList: Array<ShapeProps> = [
  {
    type: 'Textbox',
    element: <FontSize />,
  },
  {
    type: 'Line',
    element: <Line />,
  },
  {
    type: 'ArrowLeft',
    element: <ArrowLeft />,
  },
  {
    type: 'ArrowRight',
    element: <ArrowRight />,
  },
  {
    type: 'ArrowUp',
    element: <ArrowUp />,
  },
  {
    type: 'ArrowDown',
    element: <ArrowDown />,
  },
  {
    type: 'Rect',
    element: <Rectangle />,
  },
  {
    type: 'Square',
    element: <Square />,
  },
];

Shape 组件中,导入并渲染 shapeList 列表。

// Shape.vue
<template>
  <div class="title">基础图形</div>
  <div class="icon-list">
    <div class="icon" v-for="item in shapeList" :key="item.type" @click="onInsertShape(item.type)">
      <component :is="item.element" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import { shapeList } from '@/const/shapeList';

const onInsertShape = inject('onInsertShape') as Function;
</script>

核心逻辑的提取

当前使用 injectprovide 进行数据传递,所以介绍一下当前的页面结构。

Index.vue 是项目的入口文件,页面使用左右布局:侧边栏 Siderbar 组件固定宽度,Shape 组件将嵌入侧边栏;右边容器自适应宽度,填充剩余页面。

Index.vueSidebar.vue 的代码分别如下:

// Index.vue
<template>
  <Sidebar />
  <main class="cvs-container">
    <canvas id="cvs" />
  </main>
</template>

<script setup lang="ts">
import { onMounted, reactive, provide } from 'vue';
import Sidebar from '@/components/Sidebar.vue';
import Sketchpad from '@/common/sketchpad';

let sketchpad: Sketchpad;

onMounted(() => {
  sketchpad = new Sketchpad('cvs');
  provide('sketchpad', reactive(sketchpad)); // 将 sketchpad 对象传递给子孙组件
});

const onInsertShape = (shape: ShapeType) => {
  sketchpad.insertShape(shape);
};

provide('onInsertShape', onInsertShape); // 将事件传递给子孙组件
</script>

<style lang="scss" scoped>
.cvs-container {
  width: calc(100vw - 320px);
  height: 100vh;
  display: grid;
  place-items: center;
  overflow: scroll;

  #cvs {
    width: 1000px;
    height: 720px;
    background-color: var(--bgColor);
    border: 2px solid var(--shadowColor);
  }
}
</style>
// Sidebar.vue
<template>
  <aside class="sidebar">
    <main class="content">
      <Shape />
    </main>
  </aside>
</template>

<script setup lang="ts">
import Shape from '@/components/panel/Shape.vue';
</script>

<style lang="scss" scoped>
.sidebar {
  width: 320px;
  height: calc(100vh - 50px);
  background-color: var(--bgColor);
  box-shadow: 0 0 15px 0 var(--borderColor);
  font-size: 14px;
  overflow: hidden;
  display: flex;
  position: relative;
}

.content {
  flex: 1;
  padding: 15px;
  overflow-y: scroll;
}
</style>

Index.vue 中看到,实际上发挥作用的是 sketchpad.insertShape(shape) 方法。接下来,回到 Sketchpad.ts 中完善这个函数的实现。

// Sketchpad.ts
import { fabric } from 'fabric';
import { Canvas, Object } from 'fabric/fabric-impl';
import { createShape } from '@/utils/createShape';

interface Instance extends Object {
  [key: string]: any; // 兼容自定义属性
}

class Sketchpad {
  public instance: Canvas; // canvas 实例

  private canvaProps = {
    width: 1000, // 默认宽度
    height: 720, // 默认高度
    fill: '#f6f6f6', // 默认填充色
    zoom: 1, // 缩放比例
    backgroundVpt: false, // 背景受视图缩放影响
    isDrawingMode: false, // 禁止使用画笔涂鸦模式
    preserveObjectStacking: true, // 元素被选中时保持原有层级
  };

  constructor(cvsId: string) {
    this.instance = new fabric.Canvas(cvsId, this.canvaProps); // 创建 canvas 实例
  }

  insertShape(type: ShapeType) {
    const shape: Instance = createShape(type); // 生成对应类型的实例对象
    shape.top = 150;
    shape.left = 150;
    this.instance.add(shape).renderAll(); // 添加到画板上,并重新渲染画板
  }
}

我们将生成图形的逻辑抽取到 createShape 函数中,该函数根据传入的 shape 参数,返回一个对应类型的新实例。

// createShape.ts
import { ShapeType } from '@/const/shapeList';

export const createShape = (shape: ShapeType) => {
  switch (shape) {
    // 文本
    case 'Textbox': {
      return new fabric.Textbox('编辑文本', {
        width: 170,
        fontSize: 24,
        splitByGrapheme: true,
      });
    }
    // 线条
    case 'Line': {
      return new fabric.Path('M 0 0 L 200 0', {
        strokeWidth: 1.5,
        stroke: '#3c3c3c',
      });
    }
    // 左箭头
    case 'ArrowLeft': {
      const triangle = new fabric.Triangle({
        width: 15,
        height: 15,
        fill: '#3c3c3c',
      });
      const rect = new fabric.Rect({
        top: 55,
        width: 2,
        height: 100,
        fill: '#3c3c3c',
      });
      return new fabric.Group([triangle, rect], {
        top: 50,
        left: 150,
        angle: 270,
      });
    }
    // 右箭头
    case 'ArrowRight': {
      const triangle = new fabric.Triangle({
        width: 15,
        height: 15,
        fill: '#3c3c3c',
      });
      const rect = new fabric.Rect({
        top: 55,
        width: 2,
        height: 100,
        fill: '#3c3c3c',
      });
      return new fabric.Group([triangle, rect], {
        top: 50,
        left: 150,
        angle: 90,
      });
    }
    // 上箭头
    case 'ArrowUp': {
      const triangle = new fabric.Triangle({
        width: 15,
        height: 15,
        fill: '#3c3c3c',
      });
      const rect = new fabric.Rect({
        top: 55,
        width: 2,
        height: 100,
        fill: '#3c3c3c',
      });
      return new fabric.Group([triangle, rect], {
        top: 50,
        left: 150,
        angle: 360,
      });
    }
    // 下箭头
    case 'ArrowDown': {
      const triangle = new fabric.Triangle({
        width: 15,
        height: 15,
        fill: '#3c3c3c',
      });
      const rect = new fabric.Rect({
        top: 55,
        width: 2,
        height: 100,
        fill: '#3c3c3c',
      });
      return new fabric.Group([triangle, rect], {
        top: 50,
        left: 150,
        angle: 180,
      });
    }
    // 矩形
    case 'Rect': {
      return new fabric.Rect({
        width: 150,
        height: 100,
        fill: '#ffa727',
      });
    }
    // 正方形
    case 'Square': {
      return new fabric.Rect({
        width: 150,
        height: 150,
        fill: '#ffa727',
      });
    }
  }
};

到此,就可以成功实现将元素或文本添加到画板上的功能了。

画板效果1.png

画板效果2.png

移除元素

事件的绑定和使用教程:Fabric.js 事件

在用户触发点击事件、选中当前需要移除的元素后,通过添加对 Backspace 键的事件监听器,来实现移除元素的功能。

我们在构造函数中绑定点击事件,并在监听到触发 Backspace 键后移除元素:

// Sketchpad.ts
import { fabric } from 'fabric';
import { Canvas, Object } from 'fabric/fabric-impl';
import { createShape } from '@/utils/createShape';

interface Instance extends Object {
  [key: string]: any; // 兼容自定义属性
}

interface FabricObject extends Instance {
  top: number;
  left: number;
}

class Sketchpad {
  public instance: Canvas; // canvas 实例

  private canvaProps = {
    width: 1000, // 默认宽度
    height: 720, // 默认高度
    fill: '#f6f6f6', // 默认填充色
    zoom: 1, // 缩放比例
    backgroundVpt: false, // 背景受视图缩放影响
    isDrawingMode: false, // 禁止使用画笔涂鸦模式
    preserveObjectStacking: true, // 元素被选中时保持原有层级
  };

  constructor(cvsId: string) {
    this.instance = new fabric.Canvas(cvsId, this.canvaProps);
    
    this.instance.on('mouse:down', (options: IEvent) => {
      const target = options.target as FabricObject;
      if (!target) {
        return; // 如果没有选中对象,结束流程
      }

      document.onkeydown = (e: KeyboardEvent) => {
        if (target.isEditing) {
          return; // 如果选中的对象是文本并且处于编辑状态,结束流程
        }

        switch (e.code) {
          case 'Backspace': {
            this.instance.remove(target).renderAll(); // 移除当前元素后,重新渲染画板
            break;
          }
      };
    });
  }
}

到此,就可以成功实现移除元素的功能了。

复制和粘贴元素

监听到点击事件时,通过添加对应的事件监听器来实现复制、粘贴功能。

  • 复制:同时按下 ctrl + c 时,将当前选中的对象保存为 cloneObj

  • 粘贴:同时按下 ctrl + v 时,将 cloneObj 克隆出来,并基于 cloneObj 设置新的 topleft 等值,防止元素的位置重叠,最后将这个新对象也渲染到画板上。

回到构造函数中,继续完善复制和粘贴元素的功能:

// Sketchpad.ts
class Sketchpad {
  // ...
  
  // 克隆源对象
  private cloneObj: FabricObject | null = null;

  constructor(cvsId: string) {
    this.instance = new fabric.Canvas(cvsId, this.canvaProps);
    
    this.instance.on('mouse:down', (options: IEvent) => {
      const target = options.target as FabricObject;
      if (!target) {
        return; // 如果没有选中对象,结束流程
      }

      document.onkeydown = (e: KeyboardEvent) => {
        if (target.isEditing) {
          return; // 如果选中的对象是文本并且处于编辑状态,结束流程
        }

        switch (e.code) {
          case 'Backspace': {
            this.instance.remove(target).renderAll(); // 移除当前元素后,重新渲染画板
            break;
          }
          case 'KeyC': {
            if (e.ctrlKey) {
              target.clone((cloned: FabricObject) => {
                this.cloneObj = cloned; // 保存克隆源对象
              });
            }
            break;
          }
          case 'KeyV': {
            if (e.ctrlKey && this.cloneObj) {
              this.cloneObj.clone((cloned: FabricObject) => {
                cloned.set({
                  top: cloned.top + 30,
                  left: cloned.left + 30,
                  evented: true,
                });
                this.instance.setActiveObject(cloned); // 设置新元素为选中状态
                this.instance.add(cloned).renderAll(); // 添加到画板上,重新渲染画板
                this.cloneObj = cloned; // 如果要保持原来的克隆源对象,就不更新
              });
            }
            break;
          }
        }
      };
    });
  }
}

到此,就可以成功实现复制和粘贴元素的功能了。

复制、粘贴元素动画.gif

涂鸦与橡皮擦效果

  • 涂鸦效果:通过修改画板实例的 isDrawingMode 属性,来启动或关闭涂鸦模式。

  • 橡皮擦效果:将笔刷颜色替换成画板的背景颜色,即可实现简单的橡皮擦效果。

为了方便管理,我们将在画板上触发的事件集成到一个事件派发函数中,并存储在 Index.vue 中。当子组件需要调用时,使用 inject 方法获取即可。

// Index.vue
const handleAction = (action: string) => {
  const actions: Record<string, Function> = {
    Drawing: () => sketchpad.setDrawingMode(), // 切换涂鸦模式
    Eraser: () => sketchpad.setEraserMode(), // 切换橡皮擦
  };
  if (action in actions) {
    actions[action]();
  }
};

然后,在 Sketchpad.ts 中实现 setDrawingModesetEraserMode 方法。

// Sketchpad.ts
class Sketchpad {
  // ...
  setDrawingMode() {
    this.instance.isDrawingMode = !this.instance.isDrawingMode;
    // 可以自定义画笔粗细和颜色
    this.instance.freeDrawingBrush.width = 10; 
    this.instance.freeDrawingBrush.color = 'pink';
  }
  setEraserMode() {
    // 橡皮擦效果需要确保 isDrawingMode 的属性值为 true
    this.instance.isDrawingMode = true;

    this.instance.freeDrawingBrush.width = 5;
    // 同步画板的背景颜色
    this.instance.freeDrawingBrush.color = this.canvaProps.fill;
  }
}

接下来,封装一个 Toolkit 工具栏组件,导入并渲染 operationList 工具列表。

// config.tsx
export const operationList = [
  {
    type: 'Drawing',
    element: <WritingFluently />,
    title: '涂鸦',
  },
  {
    type: 'Eraser',
    element: <ClearFormat />,
    title: '橡皮擦',
  },
];
// Toolkit.vue
<template>
  <div class="toolkit">
    <div v-for="item in operationList" :key="item.type" class="icon" @click="onClick(item.type)">
      <component :is="item.element" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import { operationList } from '@/const/config';

const handleAction = inject('handleAction') as Function;

const onClick = (action: string) => {
  handleAction(action);
};
</script>

<style lang="scss">
.toolkit {
  width: 45px;
  line-height: 45px;
  text-align: center;
  border-radius: 5px;
  color: var(--textColor);
  background-color: var(--bgColor);
  box-shadow: 0 0 15px 0 var(--shadowColor);
  overflow: hidden;
  position: absolute;
  left: 360px;
  top: 80px;
  z-index: 1001;
}
</style>

到此,就可以成功实现切换涂鸦模式和使用橡皮擦的功能了。

画笔涂鸦和橡皮擦效果.gif

最后

欢迎大家指出不足之处或提出建议,一起进步。

元素的撤销与恢复功能可以看这篇:基于 Fabric.js 实现元素撤销与恢复功能。