前端使用 Konva 實現視覺化設計器(8)- 預覽框

xachary發表於2024-04-30

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

大家如果發現了明顯的 Bug,可以提 Issue 喲~

這一章我們實現一個預覽框,實時、可互動定位的。

github原始碼

gitee原始碼

示例地址

預覽框

image
image

定位方法

移動畫布,將傳入 x,y 作為畫布中心:

  // 更新中心位置
  updateCenter(x = 0, y = 0) {
    // stage 狀態
    const stageState = this.render.getStageState()

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

    // 計算節點佔用的區域(計算起點即可)
    let minX = 0
    let minY = 0
    for (const node of nodes) {
      const x = node.x()
      const y = node.y()

      if (x < minX) {
        minX = x
      }
      if (y < minY) {
        minY = y
      }
    }

    // 居中畫布
    this.render.stage.setAttrs({
      x: stageState.width / 2 - this.render.toBoardValue(minX) - this.render.toBoardValue(x),
      y: stageState.height / 2 - this.render.toBoardValue(minY) - this.render.toBoardValue(y)
    })

    // 更新背景
    this.render.draws[Draws.BgDraw.name].draw()
    // 更新比例尺
    this.render.draws[Draws.RulerDraw.name].draw()
    // 更新參考線
    this.render.draws[Draws.RefLineDraw.name].draw()
    // 更新預覽
    this.render.draws[Draws.PreviewDraw.name].draw()
  }

比較難表達,嘗試畫個圖說明:
image

為了簡化,維持畫布初始位置,可以把 minX 和 minY 視為 0。

"前"即是 stage 起始位置 也是可視區域,可視區域是固定的,當點選 x,y 座標時,為了使移動之前 x,y 相對 stage 的位置,移動到可視區域居中位置,stage 就要如圖那樣“反向”偏移。

分解步驟,可以分為 3 步,"前"、“中”、“後”,對應計算“居中畫布”處。

繪製預覽框

下面的程式碼比較長,新增了必要的註釋,會重點解釋 move 和“可視區域提示框”兩部分邏輯。

  override draw() {
    if (this.render.config.showPreview) {
      this.clear()

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

      // 預覽框的外邊距
      const previewMargin = 20

      // 預覽框 group
      const group = new Konva.Group({
        name: 'preview',
        scale: {
          x: this.render.toStageValue(this.option.size),
          y: this.render.toStageValue(this.option.size)
        },
        width: stageState.width,
        height: stageState.height
      })

      const main = this.render.stage.find('#main')[0] as Konva.Layer

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

      // 計算節點佔用的區域
      let minX = 0
      let maxX = group.width()
      let minY = 0
      let maxY = group.height()
      for (const node of nodes) {
        const x = node.x()
        const y = node.y()
        const width = node.width()
        const height = node.height()

        if (x < minX) {
          minX = x
        }
        if (x + width > maxX) {
          maxX = x + width
        }
        if (y < minY) {
          minY = y
        }
        if (y + height > maxY) {
          maxY = y + height
        }
      }

      // 根據佔用的區域調整預覽框的大小
      group.setAttrs({
        x: this.render.toStageValue(
          -stageState.x + stageState.width - maxX * this.option.size - previewMargin
        ),
        y: this.render.toStageValue(
          -stageState.y + stageState.height - maxY * this.option.size - previewMargin
        ),
        width: maxX - minX,
        height: maxY - minY
      })

      // 預覽框背景
      const bg = new Konva.Rect({
        name: this.constructor.name,
        x: minX,
        y: minY,
        width: group.width(),
        height: group.height(),
        stroke: '#666',
        strokeWidth: this.render.toStageValue(1),
        fill: '#eee'
      })

      // 根據預覽框內部拖動,同步畫布的移動
      const move = () => {
        // 略,下面有單獨說明
      }

      // 預覽框內拖動事件處理
      bg.on('mousedown', (e) => {
        if (e.evt.button === Types.MouseButton.左鍵) {
          move()
        }
        e.evt.preventDefault()
      })
      bg.on('mousemove', (e) => {
        if (this.state.moving) {
          move()
        }
        e.evt.preventDefault()
      })
      bg.on('mouseup', () => {
        this.state.moving = false
      })

      group.add(bg)

      // 預覽框 邊框
      group.add(
        new Konva.Rect({
          name: this.constructor.name,
          x: 0,
          y: 0,
          width: stageState.width,
          height: stageState.height,
          stroke: 'rgba(255,0,0,0.2)',
          strokeWidth: 1 / this.option.size,
          listening: false
        })
      )

      // 複製提取的節點,用作預覽
      for (const node of nodes) {
        const copy = node.clone()
        // 不可互動
        copy.listening(false)
        // 設定名稱用於 ignore
        copy.name(this.constructor.name)
        group.add(copy)
      }

      // 放大的時候,顯示當前可視區域提示框
      if (stageState.scale > 1) {
        // 略,下面有單獨說明
      }

      this.group.add(group)
    }
  }

透過預覽框移動畫布

image
上面介紹了“定位方法”,基於它,透過預覽框也可以使畫布同步移動,前提就是要把“預覽框”內部的座標轉換成“畫布”的座標。

      // 根據預覽框內部拖動,同步畫布的移動
      const move = () => {
        this.state.moving = true

        const pos = this.render.stage.getPointerPosition()
        if (pos) {
          const pWidth = group.width() * this.option.size
          const pHeight = group.height() * this.option.size

          const pOffsetX = pWidth - (stageState.width - pos.x - previewMargin)
          const pOffsetY = pHeight - (stageState.height - pos.y - previewMargin)

          const offsetX = pOffsetX / this.option.size
          const offsetY = pOffsetY / this.option.size

          // 點選預覽框,點選位置作為畫布中心
          this.render.positionTool.updateCenter(offsetX, offsetY)
        }
      }

