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


    作为继续创作的动力,继续求 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
              })
            )
          })
        })
      }
    

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

  • 相关阅读:
    力扣-python-两数之和
    Bootstrap页面整合(十二)
    解决Oracle SQL语句性能问题——SQL语句改写(in、not in、exists及not exists)
    剑指offer-字符串总结
    conda中cuda、cuda-toolkit、cuda-nvcc、cuda-runtime的区别
    centos8 安装 docker
    JDK新特性-Stream流
    数据库和表的基本操作
    LeetCode_59_螺旋数组Ⅱ
    Java类型推断增强:使用var简化代码
  • 原文地址:https://www.cnblogs.com/xachary/p/18117864