JS前端使用canvas實現物體的點選示例

前言

上個章節中我們已經給物體加上瞭被選中的效果,現在可以上點交互瞭,這個章節主要實現的就是物體的 hover 和 click 事件,當鼠標 hover 到物體上時,我們會改變鼠標的樣式使其變成移動的樣子;

當 hover 到控制點時則會變成對應的操作樣式;

當 click 物體時,會將物體變成激活態,也就是展示邊框和控制點。話不多說,直接開擼 🚗 💨 💨 💨

hover 的實現

首先我們來處理鼠標的 hover 事件,也就是 hover 到某個物體時把鼠標變成移動的樣式,如果是移到激活物體的控制點上就將鼠標變成相應的旋轉和縮放箭頭。具體要怎麼做呢?

顯然 canvas 本身並不支持該功能,它就是一幅畫,所有東西都被揉成可一團,所以我們是區分不瞭某個物體的。好在前面幾個章節中我們構建瞭一個 Canvas 類,把所有元素都放進瞭 _objects 裡面,現在隻要從後往前遍歷 _objects 數組,找出與鼠標有交集的第一個物體即可,找不到就是沒有選中任何物體則將鼠標置為默認樣式。之所以從後往前遍歷是因為我們繪制是有順序的,越後面添加的物體會越後面繪制,因而層級也越高,會越先被點選,所以從後往前遍歷能提高效率,也符合視覺效果。

然後再提醒一下,我們物體都是有包圍盒的,所以每個物體都可以簡化成一個矩形,於是要判斷鼠標是否 hover 到某個物體上,就變成瞭判斷鼠標是否 hover 到某個矩形上,更進一步的就是判斷點是否在矩形內部。

是不是好像有點碰撞檢測的味道呢🤯,隻不過這裡是點和矩形的碰撞。 顯然對於一個常規的沒有旋轉的矩形(top、left、width、height)和一個坐標點(x, y),大傢能很容易判斷出來,就是 x >= left && x <= left + width && y >= top && y <= top + height 這樣簡單判斷一下就行。那如果是個旋轉之後的矩形呢?誒。。。好像不怎麼好搞🤔;

又或者是個平行四邊形呢?em。。。好像也不怎麼好搞😂;那如果是任意多邊形呢?啊。。。這🥴。。。。 我們需要一種更加通用的方式來判斷點在多邊形內部,這就是實打實的數學知識瞭。一般情況下,遇到瞭這種問題可以去搜一下相關解法然後 copy 過來,這裡我會盡量解釋的明白一些(退後,此處要開始裝13瞭)。

  • 我們知道一個多邊形其實是由多條線段組成的封閉圖形,相當於這個多邊形將世界分成瞭裡外兩個部分,一部分在封閉區域裡面,一部分在封閉區域外面。
  • 現在假設我們在任意一點(鼠標坐標點),我們可以沿著該點向 x 軸方向做一條射線,然後計算出射線與多邊形邊的交點個數,如果交點為偶數個,則說明點在多邊形外部。
  • 如果交點為奇數個,則說明點在多邊形內部。這個現象很有趣🤩,大傢可以在紙上試著畫一下,隨便畫個多邊形都可以,看看是不是符合上面這個規律。

可能你畫瞭幾個多邊形發現這個方法確實是適用的,但是卻不明白為什麼我們可以用奇偶數來判斷點是否在多邊形內部呢?這裡有個通俗易懂的解釋:

  • 我們可以認為在多邊形的每條邊上都有一個小門,經過一條邊就相當於打開瞭一扇小門,假設我們在多邊形外面,那麼如果我們打開過兩個小門(偶數),說明我們進去瞭又出來瞭(點在外面);
  • 如果我們隻打開瞭一個小門,說明我們出去瞭但沒回來(點在裡面)。
  • 應用到實際生活中就是當你的小區被劃為疫情管控區的時候,這個管控區就相當於是一個多邊形,你在小區裡面(多邊形內)無聊瞭,想要出去溜達,你就必須經過一個大門(一條邊),才能到達管控區外面的世界(多邊形外)。哇🤩。。。這個比喻真的是恰到好處(自己都覺得棒😎)。

當然聰明的同學肯定也想到瞭這種方法好像會有一些問題,比如:

1、點恰好在多邊形上

2、射線經過多邊形的頂點

