前端使用 Konva 實現視覺化設計器(18)- 素材巢狀 - 載入階段

xachary發表於2024-07-22

本章主要實現素材的巢狀(載入階段)這意味著可以拖入畫布的物件,不只是圖片素材,還可以是巢狀的圖片和圖形。

請大家動動小手,給我一個免費的 Star 吧~

大家如果發現了 Bug,歡迎來提 Issue 喲~

github原始碼

gitee原始碼

示例地址

在原來的 drop 處理基礎上,增加一個 json 型別素材的處理入口:

// src/Render/handlers/DragOutsideHandlers.ts

drop: (e: GlobalEventHandlersEventMap['drop']) => {
  // 略
  this.render.assetTool[
    type === 'svg'
    ? `loadSvg`
    : type === 'gif'
    ? 'loadGif'
    : type === 'json'
    ? 'loadJson' // 新增,處理 json 型別素材
    : 'loadImg'
  ](src).then((target: Konva.Image | Konva.Group) => {
    // 圖片素材
    if (target instanceof Konva.Image) {
      // 略
    } else {
      // json 素材
      target.id(nanoid())
      target.name('asset')
      group = target
      this.render.linkTool.groupIdCover(group)
    }
  })
  // 略
}

drop 原邏輯基本不變,關鍵邏輯在 loadJson 中:

// src/Render/tools/AssetTool.ts

  // 載入節點 json
  async loadJson(src: string) {
    try {
      // 讀取 json內容
      const json = JSON.parse(await (await fetch(src)).text())

      // 子素材
      const assets = json.children

      // 重新整理id
      this.render.linkTool.jsonIdCover(assets)

      // 生成空白 stage+layer
      const stageEmpty = new Konva.Stage({
        container: document.createElement('div')
      })
      const layerEmpty = new Konva.Layer()
      stageEmpty.add(layerEmpty)

      // 空白 json 根
      const jsonRoot = JSON.parse(stageEmpty.toJSON())
      jsonRoot.children[0].children = [json]

      // 重新載入 stage
      const stageReload = Konva.Node.create(JSON.stringify(jsonRoot), document.createElement('div'))

      // 目標 group(即 json 轉化後的節點)
      const groupTarget = stageReload.children[0].children[0] as Konva.Group

      // 釋放記憶體
      stageEmpty.destroy()
      groupTarget.remove()
      stageReload.destroy()

      // 深度遍歷載入子素材
      const nodes: {
        target: Konva.Stage | Konva.Layer | Konva.Group | Konva.Node
        parent?: Konva.Stage | Konva.Layer | Konva.Group | Konva.Node
      }[] = [{ target: groupTarget }]

      while (nodes.length > 0) {
        const item = nodes.shift()
        if (item) {
          const node = item.target
          if (node instanceof Konva.Image) {
            if (node.attrs.svgXML) {
              const n = await this.loadSvgXML(node.attrs.svgXML)
              n.listening(false)
              node.parent?.add(n)
              node.remove()
            } else if (node.attrs.gif) {
              const n = await this.loadGif(node.attrs.gif)
              n.listening(false)
              node.parent?.add(n)
              node.remove()
            } else if (node.attrs.src) {
              const n = await this.loadImg(node.attrs.src)
              n.listening(false)
              node.parent?.add(n)
              node.remove()
            }
          }
          if (
            node instanceof Konva.Stage ||
            node instanceof Konva.Layer ||
            node instanceof Konva.Group
          ) {
            nodes.push(
              ...node.getChildren().map((o) => ({
                target: o,
                parent: node
              }))
            )
          }
        }
      }

      // 作用:點選空白區域可選擇
      const clickMask = new Konva.Rect({
        id: 'click-mask',
        width: groupTarget.width(),
        height: groupTarget.height()
      })
      groupTarget.add(clickMask)
      clickMask.zIndex(1)

      return groupTarget
    } catch (e) {
      console.error(e)
      return new Konva.Group()
    }
  }

