前端使用 Konva 實現視覺化設計器(3)

xachary發表於2024-04-10

github/gitee Star 終於有幾個了!

從這章開始,難度算是(或者說細節較多)升級,是不是值得更多的 Star 呢?!

繼續求 Star ,希望大家多多一鍵三連,十分感謝大家的支援~

創作不易,Star 50 個,創作加速!

github原始碼

gitee原始碼

示例地址

選擇框

image

準備工作

想要拖動一個元素,可以考慮使用節點的 draggable 屬性。

不過,想要拖動多個元素,可以使用 transformer,官網也是簡單的示例 Basic demo

按設計思路統一透過 transformer 移動/縮放所選,也意味著,元素要先選後動。

先準備一個 group、transformer、selectRect:

  // 多選器層
  groupTransformer: Konva.Group = new Konva.Group()
​
  // 多選器
  transformer: Konva.Transformer = new Konva.Transformer({
    shouldOverdrawWholeArea: true,
    borderDash: [4, 4],
    padding: 1,
    rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315, 360]
  })
​
  // 選擇框
  selectRect: Konva.Rect = new Konva.Rect({
    id: 'selectRect',
    fill: 'rgba(0,0,255,0.1)',
    visible: false
  })

先說 transformer,設定 shouldOverdrawWholeArea 為了選擇所選的空白處也能拖動;rotationSnaps 就是官方提供的 rotate 時的磁貼互動。

然後,selectRect 就是選擇框,參考的就是上面提到的 Basic demo

最後,上面的 group 比較特別,它承載了上面的 transformer 和 selectRect,且置於第一章提到的 layerCover

    // 輔助層 - 頂層
    this.groupTransformer.add(this.transformer)
    this.groupTransformer.add(this.selectRect)
    this.layerCover.add(this.groupTransformer)

selectRect 不應該被“互動”,所以加個排查判斷:

  // 忽略非素材
  ignore(node: Konva.Node) {
    // 素材有各自根 group
    const isGroup = node instanceof Konva.Group
    return !isGroup || node.id() === 'selectRect' || this.ignoreDraw(node)
  }
​

選擇

準備一些狀態變數:

  // selectRect 拉動的開始和結束座標
  selectRectStartX = 0
  selectRectStartY = 0
  selectRectEndX = 0
  selectRectEndY = 0
  // 是否正在使用 selectRect
  selecting = false

選擇開始,處理 stage 的 mousedown 事件:

    mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        // 略
​
        if (e.target === this.render.stage) {
          // 點選空白處
​
          // 清除選擇
          // 外部也需要此操作,統一放在 selectionTool中
          // 後面會提到
          this.render.selectionTool.selectingClear()
​
          // 選擇框
          if (e.evt.button === Types.MouseButton.左鍵) {
            const pos = this.render.stage.getPointerPosition()
            if (pos) {
              // 初始化狀態值
              this.selectRectStartX = pos.x
              this.selectRectStartY = pos.y
              this.selectRectEndX = pos.x
              this.selectRectEndY = pos.y
            }
​
            // 初始化大小
            this.render.selectRect.width(0)
            this.render.selectRect.height(0)
​
            // 開始選擇
            this.selecting = true
          }
        } else if (parent instanceof Konva.Transformer) {
          // transformer 點選事件交給 transformer 自己的 handler
        } else if (parent instanceof Konva.Group) {
          // 略
        }
      }

接著,處理 stage 的 mousemove 事件:

    mousemove: () => {
        // stage 狀態
        const stageState = this.render.getStageState()
​
        // 選擇框
        if (this.selecting) {
          // 選擇區域中
          const pos = this.render.stage.getPointerPosition()
          if (pos) {
            // 選擇移動後的座標
            this.selectRectEndX = pos.x
            this.selectRectEndY = pos.y
          }
​
          // 調整【選擇框】的位置和大小
          this.render.selectRect.setAttrs({
            visible: true, // 顯示
            x: this.render.toStageValue(
              Math.min(this.selectRectStartX, this.selectRectEndX) - stageState.x
            ),
            y: this.render.toStageValue(
              Math.min(this.selectRectStartY, this.selectRectEndY) - stageState.y
            ),
            width: this.render.toStageValue(Math.abs(this.selectRectEndX - this.selectRectStartX)),
            height: this.render.toStageValue(Math.abs(this.selectRectEndY - this.selectRectStartY))
          })
        }
      }