3、射線與多邊形的邊重合 確實是這樣,所以針對以上三種情況,我們還需要再加一些額外的判斷條件。

  • 1、對於第一點:需要判斷點是否在多邊形的邊上,當然這種臨界狀態你說在裡在外都可以
  • 2、對於第二點:每個頂點肯定會有兩條邊與之相連,如果兩條邊在射線的同一側,我們就算做兩個交點;如果兩條邊分別在射線的兩邊,就算做一個交點。可以用極限的思想去理解,當兩條邊在同側的話,取一條無限靠近該射線的水平線,顯然新的水平線會和兩條邊都相交;而當兩條邊在異側的話,同樣可以取一條無限靠近該射線的水平線,顯然新的水平線隻會與其中一條邊相交(這個思想也是真妙啊😎)。
  • 3、對於第三點:和第二點思想差不多,采用極限思想,把這個重合的邊想象成一個點即可,然後也要分與重合邊相鄰的兩條邊在同側還是異側兩種情況。

可能你還是不懂,所以這裡畫瞭個示意圖,咱們看圖說話:

其實上面所說的方法有個專業的名字叫做射線檢測法,它其實可以 360° 任選方向的,隻不過我們通常用水平線來算,這樣會比較簡單點。

  • 另外射線檢測法還有一個最根本的原因就是射線的無窮遠處一定在多邊形外,這樣我們才能根據交點的奇偶性來倒推位置關系。
  • 數學就是這麼巧妙的和前端結合起來瞭,一些復雜的效果歸根到底還是數學的抽象。
  • 不過雖然知道瞭大概原理,我們也不一定能寫出代碼來😂,所以這裡附上一些 fabric.js 中的核心代碼片段,有興趣的可以看看(有註釋的,放心食用🍚):
class Canvas {
    _initEvents() {
        // 首先肯定要添加事件監聽啦
        Util.addListener(this.upperCanvasEl, 'mousemove', this._onMouseMove.bind(this));
    }
    _onMouseMove(e: MouseEvent) {
        // 如果是 hover 事件,我們隻需要改變鼠標樣式,並不會重新渲染
        const style = this.upperCanvasEl.style;
        // findTarget 的過程就是看鼠標有沒有 hover 到某個物體上
        const target = this.findTarget(e);
        // 設置鼠標樣式
        if (target) {
            this._setCursorFromEvent(e, target);
        } else {
            style.cursor = this.defaultCursor;
        }
    }
    /** 檢測是否有物體在鼠標位置 */
    findTarget(e: MouseEvent): FabricObject {
        let target;
        // 從後往前遍歷所有物體,判斷鼠標點是否在物體包圍盒內
        for (let i = this._objects.length; i--; ) {
            const object = this._objects[i];
            if (object && this.containsPoint(e, object)) {
                target = object;
                break;
            }
        }
        if (target) return target;
    }
}
class FabricObject {
    /**
     * 射線檢測法:以鼠標坐標點為參照,水平向右做一條射線,求坐標點與多邊形的交點個數
     * 如果和物體相交的個數為偶數點則點在物體外部;如果為奇數點則點在內部
     * 在 fabric 中的點選多邊形其實就是點選矩形,所以針對矩形做瞭一些優化
     */
    _findCrossPoints(ex: number, ey: number, lines): number {
        let b1, // 射線的斜率
            b2, // 邊的斜率
            a1,
            a2,
            xi, // 射線與邊的交點 x
            // yi, // 射線與邊的交點 y
            xcount = 0,
            iLine; // 當前邊
        // 遍歷包圍盒的四條邊
        for (let lineKey in lines) {
            iLine = lines[lineKey];
            // 優化1:如果邊的兩個端點的 y 值都小於鼠標點的 y 值,則跳過
            if (iLine.o.y < ey && iLine.d.y < ey) continue;
            // 優化2:如果邊的兩個端點的 y 值都大於等於鼠標點的 y 值,則跳過
            if (iLine.o.y >= ey && iLine.d.y >= ey) continue;
            // 優化3:如果邊是一條垂線
            if (iLine.o.x === iLine.d.x && iLine.o.x >= ex) {
                xi = iLine.o.x;
                // yi = ey;
            } else {
                // 執行到這裡就是一條普通斜線段瞭
                // 用 y=kx+b 簡單算下射線與邊的交點即可
                b1 = 0;
                b2 = (iLine.d.y - iLine.o.y) / (iLine.d.x - iLine.o.x);
                a1 = ey - b1 * ex;
                a2 = iLine.o.y - b2 * iLine.o.x;
                xi = -(a1 - a2) / (b1 - b2);
                // yi = a1 + b1 * xi;
            }
            // 隻需要計數 xi >= ex 的情況
            if (xi >= ex) {
                xcount += 1;
            }
            // 優化4:因為 fabric 中的點選隻需要用到矩形,所以根據矩形的特質,頂多隻有兩個交點,於是就可以提前結束循環
            if (xcount === 2) {
                break;
            }
        }
        return xcount;
    }
}

