前端使用 Konva 實現視覺化設計器(12)- 連線線 - 直線

xachary發表於2024-06-02

這一章實現的連線線,目前僅支援直線連線,為了能夠不影響原有的其它功能,嘗試了2、3個實現思路,最終實測這個實現方式目前來說最為合適了。

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

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

github原始碼

gitee原始碼

示例地址

image

相關定義

  • 連線點
    image

記錄了連線點相關資訊,並不作為素材而存在,僅記錄資訊,即匯出匯入的時候,並不會出現所謂的連線點節點。

它存放在節點身上,因此匯出、匯入自然而然就可以持久化了。

src/Render/draws/LinkDraw.ts

// 連線點
export interface LinkDrawPoint {
  id: string
  groupId: string
  visible: boolean
  pairs: LinkDrawPair[]
  x: number
  y: number
}
  • 連線對
    image

一個連線點,記錄從該點出發的多條連線線資訊,作為連線對資訊存在。

src/Render/draws/LinkDraw.ts

// 連線對
export interface LinkDrawPair {
  id: string
  from: {
    groupId: string
    pointId: string
  }
  to: {
    groupId: string
    pointId: string
  }
}
  • 連線點(錨點)
    image

它是拖入素材的時候生成的真實節點,歸屬於所在的節點中,存在卻不可見,關鍵作用是同步連線點真實座標,尤其是節點發生 transform 時候,必須依賴它獲得 transform 後連線點變化。

src/Render/handlers/DragOutsideHandlers.ts

// 略
		drop: (e: GlobalEventHandlersEventMap['drop']) => {
// 略

              const points = [
                // 左
                { x: 0, y: group.height() / 2 },
                // 右
                {
                  x: group.width(),
                  y: group.height() / 2
                },
                // 上
                { x: group.width() / 2, y: 0 },
                // 下
                {
                  x: group.width() / 2,
                  y: group.height()
                }
              ]

              // 連線點資訊
              group.setAttrs({
                points: points.map(
                  (o) =>
                    ({
                      ...o,
                      id: nanoid(),
                      groupId: group.id(),
                      visible: true,
                      pairs: []
                    }) as LinkDrawPoint
                )
              })

              // 連線點(錨點)
              for (const point of group.getAttr('points') ?? []) {
                group.add(
                  new Konva.Circle({
                    name: 'link-anchor',
                    id: point.id,
                    x: point.x,
                    y: point.y,
                    radius: this.render.toStageValue(1),
                    stroke: 'rgba(0,0,255,1)',
                    strokeWidth: this.render.toStageValue(2),
                    visible: false
                  })
                )
              }

              group.on('mouseenter', () => {
                // 顯示 連線點
                this.render.linkTool.pointsVisible(true, group)
              })

              // hover 框(多選時才顯示)
              group.add(
                new Konva.Rect({
                  id: 'hoverRect',
                  width: image.width(),
                  height: image.height(),
                  fill: 'rgba(0,255,0,0.3)',
                  visible: false
                })
              )

              group.on('mouseleave', () => {
                // 隱藏 連線點
                this.render.linkTool.pointsVisible(false, group)

                // 隱藏 hover 框
                group.findOne('#hoverRect')?.visible(false)
              })
              
// 略
}
// 略
  • 連線線
    image

根據連線點資訊,繪製的線條,也不作為素材而存在,匯出匯入的時候,也不會出現所謂的連線點節點。不過,在匯出圖片、SVG和用於預覽框的時候,會直接利用線條節點匯出、顯示。

src/Render/tools/ImportExportTool.ts

// 略
  /**
   * 獲得顯示內容
   * @param withLink 是否包含線條
   * @returns 
   */
  getView(withLink: boolean = false) {
  	// 複製畫布
    const copy = this.render.stage.clone()
    // 提取 main layer 備用
    const main = copy.find('#main')[0] as Konva.Layer
    const cover = copy.find('#cover')[0] as Konva.Layer
    // 暫時清空所有 layer
    copy.removeChildren()

    // 提取節點
    let nodes = main.getChildren((node) => {
      return !this.render.ignore(node)
    })

    if (withLink) {
      nodes = nodes.concat(
        cover.getChildren((node) => {
          return node.name() === Draws.LinkDraw.name
        })
      )
    }
    
  	// 略
  }
