github/gitee Star 终于有几个了!
从这章开始,难度算是(或者说细节较多)升级,是不是值得更多的 Star 呢?!
继续求 Star ,希望大家多多一键三连,十分感谢大家的支持~
创作不易,Star 50 个,创作加速!
选择框

准备工作
想要拖动一个元素,可以考虑使用节点的 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
这样,通过【选择框】多选目标的交互就完成了。
点选

处理【未选择】节点
除了用【选择框】,也可以通过 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 { // 略 } }
效果:

移动节点
准备工作
相关状态变量:
// 拖动前的位置 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 呢?勾勾手指~