前端使用 Konva 实现可视化设计器(17)- 素材嵌套 - 生成阶段

本章主要实现素材的嵌套(生成阶段)这意味着可以拖入画布的对象,不只是图片素材,还可以是嵌套的图片和图形。在未来的章节中,应该可以实现素材成组/解散的效果。

最近难以抽出时间继续本示例更新,以至于拖到今天才更新这一章…

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

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

github源码

gitee源码

示例地址

一些调整和优化

1、原本分散在各处的不同层的 draw 方法调用,现在基本上统一调用 render 的 redraw 方法,简化代码逻辑(暂未发现明显的性能问题)。
2、修复右键无法删除连接线的问题,主要是 stage 的 contextmenu 事件实测 target 无法得到指向的 Line 实例,目前使用 Konva.Util.haveIntersection 处理,以下是该逻辑的代码片段:

// src/Render/draws/ContextmenuDraw.ts
            const linkGroup = this.render.layerCover.find(
              `.${Draws.LinkDraw.name}`
            )[0] as Konva.Group

            // 右键目标可能为 连接线
            let lineSelection: Konva.Node | null = null

            if (linkGroup) {
              const linkLines = linkGroup.find('.link-line')

              for (const line of linkLines) {
                if (
                  Konva.Util.haveIntersection({ ...pos, width: 1, height: 1 }, line.getClientRect())
                ) {
                  // 右键目标为 连接线
                  lineSelection = line
                  break
                }
              }
            }

            if (pos.x === this.state.lastPos.x || pos.y === this.state.lastPos.y) {
              // 右键 连接线/其它目标
              this.state.target = lineSelection ?? e.target
            } else {
              this.state.target = null
            }

3、原来的对齐逻辑没有考虑目标被 rotate 后的情况,现已经修复支持了,实现方式还是使用三角函数计算:

在这里插入图片描述

红框是 rotate 后的占用区域,通过旋转角度就可以计算该区域的宽高,left、right、top、bottom 也是用于计算占用区域的 x,y 坐标:
在这里插入图片描述

// src/Render/tools/AlignTool.ts
  calcNodeRotationInfo(node: Konva.Node) {
    const rotate = node.rotation()
    const offsetLeft = node.height() * Math.sin((rotate * Math.PI) / 180)
    const offsetRight = node.width() * Math.cos((rotate * Math.PI) / 180)
    const offsetTop = node.height() * Math.cos((rotate * Math.PI) / 180)
    const offsetBottom = node.width() * Math.sin((rotate * Math.PI) / 180)

    const width = Math.abs(offsetLeft) + Math.abs(offsetRight)
    const height = Math.abs(offsetTop) + Math.abs(offsetBottom)

    let x = node.x()
    if ((rotate >= 0 && rotate < 90) || (rotate >= -360 && rotate < -270)) {
      x = x - Math.abs(offsetLeft)
    } else if ((rotate >= 90 && rotate < 180) || (rotate >= -270 && rotate < -180)) {
      x = x - width
    } else if ((rotate >= 180 && rotate < 270) || (rotate >= -180 && rotate < -90)) {
      x = x - Math.abs(offsetRight)
    } else if ((rotate >= 270 && rotate < 360) || (rotate >= -90 && rotate < 0)) {
      // 无需处理
    }

    let y = node.y()
    if ((rotate >= 0 && rotate < 90) || (rotate >= -360 && rotate < -270)) {
      // 无需处理
    } else if ((rotate >= 90 && rotate < 180) || (rotate >= -270 && rotate < -180)) {
      y = y - Math.abs(offsetTop)
    } else if ((rotate >= 180 && rotate < 270) || (rotate >= -180 && rotate < -90)) {
      y = y - height
    } else if ((rotate >= 270 && rotate < 360) || (rotate >= -90 && rotate < 0)) {
      y = y - Math.abs(offsetBottom)
    }

    return {
      x,
      y,
      width,
      height
    }
  }

进入正题

