vue3 + konvajs 实现图片标注功能

1,292 阅读8分钟

了解konvajs

Konva 是一个 HTML5 Canvas JavaScript 框架,支持高性能的动画、过渡、节点嵌套、分层、过滤、缓存、桌面和移动的应用程序的事件处理等。

官方文档:konvajs.org/api/

我找到的只翻译了一部分的中文文档:konvajs-doc.bluehymn.com/docs/index.…

工作原理

Konva 的对象是以一颗树的形式保存的,Konva.Stage 是树的根节点,Stage 子节点是用户创建的图层 (Konva.Layer)。

每一个 layer 有两个 渲染器: 场景渲染器 和 图像命中检测渲染器。场景渲染器输出你所看见的内容,图像命中渲染器在隐藏的 canvas 里用于高性能的检测事件。

图层可以包含图形、嵌套图形的组、嵌套组的组。Stage(舞台),layers(图层),groups(组),和 shapes(图形) 都是虚拟节点,类似于 HTML 的 DOM 节点。

结构图

image.png

安装 konvajs

目前 konvajs 针对 React、Vue、Svelte 提供了三个 JavaScript 库,这几个库都为特定框架下 Konva 框架提供声明式和响应式绑定,同时提供了一系列 konva 组件。

在 vue3 中我们只要通过 npm install vue-konva konva --save 完成安装(根据你的包管理器)

vue2 中使用 npm install vue-konva@2 konva --save

在 Vue-Konva 中也提供了很多图形组件:v-rect, v-circle, v-ellipse, v-line, v-image, v-text, v-text-path, v-star, v-label, v-path, v-regular-polygon等

vue3 中引用 Konva

import { createApp } from 'vue';
import App from './App.vue';
import VueKonva from 'vue-konva';

const app = createApp(App);
app.use(VueKonva);
app.mount('#app');

使用

事先声明一下:由于时间紧张,我实际写出来展示的结构设计得并不是很完美,一些命名也较为混乱。

  1. 首先,需要确认你画布上的整体结构。
<template>
  <div class="marker bg-gray-50 my-1 relative mx-4" ref="markerRef">
    <v-stage ref="stageRef" :config="stageConfig">
      <!-- 主要操作图层 -->
      <v-layer
        ref="layerRef"
        :draggable="isDrag"
        :dragBoundFunc="layerDragBound"
        @mousedown="drawDefectStart"
        @mousemove="drawDefectMove"
        @mouseup="drawDefectEnd"
        @wheel="wheelForScale"
      >
        <v-group ref="groupRef" :config="groupConfig">
          <v-image ref="imageRef" :config="imgConfig"></v-image>
        </v-group>
      </v-layer>
      <v-layer>
        <!-- 居于底部的文字,只需根据数据变化,无其余操作 -->
        <v-text :config="textConfig"></v-text>
      </v-layer>
    </v-stage>
  </div>
</template>
  1. 使用 isDrag 来标记可操作图层是否是拖拽状态,鼠标单击拖拽,ctrl + 鼠标实现绘制,下面是处理鼠标交互逻辑的方法。
// 注意下面代码中 stage、layer、group 使用的是模板引用对象的 getStage 方法获取的
// 开始绘制缺陷
const drawDefectStart = (e) => {
  // 是否仅查看,其余不可绘制的情况
  if (props.isCheck) return
  // ctrl + 鼠标左键开始
  if (e.evt.ctrlKey && e.evt.button == 0) {
    // 切换至绘制状态
    isDrag.value = false
    // 标记处于绘制状态
    isDrawing.value = true
    defectInfo.value = null
    // 清空页面按钮,当矩形选中时,可以编辑、删除
    handleCancelBtn()
    // 获取图片对象
    const image = imageRef.value.getStage()
    // 当前绘制矩形
    currentDrawingRect = e.target
    // 限制只能在图片上绘制矩形
    if (e.target.className === "Image" && e.target._id !== image._id) {
      ElMessage({
        message: "请在图片上标记!",
        type: "warning",
        center: true,
        duration: 1000
      })
      return
    }
    //图形起始点只能在图片层上
    if (e.target._id === image._id) {
      //开始初始绘画
      stageMousedown(e)
      return
    }
  } else {
    // 处理选中的已绘制的目标矩形
    if (!isDrawing.value && e.target.className === "Rect" && !e.target.getAttr('name').includes('Btn')) {
      // 清空页面按钮
      handleCancelBtn()
      currentSelectedRect = e.target
      handleRectSelected()
    }
  }
}
// 正在绘制
const drawDefectMove = () => {
  if (props.isCheck) return
  if (isDrawing.value) {
    stageMousemove()
  }
}
// 绘制完成
const drawDefectEnd = (e) => {
  if (props.isCheck) return
  if (isDrawing.value && currentDrawingRect && e.evt.button === 0) {
    // 绘制完成,绘制状态标记为false
    isDrawing.value = false
    // 拖拽状态标记为true
    isDrag.value = true
    // 获取舞台对象,将鼠标样式改为默认
    stage.container().style.cursor = "default"
    // 获取绘制举行的左上角坐标和宽高
    const x = currentDrawingRect.getAttr("x")
    const y = currentDrawingRect.getAttr("y")
    const width = currentDrawingRect.getAttr("width")
    const height = currentDrawingRect.getAttr("height")
    // 如果只是点击则不会处理下一步
    if (isNaN(width) || isNaN(height) || width == 0 || height == 0) return
    // 这里是将这些数据转为实际图片大小写的一个hook
    rectInfo.value = getNaturalRectPoints(+x, +y, +width, +height)
    // 绘制完成的后续操作
    showMarker.value = true
  }
}
  1. 在实际实现中,肯定不会按照实际图片尺寸来展示图片,为了保证标记数据的真实性以及不同场景标记图形的回显正确性,必须根据图片真实大小和渲染大小比例来计算绘制矩形的实际大小。这里实现了一个简单的 hook 提供参考。
import { ref } from "vue"

export const useImageSize = () => {
  const naturalWidth = ref(0)
  const naturalHeight = ref(0)
  const scale_x = ref(1)
  const scale_y = ref(1)
  // img: HTMLImageElement, container: HTMLElement
  const getImageNaturalSizeAndScale = (img, container) => {
    // console.log(img, container)
    if (img.naturalWidth) {
      // 适用于Firefox/IE9/Safari/Chrome/Opera浏览器
      naturalWidth.value = img.naturalWidth || img.width
      naturalHeight.value = img.naturalHeight || img.height
      let renderedWidth = container.offsetWidth
      let renderedHeight = container.offsetHeight
      // 放缩比例 用实际的除以渲染的,获取真实的数值相乘即可
      scale_x.value = renderedWidth != 0 ? naturalWidth.value / renderedWidth : 1
      scale_y.value = renderedHeight != 0 ? naturalHeight.value / renderedHeight : 1
    }
  }
  // 转真实矩形大小 x: number, y: number, width: number, height: number
  const getNaturalRectPoints = (x, y, width, height) => {
    const natural_x = +Number(x * scale_x.value).toFixed(2)
    const natural_y = +Number(y * scale_y.value).toFixed(2)
    const natural_w = +Number(width * scale_x.value).toFixed(2)
    const natural_h = +Number(height * scale_y.value).toFixed(2)
    return [
      {
        x: natural_x,
        y: natural_y
      },
      {
        x: natural_x + natural_w,
        y: natural_y
      },
      {
        x: natural_x,
        y: natural_y + natural_h
      },
      {
        x: natural_x + natural_w,
        y: natural_y + natural_h
      }
    ]
  }
  // 转渲染的矩形大小,percent 是否计算渲染比例 rect: { x: number; y: number }[], percent: boolean = false
  const getPaintRectPoints = (rect, percent = false) => {
    if (rect.length === 4) {
      if (!percent) {
        // 无需计算比例
        return rect.map((item) => {
          return {
            x: +Number(item.x / scale_x.value).toFixed(2),
            y: +Number(item.y / scale_y.value).toFixed(2)
          }
        })
      }
      // 计算比例
      const calc_x = +Number((rect[0].x * 100) / naturalWidth.value).toFixed(2)
      const calc_y = +Number((rect[0].y * 100) / naturalHeight.value).toFixed(2)
      const calc_w = +Number(((rect[3].x - rect[0].x) * 100) / naturalWidth.value).toFixed(2)
      const calc_h = +Number(((rect[3].y - rect[0].y) * 100) / naturalHeight.value).toFixed(2)
      return {
        calc_x,
        calc_y,
        calc_w,
        calc_h
      }
    } else {
      return []
    }
  }

  return {
    naturalWidth,
    naturalHeight,
    scale_x,
    scale_y,
    getImageNaturalSizeAndScale,
    getNaturalRectPoints,
    getPaintRectPoints
  }
}
  1. 舞台的交互 -- 矩形绘制,这一步最重要的点就是要根据响应布局(考虑窗口放大缩小),拖拽的偏移量,缩放比例还有缩放产生的偏移量最终计算准确的 x,y 相对图片的坐标,因为获取的鼠标位置信息是相对 Stage 对象的。
