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,
    width: PropTypes.number,
    height: PropTypes.number,
    onAddShape: PropTypes.func,
    type: PropTypes.string,
    shapeWidth: PropTypes.number,
    color: PropTypes.string,
  }

  static defaultProps = {
    style: {},
    width: 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,
      width: 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,
      width: 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,
    width: 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 數組
        width: PropTypes.number,
      })),
      PropTypes.arrayOf(PropTypes.shape({
        type: PropTypes.string,
        content: PropTypes.string,
        color: PropTypes.string,
        position: PropTypes.string,
        fontSize: PropTypes.number,
      })),
    ]),
  }

  static defaultProps = {
    style: {},
    width: 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, {
      width: '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為`{width: 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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM