请大家动动小手,给我一个免费的 Star 吧~
这一章处理一下复制、粘贴、删除、画布归位、层次调整,通过右键菜单控制。
github源码
gitee源码
示例地址
复制粘贴
复制粘贴(通过快捷键)
// 复制暂存 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() // 粘贴 } } } }
逻辑比较简单,可以关注代码中的注释。
复制粘贴(右键)
/** * 复制粘贴 * @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; }
逻辑比较简单,可以关注代码中的注释。
删除
处理方法:
// 移除元素 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() }
稍微说明一下,初始位置需要考虑比例尺的大小。
层次调整
关于层次的调整,相对比较晦涩。
一些辅助方法
获取需要处理的节点,主要是处理 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源码
示例地址