/**
 * 在舞台上鼠标点下发生的事件
 * @param e 传入的event对象
 */
const stageMousedown = (e) => {
  //最好使用konva提供的鼠标xy点坐标
  // 拿到当前鼠标位置
  let mousePos = stage.getPointerPosition()
  // 计算坐标点:考虑鼠标缩放(设置group可缩放),拖拽(设置layer可拖拽)
  let x = (mousePos.x / scale - group.getAttr("x") - layer.getAttr("x")) / group.scaleX(),
    y = (mousePos.y / scale - group.getAttr("y") - layer.getAttr("y")) / group.scaleY()
  
  //绘制矩形
  drawRect(x, y, group)
  
  layer.draw()
  isDrawing.value = true
}

/**
 * 绘制矩形
 * @param (x, y, group)
 */
const drawRect = (x, y, group) => {
  const rect: any = new Konva.Rect({
    name: "rect",
    x: x,
    y: y,
    width: 0,
    height: 0,
    opacity: 1,
    draggable: false,
    stroke: "#ef4444",
    strokeScaleEnabled: false,
    strokeWidth: 2
  })
  if (rect !== null) {
    // 使用currentDrawingRect标记现Rect
    currentDrawingRect = rect
    group.add(rect)
  }
}
// 鼠标事件标记
const stageMousemove = () => {
  // 获取stage
  if (isDrawing.value) {
    // 修改一下鼠标样式
    stage.container().style.cursor = "crosshair"
    // 鼠标位置
    const mousePos = stage.getPointerPosition()
    // 计算坐标点:考虑鼠标缩放(设置group可缩放),拖拽(设置layer可拖拽))
    let x = (mousePos.x / scale - group.getAttr("x") - layer.getAttr("x")) / group.scaleX(),
      y = (mousePos.y / scale - group.getAttr("y") - layer.getAttr("y")) / group.scaleY()
    if (currentDrawingRect) {
      const width = x - currentDrawingRect.x()
      const height = y - currentDrawingRect.y()
      currentDrawingRect.width(width)
      currentDrawingRect.height(height)
      layer.draw()
    }
  }
} 
  1. 点击绘制矩形实现展示操作按钮,描述信息,比较重要的一个 API -- cancelBubble ,用来阻止事件冒泡。这里操作按钮对应的位置可以再根据情况调整使其更美观。不需要按钮时,使用 remove API 移除,destroy API 销毁。
