前端使用 Konva 實現可視化設計器(6)

請大家動動小手,給我一個免費的 Star 吧~

這一章處理一下複製、粘貼、刪除、畫布歸位、層次調整,通過右鍵菜單控制。

github源碼

gitee源碼

示例地址

複製粘貼

複製粘貼(通過快捷鍵)

image

  // 複製暫存
  pasteCache: Konva.Node[] = [];
  // 粘貼次數(用於定義新節點的偏移距離)
  pasteCount = 1;

  // 複製
  pasteStart() {
    this.pasteCache = this.render.selectionTool.selectingNodes.map((o) => {
      const copy = o.clone();
      // 恢復透明度、可交互
      copy.setAttrs({
        listening: true,
        opacity: copy.attrs.lastOpacity ?? 1,
      });
      // 清空狀態
      copy.setAttrs({
        nodeMousedownPos: undefined,
        lastOpacity: undefined,
        lastZIndex: undefined,
        selectingZIndex: undefined,
      });
      return copy;
    });
    this.pasteCount = 1;
  }

  // 粘貼
  pasteEnd() {
    if (this.pasteCache.length > 0) {
      this.render.selectionTool.selectingClear();
      this.copy(this.pasteCache);
      this.pasteCount++;
    }
  }

快捷鍵處理:

    keydown: (e: GlobalEventHandlersEventMap['keydown']) => {
        if (e.ctrlKey) {
          if (e.code === Types.ShutcutKey.C) {
            this.render.copyTool.pasteStart() // 複製
          } else if (e.code === Types.ShutcutKey.V) {
            this.render.copyTool.pasteEnd() // 粘貼
          }
        }
      }
    }

邏輯比較簡單,可以關注代碼中的註釋。

複製粘貼(右鍵)

image

  /**
   * 複製粘貼
   * @param nodes 節點數組
   * @param skip 跳過檢查
   * @returns 複製的元素
   */
  copy(nodes: Konva.Node[]) {
    const arr: Konva.Node[] = [];

    for (const node of nodes) {
      if (node instanceof Konva.Transformer) {
        // 複製已選擇
        const backup = [...this.render.selectionTool.selectingNodes];
        this.render.selectionTool.selectingClear();
        this.copy(backup);
      } else {
        // 複製未選擇
        const copy = node.clone();
        // 使新節點產生偏移
        copy.setAttrs({
          x: copy.x() + this.render.toStageValue(this.render.bgSize) * this.pasteCount,
          y: copy.y() + this.render.toStageValue(this.render.bgSize) * this.pasteCount,
        });
        // 插入新節點
        this.render.layer.add(copy);
        // 選中複製內容
        this.render.selectionTool.select([...this.render.selectionTool.selectingNodes, copy]);
      }
    }

    return arr;
  }

邏輯比較簡單,可以關注代碼中的註釋。

刪除

image

處理方法:

  // 移除元素
  remove(nodes: Konva.Node[]) {
    for (const node of nodes) {
      if (node instanceof Konva.Transformer) {
        // 移除已選擇的節點
        this.remove(this.selectionTool.selectingNodes);
        // 清除選擇
        this.selectionTool.selectingClear();
      } else {
        // 移除未選擇的節點
        node.remove();
      }
    }
  }

事件處理:

      keydown: (e: GlobalEventHandlersEventMap['keydown']) => {
        if (e.ctrlKey) {
          // 略
        } else if (e.code === Types.ShutcutKey.刪除) {
          this.render.remove(this.render.selectionTool.selectingNodes)
        }
      }

畫布歸位

邏輯比較簡單,恢復畫布比例和偏移量:

  // 恢復位置大小
  positionZoomReset() {
    this.render.stage.setAttrs({
      scale: { x: 1, y: 1 }
    })

    this.positionReset()
  }

  // 恢復位置
  positionReset() {
    this.render.stage.setAttrs({
      x: this.render.rulerSize,
      y: this.render.rulerSize
    })

    // 更新背景
    this.render.draws[Draws.BgDraw.name].draw()
    // 更新比例尺
    this.render.draws[Draws.RulerDraw.name].draw()
    // 更新參考線
    this.render.draws[Draws.RefLineDraw.name].draw()
  }