loadJson,關鍵邏輯說明:

1、jsonIdCover 把載入到的 json 內部的 id 們重新整理一遍

2、借一個空 stage 得到一個 空 stage 的 json 結構(由於素材 json 只包含素材自身結構,需要補充上層 json 結構)

3、載入拼接好的 json,得到一個新 stage

4、從 3 的 stage 中提取目標素材 group

5、載入該 group 內部的圖片素材

6、插入一個透明 Rect,使其點選 sub-asset 們之間的空白,也能選中整個 asset

最後,進行一次 linkTool.groupIdCover 處理:

// src/Render/tools/LinkTool.ts

  // 把深層 group 的 id 統一為頂層 group 的 id
  groupIdCover(group: Konva.Group) {
    const groupId = group.id()
    const subGroups = group.find('.sub-asset') as Konva.Group[]
    while (subGroups.length > 0) {
      const subGroup = subGroups.shift() as Konva.Group | undefined
      if (subGroup) {
        const points = subGroup.attrs.points
        if (Array.isArray(points)) {
          for (const point of points) {
            point.rawGroupId = point.groupId
            point.groupId = groupId
            for (const pair of point.pairs) {
              pair.from.rawGroupId = pair.from.groupId
              pair.from.groupId = groupId
              pair.to.rawGroupId = pair.to.groupId
              pair.to.groupId = groupId
            }
          }
        }

        subGroups.push(...(subGroup.find('.sub-asset') as Konva.Group[]))
      }
    }
  }

這裡的邏輯就是把 頂層 asset 的新id,透過廣度優先遍歷,下發到下面所有的 point 和 pair 上,並保留原來的 groupId(上面的 rawGroupId)為日後備用。groupId 更新之後,在連線線演算法執行的時候,會忽略同個 asset 下不同 sub-asset 的 pair 關係,即不會重複繪製內部不同 sub-asset 之間實時連線線(連線線在另存為素材 json 的時候,已經直接固化成 Line 例項了,往後將跟隨 根 asset 行動,特別是 transform 變換)。

接著,因為這次的實現,內部屬於各 sub-asset 的 point 依舊有效,首先,調整一下 pointsVisible,使其在 hover 根 asset 的時候,內部所有 point 都會顯現:

// src/Render/tools/LinkTool.ts

  pointsVisible(visible: boolean, group?: Konva.Group) {
    const start = group ?? this.render.layer

    // 查詢深層 points
    for (const asset of [
      ...(['asset', 'sub-asset'].includes(start.name()) ? [start] : []),
      ...start.find('.asset'),
      ...start.find('.sub-asset')
    ]) {
      const points = asset.getAttr('points') ?? []
      asset.setAttrs({
        points: points.map((o: any) => ({ ...o, visible }))
      })
    }

    // 重繪
    this.render.redraw()
  }

然後,關鍵要調整 LinkDraw:

// src/Render/draws/LinkDraw.ts

override draw() {
    // 略
  
    // 所有層級的素材
    const groups = [
      ...(this.render.layer.find('.asset') as Konva.Group[]),
      ...(this.render.layer.find('.sub-asset') as Konva.Group[])
    ]
    
    // 略
    
    const pairs = points.reduce((ps, point) => {
      return ps.concat(point.pairs ? point.pairs.filter((o) => !o.disabled) : [])
    }, [] as LinkDrawPair[])
    
    // 略
    
    // 連線線
    for (const pair of pairs) {
      // 多層素材,需要排除內部 pair 對
      // pair 也不能為 disabled
      if (pair.from.groupId !== pair.to.groupId && !pair.disabled) {
        // 略
      }
    }
}

1、groups 查詢要增加包含 sub-asset

2、過濾掉 disabled 的 pair 紀錄

3、過濾掉同 asset 的 pair 紀錄

其他邏輯,基本不變。

至此,關於“素材巢狀”的邏輯基本已實現。

整體程式碼對比上個功能版本,改變的並不多,對之前的程式碼影響不大。

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章