为什么选择fabricjs

canvas画布由于本身提供的api使用上并不是很便捷,所以一般在进行canvas画布使用的时候,我们会选用一些三方库。这些三方库对一些常用api进行了聚合和封装改进,更进一步的会在上层在做一些其他的处理,这里面做的比较好的有fabricjspixi,其中fabricjs相对来说时间更久,类似于jquery在js中的地位

画布需求分析

我们实现一个画布前,首先得清楚画布需要实现的基本功能。这里我们梳理一下一个画布必须包含的基础能力。

基础功能

  1. 无限画布 这应该是先有canvas画布插件统一提供的基础能力
  2. 本地上传 本地资源可以上传到画布实现渲染
  3. 画布操作
    1. 画布平移
    2. 画布缩放(鼠标位置作为中心点)
  4. 图片操作
    1. 图片拖动
    2. 图片缩放
  5. 画笔 画笔的基础参数修改,画笔粗细、颜色
  6. 橡皮擦 也是基础配置的修改,橡皮擦的大小
  7. 下载画布 如何可以完整的下载整张画布上的所有资源,包括不在屏幕内但处于画布上的元素

搭建画布

fabric创建画布

fabric创建画布其实很简单,接下来我们就通过fabric先来创建一个画布

<script src="/src/assets/js/fabric.min.js"></script>
<canvas id="c"></canvas>
new fabric.Canvas('c', {})

首先引入fabricjs,在html中创建一个canvas标签用来作为我们的目标画布。接下来要通过fabric创建一个画布,我们只需要new一个fabric.Canvas对象就可以了 此处可以有一些参数,常见的比如width、height

new fabric.Canvas('c', {    width: '800px',    height: '500px'})

封装

为了方便后面维护以及调用,我们在此处可以针对需求的画布进行对象封装,然后在对外暴露出画布操作需要的api

Stage类

提供基础的Stage类作为我们的画布对象, 在new 时调用我们上面new fabric.Canvas(‘c’, {})方法来创建一个画布
通过 innerWidth``innerHeight将画布的大小设置为全屏

class Stage {
constructor(el){
this.el = el;
this.canvas = null;
this.init();
}
init() {
if (!this.el) throw "缺少el元素";
this.canvas = new fabric.Canvas(this.el, {
width: innerWidth,
height: innerHeight,
});
}
}

此时我们再创建一个画布,通过以下方式

new Stage(c)

基本的页面布局

画布的功能比如上传、画笔、橡皮擦等都需要页面上有固定的按钮来触发,所以我们的页面需要有一个简单的布局,也就是需要一个toolbar来安置这些按钮。
so我们写一个简单的toolbar组件放在我们画布的顶部

前面我们已经创建了一个画布,接下来我们来实现画布里面的具体功能。

本地上传

我们来分析本地上传的功能,不考虑后端存储,其实本地上传就本地应用来说主要分为两部分,以图片为例:

  1. 本地图片上传
  2. 画布渲染上传的图片

本地图片上传

首先我们来实现本地图片上传,此处可以直接使用element-ui的el-upload组件来实现本地文件的上传,只需在原有上传的时候进行拦截获取就行

<el-upload
v-model:file-list="fileList"
class="upload-demo"
multiple
:limit="100"
:before-upload="handleUpload"
:show-file-list="false"
>
<el-button type="primary">本地上传</el-button>
</el-upload>
/**
* 上传时触发回调
*
* @param {Object} rawFile file对象的上传数据
* @return {boolean} 是否终止后续操作
*/
const handleUpload = (rawFile) => {
// 此处暂时不用关注,重点是获取到上传的图片数据
stage.addImg(file);
return false;
};

上面我们获取到了要上传的图片数据,接下来我们将获取到的图片资源渲染到我们画布上

画布渲染上传的图片

fabricjs中我们要把画布渲染到画布上有两种方法:

// 同步加载
fabric.Image
// 异步加载
fabric.Image.fromURL

介于我们目前已经拿到本地上传的file文件,同时本地环境下因为没有后端存储又受浏览器限制获取不到本地文件有效的url地址,所以此处加载图片我们选择同步加载
另一方面因为我们此时拿到的是file文件,所以我们首先需要做的是怎么把file文件转换为image对象
image对象很好获取,new Image()然后加载一下对应的图片就可以获取到,但image对象怎么加载file文件呢?
此处我们需要了解一个api FileReader对象, 使用FileReader我们可以读取file文件

