react中使用fabric实现对图片添加矩形标记

845 阅读4分钟

1. 安装fabric库

npm install fabric

可参考文档

2. 需要进行图片框选的react的js文件如下:

import React, { useEffect, useRef, useState } from 'react'

const [imgWrapWidth, setImgWrapWidth] = useState(0) // Canvas容器宽度
const [imgWrapHeight, setImgWrapHeight] = useState(0) // Canvas容器高度
const [imgWidth, setImgWidth] = useState(0) // 图片元素宽度
const [imgHeight, setImgHeight] = useState(0) // 图片元素高度
const canvasWrapRef = useRef(null)
const nativeImgRef = useRef(null)
const canvasImgRef = useRef(null)

// 在图片加载完成之后canvas初始化 (加载默认图片主要是为了读取图片真实宽高)
const initImgDraw = async () => {
  const imgWidth = nativeImgRef.current.naturalWidth
  const imgHeight = nativeImgRef.current.naturalHeight
  const wrapWidth = canvasWrapRef.current.offsetWidth
  const wrapHeight = (wrapWidth * imgHeight) / imgWidth
  setImgWidth(imgWidth)
  setImgHeight(imgHeight)
  setImgWrapWidth(wrapWidth)
  setImgWrapHeight(wrapHeight)

  // fabric的canvas画板事件监听
  canvasImgRef.current =new fabric.Canvas('canvasImg', {
    width: wrapWidth,
    height: wrapHeight,
    selection: true,
    selectionColor: 'transparent',
    selectionBorderColor: '#00B7EE',
    skipTargetFind: false, // 允许选中
    strokeWidth: 2,
  })

  // 设置背景图片
  const backImage = await getBase64Image(store.currentMarkedInfo?.img_detail, wrapWidth, wrapHeight)
  canvasImgRef.current.setBackgroundImage(
    backImage, 
    canvasImgRef.current.renderAll.bind(canvasImgRef.current),
  )

  canvasImgRef.current.on('mouse:down', canvasMouseDown)   // 鼠标在画布上按下
  canvasImgRef.current.on('mouse:up', canvasMouseUp)       // 鼠标在画布上松开
}

// 注意,在添加fabric的背景图片时如果不是本地图片,需要转化为base64才能在最后生成base64图片
const getBase64Image = (src, width, height) => {
  return new Promise(resolve => {
      const img = new Image()
      img.crossOrigin = ''
      img.src = src
      img.onload = () => {
        const canvas = document.createElement('canvas')
        canvas.width = width
        canvas.height = height
        const ctx = canvas.getContext('2d')
        ctx.drawImage(img, 0, 0, width, height)
        const ext = img.src.substring(img.src.lastIndexOf('.') + 1).toLowerCase()
        const dataURL = canvas.toDataURL('image/' + ext)
        resolve(dataURL)
      }
  })
}

