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