稍微說明一下,初始位置需要考慮比例尺的大小。

層次調整

關於層次的調整,相對比較晦澀。

image

一些輔助方法

獲取需要處理的節點,主要是處理 transformer 內部的節點:

  // 獲取移動節點
  getNodes(nodes: Konva.Node[]) {
    const targets: Konva.Node[] = []
    for (const node of nodes) {
      if (node instanceof Konva.Transformer) {
        // 已選擇的節點
        targets.push(...this.render.selectionTool.selectingNodes)
      } else {
        // 未選擇的節點
        targets.push(node)
      }
    }
    return targets
  }

獲得計算所需的最大、最小 zIndex:

  // 最大 zIndex
  getMaxZIndex() {
    return Math.max(
      ...this.render.layer
        .getChildren((node) => {
          return !this.render.ignore(node)
        })
        .map((o) => o.zIndex())
    )
  }

  // 最小 zIndex
  getMinZIndex() {
    return Math.min(
      ...this.render.layer
        .getChildren((node) => {
          return !this.render.ignore(node)
        })
        .map((o) => o.zIndex())
    )
  }

記錄選擇之前的 zIndex

由於被選擇的節點會被臨時置頂,會影響節點層次的調整,所以選擇之前需要記錄一下選擇之前的 zIndex:

  // 更新 zIndex 緩存
  updateLastZindex(nodes: Konva.Node[]) {
    for (const node of nodes) {
      node.setAttrs({
        lastZIndex: node.zIndex()
      })
    }
  }

處理 transformer 的置頂影響

通過 transformer 選擇的時候,所選節點的層次已經被置頂。

所以調整時需要有個步驟:

  • 記錄已經被 transformer 影響的每個節點的 zIndex(其實就是記錄置頂狀態)
  • 調整節點的層次
  • 恢復被 transformer 選擇的節點的 zIndex(其實就是恢復置頂狀態)

舉例子:

現在有節點:

A/1 B/2 C/3 D/4 E/5 F/6 G/7

記錄選擇 C D E 之前的 lastZIndex:C/3 D/4 E/5

選擇後,“臨時置頂” C D E:

A/1 B/2 F/3 G/4 C/5 D/6 E/7

此時置底了 C D E,由於上面記錄了選擇之前的 lastZIndex,直接計算 lastZIndex,變成 C/1 D/2 E/3

在 selectingClear 的時候,會根據 lastZIndex 讓 zIndex 的調整生效:

逐步變化:

0、A/1 B/2 F/3 G/4 C/5 D/6 E/7 改變 C/5 -> C/1
1、C/1 A/2 B/3 F/4 G/5 D/6 E/7 改變 D/6 -> D/2
2、C/1 D/2 A/3 B/4 F/5 G/6 E/7 改變 E/7 -> E/3
3、C/1 D/2 E/3 A/4 B/5 F/6 G/7 完成調整

因爲 transformer 的存在,調整完還要恢復原來的“臨時置頂”:

A/1 B/2 F/3 G/4 C/5 D/6 E/7

下面是記錄選擇之前的 zIndex 狀態、恢復調整之後的 zIndex 狀態的方法:

  // 記錄選擇期間的 zIndex
  updateSelectingZIndex(nodes: Konva.Node[]) {
    for (const node of nodes) {
      node.setAttrs({
        selectingZIndex: node.zIndex()
      })
    }
  }

  // 恢復選擇期間的 zIndex
  resetSelectingZIndex(nodes: Konva.Node[]) {
    nodes.sort((a, b) => a.zIndex() - b.zIndex())
    for (const node of nodes) {
      node.zIndex(node.attrs.selectingZIndex)
    }
  }