调整资源的定义

由于嵌套之后的素材,不是图片,需要额外一个封面图片 avatar,用于左侧菜单的显示:

// src/Render/types.ts
export interface AssetInfo {
  url: string
  avatar?: string // 子素材需要额外的封面
  points?: Array<AssetInfoPoint>
}

增加示例数据:

// src/App.vue
const assetsModules: Array<Types.AssetInfo> = [
  { "url": "./json/1.json", avatar: './json/1.png' },
  { "url": "./json/2.json", avatar: './json/2.png' },
  { "url": "./json/3.json", avatar: './json/3.png' },
  { "url": "./json/4.json", avatar: './json/4.png' },
  // 略
}

上面的示例数据是嵌套生成,稍后再细说实现。生成一个嵌套素材,分 2 步骤:

      <button @click="onSaveAsset">另存为元素</button>
      <button @click="onSaveAssetPNG">另存为元素图片</button>

另存为元素,输出的就是 json 文件;另存为元素图片,就是上面的封面 avatar。

// src/App.vue

// 另存为元素
function onSaveAsset() {
  if (render) {
    const a = document.createElement('a')
    const event = new MouseEvent('click')
    a.download = 'asset.json'
    a.href = window.URL.createObjectURL(new Blob([render.importExportTool.getAsset()]))
    a.dispatchEvent(event)
    a.remove()
  }
}

// 另存为元素图片
function onSaveAssetPNG() {
  if (render) {
    // 3倍尺寸、白色背景
    const url = render.importExportTool.getAssetImage(3, '#ffffff')

    const a = document.createElement('a')
    const event = new MouseEvent('click')
    a.download = 'image'
    a.href = url
    a.dispatchEvent(event)
    a.remove()
  }
}

关键就是方法 getAsset、getAssetImage,实现逻辑后面细说。

则,左侧菜单素材,优先显示 avatar 封面:

<img :src="item.avatar || item.url" />

将当前多个素材,组合成单个素材

上面提到了 getAsset,它是生成满足需求的 json 内容的,依赖关系:

getAsset(新) <- getAssetView(新) <- getView(已有)

已经存在的 getView 方法,已经用于 导出 json、另存为图片、另存为 svg,此次仅做了一些优化,它的作用基本上依然是:

1、clone 当前 stage
2、移除被认为应该 ignore 的节点
3、如需要,重新加入连接线的 Line 实例(操作过程中,连接线是实时绘制的)
4、计算并处理节点(们)占用的区域
5、返回处理好的 clone

基于 getView,为了组合多个素材,需要实现一个 getAssetView:

// src/Render/tools/ImportExportTool.ts

  /**
   * 获得显示内容(用于另存为元素)
   * @returns Konva.Stage
   */
  getAssetView() {
    const copy = this.getView(true)
    const children = copy.getChildren()[0].getChildren()

    const nodes: Konva.Stage | Konva.Layer | Konva.Group | Konva.Node[] = [...children]

    let minX = Infinity,
      maxX = -Infinity,
      minY = Infinity,
      maxY = -Infinity,
      minStartX = Infinity,
      minStartY = Infinity

    for (const node of nodes) {
      if (node instanceof Konva.Group) {
        if (node.x() < minX) {
          minX = node.x()
        }
        if (node.x() + node.width() > maxX) {
          maxX = node.x() + node.width()
        }
        if (node.y() < minY) {
          minY = node.y()
        }
        if (node.y() + node.height() > maxY) {
          maxY = node.y() + node.height()
        }

        if (node.x() < minStartX) {
          minStartX = node.x()
        }
        if (node.y() < minStartY) {
          minStartY = node.y()
        }

        // 移除辅助元素
        if (node instanceof Konva.Group) {
          const clickMask = node.findOne('#click-mask')
          if (clickMask) {
            clickMask.destroy()
          }
        }
      } else if (node instanceof Konva.Line && node.name() === 'link-line') {
        // 连线占用空间
        const points = node.points()
        for (let i = 0; i < points.length; i += 2) {
          const [x, y] = [points[i], points[i + 1]]

          if (x < minX) {
            minX = x - 1
          }
          if (x > maxX) {
            maxX = x + 1
          }
          if (y < minY) {
            minY = y - 1
          }
          if (y > maxY) {
            maxY = y + 1
          }
          if (x < minStartX) {
            minStartX = x - 1
          }
          if (y < minStartY) {
            minStartY = y - 1
          }
        }
      }
    }

    for (const node of nodes) {
      if (node instanceof Konva.Group) {
        node.x(node.x() - minStartX)
        node.y(node.y() - minStartY)
      } else if (node instanceof Konva.Line && node.name() === 'link-line') {
        const points = node.points()
        for (let i = 0; i < points.length; i += 2) {
          points[i] = points[i] - minStartX
          points[i + 1] = points[i + 1] - minStartY
        }
        node.points(points)
      }
    }

    copy.x(0)
    copy.y(0)
    copy.width(maxX - minX)
    copy.height(maxY - minY)

    return copy
  }

