組件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
