前端使用 Konva 實現可視化設計器(11)- 對齊效果

這一章補充一個效果,在多選的情況下,對目標進行對齊。基於多選整體區域對齊的基礎上,還支持基於其中一個節點進行對齊。

請大家動動小手,給我一個免費的 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源碼

示例地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章