react项目中canvas之画形状(圆形,椭圆形,方形)

组件DrawShape.jsx如下:

import React, { Component } from 'react'
// import ClassNames from 'classnames'
import PropTypes from 'prop-types'
import _ from 'lodash'
import './index.less'

class DrawShape extends Component {
  static propTypes = {
    style: PropTypes.object,
     PropTypes.number,
    height: PropTypes.number,
    onAddShape: PropTypes.func,
    type: PropTypes.string,
    shapeWidth: PropTypes.number,
    color: PropTypes.string,
  }

  static defaultProps = {
    style: {},
     1000,
    height: 1000,
    onAddShape: _.noop,
    type: 'square',
    shapeWidth: 2,
    color: '#ee4f4f',
  }

  state = {
  }

  componentDidMount() {
    const { canvasElem } = this
    this.writingCtx = canvasElem.getContext('2d')

    if (canvasElem) {
      canvasElem.addEventListener('mousedown', this.handleMouseDown)
      canvasElem.addEventListener('mousemove', this.handleMouseMove)
      canvasElem.addEventListener('mouseup', this.handleMouseUp)
      canvasElem.addEventListener('mouseout', this.handleMouseOut)
    }
  }

  componentWillUnmount() {
    const { canvasElem } = this
    if (canvasElem) {
      canvasElem.removeEventListener('mousedown', this.handleMouseDown)
      canvasElem.removeEventListener('mousemove', this.handleMouseMove)
      canvasElem.removeEventListener('mouseup', this.handleMouseUp)
      canvasElem.removeEventListener('mouseout', this.handleMouseOut)
    }
  }

  handleMouseDown = (e) => {
    this.isDrawingShape = true
    if (this.canvasElem !== undefined) {
      this.coordinateScaleX = this.canvasElem.clientWidth / this.props.width
      this.coordinateScaleY = this.canvasElem.clientHeight / this.props.height
    }
    this.writingCtx.lineWidth = this.props.shapeWidth / this.coordinateScaleX
    this.writingCtx.strokeStyle = this.props.color
    const {
      offsetX,
      offsetY,
    } = e
    this.mouseDownX = offsetX
    this.mouseDownY = offsetY
  }

  handleMouseMove = (e) => {
    if (this.isDrawingShape === true) {
      switch (this.props.type) {
        case 'square':
          this.drawRect(e)
          break
        case 'circle':
          this.drawEllipse(e)
          break
      }
    }
  }

  handleMouseUp = () => {
    this.isDrawingShape = false
    this.props.onAddShape({
      type: this.props.type,
      color: this.props.color,
       this.squeezePathX(this.props.shapeWidth / this.coordinateScaleX),
      positionX: this.squeezePathX(this.positionX),
      positionY: this.squeezePathY(this.positionY),
      dataX: this.squeezePathX(this.dataX),
      dataY: this.squeezePathY(this.dataY),
    })
    this.writingCtx.clearRect(0, 0, this.props.width, this.props.height)
  }

  handleMouseOut = (e) => {
    this.handleMouseUp(e)
  }

  drawRect = (e) => {
    const {
      offsetX,
      offsetY,
    } = e
    this.positionX = this.mouseDownX / this.coordinateScaleX
    this.positionY = this.mouseDownY / this.coordinateScaleY
    this.dataX = (offsetX - this.mouseDownX) / this.coordinateScaleX
    this.dataY = (offsetY - this.mouseDownY) / this.coordinateScaleY
    this.writingCtx.clearRect(0, 0, this.props.width, this.props.height)
    this.writingCtx.beginPath()
    this.writingCtx.strokeRect(this.positionX, this.positionY, this.dataX, this.dataY)
  }

  drawCircle = (e) => {
    const {
      offsetX,
      offsetY,
    } = e
    const rx = (offsetX - this.mouseDownX) / 2
    const ry = (offsetY - this.mouseDownY) / 2
    const radius = Math.sqrt(rx * rx + ry * ry)
    const centreX = rx + this.mouseDownX
    const centreY = ry + this.mouseDownY
    this.writingCtx.clearRect(0, 0, this.props.width, this.props.height)
    this.writingCtx.beginPath()
    this.writingCtx.arc(centreX / this.coordinateScaleX, centreY / this.coordinateScaleY, radius, 0, Math.PI * 2)
    this.writingCtx.stroke()
  }