/*鼠标在画布上按下*/
  const canvasMouseDown = (e) => {
    activeObject = canvasImgRef.current.getActiveObject()
    Object.assign(downPoint, JSON.parse(JSON.stringify(e.absolutePointer)))
    setDownPoint(downPoint)
    // downPoint = JSON.parse(JSON.stringify(e.absolutePointer))
    if (activeObject) return
  }

  /*鼠标在画布上松开*/
  const canvasMouseUp = (e) => {
    Object.assign(upPoint, JSON.parse(JSON.stringify(e.absolutePointer)))
    setUpPoint(upPoint)
    if (activeObject) return
    createRect()
  }

  /*创建矩形*/
  const createRect = (color) => {
    // 点击事件,不生成矩形
    if (JSON.stringify(downPoint) === JSON.stringify(upPoint)) {
      return
    }
    // 矩形参数计算
    let top = Math.min(downPoint.y, upPoint.y)
    let left = Math.min(downPoint.x, upPoint.x)
    let width = Math.abs(downPoint.x - upPoint.x)
    let height = Math.abs(downPoint.y - upPoint.y)
    let storkColor = color ? color : store.colorList[Object.keys(historyObject).length]
    // 矩形对象
    const rect = new fabric.Rect({
      top,
      left,
      width,
      height,
      fill: 'transparent',
      stroke: storkColor,
      strokeWidth: 2
    })
    historyObject[Object.keys(historyObject).length] = rect
    setHistoryObject(historyObject)
    // 将矩形添加到画布上
    canvasImgRef.current.add(rect)

    // 修改颜色不需要再次打开编辑框
    if (!color) {
      historyLabel[Object.keys(historyLabel).length] = {label: ''}
      setHistoryLabel(historyLabel)
      // 记录当前编辑的rect
      store.currentEditRectId = historyObject.length
      // 开启标签编辑
      store.changeIsMarking(true)
    }
  }

  /*确定添加之后重新绘制矩形框*/ 
  const changeRectColor = () => {
    abortDraw('isReDraw') // 重新绘制时先擦除
    const labelKeys = Object.keys(historyLabel)
    const currentLabel = historyLabel[labelKeys[labelKeys.length - 1]]?.label
    const index = store.colorSet.indexOf(currentLabel)

    createLabeledRect(store.colorList[index], currentLabel)
  }

  
  // 创建带标签的画布
  const createLabeledRect = (color, currentLabel) => {
    // 点击事件,不生成矩形
    if (JSON.stringify(downPoint) === JSON.stringify(upPoint)) {
      return
    }
    // 矩形参数计算
    let top = Math.min(downPoint.y, upPoint.y)
    let left = Math.min(downPoint.x, upPoint.x)
    let width = Math.abs(downPoint.x - upPoint.x)
    let height = Math.abs(downPoint.y - upPoint.y)
    let storkColor = color ? color : store.colorList[Object.keys(historyObject).length]
    // 矩形对象
    const labeledRect = new LabeledRect({
      top,
      left,
      width,
      height,
      fill: 'transparent',
      stroke: storkColor,
      strokeWidth: 2,
      label: currentLabel,
      fontColor: storkColor
    })
    historyObject[Object.keys(historyObject).length] = labeledRect
    setHistoryObject(historyObject)
    // 将矩形添加到画布上
    canvasImgRef.current.add(labeledRect)
  }
  
  /* 撤销上一步绘制*/
  const abortDraw = (isReDraw) => {
    const keyList = Object.keys(historyObject)
    if (keyList.length) {
      canvasImgRef.current.remove(historyObject[keyList.length - 1])
      delete historyObject[keyList.length - 1]
      setHistoryObject(historyObject)

      if (isReDraw !== 'isReDraw') {
        const currentLabel = JSON.parse(JSON.stringify(historyLabel[keyList.length - 1]))?.label
        delete historyLabel[keyList.length - 1]
        setHistoryLabel(historyLabel)
        store.changeLabelList('pop', {label: currentLabel, opt_id: currentLabel})
      }
    }
  }
    
  /*保存绘制*/
  const saveDraw = async () => {
    //创建新的canvas
    const rectList = canvasImgRef.current.toJSON(['canvasImg'])
    if (!rectList.objects.length) return
    const imgData = canvasImgRef.current.toDataURL('image/png', ['canvasImg'])
    const rectLabelList = rectList.objects.map((item, index) => {
      const idx = store.colorSet.indexOf(historyLabel[index].label)

      // 中心点和宽高
      return {
        color: store.colorList[idx],
        label_name: historyLabel[index].label,
        x: (item.left + 0.5 * item.width) / imgWrapWidth,
        y: (item.top + 0.5 * item.height) / imgWrapHeight,
        w: item.width / imgWrapWidth,
        h: item.height / imgWrapHeight
      }
    })
    const requetData = {
      img_name: store.currentMarkedInfo.img_detail,
      base64_img: imgData,
      width: imgWidth,
      height: imgHeight,
      label_list: rectLabelList
    }
    /*参数在传递形式写这篇文章的时候还没有确定,这里是只是一个 ajax 请求*/
    await store.fetchToImgMark(requetData)
    store.changeCurrentMarkedInfo({})
    store.changeModelShow(false)
    if (store.currentMarkTab === 1) {
      store.setUnMarkedQuery({
        page: 1
      })
      store.fetchUnMarkedList()
    } else {
      store.setMarkedQuery({
        page: 1
      })
      store.fetchMarkedList()
    }
  }

3. 需要进行框选的store文件如下:


import { observable, computed } from 'mobx'
import { http } from 'libs'
import { message } from 'antd'
import history from 'libs/history'