/**
* 画布添加元素,渲染图片
*
* @param {object} file 待上传的file数据
*/
addImg(file) {
// 读取图片数据
var reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = (e) => {
const imgobj = new Image();
imgobj.onload = () => {
// 画布加载、渲染
fabric.Image.fromObject(imgobj, (obj) => {
// 调用前面创建画布存储的canvas对象加载image
this.canvas.add(obj);
});
};
imgobj.src = e.target.result;
};
}

画布基础操作

画布平移

fabricjs没有自带的画布平移功能,那我们如何实现呢?首先我们先来整理需求。
正常的平移就是按住鼠标拖动画布但需要注意的是如果画布上存在其他元素,比如我们前面上传的图片,鼠标如果按下时刚好处于这些元素上面我们是否需要依旧进行平移操作?如果这个时候我们需要做一些其他操作呢?换句话说就是鼠标处于画布空白位置时正常进行平移, 但如果处于元素上时则不进行平移操作。
接下来我们来监听鼠标的相关事件:mouse:downmouse:movemouse:up,来实现一个基础的元素拖动功能

canvas.on("mouse:down", (opt) => {
// 鼠标按下时触发
if (!opt.target) {
let evt = opt.e;
canvas.isDragging = true; // isDragging 是自定义的,开启移动状态
canvas.lastPosX = evt.clientX; // lastPosX 是自定义的
canvas.lastPosY = evt.clientY; // lastPosY 是自定义的
}
});

canvas.on("mouse:move", (opt) => {
// 鼠标移动时触发
if (canvas.isDragging) {
let evt = opt.e;
let vpt = canvas.viewportTransform; // 聚焦视图的转换
vpt[4] += evt.clientX - canvas.lastPosX;
vpt[5] += evt.clientY - canvas.lastPosY;
canvas.requestRenderAll(); // 重新渲染
canvas.lastPosX = evt.clientX;
canvas.lastPosY = evt.clientY;
}
});

canvas.on("mouse:up", (opt) => {
// 鼠标松开时触发
canvas.setViewportTransform(canvas.viewportTransform); // 设置此画布实例的视口转换
canvas.isDragging = false; // 关闭移动状态
});

画布缩放

缩放fabricjs本身提供了API来实现这个功能,但默认的功能不符合我们的操作习惯。首先他的缩放是以画布左上角为缩放点进行的缩放,同时也不能跟进鼠标的位置自动进行中心点切换。所以我们来进行一下改造。还是先是先梳理一下我们的需求:

  1. 缩放比例展示在工具栏。同时可以在工具栏手动输入更改
  2. 缩放默认以画布中心点作为缩放中心点
  3. 鼠标如果在画布上,缩放就以鼠标的位置作为中心点进行缩放

输入缩放

输入框我们简单使用input的绑定来获取值

<input type="range" :min="zoom.min * 100" :max="zoom.max * 100" v-model="zoomVal"/>

拿到zoomVal之后我们需要把值实时更新到canvas画布中,fabricjs中canvas对象提供了setZoom方法用来设置缩放比例

stage.canvas.setZoom(Number(zoomVal) / 100);

调用后我们发现,画布确实缩放了,但缩放的中心点却是画布的左上角, 这与我们的预期不符, 此时我们希望的默认是以画布中心点进行缩放。
查询发现canvas还有一个相关的apizoomToPoint,我们可以通过设置坐标来达到预期的效果。

const handleZoomChange = (val) => {
if (typeof val === "number") {
zoom.value.val = Number(val.toFixed(2));
// stage.canvas.setZoom(Number(val) / 100); // 左上角为中心点
stage.canvas.zoomToPoint(
{
x: innerWidth / 2,
y: innerHeight / 2,
},
zoom.value.val
); // 画布中心为中心点
}
};

这就实现了画布随着我们输入框输入值变化而变化。接下来我们如何实现在画布上进行缩放呢?同时还需要保持输入框展示的值跟随画布缩放而变化

画布缩放