關於 zIndex 的調整

主要分兩種情況:已選的節點、未選的節點

  • 已選:如上面所說,調整之餘,還要處理 transformer 的置頂影響
  • 未選:直接調整即可
  // 上移
  up(nodes: Konva.Node[]) {
    // 最大zIndex
    const maxZIndex = this.getMaxZIndex()

    const sorted = this.getNodes(nodes).sort((a, b) => b.zIndex() - a.zIndex())

    // 上移
    let lastNode: Konva.Node | null = null

    if (this.render.selectionTool.selectingNodes.length > 0) {
      this.updateSelectingZIndex(sorted)

      for (const node of sorted) {
        if (
          node.attrs.lastZIndex < maxZIndex &&
          (lastNode === null || node.attrs.lastZIndex < lastNode.attrs.lastZIndex - 1)
        ) {
          node.setAttrs({
            lastZIndex: node.attrs.lastZIndex + 1
          })
        }
        lastNode = node
      }

      this.resetSelectingZIndex(sorted)
    } else {
      // 直接調整
      for (const node of sorted) {
        if (
          node.zIndex() < maxZIndex &&
          (lastNode === null || node.zIndex() < lastNode.zIndex() - 1)
        ) {
          node.zIndex(node.zIndex() + 1)
        }
        lastNode = node
      }

      this.updateLastZindex(sorted)
    }
  }

直接舉例子(忽略 transformer 的置頂影響):

現在有節點:

A/1 B/2 C/3 D/4 E/5 F/6 G/7,上移 D F

執行一次:

移動F,A/1 B/2 C/3 D/4 E/5 G/6 F/7

移動D,A/1 B/2 C/3 E/4 D/5 G/6 F/7

再執行一次:

移動F,已經到頭了,不變,A/1 B/2 C/3 E/4 D/5 G/6 F/7

移動D,A/1 B/2 C/3 E/4 G/5 D/6 F/7

再執行一次:

移動F,已經到尾了,不變,A/1 B/2 C/3 E/4 G/5 D/6 F/7

移動D,已經貼着 F 了,爲了保持 D F 的相對順序,也不變,A/1 B/2 C/3 E/4 G/5 D/6 F/7

結束

  // 下移
  down(nodes: Konva.Node[]) {
    // 最小 zIndex
    const minZIndex = this.getMinZIndex()

    const sorted = this.getNodes(nodes).sort((a, b) => a.zIndex() - b.zIndex())

    // 下移
    let lastNode: Konva.Node | null = null

    if (this.render.selectionTool.selectingNodes.length > 0) {
      this.updateSelectingZIndex(sorted)

      for (const node of sorted) {
        if (
          node.attrs.lastZIndex > minZIndex &&
          (lastNode === null || node.attrs.lastZIndex > lastNode.attrs.lastZIndex + 1)
        ) {
          node.setAttrs({
            lastZIndex: node.attrs.lastZIndex - 1
          })
        }
        lastNode = node
      }

      this.resetSelectingZIndex(sorted)
    } else {
      // 直接調整
      for (const node of sorted) {
        if (
          node.zIndex() > minZIndex &&
          (lastNode === null || node.zIndex() > lastNode.zIndex() + 1)
        ) {
          node.zIndex(node.zIndex() - 1)
        }
        lastNode = node
      }

      this.updateLastZindex(sorted)
    }
  }

直接舉例子(忽略 transformer 的置頂影響):

現在有節點:

A/1 B/2 C/3 D/4 E/5 F/6 G/7,下移 B D

執行一次:

移動B,B/1 A/2 C/3 D/4 E/5 F/6 G/7

移動D,B/1 A/2 D/3 C/4 E/5 F/6 G/7

再執行一次:

移動B,已經到頭了,不變,B/1 A/2 D/3 C/4 E/5 F/6 G/7

移動D,B/1 D/2 A/3 C/4 E/5 F/6 G/7

再執行一次:

