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

github/gitee Star 终于有几个了!

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

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

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

github源码

gitee源码

示例地址

选择框

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

准备工作

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

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

点选

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

处理【未选择】节点

除了用【选择框】,也可以通过 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 {           // 略         }       } 

效果:

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

移动节点

准备工作

相关状态变量:

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

示例地址

发表评论

评论已关闭。

相关文章