fabric.js简介
众所周知,canvas的api繁杂,对一般的前端er来说不太友好,加上平时一般也不会自己手写canvas,所以一般开发者对canvas的涉猎可能并不太深(我看红宝书的时候canvas是直接跳过的)。而当需要使用canvas开发一些定制化的需求时,echarts,antv系列,可能就无法满足了,这个时候或许fabric会是一个比较好的选择,fabric提供一种类似面向对象的方法来编写canvas,比原生稍微方便一些(然鹅官方文档太难看懂了)
故事背景
近期的一个项目中,有这么一个需求:拖拽缩放元素并且进行连线,本来我第一反应是用antv/g6去实现的,但是需要对拖拽的元素缩放并且拖拽的容器需要放文字和图表,如果使用g6的话,缩放容器,里面的内容改变不太利索(实际是我对g6不太熟),另一个重要的问题是g6元素里面放图表的话只能放g2(而且需要单独安装插件)并且不支持诸如tooltip等等功能,简单来说只能用个阉割版的(示例:https://antv-g6.gitee.io/zh/examples/item/customNode#lineChartNode)。因此我最初想的是使用vue-grid-layout
(github&&文档)进行拖拽与缩放,画线使用canvas。这样做的好处是第三方组件已经把拖拽和缩放功能全都封装好了,dom元素嵌入echarts和文本缩放也相当方便(vue-echarts的autoresize,文本使用flex布局加overflow:auto),当然画线又是一个大问题,关键点就是线要和拖拽的元素接上,简而言之就是坐标计算了。考虑到画布里面还要放图(拖拽的元素连线到图上)以及要实现连线的时候鼠标移动需要不停的重绘线,最终在同事的推荐下决定使用fabric.js来实现canvas部分。然后就发现这东西用起来一言难尽...
踩坑记录与解决
1.官方文档
就算你英语很好看他的文档也会很别扭的,建议直接看官方DEMO找自己要的,不懂的百度谷歌,最后把查找文档作为补充以及检查是否有新版api和网上的古早文章不同。
2.在vue中使用
import Fabric from 'fabric';
new Fabric.fabric.Canvas('xxx',{});
目前只能这样用
3.绘制本地图片有问题
我尝试过fabric.Image.fromURL('xxx/xxx.png',function(){})
以及new Image().src
这两种发现貌似都不能放本地图片地址(类似@/assets/...
这种),可能是我使用的方式不对,最后只剩下一种方法可用了:
const imgDOM = document.getElementById('xxx');
imgDOM.onload = () => {
const imgInstanceFirst = new Fabric.fabric.Image(imgDOM, {});
this.fabricObj.add(imgInstanceFirst);
// 将图片层级降为最低
imgInstanceFirst.sendToBack();
};
这种方法首先需要在页面上放一个隐藏的img元素,结果一开始fabric还读不到只能通过onload
事件来获取,但这样会导致画布重绘时无法执行onload,最后一个绘制图片被我写成这样了
// 绘制人体背景图
drawBodyImg() {
const imgInstance = this.getBodyImgInstance();
if (imgInstance) {
this.fabricObj.add(imgInstance);
imgInstance.sendToBack();
return;
}
const imgDOM = document.getElementById('bodyImg');
// 初始化时即使是已经存在于html中的imgdom对象也需要在onload事件中获取,否则fabric渲染不出来
imgDOM.onload = () => {
// FIXME:某些未知情况暂时无法判断 妥协做法初始化时渲染两次并移除第一次渲染的图
this.fabricObj.remove(imgInstance);
const imgInstanceFirst = new Fabric.fabric.Image(imgDOM, {...});
this.fabricObj.add(imgInstanceFirst);
imgInstanceFirst.sendToBack();
};
},
// 尝试获取人体图实例
getBodyImgInstance() {
const imgDOM = document.getElementById('bodyImg');
const imgInstance = new Fabric.fabric.Image(imgDOM, {...});
if (imgInstance.height) {
return imgInstance;
} else {
return null;
}
},
sendToBack
方法是为了确保在后面画线的时候线能在图的上面一层显示(貌似fabric是按照先后绘制顺序排层级的,先绘制的层级最高,于是我们需要将图的层级降到最低)
------------- 2021.03.30更新---------
可能是页面结构太复杂的缘故,上面的方法有小概率执行时图片还没加载好,导致最后画布里面其它内容都出来了结果最重要的图没了,最终我搞出来的解决办法是,在img标签上直接绑定load事件,执行load时将组件内设置的状态修改,并监听这个状态的变化来执行图片渲染到canvas画布的过程。
<img
v-show="false"
id="bodyImg"
src="@/assets/img/body.png"
alt=""
@load="loadBodyImg"
/>
// .......
watch: {
// 图片加载有时会比fabric加载慢
bodyImgLoaded() {
if (this.fabricObj) {
// 避免重复加载
const imgarr = this.fabricObj.getObjects().filter(v => {
return v._element && v.nodeName === 'IMG'; // 从控制台打印获取到fabricObj图片内部属性
})
if (!imgarr.length) {
this.drawBodyImg();
}
}
}
}
// .....
loadBodyImg() {
this.bodyImgLoaded = true;
},
// 正常加载时还是先执行这个方法,两边都有判断,不会重复执行,而且必定有一边会执行
drawBodyImg() {
const imgInstance = this.getBodyImgInstance();
if (imgInstance) {
this.fabricObj.add(imgInstance);
imgInstance.sendToBack();
}
},
-----------------------------
- 去除canvas对象的选中样式以及功能
fabric会默认给每一个绘制出来的canvas对象加上缩放,旋转等功能,你会看到画布上的对象有一堆的点。我是这样做的
初始化fabric对象
this.fabricObj = new Fabric.fabric.Canvas('canvasPart', {
selection: false, // 不可框选
skipTargetFind: false // 保留选中操作(在canvas对象中去掉选中样式)
});
画图(画线除了selectable其它类似,因为我的项目需要选中线)
const imgInstance = new Fabric.fabric.Image(imgDOM, {
selectable: false, // 去掉选中的效果
hasControls: false, // 关闭图层控件
hoverCursor: 'default'
});
因为我需要点击线的时候弹出删除菜单,所以不能在初始化的时候直接skipTargetFind: true
,我要做的是去除选中的样式和大部分功能,保留选中时能获取到选中对象,一旦这个属性设为true则会取消所有选中样式和功能,不需要在canvas对象里面再单独配置了。
- 绘制三次贝塞尔曲线
领导认为直线不好看,UI直接整了一个三次贝塞尔曲线,所以有两个问题,第一是如何在fabric里面绘制贝塞尔曲线,主要是用Path方法(应该就是svg的画法,注意M和C要大写),(x1,y1) (x2, y2)分别是起点和终点,c1和c2是控制点坐标(三次贝塞尔曲线需要两个控制点)
/**
* @description: 使用fabric绘制展示用的三次贝塞尔曲线
* @param {Object} fabricObj 组件内已经生成的fabric对象
* @param {Array<number>} start 起点坐标
* @param {Array<number>} end 终点坐标
* @param {String} strokeColor 线的颜色(展示用的默认灰色)
* @return {*}
*/
export function drawCubicBezierCurve(fabricObj, start, end, strokeColor = '#768C8C') {
const x1 = start[0];
const y1 = start[1];
const x2 = end[0];
const y2 = end[1];
const c1 = calcControlPoint(start, end).c1;
const c2 = calcControlPoint(start, end).c2;
const line = new Fabric.fabric.Path(`M ${x1} ${y1}C${c1[0]},${c1[1]},${c2[0]},${c2[1]},${x2},${y2}`, {
stroke: strokeColor,
hoverCursor: 'default',
fill: false,
hasControls: false // 关闭图层控件
});
fabricObj.add(line);
}
第二,计算三次贝塞尔曲线的控制点,这里面用了向量运算...
/**
* @description: 已知起点和终点近似计算三次贝塞尔曲线控制点
* @param {Array<number>} start 起点坐标
* @param {Array<number>} end 终点坐标
* @param {Number} curvature 曲率(默认0.1)
* @return {Object}
*/
export function calcControlPoint(start, end, curvature = 0.1) {
const x1 = start[0];
const y1 = start[1];
const x2 = end[0];
const y2 = end[1];
const cx1 = x1 + (x2 - x1) / 3 + (y2 - y1) * curvature;
const cy1 = y1 + (y2 - y1) / 3 + (x1 - x2) * curvature;
const cx2 = x1 + (x2 - x1) * 2 / 3 + (y1 - y2) * curvature;
const cy2 = y1 + (y2 - y1) * 2 / 3 + (x2 - x1) * curvature;
return {
c1: [Math.abs(cx1), Math.abs(cy1)],
c2: [Math.abs(cx2), Math.abs(cy2)]
};
}
- 最后碰到的一个很严重的问题,屏幕缩放问题
fabric.js里面的坐标系不能识别系统的缩放(是系统设置里面的缩放而非浏览器本身的缩放),相信一般人windows电脑都会选择系统推荐缩放吧,1080p甚至2k4k分辨率如果用原始比例的话字太小了,结果我把页面从我的外接屏拖到笔记本的屏幕上时fabric里面的坐标系直接崩坏了...
网上找的检测屏幕缩放比例的方法(可以检测到系统分辨率改变)
// 检测屏幕缩放比例
export function detectZoom() {
let ratio = 0;
const screen = window.screen;
const ua = navigator.userAgent.toLowerCase();
if (window.devicePixelRatio !== undefined) {
ratio = window.devicePixelRatio;
} else if (~ua.indexOf('msie')) {
if (screen.deviceXDPI && screen.logicalXDPI) {
ratio = screen.deviceXDPI / screen.logicalXDPI;
}
} else if (window.outerWidth !== undefined && window.innerWidth !== undefined) {
ratio = window.outerWidth / window.innerWidth;
}
return ratio;
}
然后在初始化fabric对象时需要重新计算宽高(canvasLayout为画布上一级的父元素)
this.fabricObj = new Fabric.fabric.Canvas('canvasPart', {
selection: false, // 不可框选
skipTargetFind: false // 保留选中操作(在Line中去掉选中样式)
});
const boxDOM = document.getElementById('canvasLayout');
const width = boxDOM.offsetWidth / this.pageZoom;
const height = boxDOM.offsetHeight / this.pageZoom;
this.fabricObj.setWidth(width);
this.fabricObj.setHeight(height);
this.fabricObj.renderAll();
pageZoom主要在拖动元素时计算元素与线的连接点坐标用到了,这个系统里面只要vue-grid-layout元素有改变,我就要重新计算线的起点并重绘线,通过这种办法实现了dom元素和canvas元素的绑定,听起来很low的样子,不过最后功能是都实现了。
参考文章(还有些讲fabric的api的文章找不到了...)
https://github.com/hujiulong/blog/issues/1
fabric视频教程(我还没看过,可能有些内容存在过时)
https://www.bilibili.com/video/BV1at411q7bt