要实现画布上的缩放, 我们首先需要监听鼠标的滚轮变化情况,为canvas绑定wheel事件- mouse:wheel。事件返回opt会包含fabric提供的一些数据,包括鼠标event数据

canvas.on("mouse:wheel", (opt) => {})

通过event我们可以获取到鼠标的位置信息,基于上面提到的canvas.zoomToPoint来设置缩放。基本流程如下

let zoom = canvas.getZoom(); // 获取画布当前缩放值
// 控制缩放范围在 0.01~20 的区间内
zoom *= 0.999 ** delta;
if (zoom > max) zoom = max;
if (zoom < min) zoom = min;
// 设置画布缩放比例
// 参数1:将画布的所放点设置成鼠标当前位置
// 参数2:传入缩放值
canvas.zoomToPoint(
{
x: opt.e.offsetX, // 鼠标x轴坐标
y: opt.e.offsetY, // 鼠标y轴坐标
},
zoom // 最后要缩放的值
);

这就实现了鼠标在画布上的缩放, 我们只需要把此处的zoom赋值给input的zoomVal就可以直接实现数据的双向展示。

图片基础操作

前面我们在画布中上传了图片, 作为一个工具我们需要能够对上传的元素进行一些基本的操作,比如针对图片进行缩放、拖动、旋转、框选、合并图层、解散图层等。
fabric对元素的拖动、旋转以及框选本身提供了相关功能,这里我们就不做说明了。 接下来我们依次来实现图片的缩放、框选、合并图层、解散图层。

缩放

前面画布缩放的时候我们针对canvas的mouse:wheel进行了监听,并在里面实现了画布的缩放。这里我们依旧在该监听内来实现元素的缩放。 前面介绍了canvas事件会在回调里返回event对象,所以我们基于event.target(这里和dom是一样的)来判断鼠标下是否有元素

canvas.on("mouse:wheel", (opt) => {
const { target } = opt;
let delta = opt.e.deltaY; // 滚轮向上滚一下是 -100,向下滚一下是 100
if (target && target.type === 'image') {
// 获取缩放前的信息数据
const height = target.getScaledHeight();
const width = target.getScaledWidth();
const zoomVal = target.getTotalObjectScaling();
const { scaleX, scaleY } = zoomVal;
let X = 0.9999 ** delta;
if (X > max) X = max;
if (X < min) X = min;
const Y = X;
// 设置缩放大小
target.scaleToWidth(width * X, true);
target.scaleToHeight(height * Y, true);
canvas.requestRenderAll();
}
})

元素的缩放与画布不同,需要分别针对元素的width、height进行缩放设置。同时调用canvas.requestRenderAll();来刷新画布元素

合并/解散图层

图层合并的功能我们基于分组来实现, 这块都有相关的api,唯一需要注意的是新分组创建是通过对元素的拷贝来创建分组内的元素,原有的元素需要手动删除

合并图层实现

/**
*新建分组
*
* @param {Object} canvas 当前画布对象
* @param {Object} opts 组配置参数
* @return {Object} 返回的group对象
*/
create(canvas, opts) {
// 判断当前有没有选中元素,如果没有就不执行任何操作
if (!canvas.getActiveObject()) {
return;
}
const activeObject = canvas.getActiveObject();
const { top, left } = activeObject;
const group = new fabric.Group(activeObject._objects, {
top,
left,
});
activeObject._objects.forEach((element) => {
canvas.remove(element);
});
canvas.add(group);
canvas.requestRenderAll();
return group;
}

解散图层实现

/**
* 解散分组
*
* @param {Object} canvas 画布对象
* @return {Object} 返回的group对象
*/
dispersed(canvas) {
// 判断当前有没有选中元素,如果没有就不执行任何操作
if (!canvas.getActiveObject()) {
return;
}

// 判断当前是否选中组,如果不是,就不执行任何操作, 此处需要考虑多选
if (canvas.getActiveObject().type !== "group") {
return;
}

// 先获取当前选中的对象,然后打散
canvas.getActiveObject().toActiveSelection();
}

画笔

fabric提供了Brush类, 这里我们使用PencilBrush来实现简单的画笔功能,主要包括画笔颜色更改、笔尖大小更改。
方便复用我们创建一个Brush 类来实现