区别于 getView,计算占用区域是有差异的(绿色:getView,红色:getAssetView):
在这里插入图片描述
因为,经过组合的素材,是包含连接线 Line 的实例的,所以还要考虑连接线的占用区域:
在这里插入图片描述
最后,导出之前,把所有内容,都进行一次移动,整体移动至 0,0 点。
在这里插入图片描述
接着是实现 getAsset:

// src/Render/tools/ImportExportTool.ts

  /**
   * 获得元素(用于另存为元素)
   * @returns Konva.Stage
   */
  getAsset() {
    const copy = this.getAssetView()

    const json = copy.toJSON()
    const obj = JSON.parse(json)
    const assets = obj.children[0].children

    for (const asset of assets) {
      if (asset.attrs.name === 'asset') {
        asset.attrs.name = 'sub-asset'
      }
      if (asset.attrs.selected) {
        asset.attrs.selected = false
      }
    }

    this.render.linkTool.jsonIdCover(assets)

    // 通过 stage api 导出 json
    const result = JSON.stringify({
      ...obj.children[0],
      className: 'Group',
      attrs: {
        width: copy.width(),
        height: copy.height(),
        x: 0,
        y: 0
      }
    })

    copy.destroy()
    return result
  }

主要逻辑有 2 个:
1、把 getAssetView 处理好的 stage 导出为 json。
2、把组合前多个 asset 的 name 改为 sub-asset,意为“子素材”,与组合后的 asset 区别开。
3、把组合前多个 asset 的内部 id 刷新一遍,通过 jsonIdCover 方法。

