跳至主要內容

图片批注

njrpracticefrontend图片批注大约 7 分钟约 2127 字

实现图片批注有两种方案,一种是通过 SVG 绘制,一种是通过 Canvas 绘制。以下是两者对比:

特点CanvasSVG
控制级别像素级控制矢量图形级别控制
编辑操作难以直接编辑已绘制的元素易于访问、操作和编辑 DOM 中的元素
图形类型更适合复杂绘图和动画适合简单图形和图标
性能处理大量图形和复杂交互时性能较好处理大规模动态变化时性能较差
可保存状态绘制后的图像可以保存为图片图像以 XML 格式保存,易于修改和存储
适用场景复杂交互和动态绘制静态或少量动态变化的图形

综上,选择使用 Canvas 实现图片批注。

加载图片

实现图片批注的第一步是加载图片,这里使用 FileReader 读取图片文件,然后将图片绘制到 Canvas 上。

这里需要注意的是图片绘制位置,如果图片的宽高比和容器的宽高比不一致,需要根据图片和容器的比例调整绘制位置和大小。

注意

这里需要存储当前图片,以便后续绘制批注时使用。

const fillImage = (file) => {
  const reader = new FileReader()

  reader.onload = (e) => {
    const img = new Image()
    // 保存图片副本
    imgRef.current = img
    img.onload = () => {
      drawImageInCanvas(img)
    }
    img.src = e.target.result
  }

  if (file) {
    reader.readAsDataURL(file)
  }
}

const drawImageInCanvas = (img) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef

  if (!canvas || !wrap) return

  const ctx = canvas.getContext('2d')

  const imgWidth = img.width
  const imgHeight = img.height
  const wrapWidth = wrap.offsetWidth
  const wrapHeight = wrap.offsetHeight

  // 如果图片的宽高都小于容器的宽高,则保留图片原始尺寸
  if (imgWidth <= wrapWidth && imgHeight <= wrapHeight) {
    canvas.width = imgWidth
    canvas.height = imgHeight
  } else {
    canvas.width = wrapWidth
    canvas.height = wrapHeight

    const imgRatio = imgWidth / imgHeight
    const wrapRatio = wrapWidth / wrapHeight

    // 根据图片和容器的比例关系进行调整
    if (imgRatio >= wrapRatio) {
      canvas.height = wrapWidth / imgRatio
    } else {
      canvas.width = wrapHeight * imgRatio
    }
  }

  // 绘制图像到 Canvas
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}

绘制批注

定义 Annotation 类型,用于描述批注的类型、颜色和坐标点。目前 type 只支持 linerectangle 两种类型,points 为坐标点数组,数组长度为 2,分别表示起点和终点。

interface Annotation {
  type: 'line' | 'rectangle'
  color: string
  points: Point[]
}

interface Point {
  x: number
  y: number
}

实现绘制批注的方法 drawAnnotations,遍历 annotations 数组,根据 type 绘制批注。

同时需要加入一个 annotationMode 状态,用于表示当前绘制的批注类型,当 annotationMode 变化时,重新绘制批注。

注意

下面两个代码是关键,不能省去,否则在绘制时会出现拖影及图片消失问题。

// 绘制前清空 Canvas
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height)
// 重新绘制图片
drawImageInCanvas(imgRef.current)
const drawAnnotations = () => {
  if (!annotationMode) return

  const ctx = canvasRef.current.getContext('2d')
  // 绘制前清空 Canvas
  ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height)
  // 重新绘制图片
  drawImageInCanvas(imgRef.current)

  annotations.forEach((annotation) => {
    ctx.strokeStyle = annotation.color
    // 根据 annotation.type 绘制批注
    switch (annotation.type) {
      case 'line':
        // 绘制线条
        ctx.beginPath()
        ctx.moveTo(annotation.points[0].x, annotation.points[0].y)
        ctx.lineTo(annotation.points[1].x, annotation.points[1].y)
        ctx.stroke()
        break
      case 'rectangle':
        // 绘制矩形框
        ctx.strokeRect(
          annotation.points[0].x,
          annotation.points[0].y,
          annotation.points[1].x - annotation.points[0].x,
          annotation.points[1].y - annotation.points[0].y
        )
        break
      // 其他类型的批注...
      default:
        break
    }
  })
}

