前端使用 Konva 實現視覺化設計器(2)

xachary發表於2024-04-06

作為繼續創作的動力,繼續求 github Star 能超過 50 個(目前慘淡的 0 個),望多多支援。
原始碼
示例地址

上一章,實現了“無限畫布”、“畫布移動”、“網格背景”、“比例尺”、“定位縮放”,並簡單敘述了它們實現的基本思路。

image

關於位置和距離

從原始碼裡可以發現,多處依賴了 Konva.Stage 的 width、height、x、y、scale。尤其是 scale,在繪製“網格背景”、“比例尺”中都必須利用它計算。

在這裡需要清楚,在設計互動的時候要考慮一種是“邏輯上”的位置和距離,另一種是“真實的”位置和距離。

假設 stage 寬 800 高 600,可以說“邏輯上” stage 的尺寸就是 800 x 600,可是一旦進行了“縮放”,放大到 x2.0,“真實的” stage 的可視尺寸就變成了 1600 x 1200了。
然而,stage 包含的 layer、group 是相對於 stage 進行定義的,例如,存在一個 rect(x:0,y:0,width:100,height:200),當 stage 放大到 x2.0 的時候,“真實的”可視尺寸就變成了 200 x 400 了,但此時 rect 的(width,height)並沒有改變。

因此,“邏輯上”和“真實的”的位置和距離之間就需要透過 scale 轉換,簡單地可以定義成:

  // 獲取 stage 狀態(這裡獲取的就是“真實的”位置和距離)
  getStageState() {
    return {
      width: this.stage.width(),
      height: this.stage.height(),
      scale: this.stage.scaleX(),
      x: this.stage.x(),
      y: this.stage.y()
    }
  }

  // 對於 stage 來說是保持 1:1 比例的,所以 scaleX 和 scaleY 是一樣的
  
  // 相對大小(基於 stage,且無視 scale)
  toStageValue(boardPos: number) {
    return boardPos / this.stage.scaleX()
  }

  // 絕對大小(基於可視區域畫素)
  toBoardValue(stagePos: number) {
    return stagePos * this.stage.scaleX()
  }

再舉些程式碼裡的例子:

      // src\Render\draws\BgDraw.ts
      // stage 狀態(這裡獲取的就是“真實的”位置和距離)
      const stageState = this.render.getStageState()
      
      // 格子大小
      const cellSize = this.option.size

      // 列數
      const lenX = Math.ceil(this.render.toStageValue(stageState.width) / cellSize)
      // 行數
      const lenY = Math.ceil(this.render.toStageValue(stageState.height) / cellSize)

繪製網格的時候,基本就是針對可視區域繪製,所以透過“真實的” stageState.width 和 stageState.height,就需要根據 stage 的 scale 恢復成“邏輯上”的位置和距離,除以“邏輯上”網格大小,就可以得出應該要繪製多少行和列的線了。

又如:

      // src\Render\draws\RulerDraw.ts
      
      // stage 狀態
      const stageState = this.render.getStageState()
      
      // 比例尺 - 上
      const groupTop = new Konva.Group({
        x: this.render.toStageValue(-stageState.x + this.option.size),
        y: this.render.toStageValue(-stageState.y),
        width: this.render.toStageValue(stageState.width - this.option.size),
        height: this.render.toStageValue(this.option.size)
      })
      
      // 比例尺 - 左
      const groupLeft = new Konva.Group({
        x: this.render.toStageValue(-stageState.x),
        y: this.render.toStageValue(-stageState.y + this.option.size),
        width: this.render.toStageValue(this.option.size),
        height: this.render.toStageValue(stageState.height - this.option.size)
      })

為了使“比例尺”一直貼在上邊和左邊,移動畫布的時候,就要根據畫布移動的偏移給“比例尺”定位,移動畫布使透過滑鼠移動的,屬於“真實的”的位置和距離,同理需要進行轉換。

在這裡也許會絕對奇怪,this.option.size 就是“比例尺”的粗細,目前是 40,它看起來屬於“邏輯上”的大小,為何還要經過 toStageValue 計算呢?因為視覺上“比例尺”的粗細是永遠不變的,就需要反過來處理了。
例如,當 stage 放大到 x2.0 的時候,不處理之前,粗細 40 的“比例尺”就變成粗細 80了,視覺上粗細保持不變,這個時候就需要處於 2.0 縮放倍率,恢復成粗細 40。

實現一個座標參考線

image

相比於“網格背景”、“比例尺”,更加簡單:

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

      const group = new Konva.Group()

      const pos = this.render.stage.getPointerPosition()
      if (pos) {
        if (pos.y >= this.option.padding) {
          // 橫
          group.add(
            new Konva.Line({
              name: this.constructor.name,
              points: _.flatten([
                [
                  this.render.toStageValue(-stageState.x),
                  this.render.toStageValue(pos.y - stageState.y)
                ],
                [
                  this.render.toStageValue(stageState.width - stageState.x),
                  this.render.toStageValue(pos.y - stageState.y)
                ]
              ]),
              stroke: 'rgba(255,0,0,0.2)',
              strokeWidth: this.render.toStageValue(1),
              listening: false
            })
          )
        }

        if (pos.x >= this.option.padding) {
          // 豎
          group.add(
            new Konva.Line({
              name: this.constructor.name,
              points: _.flatten([
                [
                  this.render.toStageValue(pos.x - stageState.x),
                  this.render.toStageValue(-stageState.y)
                ],
                [
                  this.render.toStageValue(pos.x - stageState.x),
                  this.render.toStageValue(stageState.height - stageState.y)
                ]
              ]),
              stroke: 'rgba(255,0,0,0.2)',
              strokeWidth: this.render.toStageValue(1),
              listening: false
            })
          )
        }
      }
      this.group.add(group)