// 略

src/Render/draws/PreviewDraw.ts

  override draw() {
      // 略
      
      const main = this.render.stage.find('#main')[0] as Konva.Layer
      const cover = this.render.stage.find('#cover')[0] as Konva.Layer
      // 提取節點
      const nodes = [
        ...main.getChildren((node) => {
          return !this.render.ignore(node)
        }),
        // 補充連線
        ...cover.getChildren((node) => {
          return node.name() === Draws.LinkDraw.name
        })
      ]
      
      // 略
  }
  • 連線線(臨時)
    image

起點滑鼠按下 -> 拖動顯示線條 -> 終點滑鼠釋放 -> 產生連線資訊 LinkDrawPoint. LinkDrawPair

// 連線線(臨時)
export interface LinkDrawState {
  linkingLine: {
    group: Konva.Group
    circle: Konva.Circle
    line: Konva.Line
  } | null
}

程式碼檔案

新增幾個關鍵的程式碼檔案:

src/Render/draws/LinkDraw.ts

根據 連線點.連結對 繪製 連線點、連線線,及其相關的事件處理

它的繪製順序,應該放在繪製 比例尺、預覽框之前。

src/Render/handlers/LinkHandlers.ts

根據 連線線(臨時)資訊,繪製/移除 連線線(臨時)

src/Render/tools/LinkTool.ts

移除連線線,控制 連線點 的顯示/隱藏

移除連線線,實際上就是移除其 連線對 資訊

// 略

export class LinkTool {
  // 略

  pointsVisible(visible: boolean, group?: Konva.Group) {
    if (group) {
      this.pointsVisibleEach(visible, group)
    } else {
      const groups = this.render.layer.find('.asset') as Konva.Group[]
      for (const group of groups) {
        this.pointsVisibleEach(visible, group)
      }
    }

    // 更新連線
    this.render.draws[Draws.LinkDraw.name].draw()
    // 更新預覽
    this.render.draws[Draws.PreviewDraw.name].draw()
  }

  remove(line: Konva.Line) {
    const { groupId, pointId, pairId } = line.getAttrs()
    if (groupId && pointId && pairId) {
      const group = this.render.layer.findOne(`#${groupId}`) as Konva.Group
      if (group) {
        const points = (group.getAttr('points') ?? []) as LinkDrawPoint[]
        const point = points.find((o) => o.id === pointId)
        if (point) {
          const pairIndex = (point.pairs ?? ([] as LinkDrawPair[])).findIndex(
            (o) => o.id === pairId
          )
          if (pairIndex > -1) {
            point.pairs.splice(pairIndex, 1)
            group.setAttr('points', points)

            // 更新連線
            this.render.draws[Draws.LinkDraw.name].draw()
            // 更新預覽
            this.render.draws[Draws.PreviewDraw.name].draw()
          }
        }
      }
    }
  }
}

關鍵邏輯

  • 繪製 連線線(臨時)
    image

src/Render/draws/LinkDraw.ts

起點滑鼠按下 'mousedown' -> 略 -> 終點滑鼠釋放 'mouseup'

// 略

