• 前端使用 Konva 实现可视化设计器(12)- 连接线 - 直线


    这一章实现的连接线,目前仅支持直线连接,为了能够不影响原有的其它功能,尝试了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源码

    示例地址

  • 相关阅读:
    Java中的自动装箱与自动拆箱
    P4769-[NOI2018]冒泡排序【组合数学,树状数组】
    UNIX网络编程卷一 学习笔记 第二章 传输层:TCP、UDP和SCTP
    基于Ubuntu部署前端项目
    pandas第8章-文本数据
    Java Timer使用介绍
    Orange3数据可视化组件概览
    前端 vue生命周期
    代码随想录刷题day56 583. 两个字符串的删除操作;72. 编辑距离;编辑距离总结篇
    语音合成技术入门之Tacotron
  • 原文地址:https://www.cnblogs.com/xachary/p/18226877