前端使用 Konva 实现可视化设计器(2)

作为继续创作的动力,继续求 github Star 能超过 50 个(目前惨淡的 0 个),望多多支持。
源码
示例地址

上一章,实现了"无限画布"、"画布移动"、"网格背景"、"比例尺"、"定位缩放",并简单叙述了它们实现的基本思路。

关于位置和距离

从源码里可以发现,多处依赖了 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 转换,简单地可以定义成:

ts 复制代码
  // 获取 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()
  }

再举些代码里的例子:

ts 复制代码
      // 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 恢复成"逻辑上"的位置和距离,除以"逻辑上"网格大小,就可以得出应该要绘制多少行和列的线了。

又如:

ts 复制代码
      // 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。

实现一个坐标参考线

相比于"网格背景"、"比例尺",更加简单:

ts 复制代码
// 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 这里传入的就是"比例尺"的粗细,目的是把"参考线"限制在"比例尺"的范围内。

实现把素材从左侧面板拖入设计区域

素材面板的实现

首先把静态目录的素材 import 进来,获得其 url:

ts 复制代码
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
  }))
})

接着简单的迭代展示在左边的区域:

css 复制代码
    & > 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;
        }
      }
    }
html 复制代码
      <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 事件实现拖拽素材到设计区域。

ts 复制代码
// 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 包裹素材。加载素材后,得知素材的原始大小,根据素材大小,以鼠标坐标作为素材拖入的中心点:

ts 复制代码
      // 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:

ts 复制代码
  // 加载图片
  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 矢量图。

ts 复制代码
  // 加载 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 原始路径。

ts 复制代码
  // 加载 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
          })
        )
      })
    })
  }

至此,就实现了"把素材从左侧面板拖入设计区域"这个交互功能了。

相关推荐
前端小助手1 分钟前
echarts制作grafana 面板之折线图
前端·javascript·echarts
lMoonRed14 分钟前
vue自定义指令elementui日期组件手动输入
前端·vue.js·elementui
帅比九日18 分钟前
【前端】 如何在 Vue.js 中使用 Mock 数据:教程与技巧
前端·javascript·vue.js
开源字节18 分钟前
前端常用的几个工具网站
前端
AcademicIdeas学境思源19 分钟前
不同专业方向如何在ChatGPT的帮助下完成选题
前端·chatgpt·easyui
乌龟跌倒22 分钟前
命令行参数、环境变量详解
linux·前端·chrome
yang29524236122 分钟前
vue中scoped原理以及scoped的穿透用法
前端·javascript·vue.js
是昔年啊26 分钟前
高频前端面试题——css篇(二)
前端·javascript·面试
天农学子39 分钟前
VUE实现TAB切换不同页面
前端·javascript·vue.js
小科tx1 小时前
前端人Web API
开发语言·前端·javascript