export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略

  override draw() {
    this.clear()

    // stage 狀態
    const stageState = this.render.getStageState()

    const groups = this.render.layer.find('.asset') as Konva.Group[]

    const points = groups.reduce((ps, group) => {
      return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
    }, [] as LinkDrawPoint[])

    const pairs = points.reduce((ps, point) => {
      return ps.concat(point.pairs ? point.pairs : [])
    }, [] as LinkDrawPair[])

    // 略

    // 連線點
    for (const point of points) {
      const group = groups.find((o) => o.id() === point.groupId)

      // 非 選擇中
      if (group && !group.getAttr('selected')) {
        const anchor = this.render.layer.findOne(`#${point.id}`)

        if (anchor) {
          const circle = new Konva.Circle({
            id: point.id,
            groupId: group.id(),
            x: this.render.toStageValue(anchor.absolutePosition().x - stageState.x),
            y: this.render.toStageValue(anchor.absolutePosition().y - stageState.y),
            radius: this.render.toStageValue(this.option.size),
            stroke: 'rgba(255,0,0,0.2)',
            strokeWidth: this.render.toStageValue(1),
            name: 'link-point',
            opacity: point.visible ? 1 : 0
          })

          // 略

          circle.on('mousedown', () => {
            this.render.selectionTool.selectingClear()

            const pos = this.render.stage.getPointerPosition()

            if (pos) {
              // 臨時 連線線 畫
              this.state.linkingLine = {
                group: group,
                circle: circle,
                line: new Konva.Line({
                  name: 'linking-line',
                  points: _.flatten([
                    [circle.x(), circle.y()],
                    [
                      this.render.toStageValue(pos.x - stageState.x),
                      this.render.toStageValue(pos.y - stageState.y)
                    ]
                  ]),
                  stroke: 'blue',
                  strokeWidth: 1
                })
              }

              this.layer.add(this.state.linkingLine.line)
            }
          })

          // 略
      }
    }
  }
}

src/Render/handlers/LinkHandlers.ts

拖動顯示線條、移除 連線線(臨時)