class Store {
  @observable isModelShow = false
  @observable currentMarkedInfo = {}
  @observable labelList = {} // 已经注释过的标签缓存对象
  @observable isMarking = false // 是否在标注中
  @observable eidtContentInfo = {} // {type: delete/confirm, value: 编辑的内容}
  @observable currentEditRectId = '' // 当前编辑的矩形框id
  @observable colorList = [
    "#FFC353", // 音速热床黄
    "#0000FF", // 纯蓝
    "#FC8746", // 音速--
    "#00F4C3", // 音速--
    "#00B7EE", // 音速--
    "#238E23", // 音速--
    "#C71585", // 适中的紫罗兰红色
    "#EA5F61", // 音速红色
    "#5F9EA0", // 军校蓝
    "#00FFFF", // 青色
    "#FFA500", // 橙色
    "#FF8C00", // 深橙色
    "#FF0000", // 纯红
    "#FF1493", // 深粉色
    "#4B0082", // 靛青
    "#6A5ACD", // 板岩暗蓝灰色
    "#1E90FF", //	道奇蓝
    "#F0E68C", // 卡其布
    "#8B008B", // 深洋红色
    "#8B4513", // 马鞍棕色
    "#D2B48C", // 晒黑
    "#F5DEB3", // 小麦色
    "#808000", // 橄榄
    "#556B2F", // 橄榄土褐色
    "#90EE90", // 淡绿色
    "#008B8B", // 深青色
  ]
  @observable colorSet = []

  changeIsOperatedUnMarked = (val) => {
    this.isOperatedUnMarked = val
  }

  changeUnMarkedPage = (page) => {
    this.unMarkedQuery.page = page
  }

  changeMarkedPage = (page) => {
    this.markedQuery.page = page
  }

  changeCurrentMarkedInfo = (markedInfo) => {
    this.currentMarkedInfo = markedInfo
  }

  changeModelShow = (val) => {
    this.isModelShow = val
  }

  changeIsMarking = (val) => [    this.isMarking = val  ]

  changeLabelList = (opt ,val) => {
    switch (opt) {
      case 'push':
        this.labelList[val.label] ? this.labelList[val.label].push({...val}) : this.labelList[val.label] = [{...val}]
        if (this.colorSet.indexOf(val.label) === -1) {
          this.colorSet.push(val.label)
        }
        break
      case 'pop':
        if (this.labelList[val.label]) {
          this.labelList[val.label].forEach((item, index) => {
            if (item.opt_id === val.opt_id) {
              // 处理颜色对应
              if (JSON.parse(JSON.stringify(this.labelList[val.label])).length === 1) {
                const currentIndex = this.colorSet.indexOf(val.label)
                this.colorSet.splice(currentIndex, currentIndex + 1)
              }
              this.labelList[val.label].splice(index, index + 1)
            }
          })
        }
        break
      case 'delete':
        this.labelList = {}
        this.colorSet = []
        break
      default:
    }
  }
  
}

export default new Store()

4. 用到的labelRect.js如下:

import { fabric } from 'fabric'
const LabeledRect = fabric.util.createClass(fabric.Rect, {
  type: 'labeledRect',
  // initialize can be of type function(options) or function(property, options), like for text.
  // no other signatures allowed.
  initialize: function(options) {
    options || (options = { });
    this.callSuper('initialize', options);
    this.set('label', options.label || '')
  },

  toObject: function() {
    return fabric.util.object.extend(this.callSuper('toObject'), {
      label: this.get('label')
    })
  },

  _render: function(ctx, options) {
    this.callSuper('_render', ctx)
    ctx.font = '18px Helvetica'
    ctx.fillStyle = this.fontColor
    ctx.fillText(this.label, -this.width/2, -this.height/2 - 5)
  }
})
export default LabeledRect

最终效果

企业微信截图_16887208709847.png

主要实现思路

  1. 先根据图片尺寸绘制fabric画布;
  2. 通过fabric创建矩形对象;
  3. 创建完矩形对象之后给矩形对象编辑一个标签,自定义,这里是(New);
  4. 编辑确认后再擦除当前绘制的矩形框,绘制带标签的矩形框;
  5. 注意管理好矩形框列表和标签列表,一一对应;
  6. 最后转化成自己想要的json对象;