移動B,已經到頭了,不變,B/1 D/2 A/3 C/4 E/5 F/6 G/7

移動D,已經貼着 B 了,爲了保持 B D 的相對順序,也不變,B/1 D/2 A/3 C/4 E/5 F/6 G/7

結束

  // 置頂
  top(nodes: Konva.Node[]) {
    // 最大 zIndex
    let maxZIndex = this.getMaxZIndex()

    const sorted = this.getNodes(nodes).sort((a, b) => b.zIndex() - a.zIndex())

    if (this.render.selectionTool.selectingNodes.length > 0) {
      // 先選中再調整
      this.updateSelectingZIndex(sorted)

      // 置頂
      for (const node of sorted) {
        node.setAttrs({
          lastZIndex: maxZIndex--
        })
      }

      this.resetSelectingZIndex(sorted)
    } else {
      // 直接調整

      for (const node of sorted) {
        node.zIndex(maxZIndex)
      }

      this.updateLastZindex(sorted)
    }
  }

從高到低,逐個移動,每次移動遞減 1

  // 置底
  bottom(nodes: Konva.Node[]) {
    // 最小 zIndex
    let minZIndex = this.getMinZIndex()

    const sorted = this.getNodes(nodes).sort((a, b) => a.zIndex() - b.zIndex())

    if (this.render.selectionTool.selectingNodes.length > 0) {
      // 先選中再調整
      this.updateSelectingZIndex(sorted)

      // 置底
      for (const node of sorted) {
        node.setAttrs({
          lastZIndex: minZIndex++
        })
      }

      this.resetSelectingZIndex(sorted)
    } else {
      // 直接調整

      for (const node of sorted) {
        node.zIndex(minZIndex)
      }

      this.updateLastZindex(sorted)
    }
  }

從低到高,逐個移動,每次移動遞增 1

調整 zIndex 的思路比較個性化,所以晦澀。要符合 konva 的 zIndex 特定,且達到目的,算法可以自行調整。

右鍵菜單

事件處理

      mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        this.state.lastPos = this.render.stage.getPointerPosition()

        if (e.evt.button === Types.MouseButton.左鍵) {
          if (!this.state.menuIsMousedown) {
            // 沒有按下菜單,清除菜單
            this.state.target = null
            this.draw()
          }
        } else if (e.evt.button === Types.MouseButton.右鍵) {
          // 右鍵按下
          this.state.right = true
        }
      },
      mousemove: () => {
        if (this.state.target && this.state.right) {
          // 拖動畫布時(右鍵),清除菜單
          this.state.target = null
          this.draw()
        }
      },
      mouseup: () => {
        this.state.right = false
      },
      contextmenu: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['contextmenu']>) => {
        const pos = this.render.stage.getPointerPosition()
        if (pos && this.state.lastPos) {
          // 右鍵目標
          if (pos.x === this.state.lastPos.x || pos.y === this.state.lastPos.y) {
            this.state.target = e.target
          } else {
            this.state.target = null
          }
          this.draw()
        }
      },
      wheel: () => {
        // 畫布縮放時,清除菜單
        this.state.target = null
        this.draw()
      }