  drawEllipse = (e) => {
    const {
      offsetX,
      offsetY,
    } = e
    const radiusX = Math.abs(offsetX - this.mouseDownX) / 2
    const radiusY = Math.abs(offsetY - this.mouseDownY) / 2
    const centreX = offsetX >= this.mouseDownX ? (radiusX + this.mouseDownX) : (radiusX + offsetX)
    const centreY = offsetY >= this.mouseDownY ? (radiusY + this.mouseDownY) : (radiusY + offsetY)
    this.positionX = centreX / this.coordinateScaleX
    this.positionY = centreY / this.coordinateScaleY
    this.dataX = radiusX / this.coordinateScaleX
    this.dataY = radiusY / this.coordinateScaleY
    this.writingCtx.clearRect(0, 0, this.props.width, this.props.height)
    this.writingCtx.beginPath()
    this.writingCtx.ellipse(this.positionX, this.positionY, this.dataX, this.dataY, 0, 0, Math.PI * 2)
    this.writingCtx.stroke()
  }

  // 将需要存储的数据根据canvas分辨率压缩至[0,1]之间的数值
  squeezePathX(value) {
    const {
      width,
    } = this.props
    return value / width
  }

  squeezePathY(value) {
    const {
      height,
    } = this.props
    return value / height
  }

  canvasElem

  writingCtx

  isDrawingShape = false

  coordinateScaleX

  coordinateScaleY

  mouseDownX = 0 // mousedown时的横坐标

  mouseDownY = 0 // mousedown时的纵坐标

  positionX // 存储形状数据的x

  positionY // 存储形状数据的y

  dataX // 存储形状数据的宽

  dataY // 存储形状数据的高

  render() {
    const {
      width,
      height,
      style,
    } = this.props

    return (
      <canvas
        width={width}
        height={height}
        style={style}
        className="draw-shape-canvas-component-wrap"
        ref={(r) => { this.canvasElem = r }}
      />
    )
  }
}

export default DrawShape

组件DrawShape.jsx对应的less如下:

.draw-shape-canvas-component-wrap {
  width: 100%;
  cursor: url('~ROOT/shared/assets/image/vn-shape-cursor-35-35.png') 22 22, nw-resize;
}

组件DrawShape.jsx对应的高阶组件DrawShape.js如下:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { observer } from 'mobx-react'

import { DrawShape } from '@dby-h5-clients/pc-1vn-components'

import localStore from '../../store/localStore'
import remoteStore from '../../store/remoteStore'

@observer
class DrawShapeWrapper extends Component {
  static propTypes = {
    id: PropTypes.string.isRequired,
    style: PropTypes.object,
  }

  static defaultProps = {
    style: {},
  }

  handleAddShape = (shapeInfo) => {
    remoteStore.getMediaResourceById(this.props.id).state.addShape({
      type: shapeInfo.type,
      color: shapeInfo.color,
       shapeInfo.width,
      position: JSON.stringify([shapeInfo.positionX, shapeInfo.positionY]),
      data: JSON.stringify([shapeInfo.dataX, shapeInfo.dataY]),
    })
  }

  render() {
    const {
      slideRenderWidth,
      slideRenderHeight,
    } = remoteStore.getMediaResourceById(this.props.id).state

    const {
      currentTask,
      drawShapeConfig,
    } = localStore.pencilBoxInfo

    if (currentTask !== 'drawShape') {
      return null
    }

    return (
      <DrawShape
        style={this.props.style}
        onAddShape={this.handleAddShape}
        height={slideRenderHeight}
        width={slideRenderWidth}
        type={drawShapeConfig.type}
        shapeWidth={drawShapeConfig.width}
        color={drawShapeConfig.color}
      />
    )
  }
}

export default DrawShapeWrapper

如上就能实现本地画形状了,但以上的逻辑是本地画完就保存到远端remote数据里,本地画的形状清除了。此适用于老师端和学生端的场景。那么在remote组件中我们要遍历remoteStore中的数据进而展示。代码如下:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import assign from 'object-assign'
import { autorun } from 'mobx'
import _ from 'lodash'
import { observer } from 'mobx-react'

import {
  drawLine,
  clearPath,
  drawWrapText,
  drawShape,
} from '~/shared/utils/drawWritings'