直接根據滑鼠定位繪製橫豎兩條線即可,在滑鼠 mousemove 和 mouseout 的時候重繪,特別地,option.padding 這裡傳入的就是“比例尺”的粗細,目的是把“參考線”限制在“比例尺”的範圍內。

實現把素材從左側皮膚拖入設計區域

素材皮膚的實現

image

首先把靜態目錄的素材 import 進來,獲得其 url:

const assetsModules: Record<string, { default: string }> = import.meta.glob(
  ['./assets/*/*.{svg,png,jpg,gif}'],
  {
    eager: true
  }
)

const assetsInfos = computed(() => {
  return Object.keys(assetsModules).map((o) => ({
    url: assetsModules[o].default
  }))
})

接著簡單的迭代展示在左邊的區域:

    & > header {
      box-shadow: 1px 0 2px 0 rgba(0, 0, 0, 0.05);
      overflow: auto;
      & > ul {
        display: flex;
        flex-wrap: wrap;
        & > li {
          width: 33.33%;
          flex-shrink: 0;
          border: 1px solid #eee;
          cursor: move;
        }
      }
    }
      <header>
        <ul>
          <li
            v-for="(item, idx) of assetsInfos"
            :key="idx"
            draggable="true"
            @dragstart="onDragstart($event, item)"
          >
            <img :src="item.url" style="object-fit: contain; width: 100%; height: 100%" />
          </li>
        </ul>
      </header>

注意設定 draggable="true",後面需利用 dragstart 事件實現拖拽素材到設計區域。

// src\App.vue
function onDragstart(e: GlobalEventHandlersEventMap['dragstart'], item: Types.AssetInfo) {
  if (e.dataTransfer) {
    e.dataTransfer.setData('src', item.url)
    e.dataTransfer.setData('type', item.url.match(/([^./]+)\.([^./]+)$/)?.[2] ?? '')
  }
}

載入素材

設計區域透過 drop 事件獲取素材的基本資訊,用一個 group 包裹素材。載入素材後,得知素材的原始大小,根據素材大小,以滑鼠座標作為素材拖入的中心點:

      // src\Render\handlers\DragOutsideHandlers.ts
      drop: (e: GlobalEventHandlersEventMap['drop']) => {
        const src = e.dataTransfer?.getData('src')
        const type = e.dataTransfer?.getData('type')

        if (src && type) {
          // stage 狀態
          const stageState = this.render.getStageState()

          this.render.stage.setPointersPositions(e)

          const pos = this.render.stage.getPointerPosition()
          if (pos) {
            this.render.assetTool[
              type === 'svg' ? `loadSvg` : type === 'gif' ? 'loadGif' : 'loadImg'
            ](src).then((image: Konva.Image) => {
              const group = new Konva.Group({
                id: nanoid(),
                width: image.width(),
                height: image.height()
              })

              this.render.layer.add(group)

              image.setAttrs({
                x: 0,
                y: 0
              })

              group.add(image)

              const x = this.render.toStageValue(pos.x - stageState.x) - group.width() / 2
              const y = this.render.toStageValue(pos.y - stageState.y) - group.height() / 2

              group.setAttrs({
                x,
                y
              })
            })
          }
        }
      }

目標是支援一般的圖片、svg 向量圖、git 動圖,載入一般的圖片比較簡單,直接用 Konva.Image 的 API:

  // 載入圖片
  async loadImg(src: string) {
    return new Promise<Konva.Image>((resolve) => {
      Konva.Image.fromURL(src, (imageNode) => {
        imageNode.setAttrs({ src })
        resolve(imageNode)
      })
    })
  }

載入 svg 向量圖,相比一般的圖片,記錄了 svg XML 內容,為後續做資料恢復的時候,可以透過 json 資料,無損恢復 svg 向量圖。

  // 載入 svg
  async loadSvg(src: string) {
    const svgXML = await (await fetch(src)).text()
    const blob = new Blob([svgXML], { type: 'image/svg+xml' })
    const url = URL.createObjectURL(blob)

    return new Promise<Konva.Image>((resolve) => {
      Konva.Image.fromURL(url, (imageNode) => {
        imageNode.setAttrs({
          svgXML
        })
        resolve(imageNode)
      })
    })
  }

載入 gif 比較麻煩,需要第三方工具按幀繪製動圖,可以參考 konva 官方示例,並記錄 gif 原始路徑。

  // 載入 gif
  async loadGif(src: string) {
    return new Promise<Konva.Image>((resolve) => {
      const canvas = document.createElement('canvas')

      gifler(src).frames(canvas, (ctx: CanvasRenderingContext2D, frame: any) => {
        canvas.width = frame.width
        canvas.height = frame.height
        ctx.drawImage(frame.buffer, 0, 0)

        this.render.layer.draw()

        resolve(
          new Konva.Image({
            image: canvas,
            gif: src
          })
        )
      })
    })
  }

至此,就實現了“把素材從左側皮膚拖入設計區域”這個互動功能了。

相關文章