稍微說一下,調整【選擇框】的位置和大小,關於 toStageValue 可以看看上一章。 width 和 height 比較好理解,開始位置 和 結束位置 相減就可以得出。

x 和 y,需從 開始位置 和 結束位置 選數值小的作為【選擇框】的 rect 起點,最後要扣除 stage 的視覺位移,畢竟它們是放在 stage 裡面的,就是 相對位置 和 視覺位置 的轉換。

結束選擇,處理 stage 的 mouseup 事件:

    mouseup: () => {
        // 選擇框
​
        // 重疊計算
        const box = this.render.selectRect.getClientRect()
        if (box.width > 0 && box.height > 0) {
          // 區域有面積
​
          // 獲取所有圖形
          const shapes = this.render.layer.getChildren((node) => {
            return !this.render.ignore(node)
          })
          
          // 提取重疊部分
          const selected = shapes.filter((shape) =>
            // 關鍵 api
            Konva.Util.haveIntersection(box, shape.getClientRect())
          )
​
          // 多選
          // 統一放在 selectionTool中,對外暴露 api
          this.render.selectionTool.select(selected)
        }
​
        // 重置
        this.render.selectRect.setAttrs({
          visible: false, // 隱藏
          x: 0,
          y: 0,
          width: 0,
          height: 0
        })
​
        // 選擇區域結束
        this.selecting = false
      }

【選擇框】的主要處理的事件就是這些,接著,看看關鍵的 selectionTool.selectingClear、selectionTool.select,直接上程式碼:

  // 選擇節點
  select(nodes: Konva.Node[]) {
    // 選之前,清一下
    this.selectingClear()
​
    if (nodes.length > 0) {
      // 用於撐開 transformer
      // 如果到這一章就到此為止,是不需要selectingNodesArea 這個 group
      // 賣個關子,留著後面解釋
      this.selectingNodesArea = new Konva.Group({
        visible: false,
        listening: false
      })
​
      // 最大zIndex
      const maxZIndex = Math.max(
        ...this.render.layer
          .getChildren((node) => {
            return !this.render.ignore(node)
          })
          .map((o) => o.zIndex())
      )
​
      // 記錄狀態
      for (const node of nodes) {
        node.setAttrs({
          nodeMousedownPos: node.position(), // 後面用於移動所選
          lastOpacity: node.opacity(), // 選中時,下面會使其變透明,記錄原有的透明度
          lastZIndex: node.zIndex() // 記錄原有的層次,後面暫時提升所選節點的層次
        })
      }
​
      // 設定透明度、提升層次、不可互動
      for (const node of nodes.sort((a, b) => a.zIndex() - b.zIndex())) {
        const copy = node.clone()
​
        this.selectingNodesArea.add(copy)
​
        node.setAttrs({
          listening: false,
          opacity: node.opacity() * 0.8,
          zIndex: maxZIndex
        })
      }
​
      // 選中的節點
      this.selectingNodes = nodes
​
      // 放進 transformer 所在的層
      this.render.groupTransformer.add(this.selectingNodesArea)
​
      // 選中的節點,放進 transformer
      this.render.transformer.nodes([...this.selectingNodes, this.selectingNodesArea])
    }
  }
  // 清空已選
  selectingClear() {
    // 清空選擇
    this.render.transformer.nodes([])

	// 移除 selectingNodesArea 
    this.selectingNodesArea?.remove()
    this.selectingNodesArea = null

    // 恢復透明度、層次、可互動
    for (const node of this.selectingNodes.sort(
      (a, b) => a.attrs.lastZIndex - b.attrs.lastZIndex
    )) {
      node.setAttrs({
        listening: true,
        opacity: node.attrs.lastOpacity ?? 1,
        zIndex: node.attrs.lastZIndex
      })
    }
    
    // 清空狀態
    for (const node of this.selectingNodes) {
      node.setAttrs({
        nodeMousedownPos: undefined,
        lastOpacity: undefined,
        lastZIndex: undefined,
        selectingZIndex: undefined
      })
    }

	// 清空選擇節點
    this.selectingNodes = []
  }