// 页面按钮
let rectOptBtns =[]
// 添加按钮
const addButtons = (rect_x, rect_y, rect_h) => {
  rectOptBtns = []
  let x = rect_x,
    y = rect_y + 10
  const editBtn = new Konva.Rect({
    name: "editBtn",
    x: x,
    y: y,
    width: 48,
    height: 24,
    opacity: 1,
    cornerRadius: 4,
    draggable: false,
    fill: "#409eff"
  })
  editBtn.on("click", (e) => {
    e.cancelBubble = true
    // 编辑操作
    handleEdit()
  })
  group.add(editBtn)
  btns.push(editBtn)
  const editText = new Konva.Text({
    x: x,
    y: y,
    width: 48,
    height: 24,
    text: '修改',
    fontSize: 14,
    fill: 'white',
    align: 'center',
    verticalAlign: 'middle',
    listening: false // 不接收点击事件
  });
  group.add(editText);
  btns.push(editText)
  const delBtn = new Konva.Rect({
    name: "delBtn",
    x: x+60,
    y: y,
    width: 48,
    height: 24,
    opacity: 1,
    cornerRadius: 4,
    draggable: false,
    fill: "#409eff",
  })
  delBtn.on('click', (e) => {
    e.cancelBubble = true
    
    // 删除操作
    handleDelete()
  })
  group.add(delBtn);
  btns.push(delBtn)
  const delText = new Konva.Text({
    x: x+60,
    y: y,
    width: 48,
    height: 24,
    text: '删除',
    fontSize: 14,
    fill: 'white',
    align: 'center',
    verticalAlign: 'middle',
    listening: false // 不接收点击事件
  });
  group.add(delText);
  rectOptBtns.push(delText)
  const cancelBtn = new Konva.Rect({
    name: "cancelBtn",
    x: x+120,
    y: y,
    width: 48,
    height: 24,
    opacity: 1,
    cornerRadius: 4,
    draggable: false,
    fill: "#409eff",
  })
  cancelBtn.on('click', (e) => {
    e.cancelBubble = true
    handleCancelBtn()
  })
  group.add(cancelBtn);
  rectOptBtns.push(cancelBtn)
  const cancelText = new Konva.Text({
    x: x+120,
    y: y,
    width: 48,
    height: 24,
    text: '取消',
    fontSize: 14,
    fill: 'white',
    align: 'center',
    verticalAlign: 'middle',
    listening: false // 不接收点击事件
  });
  group.add(cancelText);
  rectOptBtns.push(cancelText)
  const name_h = y-rect_h-50 > 0 ? y-rect_h-50 : y+28
  const rectName = new Konva.Text({
    x: x,
    y: name_h,
    width: stageWidth.value,
    height: 24,
    text: '获取的矩形展示的描述信息...',
    fontSize: 14,
    fill: '#ef4444',
    align: 'left',
    verticalAlign: 'middle',
    listening: false // 不接收点击事件
  });
  group.add(rectName);
  rectOptBtns.push(rectName)
}
// 删除按钮
// 取消
const handleCancelBtn = () => {
  currentSelectedRect = null
  // ... 一些其余逻辑
  if (rectOptBtns.length) {
    for(let i in rectOptBtns) {
      rectOptBtns[i].remove()
      rectOptBtns[i].destroy()
    }
  }
}
  1. 提一点拖拽边界限制,也是需要重视缩放比例和缩放相对偏移量带来的影响,设置固定值会在放大和缩小的情况下造成拖不动和依然能出界的情况,计算考虑的值不对结果就是边界值有问题,不中心对称什么的都有。我这里设置的不管上下左右拖拽,长宽必须保留100像素,仅供参考。
const layerDragBound = (pos) => {
  // 自定义边界限制逻辑
  const stageWidth = stage.width();
  const stageHeight = stage.height();
  const scaleX = group.scaleX()
  const scaleY = group.scaleY()
  const offsetX = group.x()
  const offsetY = group.y()
   // 根据缩放比例和偏移量计算最大x和y值边界值
  const minX = 100 - stageWidth * scaleX - offsetX
  const minY = 100 - stageHeight * scaleY - offsetY
  const maxX = stageWidth - 100 - offsetX
  const maxY = stageHeight - 100 - offsetY
  let newX = Math.max(minX, Math.min(maxX, pos.x));
  let newY = Math.max(minY, Math.min(maxY, pos.y));
  return {
    x: newX,
    y: newY
  };
}
  1. 页面销毁前,建议销毁一下 Stage,它提供了 destroyChildren API 可以销毁所有子节点。
onUnmounted(() => {
  // 移除窗口大小事件
  window.removeEventListener("resize", resizeStage)
  if (stageRef.value) {
    const stage = stageRef.value.getStage()
    // 销毁子对象
    stage.destroyChildren()
    stage.destroy()
  }
})
  1. 最终实现效果

7534915ece91ef68a92cdb18804942b2.jpg

总结

一开始技术选型我是在原生 Canvas、Fabric.js、Konva.js 中考虑的,因为需求实现时间紧张舍弃了原生方案。

Fabric.js 和 Konva.js 都是流行的用于创建基于 HTML5 Canvas 的图形应用的 JavaScript 库,两者特性也比较相似。

Fabric.js 和 Konva.js 都提供了易于理解和使用的 API 设计,使开发者能够快速上手并创建复杂的图形应用。且两者都兼容现在主流浏览器。对于 IE11, Konva.js 建议提供一些polyfill,如 Array.prototype.find,String.prototype.trimLeft,String.prototype.trimRight,Array.from。

所以我主要考虑性能和上手容易程度,很明显由于 Konva.js 的扩展库,会省去我一部分代码逻辑,而且从开发角度上看,画布整体结构层次很清晰。其次,从性能角度上讲 Konva.js 专注于图形渲染和动画性能的优化,也是很值得选择。

Konva.js 还提供了一些性能优化技巧:konvajs.org/docs/perfor…