從起點到滑鼠當前位置

  handlers = {
    stage: {
      mouseup: () => {
        const linkDrawState = (this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state

        // 臨時 連線線 移除
        linkDrawState.linkingLine?.line.remove()
        linkDrawState.linkingLine = null
      },
      mousemove: () => {
        const linkDrawState = (this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state

        const pos = this.render.stage.getPointerPosition()

        if (pos) {
          // stage 狀態
          const stageState = this.render.getStageState()

          // 臨時 連線線 畫
          if (linkDrawState.linkingLine) {
            const { circle, line } = linkDrawState.linkingLine
            line.points(
              _.flatten([
                [circle.x(), circle.y()],
                [
                  this.render.toStageValue(pos.x - stageState.x),
                  this.render.toStageValue(pos.y - stageState.y)
                ]
              ])
            )
          }
        }
      }
    }
  }
  • 產生連線資訊

src/Render/draws/LinkDraw.ts

// 略

export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略

  override draw() {
    this.clear()

    // stage 狀態
    const stageState = this.render.getStageState()

    const groups = this.render.layer.find('.asset') as Konva.Group[]

    const points = groups.reduce((ps, group) => {
      return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
    }, [] as LinkDrawPoint[])

    const pairs = points.reduce((ps, point) => {
      return ps.concat(point.pairs ? point.pairs : [])
    }, [] as LinkDrawPair[])

    // 略

    // 連線點
    for (const point of points) {
      const group = groups.find((o) => o.id() === point.groupId)

      // 非 選擇中
      if (group && !group.getAttr('selected')) {
        const anchor = this.render.layer.findOne(`#${point.id}`)

        if (anchor) {
          const circle = new Konva.Circle({
            id: point.id,
            groupId: group.id(),
            x: this.render.toStageValue(anchor.absolutePosition().x - stageState.x),
            y: this.render.toStageValue(anchor.absolutePosition().y - stageState.y),
            radius: this.render.toStageValue(this.option.size),
            stroke: 'rgba(255,0,0,0.2)',
            strokeWidth: this.render.toStageValue(1),
            name: 'link-point',
            opacity: point.visible ? 1 : 0
          })

          // 略

          circle.on('mouseup', () => {
            if (this.state.linkingLine) {
              const line = this.state.linkingLine
              // 不同連線點
              if (line.circle.id() !== circle.id()) {
                const toGroup = groups.find((o) => o.id() === circle.getAttr('groupId'))

                if (toGroup) {
                  const fromPoints = (
                    Array.isArray(line.group.getAttr('points')) ? line.group.getAttr('points') : []
                  ) as LinkDrawPoint[]

                  const fromPoint = fromPoints.find((o) => o.id === line.circle.id())

                  if (fromPoint) {
                    const toPoints = (
                      Array.isArray(toGroup.getAttr('points')) ? toGroup.getAttr('points') : []
                    ) as LinkDrawPoint[]

                    const toPoint = toPoints.find((o) => o.id === circle.id())

                    if (toPoint) {
                      if (Array.isArray(fromPoint.pairs)) {
                        fromPoint.pairs = [
                          ...fromPoint.pairs,
                          {
                            id: nanoid(),
                            from: {
                              groupId: line.group.id(),
                              pointId: line.circle.id()
                            },
                            to: {
                              groupId: circle.getAttr('groupId'),
                              pointId: circle.id()
                            }
                          }
                        ]
                      }

                      // 更新歷史
                      this.render.updateHistory()
                      this.draw()
                      // 更新預覽
                      this.render.draws[Draws.PreviewDraw.name].draw()
                    }
                  }
                }
              }

              // 臨時 連線線 移除
              this.state.linkingLine?.line.remove()
              this.state.linkingLine = null
            }
          })

          this.group.add(circle)
        }

        // 略
      }
    }
  }
}

  • 繪製 連線線
    image

src/Render/draws/LinkDraw.ts

這裡就是利用了上面提到的 連線點(錨點),透過它的 absolutePosition 獲得真實位置。

// 略

export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略

  override draw() {
    this.clear()

    // stage 狀態
    const stageState = this.render.getStageState()

    const groups = this.render.layer.find('.asset') as Konva.Group[]

    const points = groups.reduce((ps, group) => {
      return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
    }, [] as LinkDrawPoint[])

    const pairs = points.reduce((ps, point) => {
      return ps.concat(point.pairs ? point.pairs : [])
    }, [] as LinkDrawPair[])

    // 連線線
    for (const pair of pairs) {
      const fromGroup = groups.find((o) => o.id() === pair.from.groupId)
      const fromPoint = points.find((o) => o.id === pair.from.pointId)

      const toGroup = groups.find((o) => o.id() === pair.to.groupId)
      const toPoint = points.find((o) => o.id === pair.to.pointId)

      if (fromGroup && toGroup && fromPoint && toPoint) {
        const fromAnchor = this.render.layer.findOne(`#${fromPoint.id}`)
        const toAnchor = this.render.layer.findOne(`#${toPoint.id}`)

        if (fromAnchor && toAnchor) {
          const line = new Konva.Line({
            name: 'link-line',
            // 用於刪除連線線
            groupId: fromGroup.id(),
            pointId: fromPoint.id,
            pairId: pair.id,
            //
            points: _.flatten([
              [
                this.render.toStageValue(fromAnchor.absolutePosition().x - stageState.x),
                this.render.toStageValue(fromAnchor.absolutePosition().y - stageState.y)
              ],
              [
                this.render.toStageValue(toAnchor.absolutePosition().x - stageState.x),
                this.render.toStageValue(toAnchor.absolutePosition().y - stageState.y)
              ]
            ]),
            stroke: 'red',
            strokeWidth: 2
          })
          this.group.add(line)

          // 連線線 hover 效果
          line.on('mouseenter', () => {
            line.stroke('rgba(255,0,0,0.6)')
            document.body.style.cursor = 'pointer'
          })
          line.on('mouseleave', () => {
            line.stroke('red')
            document.body.style.cursor = 'default'
          })
        }
      }
    }

    // 略
  }
}

  • 繪製 連線點

src/Render/draws/LinkDraw.ts

// 略

export class LinkDraw extends Types.BaseDraw implements Types.Draw {
 // 略