值得一提,Konva 關於 zIndex 的處理比較特別,始終從 1 到 N,意味著,改變一個節點的 zIndex,將影響其他節點的 zIndex,舉個例子,假如有下面節點,數字就是對應的 zIndex:

a-1、b-2、c-3、d-4

此時我改 b 到 4(最大 zIndex),即 b-4,此時 c、d 會自動適應 zIndex,變成:

a-1、c-2、d-3、b-4

所以,上面需要兩次的 this.selectingNodes.sort 處理,舉個例子:

a/1、b/2、c/3、d/4,此時我選中 b 和 c

先置頂 b,即 a-1、c-2、d-3、b-4

後置頂 c,即 a-1、d-2、b-3、c-4

這樣就可以保證原來 b 和 c 的相對位置的基礎上,置頂 b 和 c

這樣,透過【選擇框】多選目標的互動就完成了。

點選

image

處理【未選擇】節點

除了用【選擇框】,也可以透過 ctrl + 點選 選擇節點。

回到 stage 的 mousedown 事件處理:

	mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        const parent = e.target.getParent()

        if (e.target === this.render.stage) {
          // 略
        } else if (parent instanceof Konva.Transformer) {
          // transformer 點選事件交給 transformer 自己的 handler
        } else if (parent instanceof Konva.Group) {
          if (e.evt.button === Types.MouseButton.左鍵) {
            if (!this.render.ignore(parent) && !this.render.ignoreDraw(e.target)) {
              if (e.evt.ctrlKey) {
                // 新增多選
                this.render.selectionTool.select([
                  ...this.render.selectionTool.selectingNodes,
                  parent
                ])
              } else {
                // 單選
                this.render.selectionTool.select([parent])
              }
            }
          } else {
            this.render.selectionTool.selectingClear()
          }
        }
      }

這裡比較簡單,就是處理一下已選的陣列。

處理【已選擇】節點

      // 記錄初始狀態
      mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        const anchor = this.render.transformer.getActiveAnchor()
        if (!anchor) {
          // 非變換
          if (e.evt.ctrlKey) {
            // 選擇
            if (this.render.selectionTool.selectingNodesArea) {
              const pos = this.render.stage.getPointerPosition()
              if (pos) {
                const keeps: Konva.Node[] = []
                const removes: Konva.Node[] = []

                // 從高到低,逐個判斷 已選節點 和 滑鼠點選位置 是否重疊
                let finded = false
                for (const node of this.render.selectionTool.selectingNodes.sort(
                  (a, b) => b.zIndex() - a.zIndex()
                )) {
                  if (
                    !finded &&
                    Konva.Util.haveIntersection(node.getClientRect(), {
                      ...pos,
                      width: 1,
                      height: 1
                    })
                  ) {
                    // 記錄需要移除選擇的節點
                    removes.unshift(node)
                    finded = true
                  } else {
                    keeps.unshift(node)
                  }
                }

                if (removes.length > 0) {
                  // 取消選擇
                  this.render.selectionTool.select(keeps)
                } else {
                  // 從高到低,逐個判斷 未選節點 和 滑鼠點選位置 是否重疊
                  let finded = false
                  const adds: Konva.Node[] = []
                  for (const node of this.render.layer
                    .getChildren()
                    .filter((node) => !this.render.ignore(node))
                    .sort((a, b) => b.zIndex() - a.zIndex())) {
                    if (
                      !finded &&
                      Konva.Util.haveIntersection(node.getClientRect(), {
                        ...pos,
                        width: 1,
                        height: 1
                      })
                    ) {
                      // 記錄需要增加選擇的節點
                      adds.unshift(node)
                      finded = true
                    }
                  }
                  if (adds.length > 0) {
                    // 新增選擇
                    this.render.selectionTool.select([
                      ...this.render.selectionTool.selectingNodes,
                      ...adds
                    ])
                  }
                }
              }
            }
          } else {
            // 略
          }
        } else {
          // 略
        }
      }