至於物體周圍的幾個控制點呢,也是一樣的,它們也是個矩形,所以要判斷點是否在控制點內也是一樣的套路一樣的邏輯,這裡就不展開瞭。

click 的實現

再來說說點選是怎麼實現的,這個也很簡單,和 hover 的道理如出一轍,我們能夠獲取到 hover 時的物體,同樣也能夠獲取到點擊時的物體,都是判斷點是否在矩形內(你說巧不巧),然後將該物體的 active 屬性設置為 true,其他物體設置為 false 即可,這樣我們重新渲染的時候,物體會根據 active 屬性自動調用 drawBordersdrawControls 方法,看起來物體就被選中瞭,註意 hover 的時候不會導致重繪,隻改變鼠標樣式;

點選會導致重繪並改變鼠標樣式。另外我們還可以對點選進行一些優化,比如記錄最近一個激活的物體,然後點選的時候先判斷鼠標點是否在最近一個激活物體的內部,如果在,就可以省去遍歷的過程瞭。

矩形的坐標哪來的

其實上面的講解我特意漏說瞭一個點,就是包圍盒和控制點的那個矩形是怎麼來的,目前我們隻是單純的畫出瞭邊框和控制點,但是並沒有記錄它們的寬高和位置,所以現在我們需要在初始化物體的時候進行一些簡單計算並用變量 oCoords 保存起來,就像這樣:

export interface Coords {
    /** 左上控制點 */
    tl: Coord;
    /** 右上控制點 */
    tr: Coord;
    /** 右下控制點 */
    br: Coord;
    /** 左下控制點 */
    bl: Coord;
    /** 左中控制點 */
    ml: Coord;
    /** 上中控制點 */
    mt: Coord;
    /** 右中控制點 */
    mr: Coord;
    /** 下中控制點 */
    mb: Coord;
    /** 上中旋轉控制點 */
    mtr: Coord;
}
class Canvas {
    _initObject(obj: FabricObject) {
        obj.setCoords(); // 記錄控制點位置和大小,其實就是各個矩形的頂點坐標
        obj.canvas = this;
    }
}

具體計算方法比較繁瑣,我就不貼上來瞭,有興趣的可以去看看源碼,這裡就簡單放個圖:

以上圖的矩形為例子,其實就是算出上圖矩形四個頂點的位置,寫的時候你隻需要考慮一個點(比如圖中右上角的頂點)是怎麼算的就行,其他點都是一樣的,相信你慢慢算一定可以算出來的😂。

當然如果物體的某些屬性改變瞭,比如物體經過變換,記得需要及時更新 oCoords 的值。

點在多邊形內的其他判斷方法

其實判斷點是否在多邊形內部還有其他方法,比如:

  • 用 canvas 自身的 api isPointInPath
  • 將多邊形切割成多個三角形,然後判斷點是否在某個三角形內部
  • 轉角累加法
  • 面積法

… 這裡我稍微說下另一種比較有意思的方法,如果不理解射線檢測法的同學,我們還能這麼搞:假設矩形旋轉瞭一定角度,那我們將鼠標坐標點也旋轉一下,這樣旋轉後的坐標點就不就又和矩形是同一個水平垂直方向嗎,就像下圖這樣👇🏻:

上述方法的核心要點就是將鼠標點換算成物體自身坐標系下的點(寫成矩陣的形式會比較方便點),然後再用原始的方法判斷即可,是不是看起來也挺方便的樣子。

穿透

現在我們來擴充下另外一個知識點,就是目前我們點選物體的時候,其實是點選包圍盒,當點到物體四周空白區域的時候,物體也是會被選中的,如果不想把空白區域也算在物體的點擊范圍內(比如 png 圖片),那該怎麼做呢?

這個東西挺有意思的,可以停個幾秒種,思考一下下🤔。。。。 顯然我們要在上文所說的 findTarget 中做文章,除瞭判斷點是否在包圍盒內,還要進一步判斷點擊的是不是空白的地方,所謂空白,一定程度上可以理解成是透明的地方。

