首页 > 其他分享 >前端使用 Konva 实现可视化设计器(6)

前端使用 Konva 实现可视化设计器(6)

时间:2024-04-20 09:12:48浏览次数:23  
标签:node const render Konva zIndex 可视化 sorted 前端

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

示例地址

标签:node,const,render,Konva,zIndex,可视化,sorted,前端
From: https://www.cnblogs.com/xachary/p/18147185

相关文章

  • Vue前端开发常用第三方资源库
    Vue前端第三方库集合......
  • 基于三维地图开发的智慧展馆可视化系统建设方案有何推荐?
    基于三维地图开发的智慧展馆可视化系统建设方案有何推荐?     关注者7被浏览10,520关注问题​写回答​邀请回答​好问题​添加评论​分享​  查看全部5个回答粉刷匠 ​ 关注  1人赞同了......
  • 科学可视化软件介绍 – OpenSceneGraph
    科学可视化软件介绍–OpenSceneGraph叶刘克拉克莫夫​中国石油大学有机化工硕士​关注他 6人赞同了该文章 OpenSceneGraph(有时候简称为OSG)是一个开源的高性能三维图形开发工具包,供软件开发人员在可视模拟、游戏、虚拟现实、科学可视化和建模......
  • 2024-04-19 前端常见面试题汇总(js篇)
    以下是前端面试中关于JavaScript的一些常见问题及其答案,共包含超过50个问题:1.解释一下JavaScript中的变量提升(Hoisting)。变量提升是指在JavaScript中,变量和函数的声明会被提升到其所在作用域的最顶部。但需要注意,只有声明会被提升,赋值操作不会。2.解释一下JavaScript中的闭包(C......
  • 若依框架中配置使得每次打包不修改前端访问接口IP和端口
    1.添加配置文件配置文件中的数据和application.yml中的context-path保持一致 2.添加配置代码<scripttype="text/javascript">document.write("<scriptsrc='./static/config.js?v="+newDate().getTime()+"'><\/script>");&......
  • 2024-04-19 前端常见面试题汇总(html篇)
    1、xhtml和html有什么区别?语法要求:XHTML要求严格的XML语法,例如所有标签必须小写,所有标签必须关闭(即使是空元素也要使用闭合标签),所有属性必须使用引号。HTML语法相对更宽松,不强制要求标签闭合,标签和属性的大小写不敏感。文件类型:XHTML文档必须以.xml、.xhtml或者.xhtml......
  • 前端如何使用Javascript实现一个简单的发布订阅模式
    在前端开发中,我们经常需要处理事件的订阅与发布,以实现组件之间的解耦和通信。本文将介绍如何使用JavaScript实现一个简单的发布订阅模式,通过分步写代码的方式,带领读者一步步完成实现过程。步骤一:定义EventEmitter类首先,我们需要定义一个名为EventEmitter的类,作为发布订阅......
  • 今天依然是写前端的一天--前端页面的搭建
    <!--Login.vue--><scriptsetup>import{User,Lock}from'@element-plus/icons-vue'import{ref}from'vue'//控制注册与登录表单的显示,默认显示注册constisRegister=ref(false)//定义数据模型constregisterData=ref({username:'&#......
  • 如何从头手动制作一个冲压仿真软件 —— 《冲压模可视化仿真模拟》
    因为工作需要,前段时间曾思考过如何手动做一个冲压仿真软件,但是研究发现这东西居然需要用到很多的数学和物理学的知识,而这方面的知识我又不具备,于是只好作罢,但是后来看到了本文中的这个论文,虽然没有看到全文,但是感觉这个主题还是比较贴切的。原文地址:https://wap.cnki.net/touch......
  • Web前端基础
    HTML&CSS基础HTML:结构(页面元素和内容)css:表现(网页元素的外观和位置等页面样式)行为:JavaScript:行为(网页模型定义与页面交互)排版标签排版标签标题标签:h系列标签重要程度依次递减特点:独占一行、h1-h6文字逐渐减小段落标签:p特点:段落之间存在间隙、独占一行文本格式化标签场景......