@observer
class RemoteWritingCanvas extends Component {
  static propTypes = {
    style: PropTypes.object,
     PropTypes.number,
    height: PropTypes.number,
    remoteWritings: PropTypes.oneOfType([
      PropTypes.arrayOf(PropTypes.shape({
        type: PropTypes.string,
        color: PropTypes.string,
        lineCap: PropTypes.string,
        lineJoin: PropTypes.string,
        points: PropTypes.string, // JSON 数组
         PropTypes.number,
      })),
      PropTypes.arrayOf(PropTypes.shape({
        type: PropTypes.string,
        content: PropTypes.string,
        color: PropTypes.string,
        position: PropTypes.string,
        fontSize: PropTypes.number,
      })),
    ]),
  }

  static defaultProps = {
    style: {},
     1000,
    height: 1000,
    remoteWritings: [],
  }


  componentDidMount() {
    this.writingCtx = this.canvasElem.getContext('2d')

    this.cancelAutoRuns = [
      autorun(this.drawWritingsAutoRun, { name: 'auto draw writings' }),
    ]

    // resize 后 恢复划线
    this.resizeObserver = new ResizeObserver(() => {
      this.drawWritingsAutoRun()
    })

    this.resizeObserver.observe(this.canvasElem)
  }

  componentWillUnmount() {
    this.resizeObserver.unobserve(this.canvasElem)
    _.forEach(this.cancelAutoRuns, f => f())
  }

  canvasElem

  writingCtx

  drawWritingsAutoRun = () => {
    // todo 性能优化,过滤已画划线
    this.writingCtx.clearRect(0, 0, this.props.width, this.props.height)
    _.map(this.props.remoteWritings, (writing) => {
      if (['markPen', 'eraser', 'pencil'].indexOf(writing.type) > -1) {
        const {
          type,
          color,
          lineCap,
          lineJoin,
          points,
          width,
        } = writing

        const canvasWidth = this.props.width
        switch (type) {
          case 'eraser':
            clearPath(this.writingCtx, this.recoverPath(JSON.parse(points)), width * canvasWidth)
            break
          case 'pencil': // 同 markPen
          case 'markPen':
            drawLine(this.writingCtx, this.recoverPath(JSON.parse(points)), color, width * canvasWidth, lineJoin, lineCap)
            break
        }
      }
      if (writing.type === 'text') {
        const {
          color,
          content,
          fontSize,
          position,
        } = writing

        const [x, y] = this.recoverPath(JSON.parse(position))

        drawWrapText({
          canvasContext: this.writingCtx,
          text: content,
          color,
          fontSize: fontSize * this.props.width,
          x,
          y,
        })
      }
      if (['square', 'circle'].indexOf(writing.type) > -1) {
        const {
          type,
          color,
          position,
          data,
        } = writing
        const width = this.recoverPathX(writing.width)
        let [positionX, positionY] = JSON.parse(position)
        let [dataX, dataY] = JSON.parse(data)
        positionX = this.recoverPathX(positionX)
        positionY = this.recoverPathY(positionY)
        dataX = this.recoverPathX(dataX)
        dataY = this.recoverPathY(dataY)
        drawShape({
          writingCtx: this.writingCtx,
          type,
          color,
          width,
          positionX,
          positionY,
          dataX,
          dataY,
        })
      }
    })
  }

  // 将[0,1]之间的坐标点根据canvas分辨率进行缩放
  recoverPath(path) {
    const {
      width,
      height,
    } = this.props
    return _.map(path, (val, i) => (i % 2 === 0 ? val * width : val * height))
  }

  recoverPathX(value) {
    const {
      width,
    } = this.props
    return value * width
  }

  recoverPathY(value) {
    const {
      height,
    } = this.props
    return value * height
  }

  render() {
    const {
      width,
      height,
      style,
    } = this.props
    const wrapStyles = assign({}, style, {
       '100%',
    })

    return (
      <canvas
        className="remote-writing-canvas-component-wrap"
        width={width}
        height={height}
        style={wrapStyles}
        ref={(r) => { this.canvasElem = r }}
      />
    )
  }
}

export default RemoteWritingCanvas

其中用到的画图的工具函数来自于drawWritings:内部代码如下:

/**
 * 画一整条线
 * @param ctx
 * @param points
 * @param color
 * @param width
 * @param lineJoin
 * @param lineCap
 */
