小工具知识网

当前位置: 首页 > 行业知识 > 前端使用 Konva 实现可视化设计器(3)- 单选、多选、选择框

行业知识

前端使用 Konva 实现可视化设计器(3)- 单选、多选、选择框

2026-11-15 17:05:31 来源:互联网转载

github/gitee Star 终于有几个了!

从这章开始,难度算是(或者说细节较多)升级,是不是值得更多的 Star 呢?!

继续求 Star ,希望大家多多一键三连,十分感谢大家的支持~

创作不易,Star 50 个,创作加速!

github源码

gitee源码

示例地址

选择框

准备工作

想要拖动一个元素,可以考虑使用节点的 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) => {        // 略        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

这样,通过【选择框】多选目标的交互就完成了。

点选

处理【未选择】节点

除了用【选择框】,也可以通过 ctrl + 点击 选择节点。

回到 stage 的 mousedown 事件处理:

mousedown: (e: Konva.KonvaEventObject) => {        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) => {        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 {          // 略        }      }

效果:

移动节点

准备工作

相关状态变量:

  // 拖动前的位置  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) => {        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源码

    示例地址

上一篇:系统架构设计师考试大纲

下一篇:函数库全解析,概念、应用与不可或缺的重要性!