useEffect(() => {
  drawAnnotations()
}, [annotations, annotationMode])

记录起点终点

定义 isDrawingstartPointendPoint 状态,用于记录是否正在绘制、起点和终点坐标。

mousedown 事件中记录起点信息,在 mousemove 事件中记录终点信息,然后在 mouseup 事件中将批注信息添加到 annotations 数组中。

注意

mousemove 事件中需要重复绘制批注信息。

const [isDrawing, setIsDrawing] = useState(false) // 是否正在绘制
const [startPoint, setStartPoint] = useState({ x: 0, y: 0 }) // 绘制起点
const [endPoint, setEndPoint] = useState({ x: 0, y: 0 }) // 绘制终点

// 鼠标点击事件处理程序
const handleMouseDown = (e) => {
  if (!annotationMode) {
    setIsDrawing(false)
    return
  }

  setIsDrawing(true)
  const canvas = canvasRef.current
  const rect = canvas.getBoundingClientRect()

  setStartPoint({
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  })
}

// 鼠标移动事件处理程序
const handleMouseMove = (e) => {
  if (!annotationMode) {
    setIsDrawing(false)
    return
  }
  if (!isDrawing) return

  const canvas = canvasRef.current
  const ctx = canvas.getContext('2d')
  const rect = canvas.getBoundingClientRect()
  const currentX = e.clientX - rect.left
  const currentY = e.clientY - rect.top

  drawAnnotations()

  // 绘制当前批注的预览效果
  switch (annotationMode) {
    case 'line':
      ctx.beginPath()
      ctx.moveTo(startPoint.x, startPoint.y)
      ctx.lineTo(currentX, currentY)
      ctx.stroke()
      break
    case 'rectangle':
      const width = currentX - startPoint.x
      const height = currentY - startPoint.y
      ctx.strokeRect(startPoint.x, startPoint.y, width, height)
      break
    // 其他类型的批注...
    default:
      break
  }

  // 实时更新终点坐标
  setEndPoint({ x: currentX, y: currentY })
}

// 鼠标释放事件处理程序
const handleMouseUp = (e) => {
  if (!annotationMode) {
    setIsDrawing(false)
    return
  }

  if (isDrawing) {
    setIsDrawing(false)

    // 更新终点坐标
    const canvas = canvasRef.current
    const rect = canvas.getBoundingClientRect()
    const mouseX = e.clientX - rect.left
    const mouseY = e.clientY - rect.top
    setEndPoint({ x: mouseX, y: mouseY })

    // 将绘制的信息添加到 annotations 状态中
    setAnnotations([
      ...annotations,
      {
        type: annotationMode,
        color: 'red',
        points: [startPoint, { x: mouseX, y: mouseY }]
        // 可以添加其他所需信息
      }
    ])
  }
}

// 添加事件监听器
useEffect(() => {
  const { current: canvas } = canvasRef
  if (!canvasRef.current) return
  canvas.addEventListener('mousedown', handleMouseDown)
  canvas.addEventListener('mousemove', handleMouseMove)
  canvas.addEventListener('mouseup', handleMouseUp)
  return () => {
    canvas.removeEventListener('mousedown', handleMouseDown)
    canvas.removeEventListener('mousemove', handleMouseMove)
    canvas.removeEventListener('mouseup', handleMouseUp)
  }
}, [annotations, annotationMode, isDrawing])

图片缩放

使用 zoomFactorRef 保存实时缩放比例(使用 state 会拿不到最新值)。监听缩放系数,设置 canvas 的样式实现缩放。

const zoomFactorRef = useRef(1)

// 监听鼠标滚轮事件
const handleMouseWheel = (e) => {
  e.preventDefault()
  const { deltaY } = e
  const newScale =
    deltaY > 0
      ? (zoomFactorRef.current * 10 - 0.1 * 10) / 10
      : (zoomFactorRef.current * 10 + 0.1 * 10) / 10
  if (newScale < 0.1 || newScale > 2) return
  zoomFactorRef.current = newScale
}