export function drawLine(ctx, points, color, width, lineJoin = 'miter', lineCap = 'round') {
  if (points.length >= 2) {
    ctx.lineWidth = width
    ctx.strokeStyle = color
    ctx.lineCap = lineCap
    ctx.lineJoin = lineJoin
    ctx.beginPath()
    if (points.length === 2) {
      ctx.arc(points[0], points[1], width, 0, Math.PI * 2)
    } else {
      if (points.length > 4) {
        ctx.moveTo(points[0], points[1])
        for (let i = 2; i < points.length - 4; i += 2) {
          ctx.quadraticCurveTo(points[i], points[i + 1], (points[i] + points[i + 2]) / 2, (points[i + 1] + points[i + 3]) / 2)
        }
        ctx.lineTo(points[points.length - 2], points[points.length - 1])
      } else {
        ctx.moveTo(points[0], points[1])
        ctx.lineTo(points[2], points[3])
      }
    }
    ctx.stroke()
    ctx.closePath()
  }
}

/**
 * 画一个点,根据之前已经存在的线做优化
 * @param ctx
 * @param point
 * @param prevPoints
 * @param color
 * @param width
 * @param lineJoin
 * @param lineCap
 */
export function drawPoint(ctx, point, prevPoints, color, width, lineJoin = 'miter', lineCap = 'round') {
  ctx.lineWidth = width
  ctx.strokeStyle = color
  ctx.lineCap = lineCap
  ctx.lineJoin = lineJoin
  const prevPointsLength = prevPoints.length
  if (prevPointsLength === 0) { // 画一个点
    ctx.arc(point[0], point[1], width, 0, Math.PI * 2)
  } else if (prevPointsLength === 2) { // 开始划线
    ctx.beginPath()
    ctx.moveTo(...point)
  } else { // 继续划线
    ctx.lineTo(...point)
  }
  ctx.stroke()
}

/**
 * 画一组线,支持半透明划线,每次更新会清除所有划线后重画一下
 * @param ctx
 * @param lines 二维数组,元素是划线点组成的数组, eg [[1,2,3,4],[1,2,3,4,5,6],...]
 * @param color
 * @param width
 * @param lineJoin
 * @param lineCap
 * @param canvasWith
 * @param canvasHeight
 */
export function drawOpacityLines(ctx, lines, canvasWith = 10000, canvasHeight = 10000) {
  ctx.clearRect(0, 0, canvasWith, canvasHeight)

  for (let i = 0; i < lines.length; i += 1) {
    const {
      points,
      color,
      width,
      lineJoin,
      lineCap,
    } = lines[i]
    const pointsLength = points.length

    if (pointsLength > 2) {
      ctx.strokeStyle = color
      ctx.lineCap = lineCap
      ctx.lineJoin = lineJoin
      ctx.lineWidth = width
      ctx.beginPath()

      if (pointsLength > 4) {
        ctx.moveTo(points[0], points[1])
        for (let j = 2; j < pointsLength - 4; j += 2) {
          ctx.quadraticCurveTo(points[j], points[j + 1], (points[j] + points[j + 2]) / 2, (points[j + 1] + points[j + 3]) / 2)
        }
        ctx.lineTo(points[pointsLength - 2], points[pointsLength - 1])
      } else {
        ctx.moveTo(points[0], points[1])
        ctx.lineTo(points[2], points[3])
      }

      ctx.stroke()
      ctx.closePath()
    }
  }
}

/**
 * 擦除路径
 * @param ctx
 * @param {Array} points
 * @param width
 */
export function clearPath(ctx, points, width) {
  const pointsLength = points.length
  if (pointsLength > 0) {
    ctx.beginPath()
    ctx.globalCompositeOperation = 'destination-out'

    if (pointsLength === 2) { // 一个点
      ctx.arc(points[0], points[1], width / 2, 0, 2 * Math.PI)
      ctx.fill()
    } else if (pointsLength >= 4) {
      ctx.lineWidth = width
      ctx.lineJoin = 'round'
      ctx.lineCap = 'round'
      ctx.moveTo(points[0], points[1])
      for (let j = 2; j <= pointsLength - 2; j += 2) {
        ctx.lineTo(points[j], points[j + 1])
      }
      ctx.stroke()
    }
    ctx.closePath()
    ctx.globalCompositeOperation = 'source-over'
  }
}