  override draw() {
    this.clear()

    // stage 狀態
    const stageState = this.render.getStageState()

    const groups = this.render.layer.find('.asset') as Konva.Group[]

    const points = groups.reduce((ps, group) => {
      return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
    }, [] as LinkDrawPoint[])

    const pairs = points.reduce((ps, point) => {
      return ps.concat(point.pairs ? point.pairs : [])
    }, [] as LinkDrawPair[])

    // 略

    // 連線點
    for (const point of points) {
      const group = groups.find((o) => o.id() === point.groupId)

      // 非 選擇中
      if (group && !group.getAttr('selected')) {
        const anchor = this.render.layer.findOne(`#${point.id}`)

        if (anchor) {
          const circle = new Konva.Circle({
            id: point.id,
            groupId: group.id(),
            x: this.render.toStageValue(anchor.absolutePosition().x - stageState.x),
            y: this.render.toStageValue(anchor.absolutePosition().y - stageState.y),
            radius: this.render.toStageValue(this.option.size),
            stroke: 'rgba(255,0,0,0.2)',
            strokeWidth: this.render.toStageValue(1),
            name: 'link-point',
            opacity: point.visible ? 1 : 0
          })

          // hover 效果
          circle.on('mouseenter', () => {
            circle.stroke('rgba(255,0,0,0.5)')
            circle.opacity(1)
            document.body.style.cursor = 'pointer'
          })
          circle.on('mouseleave', () => {
            circle.stroke('rgba(255,0,0,0.2)')
            circle.opacity(0)
            document.body.style.cursor = 'default'
          })

          // 略
      }
    }
  }
}

  • 複製

有幾個關鍵:

  1. 更新 id,包括:節點、連線點、錨點、連線對
  2. 重新繫結相關事件

src/Render/tools/CopyTool.ts

// 略

export class CopyTool {
  // 略

  /**
   * 複製貼上
   * @param nodes 節點陣列
   * @param skip 跳過檢查
   * @returns 複製的元素
   */
  copy(nodes: Konva.Node[]) {
    const clones: Konva.Group[] = []

    for (const node of nodes) {
      if (node instanceof Konva.Transformer) {
        // 複製已選擇
        const backup = [...this.render.selectionTool.selectingNodes]
        this.render.selectionTool.selectingClear()
        this.copy(backup)

        return
      } else {
        // 複製未選擇(先記錄,後處理)
        clones.push(node.clone())
      }
    }

    // 處理克隆節點

    // 新舊 id 對映
    const groupIdChanges: { [index: string]: string } = {}
    const pointIdChanges: { [index: string]: string } = {}

    // 新 id、新事件
    for (const copy of clones) {
      const gid = nanoid()
      groupIdChanges[copy.id()] = gid
      copy.id(gid)

      const pointsClone = _.cloneDeep(copy.getAttr('points') ?? [])
      copy.setAttr('points', pointsClone)

      for (const point of pointsClone) {
        const pid = nanoid()
        pointIdChanges[point.id] = pid

        const anchor = copy.findOne(`#${point.id}`)
        anchor?.id(pid)

        point.id = pid

        point.groupId = copy.id()
        point.visible = false
      }

      copy.off('mouseenter')
      copy.on('mouseenter', () => {
        // 顯示 連線點
        this.render.linkTool.pointsVisible(true, copy)
      })
      copy.off('mouseleave')
      copy.on('mouseleave', () => {
        // 隱藏 連線點
        this.render.linkTool.pointsVisible(false, copy)

        // 隱藏 hover 框
        copy.findOne('#hoverRect')?.visible(false)
      })

      // 使新節點產生偏移
      copy.setAttrs({
        x: copy.x() + this.render.toStageValue(this.render.bgSize) * this.pasteCount,
        y: copy.y() + this.render.toStageValue(this.render.bgSize) * this.pasteCount
      })
    }

    // pairs 新 id
    for (const copy of clones) {
      const points = copy.getAttr('points') ?? []
      for (const point of points) {
        for (const pair of point.pairs) {
          // id 換新
          pair.id = nanoid()
          pair.from.groupId = groupIdChanges[pair.from.groupId]
          pair.from.pointId = pointIdChanges[pair.from.pointId]
          pair.to.groupId = groupIdChanges[pair.to.groupId]
          pair.to.pointId = pointIdChanges[pair.to.pointId]
        }
      }
    }

    // 略
  }
}

接下來,計劃實現下面這些功能:

  • 連線線 - 折線(頭疼)
  • 等等。。。

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章