//监听缩放画布
useEffect(() => {
  const { current: canvas } = canvasRef
  canvas &&
    (canvas.style.transform = `scale(${zoomFactorRef.current},${zoomFactorRef.current})`)
}, [zoomFactorRef.current])

最终代码

最终代码如下,还存在优化的空间,比如批注历史信息、撤销、重做等。

图片批注
const { useState, useRef, useEffect } = React

export default () => {
  const wrapRef = useRef(null)
  const canvasRef = useRef(null)

  const [annotations, setAnnotations] = useState([])
  const [annotationMode, setAnnotationMode] = useState()
  const imgRef = useRef(null)

  const [isDrawing, setIsDrawing] = useState(false) // 是否正在绘制
  const [startPoint, setStartPoint] = useState({ x: 0, y: 0 }) // 绘制起点
  const [endPoint, setEndPoint] = useState({ x: 0, y: 0 }) // 绘制终点
  const [zoomFactor, setZoomFactor] = useState(1) // 缩放系数
  const zoomFactorRef = useRef(1) // 缩放系数

  const fillImage = (file) => {
    const reader = new FileReader()

    reader.onload = (e) => {
      const img = new Image()
      imgRef.current = img
      img.onload = () => {
        drawImageInCanvas(img)
      }
      img.src = e.target.result
    }

    if (file) {
      reader.readAsDataURL(file)
    }
  }

  const drawImageInCanvas = (img) => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef

    if (!canvas || !wrap) return

    const ctx = canvas.getContext('2d')

    const imgWidth = img.width
    const imgHeight = img.height
    const wrapWidth = wrap.offsetWidth
    const wrapHeight = wrap.offsetHeight

    // 如果图片的宽高都小于容器的宽高,则保留图片原始尺寸
    if (imgWidth <= wrapWidth && imgHeight <= wrapHeight) {
      canvas.width = imgWidth
      canvas.height = imgHeight
    } else {
      canvas.width = wrapWidth
      canvas.height = wrapHeight

      const imgRatio = imgWidth / imgHeight
      const wrapRatio = wrapWidth / wrapHeight

      // 根据图片和容器的比例关系进行调整
      if (imgRatio >= wrapRatio) {
        canvas.height = wrapWidth / imgRatio
      } else {
        canvas.width = wrapHeight * imgRatio
      }
    }

    // 绘制图像到 Canvas
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
  }

  const handleImageChange = (e) => {
    fillImage(e.target.files[0])
    setAnnotations([])
  }

  const drawAnnotations = () => {
    if (!annotationMode) return

    const ctx = canvasRef.current.getContext('2d')
    ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height)
    drawImageInCanvas(imgRef.current)

    annotations.forEach((annotation) => {
      ctx.strokeStyle = annotation.color
      // 根据 annotation.type 绘制批注
      switch (annotation.type) {
        case 'line':
          // 绘制线条
          ctx.beginPath()
          ctx.moveTo(annotation.points[0].x, annotation.points[0].y)
          ctx.lineTo(annotation.points[1].x, annotation.points[1].y)
          ctx.stroke()
          break
        case 'rectangle':
          // 绘制矩形框
          ctx.strokeRect(
            annotation.points[0].x,
            annotation.points[0].y,
            annotation.points[1].x - annotation.points[0].x,
            annotation.points[1].y - annotation.points[0].y
          )
          break
        // 其他类型的批注...
        default:
          break
      }
    })
  }

  useEffect(() => {
    drawAnnotations()
  }, [annotations, annotationMode])

  // 鼠标点击事件处理程序
  const handleMouseDown = (e) => {
    if (!annotationMode) {
      setIsDrawing(false)
      return
    }

    setIsDrawing(true)
    const canvas = canvasRef.current
    const rect = canvas.getBoundingClientRect()

    setStartPoint({
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    })
  }

  // 鼠标移动事件处理程序
  const handleMouseMove = (e) => {
    if (!annotationMode) {
      setIsDrawing(false)
      return
    }
    if (!isDrawing) return

    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    const rect = canvas.getBoundingClientRect()
    const currentX = e.clientX - rect.left
    const currentY = e.clientY - rect.top

    drawAnnotations()

    // 绘制当前批注的预览效果
    switch (annotationMode) {
      case 'line':
        ctx.beginPath()
        ctx.moveTo(startPoint.x, startPoint.y)
        ctx.lineTo(currentX, currentY)
        ctx.stroke()
        break
      case 'rectangle':
        const width = currentX - startPoint.x
        const height = currentY - startPoint.y
        ctx.strokeRect(startPoint.x, startPoint.y, width, height)
        break
      // 其他类型的批注...
      default:
        break
    }

    // 实时更新终点坐标
    setEndPoint({ x: currentX, y: currentY })
  }

  // 鼠标释放事件处理程序
  const handleMouseUp = (e) => {
    if (!annotationMode) {
      setIsDrawing(false)
      return
    }

    if (isDrawing) {
      setIsDrawing(false)

      // 更新终点坐标
      const canvas = canvasRef.current
      const rect = canvas.getBoundingClientRect()
      const mouseX = e.clientX - rect.left
      const mouseY = e.clientY - rect.top
      setEndPoint({ x: mouseX, y: mouseY })

      // 将绘制的信息添加到 annotations 状态中
      setAnnotations([
        ...annotations,
        {
          type: annotationMode,
          color: 'red',
          points: [startPoint, { x: mouseX, y: mouseY }]
          // 可以添加其他所需信息
        }
      ])
    }
  }

  // 监听鼠标滚轮事件
  const handleMouseWheel = (e) => {
    e.preventDefault()
    const { deltaY } = e
    const newScale =
      deltaY > 0
        ? (zoomFactorRef.current * 10 - 0.1 * 10) / 10
        : (zoomFactorRef.current * 10 + 0.1 * 10) / 10
    if (newScale < 0.1 || newScale > 2) return
    setZoomFactor(newScale)
    zoomFactorRef.current = newScale
  }

  //监听缩放画布
  useEffect(() => {
    const { current: canvas } = canvasRef
    canvas &&
      (canvas.style.transform = `scale(${zoomFactorRef.current},${zoomFactorRef.current})`)
  }, [zoomFactorRef.current])

  useEffect(() => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef
    if (!canvasRef.current) return
    canvas.addEventListener('mousedown', handleMouseDown)
    canvas.addEventListener('mousemove', handleMouseMove)
    canvas.addEventListener('mouseup', handleMouseUp)
    wrap.addEventListener('wheel', handleMouseWheel)

    return () => {
      canvas.removeEventListener('mousedown', handleMouseDown)
      canvas.removeEventListener('mousemove', handleMouseMove)
      canvas.removeEventListener('mouseup', handleMouseUp)
      wrap.removeEventListener('wheel', handleMouseWheel)
    }
  }, [annotations, annotationMode, isDrawing])

  const handleAnnotationModeChange = (e) => {
    console.log(e.target.value ? e.target.value : undefined, '批注模式')
    setAnnotationMode(e.target.value ? e.target.value : undefined)
  }

  return (
    <>
      <div
        ref={wrapRef}
        style={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          height: '300px',
          width: '500px',
          backgroundColor: '#edeef0'
        }}
      >
        <canvas ref={canvasRef} />
      </div>
      <fieldset style={{ boxSizing: 'border-box', width: 500, marginTop: 10 }}>
        <legend>图片批注</legend>

        <div style={{ marginBottom: 10 }}>
          <input
            type="radio"
            id="none"
            name="type"
            value={undefined}
            checked={annotationMode === undefined}
            onChange={handleAnnotationModeChange}
          />
          <label htmlFor="none"></label>

          <input
            type="radio"
            id="line"
            name="type"
            value="line"
            checked={annotationMode === 'line'}
            onChange={handleAnnotationModeChange}
          />
          <label htmlFor="line">线</label>

          <input
            type="radio"
            id="rectangle"
            name="type"
            value="rectangle"
            checked={annotationMode === 'rectangle'}
            onChange={handleAnnotationModeChange}
          />
          <label htmlFor="rectangle"></label>
        </div>

        <input type="file" accept="image/*" onChange={handleImageChange} />
      </fieldset>
    </>
  )
}