主页
手机版
扫描查看手机站
所在位置:首页 → 教程资讯 → 前端使用 Konva 实现可视化设计器(11)- 对齐效果

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

发布: 更新时间:2024-05-19 09:02:06

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

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

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

github源码

gitee源码

示例地址

基于整体的对齐

垂直居中

水平居中

左对齐

右对齐

上对齐

下对齐

基于目标节点的对齐

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

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

左对齐(基于目标节点)

右对齐(基于目标节点)

上对齐(基于目标节点)

下对齐(基于目标节点)

对齐逻辑

放在 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源码

示例地址

软件上新 查看更多