class Brush {
constructor() {
this.freeDrawingBrush = null;
}
/**
*新建画笔
*
* @param {Object} canvas 当前画布对象
*/
create(canvas) {
canvas.isDrawingMode = true;
if (canvas.isDrawingMode) {
canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
this.freeDrawingBrush = canvas.freeDrawingBrush;
}
}
/**
*设置画笔颜色
*
* @param {string} value 橡皮擦颜色
*/
setColor(value) {
this.freeDrawingBrush.color = value;
}
/**
*设置画笔宽度
*
* @param {number} value 橡皮擦宽度
*/
setWidth(value) {
this.freeDrawingBrush.width = parseInt(value, 10) || 1;
}
/**
*销毁画笔
*
* @param {Object} canvas 当前画布对象
*/
destory(canvas) {
canvas.freeDrawingBrush = null;
canvas.isDrawingMode = false;
this.freeDrawingBrush = null;
}
}

创建消耗时需要把前面创建的canvas对象传进来

橡皮擦

fabric没有橡皮擦相关的实现。我们先来简单的梳理一下橡皮擦的功能,其实很简单就是鼠标滑过的地方按照轨迹清除相关的元素。反过来实现我们可以进行轨迹背景填充。也就是使用画笔来实现橡皮擦的轨迹填充上canvas的背景色。
具体功能实现可以调用上main的Brush类。这里就不在重复实现。

下载画布

现在我们的画布上已经有了很多元素,各种操作结束后我们如何把编辑的效果下载下来呢?
fabric针对canvas提供了toDataURL的API可以以base64格式输出画布数据, 我们基于这个api来实现画布下载的功能
在这之前首先我们基于a标签的download来实现一个基础的下载能力,

// 下载功能
let a = document.createElement("a");
a.setAttribute("href", url);
a.setAttribute("download", name);
a.setAttribute("target", "_blank");
a.setAttribute("id", "downLoad");
a.click();

接着我们来调用toDataURL,将返回的base64赋值给a标签的href

const baseimg = canvas.toDataURL()
// 下载功能
let a = document.createElement("a");
a.setAttribute("href", baseimg);
a.setAttribute("download", 'test.jpg');
a.setAttribute("target", "_blank");
a.setAttribute("id", "downLoad");
a.click();

但这个时候我们就会发现下载下来的图片有些不对,那是因为默认下来下来的只是当前屏幕内的, 所以我们需要在toDataURL的时候对画布进行剪切。也就是说我们需要算出画布上所有元素的范围,然后用这个范围对画布进行裁剪,再下载裁剪后的画布。上代码

/**
* 下载画布
*
* @param {string} type 可以下载的图片格式
*/
const outputImg = (type) => {
type = type || "jpeg";
// 通过偏移量、宽高处理全图
const all = this.getAllObject();
// 计算画布元素边界
const maxleftObj = _.maxBy(all, (o) => {
return o.left;
});
const minleftObj = _.minBy(all, (o) => {
return o.left;
});
const maxtopObj = _.maxBy(all, (o) => {
return o.top;
});
const mintopObj = _.minBy(all, (o) => {
return o.top;
});
// 剪切画布, 输出画布数据base64格式
// console.log(111, minleftObj, minleftObj.left, mintopObj, mintopObj.top);
const baseimg = this.canvas.toDataURL({
format: type,
quality: 1,
// multiplier: this.zoom,
withoutTransform: true,
left: minleftObj.getBoundingRect().left,
top: mintopObj.getBoundingRect().top,
width: (maxleftObj.left - minleftObj.left + maxleftObj.width) * this.zoom,
height: (maxtopObj.top - mintopObj.top + mintopObj.height) * this.zoom,
});
this.canvas.requestRenderAll();
// 下载功能
let a = document.createElement("a");
a.setAttribute("href", baseimg);
a.setAttribute("download", `${Date.parse(new Date())}.${type}`);
a.setAttribute("target", "_blank");
a.setAttribute("id", "downLoad");
a.click();
}

到这里我们已经实现了一个画布的基础能力,基于这些能力我们就可以做很多事。但这里还有很大的优化空间。比如图片数据量大的时候如何提高canva的渲染能力,虽然fabric内部已经做了离线渲染,但其实我们依旧可以针对元素进行可是区域内外的分批渲染等