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