邏輯說明都在註釋裏了,主要處理的是右鍵菜單出現的位置,以及出現和消失的時機,最後是右鍵的目標。

  override draw() {
    this.clear()

    if (this.state.target) {
      // 菜單數組
      const menus: Array<{
        name: string
        action: (e: Konva.KonvaEventObject<MouseEvent>) => void
      }> = []

      if (this.state.target === this.render.stage) {
        // 空白處
        menus.push({
          name: '恢復位置',
          action: () => {
            this.render.positionTool.positionReset()
          }
        })
        menus.push({
          name: '恢復大小位置',
          action: () => {
            this.render.positionTool.positionZoomReset()
          }
        })
      } else {
        // 未選擇:真實節點,即素材的容器 group 
        // 已選擇:transformer
        const target = this.state.target.parent

        // 目標
        menus.push({
          name: '複製',
          action: () => {
            if (target) {
              this.render.copyTool.copy([target])
            }
          }
        })
        menus.push({
          name: '刪除',
          action: () => {
            if (target) {
              this.render.remove([target])
            }
          }
        })
        menus.push({
          name: '置頂',
          action: () => {
            if (target) {
              this.render.zIndexTool.top([target])
            }
          }
        })
        menus.push({
          name: '上一層',
          action: () => {
            if (target) {
              this.render.zIndexTool.up([target])
            }
          }
        })
        menus.push({
          name: '下一層',
          action: () => {
            if (target) {
              this.render.zIndexTool.down([target])
            }
          }
        })
        menus.push({
          name: '置底',
          action: () => {
            if (target) {
              this.render.zIndexTool.bottom([target])
            }
          }
        })
      }

      // stage 狀態
      const stageState = this.render.getStageState()

      // 繪製右鍵菜單
      const group = new Konva.Group({
        name: 'contextmenu',
        width: stageState.width,
        height: stageState.height
      })

      let top = 0
      // 菜單每項高度
      const lineHeight = 30

      const pos = this.render.stage.getPointerPosition()
      if (pos) {
        for (const menu of menus) {
          // 框
          const rect = new Konva.Rect({
            x: this.render.toStageValue(pos.x - stageState.x),
            y: this.render.toStageValue(pos.y + top - stageState.y),
            width: this.render.toStageValue(100),
            height: this.render.toStageValue(lineHeight),
            fill: '#fff',
            stroke: '#999',
            strokeWidth: this.render.toStageValue(1),
            name: 'contextmenu'
          })
          // 標題
          const text = new Konva.Text({
            x: this.render.toStageValue(pos.x - stageState.x),
            y: this.render.toStageValue(pos.y + top - stageState.y),
            text: menu.name,
            name: 'contextmenu',
            listening: false,
            fontSize: this.render.toStageValue(16),
            fill: '#333',
            width: this.render.toStageValue(100),
            height: this.render.toStageValue(lineHeight),
            align: 'center',
            verticalAlign: 'middle'
          })
          group.add(rect)
          group.add(text)

          // 菜單事件
          rect.on('click', (e) => {
            if (e.evt.button === Types.MouseButton.左鍵) {
              // 觸發事件
              menu.action(e)

              // 移除菜單
              this.group.removeChildren()
              this.state.target = null
            }

            e.evt.preventDefault()
            e.evt.stopPropagation()
          })
          rect.on('mousedown', (e) => {
            if (e.evt.button === Types.MouseButton.左鍵) {
              this.state.menuIsMousedown = true
              // 按下效果
              rect.fill('#dfdfdf')
            }

            e.evt.preventDefault()
            e.evt.stopPropagation()
          })
          rect.on('mouseup', (e) => {
            if (e.evt.button === Types.MouseButton.左鍵) {
              this.state.menuIsMousedown = false
            }
          })
          rect.on('mouseenter', (e) => {
            if (this.state.menuIsMousedown) {
              rect.fill('#dfdfdf')
            } else {
              // hover in
              rect.fill('#efefef')
            }

            e.evt.preventDefault()
            e.evt.stopPropagation()
          })
          rect.on('mouseout', () => {
            // hover out
            rect.fill('#fff')
          })
          rect.on('contextmenu', (e) => {
            e.evt.preventDefault()
            e.evt.stopPropagation()
          })

          top += lineHeight - 1
        }
      }

      this.group.add(group)
    }
  }

邏輯也不復雜,根據右鍵的目標分配相應的菜單項

空白處:恢復位置、大小

節點:複製、刪除、上移、下移、置頂、置底

繪製右鍵菜單

右鍵的目標有二種情況:空白處、單個/多選節點。

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

  • 實時預覽窗
  • 導出、導入
  • 對齊效果
  • 等等。。。

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

源碼

gitee源碼

示例地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章