/**
 * 写字
 * @param {object} textInfo
 * @param textInfo.canvasContext
 * @param textInfo.text
 * @param textInfo.color
 * @param textInfo.fontSize
 * @param textInfo.x
 * @param textInfo.y
 */
export function drawText(
  {
    canvasContext,
    text,
    color,
    fontSize,
    x,
    y,
  },
) {
  canvasContext.font = `normal normal ${fontSize}px Airal`
  canvasContext.fillStyle = color
  canvasContext.textBaseline = 'middle'
  canvasContext.fillText(text, x, y)
}

/**
 * 写字,超出canvas右侧边缘自动换行
 * @param {object} textInfo
 * @param textInfo.canvasContext
 * @param textInfo.text
 * @param textInfo.color
 * @param textInfo.fontSize
 * @param textInfo.x
 * @param textInfo.y
 */
export function drawWrapText(
  {
    canvasContext,
    text,
    color,
    fontSize,
    x,
    y,
  },
) {
  if (typeof text !== 'string' || typeof x !== 'number' || typeof y !== 'number') {
    return
  }
  const canvasWidth = canvasContext.canvas.width
  canvasContext.font = `normal normal ${fontSize}px sans-serif`
  canvasContext.fillStyle = color
  canvasContext.textBaseline = 'middle'

  // 字符分隔为数组
  const arrText = text.split('')
  let line = ''

  let calcY = y
  for (let n = 0; n < arrText.length; n += 1) {
    const testLine = line + arrText[n]
    const metrics = canvasContext.measureText(testLine)
    const testWidth = metrics.width
    if (testWidth > canvasWidth - x && n > 0) {
      canvasContext.fillText(line, x, calcY)
      line = arrText[n]
      calcY += fontSize
    } else {
      line = testLine
    }
  }
  canvasContext.fillText(line, x, calcY)
}

/**
 * 画形状
 * @param {object} shapeInfo
 * @param shapeInfo.writingCtx
 * @param shapeInfo.type
 * @param shapeInfo.color
 * @param shapeInfo.width
 * @param shapeInfo.positionX
 * @param shapeInfo.positionY
 * @param shapeInfo.dataX
 * @param shapeInfo.dataY
 */
export function drawShape(
  {
    writingCtx,
    type,
    color,
    width,
    positionX,
    positionY,
    dataX,
    dataY,
  },
) {
  writingCtx.lineWidth = width
  writingCtx.strokeStyle = color
  if (type === 'square') {
    writingCtx.beginPath()
    writingCtx.strokeRect(positionX, positionY, dataX, dataY)
  }
  if (type === 'circle') {
    writingCtx.beginPath()
    writingCtx.ellipse(positionX, positionY, dataX, dataY, 0, 0, Math.PI * 2)
    writingCtx.stroke()
  }
}

canvas 有两种宽高设置 :

1. 属性height、width,设置的是canvas的分辨率,即画布的坐标范围。如果 `canvasElem.height = 200; canvasElem.width = 400;` 其右下角对应坐标是(200, 400) 。

2. 样式style里面的height 和width,设置实际显示大小。如果同样是上面提到的canvasElem,style为`{ 100px; height: 100px}`, 监听canvasElem 的 mouseDown,点击右下角在event中获取到的鼠标位置坐标`(event.offsetX, event.offsetY)` 应该是`(100, 100)`。

将鼠标点击位置画到画布上需要进行一个坐标转换trans 使得`trans([100, 100]) == [200, 400]` `trans`对坐标做以下转换然后返回 - x * canvas横向最大坐标 / 显示宽度 - y * canvas纵向最大坐标 / 显示高度 参考代码 trans = ([x, y]) => { const scaleX = canvasElem.width / canvasElem.clientWidth const scaleY = canvasElem.height / canvasElem.clientHeight return [x * scaleX, y * scaleY] } 通常我们课件显示区域是固定大小的(4:3 或16:9),显示的课件大小和比例是不固定的,显示划线的canvas宽度占满课件显示区域,其分辨率是根据加载的课件图片的分辨率计算得来的,所以我们通常需要在划线时对坐标进行的转换。

小结:如果觉得以上太麻烦,只是想在本地实现画简单的直线、形状等等,可以参考这篇文章:https://blog.csdn.net/qq_31164127/article/details/72929871

原文地址:https://www.cnblogs.com/chenbeibei520/p/11096319.html