於是這就要用到前幾個章節提到過的第三個畫佈 cacheCanvasEl 緩存畫佈,在點擊到瞭包圍盒之後我們還需要把這個物體畫到這個緩存畫佈上,然後用 getImageData 來獲取鼠標位置所在點的像素信息,當然我們允許有誤差,所以會取這個鼠標點周圍的一小塊正方形的像素信息,接著遍歷每個像素,如果找到一個像素中 rgba 的 a 的值 > 0 就說明至少有一個顏色存在,亦即不透明,退出循環,否則就是透明的,最後清除 getImageData 變量,清除緩沖層畫佈即可。

是不是有種豁然開朗的感覺🤩,有瞭思路,代碼實現起來就比較簡單瞭:

class Canvas {
    /**
     * 用緩沖層判斷物體是否透明,目前默認都是不透明,可以加一些參數屬性,比如允許有幾個像素的誤差
     * @param {FabricObject} target 物體
     * @param {number} x 鼠標的 x 值
     * @param {number} y 鼠標的 y 值
     * @param {number} tolerance 允許鼠標的誤差范圍
     * @returns
     */
    _isTargetTransparent(target: FabricObject, x: number, y: number, tolerance: number = 0) {
        // 1、在緩沖層繪制物體
        // 2、通過 getImageData 獲取鼠標位置的像素數據信息
        // 3、遍歷像素數據,如果找到一個 rgba 中的 a 的值 > 0 就說明至少有一個顏色,亦即不透明,退出循環
        // 4、清空 getImageData 變量,並清除緩沖層畫佈
        let cacheContext = this.contextCache;
        this._draw(cacheContext, target);
        if (tolerance > 0) { // 如果允許誤差
            if (x > tolerance) {
                x -= tolerance;
            } else {
                x = 0;
            }
            if (y > tolerance) {
                y -= tolerance;
            } else {
                y = 0;
            }
        }
        let isTransparent = true;
        let imageData = cacheContext.getImageData(x, y, tolerance * 2 || 1, tolerance * 2 || 1);
        for (let i = 3; i < imageData.data.length; i += 4) { // 隻要看第四項透明度即可
            let temp = imageData.data[i];
            isTransparent = temp <= 0;
            if (isTransparent === false) break; // 找到一個顏色就停止
        }
        imageData = null;
        this.clearContext(cacheContext);
        return isTransparent;
    }
}

怎麼樣,這個方法看起來還是有點意思的,而且通俗易懂。

當然瞭,這對不同物體可以有不同的檢測方法:

比如物體是一個幾何圖形,假設是正多邊形,同樣的,我們希望選中的是正多邊形,而不是正多邊形包圍盒所形成的的矩形,這時候隻需要把點選物體包圍盒的邏輯改成點選正多邊形的邏輯即可,同樣采用的是射線檢測法(怎麼又繞回來瞭😅);

如果物體是條線段,就變成瞭點是否在線上的檢測;

如果是個圓,那就更簡單瞭,諸如此類。。。

此外還有一種空間換時間的取巧方法,就是在創建物體的時候在離屏 canvas 上多繪制一個和這個物體形狀大小一樣的純色物體,畫佈上的物體都有各自的顏色並且唯一,然後做一個 { color: object } 的映射,之後我們點選的時候主要是通過點擊坐標獲取到對應離屏 canvas 上的純顏色,再根據映射取出對應的物體即可,這也是一種方法。

本章小結

這個章節我們主要實現瞭如何處理物體的 hover 和 click 事件,本質其實就是如何如何判斷一個點在多邊形內部,你可能聽過一些方法,但不知道實際開發時是怎麼應用上的,希望讀完本章你能記得射線檢測法的應用,它的核心就是越過一條邊裡外兩個世界就會互相交換。然後這裡是簡版 fabric.js 的代碼鏈接,有興趣的可以看看。好啦,本次分享就到這裡,有什麼問題歡迎點贊評論留言,我們下期再見,拜拜👋🏻

canvas 中物體邊框和控制點的實現(四)🏖

實現一個輕量 fabric.js 系列三(物體基類)🏖

實現一個輕量 fabric.js 系列二(畫佈初始化)🏖

實現一個輕量 fabric.js 系列一(摸透 canvas)💥

以上就是JS前端使用canvas實現物體的點選示例的詳細內容,更多關於JS前端canvas物體點選的資料請關註WalkonNet其它相關文章!

推薦閱讀: