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