效果:

image

移動節點

準備工作

相關狀態變數:

  // 拖動前的位置
  transformerMousedownPos: Konva.Vector2d = { x: 0, y: 0 }

  // 拖動偏移
  groupImmediateLocOffset: Konva.Vector2d = { x: 0, y: 0 }

相關方法,處理 transformer 事件中會使用到:

  // 透過偏移量(selectingNodesArea)移動【目標節點】
  selectingNodesPositionByOffset(offset: Konva.Vector2d) {
    for (const node of this.render.selectionTool.selectingNodes) {
      const x = node.attrs.nodeMousedownPos.x + offset.x
      const y = node.attrs.nodeMousedownPos.y + offset.y
      node.x(x)
      node.y(y)
    }

    const area = this.render.selectionTool.selectingNodesArea
    if (area) {
      area.x(area.attrs.areaMousedownPos.x + offset.x)
      area.y(area.attrs.areaMousedownPos.y + offset.y)
    }
  }

  // 重置【目標節點】的 nodeMousedownPos
  selectingNodesPositionReset() {
    for (const node of this.render.selectionTool.selectingNodes) {
      node.attrs.nodeMousedownPos.x = node.x()
      node.attrs.nodeMousedownPos.y = node.y()
    }
  }

  // 重置 transformer 狀態
  transformerStateReset() {
    // 記錄 transformer pos
    this.transformerMousedownPos = this.render.transformer.position()
  }

  // 重置 selectingNodesArea 狀態
  selectingNodesAreaReset() {
    this.render.selectionTool.selectingNodesArea?.setAttrs({
      areaMousedownPos: {
        x: 0,
        y: 0
      }
    })
  }

  // 重置
  reset() {
    this.transformerStateReset()
    this.selectingNodesPositionReset()
    this.selectingNodesAreaReset()
  }

主要透過處理 transformer 的事件:

      transformend: () => {
        // 變換結束

        // 重置狀態
        this.reset()
      },
      //
      dragstart: () => {
        this.render.selectionTool.selectingNodesArea?.setAttrs({
          areaMousedownPos: this.render.selectionTool.selectingNodesArea?.position()
        })
      },
      // 拖動
      dragmove: () => {
        // 拖動中
        this.selectingNodesPositionByOffset(this.groupImmediateLocOffset)
      },
      dragend: () => {
        // 拖動結束

        this.selectingNodesPositionByOffset(this.groupImmediateLocOffset)

        // 重置狀態
        this.reset()
      }

還有這:

      // 記錄初始狀態
      mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        const anchor = this.render.transformer.getActiveAnchor()
        if (!anchor) {
          // 非變換
          if (e.evt.ctrlKey) {
            // 略
          } else {
            if (this.render.selectionTool.selectingNodesArea) {
              // 拖動前
              // 重置狀態
              this.reset()
            }
          }
        } else {
          // 變換前

          // 重置狀態
          this.reset()
        }
      }

還要處理 transformer 的配置 dragBoundFunc,從它獲得 groupImmediateLocOffset 偏移量:

    // 拖動中
    dragBoundFunc: (pos: Konva.Vector2d) => {
      // transform pos 偏移
      const transformPosOffsetX = pos.x - this.transformerMousedownPos.x
      const transformPosOffsetY = pos.y - this.transformerMousedownPos.y

      // group loc 偏移
      this.groupImmediateLocOffset = {
        x: this.render.toStageValue(transformPosOffsetX),
        y: this.render.toStageValue(transformPosOffsetY)
      }

      return pos

      // 接著到 dragmove 事件處理
    }

接下來,計劃實現下面這些功能:

  • 放大縮小所選的“磁貼效果”(基於網格)
  • 拖動所選的“磁貼效果”(基於網格)
  • 節點層次單個、批次調整
  • 鍵盤複製、貼上
  • 等等。。。

是不是更加有趣呢?是不是值得更多的 Star 呢?勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章