canvas?中如何實現(xiàn)物體的框選
前言
雖然這兩個月基金漲的還行,但是離回本還有一大大大段距離??。
今天呢,我們要實現(xiàn)的是 canvas 中物體的框選功能,大概就像下面這個樣子:

然后話不多說,直接開擼 ???
框選的實現(xiàn)
先來說下拖藍選區(qū)(鼠標(biāo)拖拽區(qū)域)的實現(xiàn)方式吧,仔細觀察你會發(fā)現(xiàn)選區(qū)其實就是個普通矩形,這個區(qū)域由鼠標(biāo)按下的點和拖動的終點組成,通過這兩點我們就能夠確認(rèn)一個規(guī)規(guī)矩矩的矩形(邊和 xy 軸平行),那在哪里繪制呢?還記得我們之前說過的么,所有的交互都是在上層畫布進行的,所以它理所當(dāng)然的應(yīng)該繪制在上層畫布,并且這樣一來還可以避免重繪所有的物體。
- 然后抬起鼠標(biāo)的時候又要做些什么呢?
首先要做的就是把上層畫布的拖藍選區(qū)清除掉,再來就是不可避免的要遍歷所有物體,找出和這個拖藍選區(qū)有交集的所有物體。顯然這又是一個數(shù)學(xué)問題,等價于判斷兩個矩形是否相交,相比之前判斷點是否在矩形內(nèi)部好像又麻煩了一丟丟,因為我們并沒有直觀的思路,并且還希望最好還可以推廣到兩個多邊形,em...這里可以先思考幾秒鐘??。。。
- 仔細想想兩個矩形相交會有什么效果呢?
它們的邊必相交,所以問題又可以轉(zhuǎn)化為判斷兩個矩形的邊是否相交。那如何判斷兩個矩形的邊是否相交呢,稍微一想,最根本的就是判斷兩條邊是否相交,這么一來,是不是稍微明朗了一點??。
具體一點就是:假設(shè)現(xiàn)在有物體 A 和物體 B,我們可以用 A 的第一條邊去遍歷 B 的每條邊,如果能找到一個交點就說明兩個物體相交;
否則繼續(xù)用 A 的第二條邊去遍歷 B 的每條邊,以此類推,如果遍歷完了所有的還是沒有交點,則說明物體 A、B 不相交。
當(dāng)然這種方法還不夠完全,少了一種特例,就是物體 A、B 還可能是包含與被包含的關(guān)系,比如物體被拖藍選區(qū)完全包圍,它們的邊是沒有交點的,所以我們也應(yīng)該囊括這種情況,這種包含關(guān)系判斷起來就比較簡單了,就是比較下兩個物體的最大最小 xy 值即可。
經(jīng)過上面簡單的推論不難得出,最基本的判斷就是看兩條線段是否相交,常規(guī)的解法就是:
- 因為每條線段的端點是已知的,所以能求出兩條線段所在的直線方程(注意直線和線段的措詞,后面內(nèi)容也是)
- 如果兩條直線斜率相同,那兩條線段肯定不相交
- 如果斜率不同,就需要聯(lián)立方程組求解
- 不過這個求解結(jié)果是直線的交點,最后還要簡單校驗下這個解是不是在兩個線段的坐標(biāo)范圍內(nèi)才行 這個就是最樸實無華的解法啦,我們先這么理解就行。其實在圖形學(xué)中,類似這種運算都是用向量來計算的,比如用向量叉乘來判斷線段是否相交,fabric.js 中也是用這樣的思想,不過這個系列我并沒有強調(diào)向量的概念,因為容易勸退,所以這些內(nèi)容我會在這個系列的最后幾個章節(jié)中單獨寫一篇來講解,這里就簡單貼下代碼,可跳過????:
/**
* 判斷兩條線段是否想交
* @param a1 線段1 起點
* @param a2 線段1 終點
* @param b1 線段2 起點
* @param b2 線段3 終點
*/
static intersectLineLine(a1: Point, a2: Point, b1: Point, b2: Point): Intersection {
// 向量叉乘公式 `a??b = (x1, y1)??(x2, y2) = x1y2 - x2y1`
let result,
// b1->b2向量 與 a1->b1向量的向量叉乘
ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x),
// a1->a2向量 與 a1->b1向量的向量叉乘
ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x),
// a1->a2向量 與 b1->b2向量的向量叉乘
u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
if (u_b !== 0) {
let ua = ua_t / u_b,
ub = ub_t / u_b;
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
result = new Intersection('Intersection');
result.points.push(new Point(a1.x + ua * (a2.x - a1.x), a1.y + ua * (a2.y - a1.y)));
} else {
result = new Intersection('No Intersection');
}
} else {
// u_b == 0時,角度為0或者180 平行或者共線不屬于相交
if (ua_t === 0 || ub_t === 0) {
result = new Intersection('Coincident');
} else {
result = new Intersection('Parallel');
}
}
return result;
}
- 現(xiàn)在假設(shè)我們通過上面的方法找到了所有與拖藍選區(qū)相交的物體,那之后要做什么呢???
可以看到框選的最終效果就是用一個更大的包圍盒把所有物體都框起來,最終生成的也只有外面的包圍盒和控制點,被包裹的物體則只進行邊框繪制,而沒有控制點。
里面的物體好繪制,就是把物體設(shè)置成選中態(tài)即可,只是不繪制控制點(多加一個變量的事)。那外面的包圍盒呢,怎么將這個大的包圍盒和多個物體進行關(guān)聯(lián)呢,這里又可以停下來想個幾秒鐘啦??。。。
Group 類的實現(xiàn)
一個大的包圍盒和多個物體,能想到什么呢?
其實我們所有的物體是不是都在畫布中,畫布就可以看做是一個很大的包圍盒,框住所有物體,所有物體也都依附于這個畫布,這很形象,也順便引出了接下來要介紹的組(Group)的概念。
Group 本身也繼承于 FabricObject 類,它也是個物體,只不過這個物體下面還會有很多個小物體;
至于組的包圍盒,和一個普通物體類似,找出所有子物體的最大最小 xy 值即可,這里我們直接看代碼應(yīng)該會更好理解(具體代碼可以隨便瞟一瞟,但是注釋一定要看哦)????:
/**
* Group 類,可用于自己手動組合幾個物體,也可以用于拖藍選區(qū)包圍的物體
* Group 雖然繼承至 FabricObject,但是要注意獲取某些屬性有時是沒有的,因為子物體的屬性各不相同
*/
class Group extends FabricObject {
public type: string = 'group';
public objects: FabricObject[]; // 組中所有的物體
constructor(objects: FabricObject[], options: any = {}) {
super(options);
this.objects = objects || [];
this._calcBounds(); // 計算組的包圍盒
this._updateObjectsCoords(); // 更新組中的物體信息
}
/** 計算組的包圍盒 */
_calcBounds() {
// 就是求子物體中所有 objects 的最大最小 xy 值
}
/** 更新所有子物體的坐標(biāo)值,像這種情況子物體都是以父元素的坐標(biāo)系為參考,而不是畫布的坐標(biāo)系為參考 */
_updateObjectsCoords() {
let groupDeltaX = this.left,
groupDeltaY = this.top;
this.objects.forEach((object) => {
let objectLeft = object.get('left'),
objectTop = object.get('top');
object.set('originalLeft', objectLeft);
object.set('originalTop', objectTop);
object.set('left', objectLeft - groupDeltaX);
object.set('top', objectTop - groupDeltaY);
object.setCoords();
// 當(dāng)有選中組的時候,不顯示子物體的控制點
object.orignHasControls = object.hasControls;
object.hasControls = false;
});
}
/** 將物體添加到 group 中 */
add(object: FabricObject) {
this.objects.push(object);
return this;
}
/** 將物體從 group 中移除 */
remove(object: FabricObject) {
Util.removeFromArray(this.objects, object);
return this;
}
/** 將物體添加到 group 中,并重新計算位置尺寸等 */
addWithUpdate(object: FabricObject): Group {
this._restoreObjectsState();
this.objects.push(object);
this._calcBounds();
this._updateObjectsCoords();
return this;
}
/** 將物體從組中移除,并重新計算組的大小位置 */
removeWithUpdate(object: FabricObject) {
this._restoreObjectsState();
Util.removeFromArray(this.objects, object);
object.setActive(false);
this._calcBounds();
this._updateObjectsCoords();
return this;
}
/** 組的渲染會特殊一點,它主要是子物體的渲染,但是組的變換會影響所有子物體的變換 */
render(ctx: CanvasRenderingContext2D) {
ctx.save();
this.transform(ctx); // 組有自身的變換,會影響所有子物體
for (let i = 0, len = this.objects.length; i < len; i++) { // 遍歷繪制組中所有物體
let object = this.objects[i],
object.render(ctx); // 回顧一下:每個物體的 render = 每個物體的 transform + 每個物體的 _render
}
if (this.active) { // 組是否被選中
this.drawBorders(ctx);
this.drawControls(ctx);
}
ctx.restore();
this.setCoords();
}
}
所以我們把 Group 當(dāng)做一個普通的大物體就行,里面的子物體該怎么繪制還是怎么繪制,當(dāng) hover 和 click 的時候只要判斷 Group 的包圍盒即可,里面的子物體是不用去遍歷的,因為它們是一個整體。
但是要注意的是上面代碼中的 _updateObjectsCoords 方法,當(dāng)我們把某些物體放進一個 Group 的時候,需要修改其 top 和 left 值,使其位置變?yōu)橄鄬?Group 的位置,而不是相對于畫布的位置,這點要尤其注意,類似這種嵌套關(guān)系,子物體的位置一般都是相對于其父元素來說的,而不是畫布的位置??。
回過頭來再說說框選,當(dāng)鼠標(biāo)抬起的時候,我們會找出與拖藍選區(qū)相交的所有物體:
- 如果只有一個物體與之相交的話,其實就變成了普通點選的情況,我們直接將該物體的置為選中態(tài)即可
- 如果有多個物體相交,那就需要臨時創(chuàng)建一個 Group 實例,叫
_activeGroup,將這些物體都添加進來,然后對這個臨時組完成一些操作之后再銷毀這個組即可 來看下核心代碼????,也是很通俗易懂的:
class Canvas {
/**
* 獲取拖藍選區(qū)包圍的元素
* 如果只有一個物體,那就是普通的點選;如果有多個物體,那就生成一個組
*/
_findSelectedObjects(e: MouseEvent) {
let objects: FabricObject[] = [], // 存儲最終框選的元素
x1 = this._groupSelector.ex,
y1 = this._groupSelector.ey,
x2 = x1 + this._groupSelector.left,
y2 = y1 + this._groupSelector.top,
selectionX1Y1 = new Point(Math.min(x1, x2), Math.min(y1, y2)),
selectionX2Y2 = new Point(Math.max(x1, x2), Math.max(y1, y2));
for (let i = 0, len = this._objects.length; i < len; ++i) {
let currentObject = this._objects[i];
// 物體是否與拖藍選區(qū)相交或者被選區(qū)包含,用到的就是前面說過的多邊形相交算法,具體的算法會在文末附上
if (currentObject.intersectsWithRect(selectionX1Y1, selectionX2Y2) || currentObject.isContainedWithinRect(selectionX1Y1, selectionX2Y2)) {
currentObject.setActive(true);
objects.push(currentObject);
}
}
if (objects.length === 1) { // 如果只有一個物體被選中
this.setActiveObject(objects[0], e);
} else if (objects.length > 1) { // 如果有多個物體被選中
const newGroup = new Group(objects);
this.setActiveGroup(newGroup);
}
this.renderAll();
}
setActiveGroup(group: Group): Canvas {
this._activeGroup = group;
if (group) {
group.canvas = this;
group.setActive(true);
}
return this;
}
}
上面代碼中要注意的就是我們還需要對 renderAll 這個繪制方法做一些修改,就是把所有激活的物體都放到最后繪制,就像下面這樣????:
class Canvas {
renderAll(): Canvas {
...
// 先將物體排個序,這樣才能體現(xiàn)出層級關(guān)系,簡單來說就是先繪制未激活物體,再繪制激活物體
const sortedObjects = this._chooseObjectsToRender();
for (let i = 0, len = sortedObjects.length; i < len; ++i) {
this._draw(canvasToDrawOn, sortedObjects[i]);
}
return this;
}
/** 將所有物體分成兩個組,一組是未激活態(tài),一組是激活態(tài),然后將激活組放在最后,這樣就能夠繪制到最上層 */
_chooseObjectsToRender() {
// 當(dāng)前有沒有激活的物體
let activeObject = this.getActiveObject();
// 當(dāng)前有沒有激活的組(也就是多個物體)
let activeGroup = this.getActiveGroup();
// 最終要渲染的物體順序,也就是把激活的物體放在后面繪制
let objsToRender = [];
if (activeGroup) { // 如果選中多個物體
const activeGroupObjects = [];
for (let i = 0, length = this._objects.length; i < length; i++) {
let object = this._objects[i];
if (activeGroup.contains(object)) {
activeGroupObjects.push(object);
} else {
objsToRender.push(object);
}
}
objsToRender.push(activeGroup);
} else if (activeObject) { // 如果只選中一個物體
let index = this._objects.indexOf(activeObject);
objsToRender = this._objects.slice();
if (index > -1) {
objsToRender.splice(index, 1);
objsToRender.push(activeObject);
}
} else { // 所有物體都沒被選中
objsToRender = this._objects;
}
return objsToRender;
}
當(dāng)然如果是框選或點擊到空白處,只要把所有物體的 active 屬性都設(shè)置 false 就行了。但有同學(xué)肯定又會有疑問了,上面這樣的排序繪制好像并不能精確控制每個物體的層級關(guān)系,如果我們需要做個上移一層、下移一層的功能該怎么搞呢?
這個也很簡單,在 html 中也已經(jīng)給了我們答案,就是用 z-index,我們給每個物體多加一個 zIndex 屬性就行了,之后直接用 zIndex 排序就行。
其實在 canvas 上繪制東西和瀏覽器展示頁面內(nèi)容這個過程很像很像,很多思想都是共通的,比如盒模型、元素的繼承、transform、zIndex、top、left 等常見的 css 屬性,以及后續(xù)會提到的事件監(jiān)聽,只不過我們習(xí)慣了用 html 和 css 去描繪這個頁面,而 canvas 需要我們用 js 去描述,canvas 庫則是提供了這個橋梁,極大方便了我們開發(fā)。
小結(jié)
這個章節(jié)我們主要講的是 canvas 中框選和 Group 類的實現(xiàn),最重要的有以下幾點:
- 判斷兩個多邊形相交的方法:判斷各個邊是否相交 && 整體包含關(guān)系
- 框選的時候我們會臨時生成一個組,之后銷毀即可
- 組的變換會影響到其子元素的變換
- 渲染的時候需要將所有激活的物體放在后面繪制,有需要的話可以加上 zIndex 屬性進行精確控制
- 另外補充一點:Group 也讓我們整個物體鏈變成了樹形結(jié)構(gòu)
然后這里是簡版 fabric.js 的代碼鏈接,有興趣的可以看看。下個章節(jié)我們會講解怎么對一個物體進行各種變換操作(拖拽、縮放、旋轉(zhuǎn)),也是本系列最重要的章節(jié)之一??。
canvas 中物體邊框和控制點的實現(xiàn)(四)??
實現(xiàn)一個輕量 fabric.js 系列三(物體基類)??
實現(xiàn)一個輕量 fabric.js 系列二(畫布初始化)??
實現(xiàn)一個輕量 fabric.js 系列一(摸透 canvas)??
以上就是canvas 中如何實現(xiàn)物體的框選的詳細內(nèi)容,更多關(guān)于canvas物體框選的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
微信小程序 網(wǎng)絡(luò)API發(fā)起請求詳解
這篇文章主要介紹了微信小程序 網(wǎng)絡(luò)API發(fā)起請求詳解的相關(guān)資料,需要的朋友可以參考下2016-11-11
mitt tiny-emitter發(fā)布訂閱應(yīng)用場景源碼解析
這篇文章主要為大家介紹了mitt tiny-emitter發(fā)布訂閱應(yīng)用場景源碼解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-12-12
微信小程序 加載 app-service.js 錯誤解決方法
這篇文章主要介紹了微信小程序 加載 app-service.js 錯誤詳解的相關(guān)資料,在開發(fā)微信小程序過程中出現(xiàn)了app-services.js的錯誤,并解決此問題,需要的朋友可以參考下2016-10-10