// src/Render/tools/LinkTool.ts

  // 刷新 json 的 id、事件
  jsonIdCover(assets: any[]) {
    let deepAssets = [...assets]
    const idMap = new Map()

    while (deepAssets.length > 0) {
      const asset = deepAssets.shift()
      if (asset) {
        if (Array.isArray(asset.attrs.points)) {
          for (const point of asset.attrs.points) {
            if (Array.isArray(point.pairs)) {
              for (const pair of point.pairs) {
                if (pair.from.groupId && !idMap.has(pair.from.groupId)) {
                  idMap.set(pair.from.groupId, 'g:' + nanoid())
                }

                if (pair.to.groupId && !idMap.has(pair.to.groupId)) {
                  idMap.set(pair.to.groupId, 'g:' + nanoid())
                }

                if (pair.from.pointId && !idMap.has(pair.from.pointId)) {
                  idMap.set(pair.from.pointId, 'p:' + nanoid())
                }

                if (pair.to.pointId && !idMap.has(pair.to.pointId)) {
                  idMap.set(pair.to.pointId, 'p:' + nanoid())
                }
              }
            }

            if (point.id) {
              if (!idMap.has(point.id)) {
                idMap.set(point.id, 'p:' + nanoid())
              }
            }

            if (point.groupId) {
              if (!idMap.has(point.groupId)) {
                idMap.set(point.groupId, 'g:' + nanoid())
              }
            }
          }
        }

        if (asset.attrs.id) {
          if (!idMap.has(asset.attrs.id)) {
            idMap.set(asset.attrs.id, 'n:' + nanoid())
          }
        }

        if (Array.isArray(asset.children)) {
          deepAssets.push(...asset.children)
        }
      }
    }

    deepAssets = [...assets]

    while (deepAssets.length > 0) {
      const asset = deepAssets.shift()
      if (asset) {
        if (idMap.has(asset.attrs.id)) {
          asset.attrs.id = idMap.get(asset.attrs.id)
        }

        if (Array.isArray(asset.attrs.points)) {
          for (const point of asset.attrs.points) {
            if (Array.isArray(point.pairs)) {
              for (const pair of point.pairs) {
                pair.disabled = true

                if (pair.id) {
                  pair.id = 'pr:' + nanoid()
                }

                if (idMap.has(pair.from.groupId)) {
                  pair.from.groupId = idMap.get(pair.from.groupId)
                }
                if (idMap.has(pair.to.groupId)) {
                  pair.to.groupId = idMap.get(pair.to.groupId)
                }
                if (idMap.has(pair.from.pointId)) {
                  pair.from.pointId = idMap.get(pair.from.pointId)
                }
                if (idMap.has(pair.to.pointId)) {
                  pair.to.pointId = idMap.get(pair.to.pointId)
                }
              }
            }

            if (idMap.has(point.id)) {
              const anchor = asset.children.find((o: any) => o.attrs.id === point.id)
              point.id = idMap.get(point.id)
              if (anchor) {
                anchor.attrs.id = point.id
              }
            }

            if (idMap.has(point.groupId)) {
              point.groupId = idMap.get(point.groupId)
            }
          }
        }

        if (Array.isArray(asset.children)) {
          deepAssets.push(...asset.children)
        }
      }
    }
  }

jsonIdCover 对 json 结构进行一次广度优先遍历,把所有 asset 的 id(groupId)刷新一遍,连带关联的 point、pair、anchor 的 id、groupId 要同步更新。

特别地,将 pair.disabled 记录为 true,原因是,在组合的时候,连接线直接当作 Line 实例也包含进来了,在保留该 pair 记录的同时,标记后,在后续的 Link draw 的时候会忽略这些 pair。

此时,另存为元素的 json 已经处理好,可以导出了。

组合后的素材封面

生成封面,也是依赖 getAssetView 处理好的 stage 克隆,基本与原来的 getImage 一样:

// src/Render/tools/ImportExportTool.ts

  // 获取元素图片
  getAssetImage(pixelRatio = 1, bgColor?: string) {
    // 获取可视节点和 layer
    const copy = this.getAssetView()

    // 背景层
    const bgLayer = new Konva.Layer()

    // 背景矩形
    const bg = new Konva.Rect({
      listening: false
    })
    bg.setAttrs({
      x: -copy.x(),
      y: -copy.y(),
      width: copy.width(),
      height: copy.height(),
      fill: bgColor
    })

    // 添加背景
    bgLayer.add(bg)

    // 插入背景
    const children = copy.getChildren()
    copy.removeChildren()
    copy.add(bgLayer)
    copy.add(children[0], ...children.slice(1))

    const url = copy.toDataURL({ pixelRatio })
    copy.destroy()

    // 通过 stage api 导出图片
    return url
  }

通过 getAsset、getAssetImage 就可以获得组合后的素材的 json 文件、封面图片了。

生成的示例,可以参考左侧菜单新增的 4 个素材,放在静态目录 public/json 中。

在这里插入图片描述

示例 3 = 示例 1 + 示例 2;示例 4 = 示例 3 + 其他,嵌套了多层。

More Stars please!勾勾手指~

源码

gitee源码

示例地址

  • 11
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值