• 前端使用 Konva 实现可视化设计器(6)- 复制粘贴、删除、位置、zIndex调整


    请大家动动小手,给我一个免费的 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'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'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) => 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源码

    示例地址

  • 相关阅读:
    MATLAB: 3D raw 数据可视化
    Vue2和Vue3的响应式原理及区别
    《计算机体系结构量化研究方法第六版》1.6 成本趋势
    浅浅的计算机网络知识
    BLE学习(2):广播包报文格式详解
    中国ui设计师年终工作总结
    8+NAM+分型+单细胞生信思路
    MATLAB中alignsignals函数使用
    设计模式系列详解 -- 单例模式
    yolov3map、召回率随轮次增加下降
  • 原文地址:https://www.cnblogs.com/xachary/p/18147185