上面轉換的思路就是:

1、透過 group 和倍數反推計算佔用的區域可視大小
2、計算可視居中座標
3、計算邏輯居中座標(使用倍數恢復)
4、透過 updateCenter 居中

可視區域提示框

當放大的時候,會顯示當前可視區域提示框
image

      // 放大的時候,顯示當前可視區域提示框
      if (stageState.scale > 1) {
        // 畫布可視區域起點座標(左上)
        let x1 = this.render.toStageValue(-stageState.x + this.render.rulerSize)
        let y1 = this.render.toStageValue(-stageState.y + this.render.rulerSize)
        // 限制可視區域提示框不能超出預覽區域
        x1 = x1 > minX ? x1 : minX
        x1 = x1 < maxX ? x1 : maxX
        y1 = y1 > minY ? y1 : minY
        y1 = y1 < maxY ? y1 : maxY

        // 畫布可視區域起點座標(右下)
        let x2 =
          this.render.toStageValue(-stageState.x + this.render.rulerSize) +
          this.render.toStageValue(stageState.width)
        let y2 =
          this.render.toStageValue(-stageState.y + this.render.rulerSize) +
          this.render.toStageValue(stageState.height)
        // 限制可視區域提示框不能超出預覽區域
        x2 = x2 > minX ? x2 : minX
        x2 = x2 < maxX ? x2 : maxX
        y2 = y2 > minY ? y2 : minY
        y2 = y2 < maxY ? y2 : maxY

        // 可視區域提示框 連線座標序列
        let points: Array<[x: number, y: number]> = []

        // 可視區域提示框“超出”預覽區域影響的“邊”不做繪製
        // "超出"(上面實際處理:把超過的座標設定為上/下線,判斷方式如[以正規表示式表示]:(x|y)(1|2) === (min|max)(X|Y))
        //
        // 簡單直接窮舉 9 種情況:
        // 不超出
        // 上超出 下超出
        // 左超出 右超出
        // 左上超出 右上超出
        // 左下超出 右下超出

        // 不超出,繪製完整矩形
        if (
          x1 > minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 < maxX &&
          y1 > minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 < maxY
        ) {
          points = [
            [x1, y1],
            [x2, y1],
            [x2, y2],
            [x1, y2],
            [x1, y1]
          ]
        }
        // 上超出,不繪製“上邊”
        if (
          x1 > minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 < maxX &&
          y1 === minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 < maxY
        ) {
          points = [
            [x2, y1],
            [x2, y2],
            [x1, y2],
            [x1, y1]
          ]
        }
        // 下超出,不繪製“下邊”
        if (
          x1 > minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 < maxX &&
          y1 > minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 === maxY
        ) {
          points = [
            [x1, y2],
            [x1, y1],
            [x2, y1],
            [x2, y2]
          ]
        }
        // 左超出,不繪製“左邊”
        if (
          x1 === minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 < maxX &&
          y1 > minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 < maxY
        ) {
          points = [
            [x1, y1],
            [x2, y1],
            [x2, y2],
            [x1, y2]
          ]
        }
        // 右超出,不繪製“右邊”
        if (
          x1 > minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 === maxX &&
          y1 > minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 < maxY
        ) {
          points = [
            [x2, y1],
            [x1, y1],
            [x1, y2],
            [x2, y2]
          ]
        }
        // 左上超出,不繪製“上邊”、“左邊”
        if (
          x1 === minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 < maxX &&
          y1 === minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 < maxY
        ) {
          points = [
            [x2, y1],
            [x2, y2],
            [x1, y2]
          ]
        }
        // 右上超出,不繪製“上邊”、“右邊”
        if (
          x1 > minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 === maxX &&
          y1 === minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 < maxY
        ) {
          points = [
            [x2, y2],
            [x1, y2],
            [x1, y1]
          ]
        }
        // 左下超出,不繪製“下邊”、“左邊”
        if (
          x1 === minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 < maxX &&
          y1 > minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 === maxY
        ) {
          points = [
            [x1, y1],
            [x2, y1],
            [x2, y2]
          ]
        }
        // 右下超出,不繪製“下邊”、“右邊”
        if (
          x1 > minX &&
          x1 < maxX &&
          x2 > minX &&
          x2 === maxX &&
          y1 > minY &&
          y1 < maxY &&
          y2 > minY &&
          y2 === maxY
        ) {
          points = [
            [x2, y1],
            [x1, y1],
            [x1, y2]
          ]
        }

        // 可視區域提示框
        group.add(
          new Konva.Line({
            name: this.constructor.name,
            points: _.flatten(points),
            stroke: 'blue',
            strokeWidth: 1 / this.option.size,
            listening: false
          })
        )
      }

      // 複製提取的節點,用作預覽
      for (const node of nodes) {
        const copy = node.clone()
        // 不可互動
        copy.listening(false)
        // 設定名稱用於 ignore
        copy.name(this.constructor.name)
        group.add(copy)
      }

      this.group.add(group)
    }
  }

除了上面必要的註釋,還是畫一張圖表示這 9 種情況:
image
實際上,不希望“提示框”超出“預覽框”,於是才有上面“窮舉”的處理邏輯。
image
image

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

  • 對齊效果
  • 連線線
  • 等等。。。

是不是值得更多的 Star 呢?勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章