前言
最近闲来无事,打算实现一个简易画板。
本项目基于 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>
核心逻辑的提取
当前使用 inject
和 provide
进行数据传递,所以介绍一下当前的页面结构。
Index.vue
是项目的入口文件,页面使用左右布局:侧边栏 Siderbar
组件固定宽度,Shape
组件将嵌入侧边栏;右边容器自适应宽度,填充剩余页面。
Index.vue
和 Sidebar.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',
});
}
}
};
到此,就可以成功实现将元素或文本添加到画板上的功能了。
移除元素
事件的绑定和使用教程: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
设置新的top
和left
等值,防止元素的位置重叠,最后将这个新对象也渲染到画板上。
回到构造函数中,继续完善复制和粘贴元素的功能:
// 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;
}
}
};
});
}
}
到此,就可以成功实现复制和粘贴元素的功能了。
涂鸦与橡皮擦效果
-
涂鸦效果:通过修改画板实例的
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
中实现 setDrawingMode
和 setEraserMode
方法。
// 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>
到此,就可以成功实现切换涂鸦模式和使用橡皮擦的功能了。
最后
欢迎大家指出不足之处或提出建议,一起进步。
元素的撤销与恢复功能可以看这篇:基于 Fabric.js 实现元素撤销与恢复功能。