基于 Fabric.js 实现一个简易画板

最近这段时间闲着没事做,就打算做一个简易编辑器来玩。

本文对于创建项目的过程就不再赘述,直接进入主题。

前言

本项目基于 Vue3 + fabric.js 技术栈开发,没有使用样式框架,图标库是 IconPark

此处附上 fabric.js 官网:fabric.js 中文教程

js 复制代码
// fabric.js
pnpm i fabric
// icon-park 图标库
pnpm i @icon-park/vue-next 
// 其他声明
pnpm i @types/fabric -D

在画板上添加文字或图形

定义 Sketchpad 画板类

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

首先,在入口文件 Index.vue(下文会给出代码)中创建一个 canvas 容器,并设置它的 id 属性。接下来,我们将生成的画板实例绑定到已创建的 canvas 容器上。

Sketchpad 类的构造函数 constructor 接受一个参数,就是用于绑定 canvas 容器的 id 标识。目前为止,需要实现的部分代码如下:

ts 复制代码
// Sketchpad.ts
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 中,以便后续的修改和管理。

tsx 复制代码
// shapeList.tsx
// 忽略图标的引入,可以按需在官网中搜索、替换

export type ShapeType = 'Textbox' | 'Line' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'Rect' | 'Square' | 'Triangle' | 'RightTriangle' | 'Circle' | 'Ellipse' | 'Parallel' | 'Diamond' | 'Polygon';

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 />,
  },
  {
    type: 'Triangle',
    element: <Triangle />,
  },
  {
    type: 'RightTriangle',
    element: <RightTriangle />,
  },
  {
    type: 'Circle',
    element: <Round />,
  },
  {
    type: 'Ellipse',
    element: <Ellipse />,
  },
  {
    type: 'Parallel',
    element: <Parallel />,
  },
  {
    type: 'Diamond',
    element: <Diamond />,
  },
  {
    type: 'Polygon',
    element: <Polygon />,
  },
];

接下来,在图形面板 Shape 组件中导入并渲染 shapeList 列表。

ts 复制代码
// 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>

页面布局的设计

Index.vue 文件是本项目的入口文件,其中使用左右布局:侧边栏 Siderbar 组件固定宽度,右边容器自适应宽度,填充满剩余页面。上述提到的图形面板 Shape 组件将嵌入侧边栏。

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

ts 复制代码
// 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)); // 响应式传递
});

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

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

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

  #cvs {
    width: 1000px;
    height: 720px;
    background-color: var(--bgColor);
    border: 2px solid var(--shadowColor);
  }
}
</style>
ts 复制代码
// 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 文件中,完善这个函数的实现。

为了方便后续的修改和优化,我们将生成图形的核心逻辑抽取出来,单独创建一个 createShape 函数。

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

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

class Sketchpad {
  // ...

  insertShape(type: ShapeType) {
    const shape: Instance = createShape(type);
    shape.top = 150;
    shape.left = 150;
    
    this.instance.add(shape).renderAll(); // 添加到画板上并重新渲染画板
  }
}

核心逻辑

createShape 函数的功能:根据传入的 ShapeType 类型,在画板上添加一个对应的新实例。

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',
      });
    }
    // ...
  }
};

到此,将图形或文本添加到画板上的核心函数已经实现,看一下实际效果如何:

相关推荐
彭世瑜几秒前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4041 分钟前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish2 分钟前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five3 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序3 分钟前
vue3 封装request请求
java·前端·typescript·vue
临枫5414 分钟前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
前端每日三省5 分钟前
面试题-TS(八):什么是装饰器(decorators)?如何在 TypeScript 中使用它们?
开发语言·前端·javascript
小刺猬_9856 分钟前
(超详细)数组方法 ——— splice( )
前端·javascript·typescript
渊兮兮7 分钟前
Vue3 + TypeScript +动画,实现动态登陆页面
前端·javascript·css·typescript·动画
鑫宝Code7 分钟前
【TS】TypeScript中的接口(Interface):对象类型的强大工具
前端·javascript·typescript