前端使用 Konva 实现可视化设计器(11)- 对齐效果

时间:2024-05-19 12:12:30

这一章补充一个效果,在多选的情况下,对目标进行对齐。基于多选整体区域对齐的基础上,还支持基于其中一个节点进行对齐。

请大家动动小手,给我一个免费的 Star 吧~

大家如果发现了 Bug,欢迎来提 Issue 哟~

github源码

gitee源码

示例地址

基于整体的对齐

垂直居中

image

水平居中

image

左对齐

image

右对齐

image

上对齐

image

下对齐

image

基于目标节点的对齐

垂直居中(基于目标节点)

image

水平居中(基于目标节点)

image

左对齐(基于目标节点)

image

右对齐(基于目标节点)

image

上对齐(基于目标节点)

image

下对齐(基于目标节点)

image

对齐逻辑

放在 src/Render/tools/AlignTool.ts

import { Render } from '../index'
//
import * as Types from '../types'
import * as Draws from '../draws'
import Konva from 'konva'

export class AlignTool {
  static readonly name = 'AlignTool'

  private render: Render
  constructor(render: Render) {
    this.render = render
  }

  // 对齐参考点
  getAlignPoints(target?: Konva.Node | Konva.Transformer): { [index: string]: number } {
    let width = 0,
      height = 0,
      x = 0,
      y = 0

    if (target instanceof Konva.Transformer) {
      // 选择器
      // 转为 逻辑觉尺寸
      ;[width, height] = [
        this.render.toStageValue(target.width()),
        this.render.toStageValue(target.height())
      ]
      ;[x, y] = [
        this.render.toStageValue(target.x()) - this.render.rulerSize,
        this.render.toStageValue(target.y()) - this.render.rulerSize
      ]
    } else if (target !== void 0) {
      // 节点
      // 逻辑尺寸
      ;[width, height] = [target.width(), target.height()]
      ;[x, y] = [target.x(), target.y()]
    } else {
      // 默认为选择器
      return this.getAlignPoints(this.render.transformer)
    }

    return {
      [Types.AlignType.垂直居中]: x + width / 2,
      [Types.AlignType.左对齐]: x,
      [Types.AlignType.右对齐]: x + width,
      [Types.AlignType.水平居中]: y + height / 2,
      [Types.AlignType.上对齐]: y,
      [Types.AlignType.下对齐]: y + height
    }
  }

  align(type: Types.AlignType, target?: Konva.Node) {
    // 对齐参考点(所有)
    const points = this.getAlignPoints(target)

    // 对齐参考点
    const point = points[type]

    // 需要移动的节点
    const nodes = this.render.transformer.nodes().filter((node) => node !== target)

    // 移动逻辑
    switch (type) {
      case Types.AlignType.垂直居中:
        for (const node of nodes) {
          node.x(point - node.width() / 2)
        }
        break
      case Types.AlignType.水平居中:
        for (const node of nodes) {
          node.y(point - node.height() / 2)
        }
        break
      case Types.AlignType.左对齐:
        for (const node of nodes) {
          node.x(point)
        }
        break
      case Types.AlignType.右对齐:
        for (const node of nodes) {
          node.x(point - node.width())
        }
        break
      case Types.AlignType.上对齐:
        for (const node of nodes) {
          node.y(point)
        }
        break
      case Types.AlignType.下对齐:
        for (const node of nodes) {
          node.y(point - node.height())
        }
        break
    }
    // 更新历史
    this.render.updateHistory()
    // 更新预览
    this.render.draws[Draws.PreviewDraw.name].draw()
  }
}

还是比较容易理解的,要注意的主要是 transformer 获得的 size 和 position 是视觉尺寸,需要转为逻辑尺寸。

功能入口

准备些枚举值:

export enum AlignType {
  垂直居中 = 'Middle',
  左对齐 = 'Left',
  右对齐 = 'Right',
  水平居中 = 'Center',
  上对齐 = 'Top',
  下对齐 = 'Bottom'
}

按钮

在这里插入图片描述

      <button @click="onRestore">导入</button>
      <button @click="onSave">导出</button>
      <button @click="onSavePNG">另存为图片</button>
      <button @click="onSaveSvg">另存为Svg</button>
      <button @click="onPrev" :disabled="historyIndex <= 0">上一步</button>
      <button @click="onNext" :disabled="historyIndex >= history.length - 1">下一步</button>
      <!-- 新增 -->
      <button @click="onAlign(Types.AlignType.垂直居中)" :disabled="noAlign">垂直居中</button>
      <button @click="onAlign(Types.AlignType.左对齐)" :disabled="noAlign">左对齐</button>
      <button @click="onAlign(Types.AlignType.右对齐)" :disabled="noAlign">右对齐</button>
      <button @click="onAlign(Types.AlignType.水平居中)" :disabled="noAlign">水平居中</button>
      <button @click="onAlign(Types.AlignType.上对齐)" :disabled="noAlign">上对齐</button>
      <button @click="onAlign(Types.AlignType.下对齐)" :disabled="noAlign">下对齐</button>

按键生效的条件是,必须是多选,所以 render 需要暴露一个事件,跟踪选择节点:

		render = new Render(stageElement.value!, {
            // 略
            //
            on: {
              historyChange: (records: string[], index: number) => {
                history.value = records
                historyIndex.value = index
              },
              // 新增
              selectionChange: (nodes: Konva.Node[]) => {
                selection.value = nodes
              }
            }
          })

条件判断:

// 选择项
const selection: Ref<Konva.Node[]> = ref([])
// 是否可以进行对齐
const noAlign = computed(() => selection.value.length <= 1)
// 对齐方法
function onAlign(type: Types.AlignType) {
  render?.alignTool.align(type)
}

触发事件的地方:
src/Render/tools/SelectionTool.ts

  // 清空已选
  selectingClear() {
    // 选择变化了
    if (this.selectingNodes.length > 0) {
      this.render.config.on?.selectionChange?.([])
    }
    // 略
  }

  // 选择节点
  select(nodes: Konva.Node[]) {
    // 选择变化了
    if (nodes.length !== this.selectingNodes.length) {
      this.render.config.on?.selectionChange?.(nodes)
    }
    // 略
  }

右键菜单

在这里插入图片描述
在多选区域的空白处的时候右键,功能与按钮一样,不多赘述。

右键菜单(基于目标节点)

在这里插入图片描述
基于目标,比较特别,在多选的情况下,给内部的节点增加一个 hover 效果。
首先,拖入元素的时候,给每个节点准备一个 Konva.Rect 作为 hover 效果,默认不显示,且列入忽略的部分。

src/Render/handlers/DragOutsideHandlers.ts:

              // hover 框(多选时才显示)
              group.add(
                new Konva.Rect({
                  id: 'hoverRect',
                  width: image.width(),
                  height: image.height(),
                  fill: 'rgba(0,255,0,0.3)',
                  visible: false
                })
              )
              // 隐藏 hover 框
              group.on('mouseleave', () => {
                group.findOne('#hoverRect')?.visible(false)
              })

src/Render/index.ts:

  // 忽略非素材
  ignore(node: Konva.Node) {
    // 素材有各自根 group
    const isGroup = node instanceof Konva.Group
    return (
      !isGroup || node.id() === 'selectRect' || node.id() === 'hoverRect' || this.ignoreDraw(node)
    )
  }

src/Render/handlers/SelectionHandlers.ts:

 // 子节点 hover
      mousemove: () => {
        const pos = this.render.stage.getPointerPosition()
        if (pos) {
          // 获取所有图形
          const shapes = this.render.transformer.nodes()

          // 隐藏 hover 框
          for (const shape of shapes) {
            if (shape instanceof Konva.Group) {
              shape.findOne('#hoverRect')?.visible(false)
            }
          }

          // 多选
          if (shapes.length > 1) {
            // zIndex 倒序(大的优先)
            shapes.sort((a, b) => b.zIndex() - a.zIndex())

            // 提取重叠目标
            const selected = shapes.find((shape) =>
              // 关键 api
              Konva.Util.haveIntersection({ ...pos, width: 1, height: 1 }, shape.getClientRect())
            )

            // 显示 hover 框
            if (selected) {
              if (selected instanceof Konva.Group) {
                selected.findOne('#hoverRect')?.visible(true)
              }
            }
          }
        }
      },
      mouseleave: () => {
        // 隐藏 hover 框
        for (const shape of this.render.transformer.nodes()) {
          if (shape instanceof Konva.Group) {
            shape.findOne('#hoverRect')?.visible(false)
          }
        }
      }

需要注意的是,hover 优先级是基于节点的 zIndex,所以判断 hover 之前,需要进行一次排序。
判断 hover,这里使用 Konva.Util.haveIntersection,判断两个 rect 是否重叠,鼠标表达为大小为 1 的 rect。
用 find 找到 hover 的目标节点,使用 find 找到第一个即可,第一个就是 zIndex 最大最上层那个。
把 hover 的目标节点内部的 hoverRect 显示出来就行了。
同样的,就可以判断是基于目标节点的右键菜单:
src/Render/draws/ContextmenuDraw.ts:

        if (target instanceof Konva.Transformer) {
          const pos = this.render.stage.getPointerPosition()

          if (pos) {
            // 获取所有图形
            const shapes = target.nodes()
            if (shapes.length > 1) {
              // zIndex 倒序(大的优先)
              shapes.sort((a, b) => b.zIndex() - a.zIndex())

              // 提取重叠目标
              const selected = shapes.find((shape) =>
                // 关键 api
                Konva.Util.haveIntersection({ ...pos, width: 1, height: 1 }, shape.getClientRect())
              )

              // 对齐菜单
              menus.push({
                name: '垂直居中' + (selected ? '于目标' : ''),
                action: () => {
                  this.render.alignTool.align(Types.AlignType.垂直居中, selected)
                }
              })
              menus.push({
                name: '左对齐' + (selected ? '于目标' : ''),
                action: () => {
                  this.render.alignTool.align(Types.AlignType.左对齐, selected)
                }
              })
              menus.push({
                name: '右对齐' + (selected ? '于目标' : ''),
                action: () => {
                  this.render.alignTool.align(Types.AlignType.右对齐, selected)
                }
              })
              menus.push({
                name: '水平居中' + (selected ? '于目标' : ''),
                action: () => {
                  this.render.alignTool.align(Types.AlignType.水平居中, selected)
                }
              })
              menus.push({
                name: '上对齐' + (selected ? '于目标' : ''),
                action: () => {
                  this.render.alignTool.align(Types.AlignType.上对齐, selected)
                }
              })
              menus.push({
                name: '下对齐' + (selected ? '于目标' : ''),
                action: () => {
                  this.render.alignTool.align(Types.AlignType.下对齐, selected)
                }
              })
            }
          }
        }

接下来,计划实现下面这些功能:

  • 连接线
  • 等等。。。

More Stars please!勾勾手指~

源码

gitee源码

示例地址