fabric.js簡介
眾所周知渺鹦,canvas的api繁雜约巷,對一般的前端er來說不太友好枝冀,加上平時一般也不會自己手寫canvas痊夭,所以一般開發(fā)者對canvas的涉獵可能并不太深(我看紅寶書的時候canvas是直接跳過的)刁岸。而當(dāng)需要使用canvas開發(fā)一些定制化的需求時,echarts她我,antv系列虹曙,可能就無法滿足了,這個時候或許fabric會是一個比較好的選擇番舆,fabric提供一種類似面向?qū)ο蟮姆椒▉砭帉慶anvas酝碳,比原生稍微方便一些(然鵝官方文檔太難看懂了)
故事背景
近期的一個項目中,有這么一個需求:拖拽縮放元素并且進(jìn)行連線合蔽,本來我第一反應(yīng)是用antv/g6去實現(xiàn)的击敌,但是需要對拖拽的元素縮放并且拖拽的容器需要放文字和圖表介返,如果使用g6的話拴事,縮放容器,里面的內(nèi)容改變不太利索(實際是我對g6不太熟)圣蝎,另一個重要的問題是g6元素里面放圖表的話只能放g2(而且需要單獨安裝插件)并且不支持諸如tooltip等等功能刃宵,簡單來說只能用個閹割版的(示例:https://antv-g6.gitee.io/zh/examples/item/customNode#lineChartNode)。因此我最初想的是使用vue-grid-layout
(github&&文檔)進(jìn)行拖拽與縮放徘公,畫線使用canvas牲证。這樣做的好處是第三方組件已經(jīng)把拖拽和縮放功能全都封裝好了,dom元素嵌入echarts和文本縮放也相當(dāng)方便(vue-echarts的autoresize关面,文本使用flex布局加overflow:auto)坦袍,當(dāng)然畫線又是一個大問題,關(guān)鍵點就是線要和拖拽的元素接上等太,簡而言之就是坐標(biāo)計算了捂齐。考慮到畫布里面還要放圖(拖拽的元素連線到圖上)以及要實現(xiàn)連線的時候鼠標(biāo)移動需要不停的重繪線缩抡,最終在同事的推薦下決定使用fabric.js來實現(xiàn)canvas部分奠宜。然后就發(fā)現(xiàn)這東西用起來一言難盡...
踩坑記錄與解決
1.官方文檔
就算你英語很好看他的文檔也會很別扭的,建議直接看官方DEMO找自己要的瞻想,不懂的百度谷歌压真,最后把查找文檔作為補(bǔ)充以及檢查是否有新版api和網(wǎng)上的古早文章不同。
2.在vue中使用
import Fabric from 'fabric';
new Fabric.fabric.Canvas('xxx',{});
目前只能這樣用
3.繪制本地圖片有問題
我嘗試過fabric.Image.fromURL('xxx/xxx.png',function(){})
以及new Image().src
這兩種發(fā)現(xiàn)貌似都不能放本地圖片地址(類似@/assets/...
這種)蘑险,可能是我使用的方式不對滴肿,最后只剩下一種方法可用了:
const imgDOM = document.getElementById('xxx');
imgDOM.onload = () => {
const imgInstanceFirst = new Fabric.fabric.Image(imgDOM, {});
this.fabricObj.add(imgInstanceFirst);
// 將圖片層級降為最低
imgInstanceFirst.sendToBack();
};
這種方法首先需要在頁面上放一個隱藏的img元素,結(jié)果一開始fabric還讀不到只能通過onload
事件來獲取佃迄,但這樣會導(dǎo)致畫布重繪時無法執(zhí)行onload泼差,最后一個繪制圖片被我寫成這樣了
// 繪制人體背景圖
drawBodyImg() {
const imgInstance = this.getBodyImgInstance();
if (imgInstance) {
this.fabricObj.add(imgInstance);
imgInstance.sendToBack();
return;
}
const imgDOM = document.getElementById('bodyImg');
// 初始化時即使是已經(jīng)存在于html中的imgdom對象也需要在onload事件中獲取竿音,否則fabric渲染不出來
imgDOM.onload = () => {
// FIXME:某些未知情況暫時無法判斷 妥協(xié)做法初始化時渲染兩次并移除第一次渲染的圖
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更新---------
可能是頁面結(jié)構(gòu)太復(fù)雜的緣故春瞬,上面的方法有小概率執(zhí)行時圖片還沒加載好,導(dǎo)致最后畫布里面其它內(nèi)容都出來了結(jié)果最重要的圖沒了套啤,最終我搞出來的解決辦法是宽气,在img標(biāo)簽上直接綁定load事件,執(zhí)行l(wèi)oad時將組件內(nèi)設(shè)置的狀態(tài)修改潜沦,并監(jiān)聽這個狀態(tài)的變化來執(zhí)行圖片渲染到canvas畫布的過程萄涯。
<img
v-show="false"
id="bodyImg"
src="@/assets/img/body.png"
alt=""
@load="loadBodyImg"
/>
// .......
watch: {
// 圖片加載有時會比fabric加載慢
bodyImgLoaded() {
if (this.fabricObj) {
// 避免重復(fù)加載
const imgarr = this.fabricObj.getObjects().filter(v => {
return v._element && v.nodeName === 'IMG'; // 從控制臺打印獲取到fabricObj圖片內(nèi)部屬性
})
if (!imgarr.length) {
this.drawBodyImg();
}
}
}
}
// .....
loadBodyImg() {
this.bodyImgLoaded = true;
},
// 正常加載時還是先執(zhí)行這個方法,兩邊都有判斷唆鸡,不會重復(fù)執(zhí)行涝影,而且必定有一邊會執(zhí)行
drawBodyImg() {
const imgInstance = this.getBodyImgInstance();
if (imgInstance) {
this.fabricObj.add(imgInstance);
imgInstance.sendToBack();
}
},
-----------------------------
- 去除canvas對象的選中樣式以及功能
fabric會默認(rèn)給每一個繪制出來的canvas對象加上縮放,旋轉(zhuǎn)等功能争占,你會看到畫布上的對象有一堆的點燃逻。我是這樣做的
初始化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, // 關(guān)閉圖層控件
hoverCursor: 'default'
});
因為我需要點擊線的時候彈出刪除菜單臂痕,所以不能在初始化的時候直接skipTargetFind: true
伯襟,我要做的是去除選中的樣式和大部分功能,保留選中時能獲取到選中對象握童,一旦這個屬性設(shè)為true則會取消所有選中樣式和功能姆怪,不需要在canvas對象里面再單獨配置了。
- 繪制三次貝塞爾曲線
領(lǐng)導(dǎo)認(rèn)為直線不好看澡绩,UI直接整了一個三次貝塞爾曲線稽揭,所以有兩個問題,第一是如何在fabric里面繪制貝塞爾曲線肥卡,主要是用Path方法(應(yīng)該就是svg的畫法溪掀,注意M和C要大寫),(x1,y1) (x2, y2)分別是起點和終點召调,c1和c2是控制點坐標(biāo)(三次貝塞爾曲線需要兩個控制點)
/**
* @description: 使用fabric繪制展示用的三次貝塞爾曲線
* @param {Object} fabricObj 組件內(nèi)已經(jīng)生成的fabric對象
* @param {Array<number>} start 起點坐標(biāo)
* @param {Array<number>} end 終點坐標(biāo)
* @param {String} strokeColor 線的顏色(展示用的默認(rèn)灰色)
* @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 // 關(guān)閉圖層控件
});
fabricObj.add(line);
}
第二膨桥,計算三次貝塞爾曲線的控制點,這里面用了向量運算...
/**
* @description: 已知起點和終點近似計算三次貝塞爾曲線控制點
* @param {Array<number>} start 起點坐標(biāo)
* @param {Array<number>} end 終點坐標(biāo)
* @param {Number} curvature 曲率(默認(rèn)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)]
};
}
- 最后碰到的一個很嚴(yán)重的問題唠叛,屏幕縮放問題
fabric.js里面的坐標(biāo)系不能識別系統(tǒng)的縮放(是系統(tǒng)設(shè)置里面的縮放而非瀏覽器本身的縮放)只嚣,相信一般人windows電腦都會選擇系統(tǒng)推薦縮放吧,1080p甚至2k4k分辨率如果用原始比例的話字太小了艺沼,結(jié)果我把頁面從我的外接屏拖到筆記本的屏幕上時fabric里面的坐標(biāo)系直接崩壞了...
網(wǎng)上找的檢測屏幕縮放比例的方法(可以檢測到系統(tǒng)分辨率改變)
// 檢測屏幕縮放比例
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主要在拖動元素時計算元素與線的連接點坐標(biāo)用到了册舞,這個系統(tǒng)里面只要vue-grid-layout元素有改變,我就要重新計算線的起點并重繪線障般,通過這種辦法實現(xiàn)了dom元素和canvas元素的綁定调鲸,聽起來很low的樣子盛杰,不過最后功能是都實現(xiàn)了。
參考文章(還有些講fabric的api的文章找不到了...)
https://github.com/hujiulong/blog/issues/1
fabric視頻教程(我還沒看過藐石,可能有些內(nèi)容存在過時)
https://www.bilibili.com/video/BV1at411q7bt