canvas换图时候会闪烁_Canvas实现图片上标注、缩放、移动和保存历史状态,纯干货(附CSS 3变化公式)...

(给前端大学加星标,提升前端技能.)

作者:zhcxk1998

https://juejin.im/user/5d4304bdf265da03d15531dc

哈哈哈俺又来啦,这次带来的是canvas实现一些画布功能的文章,希望大家喜欢!这个css3变化公式可以适用于平常我们使用的transform属性或者是移动端我们缩放地图啊之类的都可以哟!

4e56d14a1005da2268b9ecb7d3e38f81.gif

前言

因为也是大三了,最近俺也在找实习,之前有一个自己的小项目:

https://github.com/zhcxk1998/School-Partners

面试官说可以往深层次思考一下,或许加一些新的功能来增加项目的难度,他提了几个建议,其中一个就是试卷在线批阅,老师可以在上面对作业进行批注,圈圈点点等俺当天晚上就开始研究这个东东哈哈哈,终于被我研究出来啦!

采用的是canvas绘制画笔,由css3的transform属性来进行平移与缩放,因为呢考虑到如果用canvas的drawImage或者scale等属性进行变化,生成出来的图片也会有影响,想着直接css3变化,canvas用来做画笔等功能。大佬们有何妙招,在评论区指点指点!

(希望大家可以留下宝贵的赞与star嘻嘻)

效果预览

634f0256ed8681eadbc3bc9791d927a5.gif

动图是放cdn的,如果访问不了,可以登录在线尝试尝试:http://test.algbb.cn/#/admin/content/mark-paper

公式推导

如果不想看公式如何推导,可以直接跳过看后面的具体实现~

1.坐标转换公式

转换公式介绍

其实一开始也是想在网上找一下有没有相关的资料,但是可惜找不到,所以就自己慢慢的推出来了。我就举一下横坐标的例子吧!

通用公式

这个公式是表示,通过公式来将鼠标按下的坐标转换为画布中的相对坐标,这一点尤为重要

(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX

参数解释

transformOrigin: transform变化的基点(通过这个属性来控制元素以哪里进行变化)downX: 鼠标按下的坐标(注意,用的时候需要减去容器左偏移距离,因为我们要的是相对于容器的坐标)scale: 缩放倍数,默认为1translateX: 平移的距离

推导过程

这个公式的话,其实就比较通用,可以用在别的利用到transform属性的场景,至于怎么推导的话,我是用的笨办法

具体的测试代码,放在文末,需要自取~

1. 先做出两个相同的元素,然后标记上坐标,并且设置容器属性overflow:hidden来隐藏溢出内容

791b15b695b7ecd6dc410cb531f81ffc.png

ok,现在就有两个一样的矩阵啦,我们为他标记上一些红点,然后我们对左边的进行css3的样式变化transform

矩形的宽高是360px * 360px的,我们定义一下他的变化属性,变化基点选择正中心,放大3倍

// csstransform-origin: 180px 180px;transform: scale(3, 3);

得到如下结果

927453d80d0828db2ab4ea6cddc02a23.png

ok,我们现在对比一下上面的结果,就会发现,放大3倍的时候,恰好是中间黑色方块占据了全部宽度。接下来我们就可以对这些点与原先没有进行变化(右边)的矩形进行对比就可以得到他们坐标的关系啦

2. 开始对两个坐标进行对比,然后推出公式

现在举一个简单的例子吧,例如我们算一下左上角的坐标(现在已经标记为黄色了)

8e58de81c9c881c107e15f3308eb48e9.png

其实我们其实就可以直接心算出来坐标的关系啦
(这里左边计算坐标的值是我们鼠标按下的坐标)
(这里左边计算坐标的值是我们鼠标按下的坐标)
(这里左边计算坐标的值是我们鼠标按下的坐标)

  • 因为宽高是360px,所以分成3等份,每份宽度是120px

  • 因为变化之后容器的宽高是不变的,变化的只有矩形本身

  • 我们可以得出左边的黄色标记坐标是x:120 y:0,右边的黄色标记为x:160 y:120(这个其实肉眼看应该就能看出来了,实在不行可以用纸笔算一算)

这个坐标可能有点特殊,我们再换几个来计算计算(根据特殊推一般)

20fcf9b6ea28f3ceaf6ce0fb5207df1f.png
  • 蓝色标记:左边:x:120 y:120,右边:x: 160 y:160

  • 绿色标记:左边:x: 240 y:240,右边:x: 200: y:200

好了,我们差不多已经可以拿到坐标之间的关系了,我们可以列一个表

10d77bfb3bcb775f5211ab05e0bbef85.png

还觉得不放心?我们可以换一下,缩放倍数与容器宽高等进行计算

1f4153046ad641dc8a8a6454d52e61e3.png

不知道大家有没有感觉呢,然后我们就可以慢慢根据坐标推出通用的公式啦

(transformOrigin - downX) / scale * (scale-1) + down - translateX = point

当然,我们或许还有这个translateX没有尝试,这个就比较简单一点了,脑内模拟一下,就知道我们可以减去位移的距离就ok啦。我们测试一下

我们先修改一下样式,新增一下位移的距离

transform-origin: 180px 180px;transform: scale(3, 3) translate(-40px,-40px);
6febf47cc8f489c4ec1105c20bfce8e6.png

还是我们上面的状态,ok,我们现在蓝色跟绿色的标记还是一一对应的,那我们看看现在的坐标情况

  • 蓝色:左边:x:0 y:0,右边:x:160 y:160

  • 绿色:左边:x:120 y:120,右边:x:200 y:200

我们分别运用公式算一下出来的坐标是怎么样的(以下为经过坐标换算)

  • 蓝色:左边:x:120 y:120,右边:x:160 y:160

  • 绿色:左边:x:160 y:160,右边:x:200 y:200

不难发现,我们其实就相差了与位移距离translateX/translateY的差值,所以,我们只需要减去位移的距离就可以完美的进行坐标转换啦

测试公式

根据上面的公式,我们可以简单测试一下!这个公式到底能不能生效!!!

我们直接沿用上面的demo,测试一下如果元素进行了变化,我们鼠标点下的地方生成一个标记,位置是否显示正确。看起来很ok啊(手动滑稽)

const wrap = document.getElementById('wrap')wrap.onmousedown = function (e) {  const downX = e.pageX - wrap.offsetLeft  const downY = e.pageY - wrap.offsetTop  const scale = 3  const translateX = -40  const translateY = -40  const transformOriginX = 180  const transformOriginY = 180  const dot = document.getElementById('dot')  dot.style.left = (transformOriginX - downX) / scale * (scale - 1) + downX - translateX + 'px'  dot.style.top = (transformOriginY - downY) / scale * (scale - 1) + downY - translateY + 'px'}
3e280b8be6a45a7f6ae29301ea1ae07d.gif

可能有人会问,为什么要减去这个offsetLeftoffsetTop呢,因为我们上面反复强调,我们计算的是鼠标点击的坐标,而这个坐标还是相对于我们展示容器的坐标,所以我们要减去容器本身的偏移量才行。

组件设计

既然demo啥的都已经测试了ok了,我们接下来就逐一分析一下这个组件应该咋设计好呢(目前仍为低配版,之后再进行优化完善)

1. 基本的画布构成

5b8cdc7e9024e528fb393bdce928c2fd.png

我们先简单分析一下这个构成吧,其实主要就是一个画布的容器,右边一个工具栏,仅此而已

8522e928e57e25167793527354cef88b.png

大体就这样子啦!

ref={canvasRef} className="mark-paper__canvas">

很可惜,这个东东与您的电脑不搭!

我们唯一需要的一点就是,容器需要设置属性overflow: hidden用来隐藏内部canvas画布溢出的内容,也就是说,我们要控制我们可视的区域。同时我们需要动态获取容器宽高来为canvas设置尺寸

2. 初始化canvas画布与填充图片

我们可以弄个方法来初始化并且填充画布,以下截取主要部分,其实就是为canvas画布设置尺寸与填充我们的图片

const fillImage = async () => {  // 此处省略...  const img: HTMLImageElement = new Image()  img.src = await getURLBase64(fillImageSrc)  img.onload = () => {    canvas.width = img.width    canvas.height = img.height    context.drawImage(img, 0, 0)    // 设置变化基点,为画布容器中央    canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`    // 清除上一次变化的效果    canvas.style.transform = ''  }}

3. 监听canvas画布的各种鼠标事件

这个控制移动的话,我们首先可以弄一个方法来监听画布鼠标的各种事件,可以区分不同的模式来进行不同的事件处理

const handleCanvas = () => {  const { current: canvas } = canvasRef  const { current: wrap } = wrapRef  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')  if (!context || !wrap) return  // 清除上一次设置的监听,以防获取参数错误  wrap.onmousedown = null  wrap.onmousedown = function (event: MouseEvent) {    const downX: number = event.pageX    const downY: number = event.pageY    // 区分我们现在选择的鼠标模式:移动、画笔、橡皮擦    switch (mouseMode) {      case MOVE_MODE:        handleMoveMode(downX, downY)        break      case LINE_MODE:        handleLineMode(downX, downY)        break      case ERASER_MODE:        handleEraserMode(downX, downY)        break      default:        break    }  }

4. 实现画布移动

这个就比较好办啦,我们只需要利用鼠标按下的坐标,和我们拖动的距离就可以实现画布的移动啦,因为涉及到每次移动都需要计算最新的位移距离,我们可以定义几个变量来进行计算。

这里监听的是容器的鼠标事件,而不是canvas画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作

d4657515428a57eccb87af38454dc925.png

简单的总结一下:

  • 传入鼠标按下的坐标

  • 计算当前位移距离,并更新css变化效果

  • 鼠标抬起时更新最新的位移状态

// 定义一些变量,来保存当前/最新的移动状态// 当前位移的距离const translatePointXRef: MutableRefObject = useRef(0)const translatePointYRef: MutableRefObject = useRef(0)// 上一次位移结束的位移距离const fillStartPointXRef: MutableRefObject = useRef(0)const fillStartPointYRef: MutableRefObject = useRef(0)// 移动时候的监听函数const handleMoveMode = (downX: number, downY: number) => {  const { current: canvas } = canvasRef  const { current: wrap } = wrapRef  const { current: fillStartPointX } = fillStartPointXRef  const { current: fillStartPointY } = fillStartPointYRef  if (!canvas || !wrap || mouseMode !== 0) return  // 为容器添加移动事件,可以在空白处移动图片  wrap.onmousemove = (event: MouseEvent) => {    const moveX: number = event.pageX    const moveY: number = event.pageY    // 更新现在的位移距离,值为:上一次位移结束的坐标+移动的距离    translatePointXRef.current = fillStartPointX + (moveX - downX)    translatePointYRef.current = fillStartPointY + (moveY - downY)    // 更新画布的css变化    canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`  }  wrap.onmouseup = (event: MouseEvent) => {    const upX: number = event.pageX    const upY: number = event.pageY    // 取消事件监听    wrap.onmousemove = null    wrap.onmouseup = null;    // 鼠标抬起时候,更新“上一次唯一结束的坐标”    fillStartPointXRef.current = fillStartPointX + (upX - downX)    fillStartPointYRef.current = fillStartPointY + (upY - downY)  }}

5. 实现画布缩放

画布缩放我主要通过右侧的滑动条以及鼠标滚轮来实现,首先我们再监听画布鼠标事件的函数中加一下监听滚轮的事件

总结一下:

  • 监听鼠标滚轮的变化

  • 更新缩放倍数,并改变样式

// 监听鼠标滚轮,更新画布缩放倍数const handleCanvas = () => {  const { current: wrap } = wrapRef  // 省略一万字...  wrap.onwheel = null  wrap.onwheel = (e: MouseWheelEvent) => {    const { deltaY } = e    // 这里要注意一下,我是0.1来递增递减,但是因为JS使用IEEE 754,来计算,所以精度有问题,我们自己处理一下    const newScale: number = deltaY > 0      ? (canvasScale * 10 - 0.1 * 10) / 10      : (canvasScale * 10 + 0.1 * 10) / 10    if (newScale < 0.1 || newScale > 2) return    setCanvasScale(newScale)  }}// 监听滑动条来控制缩放  min={0.1}  max={2.01}  step={0.1}  value={canvasScale}  tipFormatter={(value) => `${(value).toFixed(2)}x`}  onChange={handleScaleChange} />const handleScaleChange = (value: number) => {  setCanvasScale(value)}

接着我们使用hooks的副作用函数,依赖于画布缩放倍数来进行样式的更新

//监听缩放画布useEffect(() => {  const { current: canvas } = canvasRef  const { current: translatePointX } = translatePointXRef  const { current: translatePointY } = translatePointYRef  canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)}, [canvasScale])

6. 实现画笔绘制

这个就需要用到我们之前推导出来的公式啦!因为呢,仔细想一下,如果我们缩放位移之后,我们鼠标按下的位置,他的坐标可能就相对于画布来说会有变化,所以我们需要转换一下才能进行鼠标按下的位置与画布的位置一一对应的效果

稍微总结一下:

  • 传入鼠标按下的坐标

  • 通过公式转换,开始在对应坐标下绘制

  • 鼠标抬起时,取消事件监听

// 利用公式转换一下坐标const generateLinePoint = (x: number, y: number) => {  const { current: wrap } = wrapRef  const { current: translatePointX } = translatePointXRef  const { current: translatePointY } = translatePointYRef  const wrapWidth: number = wrap?.offsetWidth || 0  const wrapHeight: number = wrap?.offsetHeight || 0  // 缩放位移坐标变化规律  // (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX  const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX  const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY  return {    pointX,    pointY  }}// 监听鼠标画笔事件const handleLineMode = (downX: number, downY: number) => {  const { current: canvas } = canvasRef  const { current: wrap } = wrapRef  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')  if (!canvas || !wrap || !context) return  const offsetLeft: number = canvas.offsetLeft  const offsetTop: number = canvas.offsetTop  // 减去画布偏移的距离(以画布为基准进行计算坐标)  downX = downX - offsetLeft  downY = downY - offsetTop  const { pointX, pointY } = generateLinePoint(downX, downY)  context.globalCompositeOperation = "source-over"  context.beginPath()  // 设置画笔起点  context.moveTo(pointX, pointY)  canvas.onmousemove = null  canvas.onmousemove = (event: MouseEvent) => {    const moveX: number = event.pageX - offsetLeft    const moveY: number = event.pageY - offsetTop    const { pointX, pointY } = generateLinePoint(moveX, moveY)    // 开始绘制画笔线条~    context.lineTo(pointX, pointY)    context.stroke()  }  canvas.onmouseup = () => {    context.closePath()    canvas.onmousemove = null    canvas.onmouseup = null  }}

7. 橡皮擦的实现

橡皮擦目前还有点问题,现在的话是通过将canvas画布的背景图片 + globalCompositeOperation这个属性来模拟橡皮擦的实现,不过,这时候图片生成出来之后,橡皮擦的痕迹会变成白色,而不是透明

此步骤与画笔实现差不多,只有一点点小变动

  • 设置属性context.globalCompositeOperation = "destination-out"

// 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色const handleEraserMode = (downX: number, downY: number) => {  const { current: canvas } = canvasRef  const { current: wrap } = wrapRef  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')  if (!canvas || !wrap || !context) return  const offsetLeft: number = canvas.offsetLeft  const offsetTop: number = canvas.offsetTop  downX = downX - offsetLeft  downY = downY - offsetTop  const { pointX, pointY } = generateLinePoint(downX, downY)  context.beginPath()  context.moveTo(pointX, pointY)  canvas.onmousemove = null  canvas.onmousemove = (event: MouseEvent) => {    const moveX: number = event.pageX - offsetLeft    const moveY: number = event.pageY - offsetTop    const { pointX, pointY } = generateLinePoint(moveX, moveY)    context.globalCompositeOperation = "destination-out"    context.lineWidth = lineWidth    context.lineTo(pointX, pointY)    context.stroke()  }  canvas.onmouseup = () => {    context.closePath()    canvas.onmousemove = null    canvas.onmouseup = null  }}

8. 撤销与恢复的功能实现

这个的话,我们首先需要了解常见的撤销与恢复的功能的逻辑分几种情况吧

  • 若当前状态处于第一个位置,则不允许撤销

  • 若当前状态处于最后一个位置,则不允许恢复

  • 如果当前撤销了,然而更新了状态,则取当前状态为最新的状态(也就是说不允许恢复了,这个刚更新的状态就是最新的)

画布状态的更新

所以我们需要设置一些变量来存,状态列表,与当前画笔的状态下标

// 定义参数存东东const canvasHistroyListRef: MutableRefObject = useRef([])const [canvasCurrentHistory, setCanvasCurrentHistory] = useState(0)

我们还需要在初始化canvas的时候,我们就添加入当前的状态存入列表中,作为最先开始的空画布状态

const fillImage = async () => {  // 省略一万字...  img.src = await getURLBase64(fillImageSrc)  img.onload = () => {    const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)    canvasHistroyListRef.current = []    canvasHistroyListRef.current.push(imageData)    setCanvasCurrentHistory(1)  }}

然后我们就实现一下,画笔更新时候,我们也需要将当前的状态添加入画笔状态列表,并且更新当前状态对应的下标,还需要处理一下一些细节

总结一下:

  • 鼠标抬起时,获取当前canvas画布状态

  • 添加进状态列表中,并且更新状态下标

  • 如果当前处于撤销状态,若使用画笔更新状态,则将当前的最为最新的状态,原先位置之后的状态全部清空

const handleLineMode = (downX: number, downY: number) => {  // 省略一万字...  canvas.onmouseup = () => {    const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)    // 如果此时处于撤销状态,此时再使用画笔,则将之后的状态清空,以刚画的作为最新的画布状态    if (canvasCurrentHistory < canvasHistroyListRef.current.length) {      canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)    }    canvasHistroyListRef.current.push(imageData)    setCanvasCurrentHistory(canvasCurrentHistory + 1)    context.closePath()    canvas.onmousemove = null    canvas.onmouseup = null  }}

画布状态的撤销与恢复

ok,其实现在关于画布状态的更新,我们已经完成了。接下来我们需要处理一下状态的撤销与恢复的功能啦

我们先定义一下这个工具栏吧

a3bd0734beb54ce67e518db6b61a4b06.png

然后我们设置对应的事件,分别是撤销,恢复,与清空,其实都很容易看懂,最多就是处理一下边界情况。

const handleRollBack = () => {  const isFirstHistory: boolean = canvasCurrentHistory === 1  if (isFirstHistory) return  setCanvasCurrentHistory(canvasCurrentHistory - 1)}const handleRollForward = () => {  const { current: canvasHistroyList } = canvasHistroyListRef  const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length  if (isLastHistory) return  setCanvasCurrentHistory(canvasCurrentHistory + 1)}const handleClearCanvasClick = () => {  const { current: canvas } = canvasRef  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')  if (!canvas || !context || canvasCurrentHistory === 0) return  // 清空画布历史  canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]  setCanvasCurrentHistory(1)  message.success('画布清除成功!')}

事件设置好之后,我们就可以开始监听一下这个canvasCurrentHistory当前状态下标,使用副作用函数进行处理

useEffect(() => {  const { current: canvas } = canvasRef  const { current: canvasHistroyList } = canvasHistroyListRef  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')  if (!canvas || !context || canvasCurrentHistory === 0) return  context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)}, [canvasCurrentHistory])

为canvas画布填充图像信息!

这样就大功告成啦!!!

9. 实现鼠标图标的变化

我们简单的处理一下,画笔模式则是画笔的图标,橡皮擦模式下鼠标是橡皮擦,移动模式下就是普通的移动图标

切换模式时候,设置一下不同的图标

const handleMouseModeChange = (event: RadioChangeEvent) => {  const { target: { value } } = event  const { current: canvas } = canvasRef  const { current: wrap } = wrapRef  setmouseMode(value)  if (!canvas || !wrap) return  switch (value) {    case MOVE_MODE:      canvas.style.cursor = 'move'      wrap.style.cursor = 'move'      break    case LINE_MODE:      canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`      wrap.style.cursor = 'default'      break    case ERASER_MODE:      message.warning('橡皮擦功能尚未完善,保存图片会出现错误')      canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`      wrap.style.cursor = 'default'      break    default:      canvas.style.cursor = 'default'      wrap.style.cursor = 'default'      break  }}

10. 切换图片

现在的话只是一个demo状态,通过点击选择框,切换不同的图片

88ec71d1fc8800c0ba3822c2613627e2.png
// 重置变换参数,重新绘制图片useEffect(() => {  setIsLoading(true)  translatePointXRef.current = 0  translatePointYRef.current = 0  fillStartPointXRef.current = 0  fillStartPointYRef.current = 0  setCanvasScale(1)  fillImage()}, [fillImageSrc])const handlePaperChange = (value: string) => {  const fillImageList = {    'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',    'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',    'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',  }  setFillImageSrc(fillImageList[value])}

注意事项

注意容器的偏移量

我们需要注意一下,因为公式中的downX是相对容器的坐标,也就是说,我们需要减去容器的偏移量,这种情况会出现在使用了margin等参数,或者说上方或者左侧有别的元素的情况

我们输出一下我们红色的元素的offsetLeft等属性,会发现他是已经本身就有50的偏移量了,我们计算鼠标点击的坐标的时候就要减去这一部分的偏移量

ae1962309f99ccf54900a9a02c929c42.png
window.onload = function () {  const test = document.getElementById('test')  console.log(`offsetLeft: ${test.offsetLeft}, offsetHeight: ${test.offsetTop}`)}html,body {  margin: 0;  padding: 0;}#test {  width: 50px;  height: 50px;  margin-left: 50px;  background: red;}

注意父组件使用relative相对布局的情况

假如我们现在有一种这种的布局,打印红色元素的偏移量,看起来都挺正常的

c733a8a3242d4432f8f3c64171b05d8b.png

但是如果我们目标元素的父元素(也就是黄色部分)设置relative相对布局

.wrap {  position: relative;  width: 400px;  height: 300px;  background: yellow;}

这时候我们打印出来的偏移量会是多少呢

cf41a734e19909c40a9e0a0e252f6344.png

两次答案不一样啊,因为我们的偏移量是根据相对位置来计算的,如果父容器使用相对布局,则会影响我们子元素的偏移量

组件代码(低配版)

import React, { FC, ComponentType, useEffect, useRef, RefObject, useState, MutableRefObject } from 'react'import { CustomBreadcrumb } from '@/admin/components'import { RouteComponentProps } from 'react-router-dom';import { FormComponentProps } from 'antd/lib/form';import {  Slider, Radio, Button, Tooltip, Icon, Select, Spin, message, Popconfirm} from 'antd';import './index.scss'import { RadioChangeEvent } from 'antd/lib/radio';import { getURLBase64 } from '@/admin/utils/getURLBase64'const { Option, OptGroup } = Select;type MarkPaperProps = RouteComponentProps & FormComponentPropsconst MarkPaper: FC = (props: MarkPaperProps) => {  const MOVE_MODE: number = 0  const LINE_MODE: number = 1  const ERASER_MODE: number = 2  const canvasRef: RefObject = useRef(null)  const containerRef: RefObject = useRef(null)  const wrapRef: RefObject = useRef(null)  const translatePointXRef: MutableRefObject = useRef(0)  const translatePointYRef: MutableRefObject = useRef(0)  const fillStartPointXRef: MutableRefObject = useRef(0)  const fillStartPointYRef: MutableRefObject = useRef(0)  const canvasHistroyListRef: MutableRefObject = useRef([])  const [lineColor, setLineColor] = useState('#fa4b2a')  const [fillImageSrc, setFillImageSrc] = useState('')  const [mouseMode, setmouseMode] = useState(MOVE_MODE)  const [lineWidth, setLineWidth] = useState(5)  const [canvasScale, setCanvasScale] = useState(1)  const [isLoading, setIsLoading] = useState(false)  const [canvasCurrentHistory, setCanvasCurrentHistory] = useState(0)  useEffect(() => {    setFillImageSrc('http://cdn.algbb.cn/test/canvasTest.jpg')  }, [])  // 重置变换参数,重新绘制图片  useEffect(() => {    setIsLoading(true)    translatePointXRef.current = 0    translatePointYRef.current = 0    fillStartPointXRef.current = 0    fillStartPointYRef.current = 0    setCanvasScale(1)    fillImage()  }, [fillImageSrc])  // 画布参数变动时,重新监听canvas  useEffect(() => {    handleCanvas()  }, [mouseMode, canvasScale, canvasCurrentHistory])  // 监听画笔颜色变化  useEffect(() => {    const { current: canvas } = canvasRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    if (!context) return    context.strokeStyle = lineColor    context.lineWidth = lineWidth    context.lineJoin = 'round'    context.lineCap = 'round'  }, [lineWidth, lineColor])  //监听缩放画布  useEffect(() => {    const { current: canvas } = canvasRef    const { current: translatePointX } = translatePointXRef    const { current: translatePointY } = translatePointYRef    canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)  }, [canvasScale])  useEffect(() => {    const { current: canvas } = canvasRef    const { current: canvasHistroyList } = canvasHistroyListRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    if (!canvas || !context || canvasCurrentHistory === 0) return    context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)  }, [canvasCurrentHistory])  const fillImage = async () => {    const { current: canvas } = canvasRef    const { current: wrap } = wrapRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    const img: HTMLImageElement = new Image()    if (!canvas || !wrap || !context) return    img.src = await getURLBase64(fillImageSrc)    img.onload = () => {      // 取中间渲染图片      // const centerX: number = canvas && canvas.width / 2 - img.width / 2 || 0      // const centerY: number = canvas && canvas.height / 2 - img.height / 2 || 0      canvas.width = img.width      canvas.height = img.height      // 背景设置为图片,橡皮擦的效果才能出来      canvas.style.background = `url(${img.src})`      context.drawImage(img, 0, 0)      context.strokeStyle = lineColor      context.lineWidth = lineWidth      context.lineJoin = 'round'      context.lineCap = 'round'      // 设置变化基点,为画布容器中央      canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`      // 清除上一次变化的效果      canvas.style.transform = ''      const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)      canvasHistroyListRef.current = []      canvasHistroyListRef.current.push(imageData)      // canvasCurrentHistoryRef.current = 1      setCanvasCurrentHistory(1)      setTimeout(() => { setIsLoading(false) }, 500)    }  }  const generateLinePoint = (x: number, y: number) => {    const { current: wrap } = wrapRef    const { current: translatePointX } = translatePointXRef    const { current: translatePointY } = translatePointYRef    const wrapWidth: number = wrap?.offsetWidth || 0    const wrapHeight: number = wrap?.offsetHeight || 0    // 缩放位移坐标变化规律    // (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX    const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX    const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY    return {      pointX,      pointY    }  }  const handleLineMode = (downX: number, downY: number) => {    const { current: canvas } = canvasRef    const { current: wrap } = wrapRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    if (!canvas || !wrap || !context) return    const offsetLeft: number = canvas.offsetLeft    const offsetTop: number = canvas.offsetTop    // 减去画布偏移的距离(以画布为基准进行计算坐标)    downX = downX - offsetLeft    downY = downY - offsetTop    const { pointX, pointY } = generateLinePoint(downX, downY)    context.globalCompositeOperation = "source-over"    context.beginPath()    context.moveTo(pointX, pointY)    canvas.onmousemove = null    canvas.onmousemove = (event: MouseEvent) => {      const moveX: number = event.pageX - offsetLeft      const moveY: number = event.pageY - offsetTop      const { pointX, pointY } = generateLinePoint(moveX, moveY)      context.lineTo(pointX, pointY)      context.stroke()    }    canvas.onmouseup = () => {      const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)      // 如果此时处于撤销状态,此时再使用画笔,则将之后的状态清空,以刚画的作为最新的画布状态      if (canvasCurrentHistory < canvasHistroyListRef.current.length) {        canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)      }      canvasHistroyListRef.current.push(imageData)      setCanvasCurrentHistory(canvasCurrentHistory + 1)      context.closePath()      canvas.onmousemove = null      canvas.onmouseup = null    }  }  const handleMoveMode = (downX: number, downY: number) => {    const { current: canvas } = canvasRef    const { current: wrap } = wrapRef    const { current: fillStartPointX } = fillStartPointXRef    const { current: fillStartPointY } = fillStartPointYRef    if (!canvas || !wrap || mouseMode !== 0) return    // 为容器添加移动事件,可以在空白处移动图片    wrap.onmousemove = (event: MouseEvent) => {      const moveX: number = event.pageX      const moveY: number = event.pageY      translatePointXRef.current = fillStartPointX + (moveX - downX)      translatePointYRef.current = fillStartPointY + (moveY - downY)      canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`    }    wrap.onmouseup = (event: MouseEvent) => {      const upX: number = event.pageX      const upY: number = event.pageY      wrap.onmousemove = null      wrap.onmouseup = null;      fillStartPointXRef.current = fillStartPointX + (upX - downX)      fillStartPointYRef.current = fillStartPointY + (upY - downY)    }  }  // 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色  const handleEraserMode = (downX: number, downY: number) => {    const { current: canvas } = canvasRef    const { current: wrap } = wrapRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    if (!canvas || !wrap || !context) return    const offsetLeft: number = canvas.offsetLeft    const offsetTop: number = canvas.offsetTop    downX = downX - offsetLeft    downY = downY - offsetTop    const { pointX, pointY } = generateLinePoint(downX, downY)    context.beginPath()    context.moveTo(pointX, pointY)    canvas.onmousemove = null    canvas.onmousemove = (event: MouseEvent) => {      const moveX: number = event.pageX - offsetLeft      const moveY: number = event.pageY - offsetTop      const { pointX, pointY } = generateLinePoint(moveX, moveY)      context.globalCompositeOperation = "destination-out"      context.lineWidth = lineWidth      context.lineTo(pointX, pointY)      context.stroke()    }    canvas.onmouseup = () => {      const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)      if (canvasCurrentHistory < canvasHistroyListRef.current.length) {        canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)      }      canvasHistroyListRef.current.push(imageData)      setCanvasCurrentHistory(canvasCurrentHistory + 1)      context.closePath()      canvas.onmousemove = null      canvas.onmouseup = null    }  }  const handleCanvas = () => {    const { current: canvas } = canvasRef    const { current: wrap } = wrapRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    if (!context || !wrap) return    // 清除上一次设置的监听,以防获取参数错误    wrap.onmousedown = null    wrap.onmousedown = function (event: MouseEvent) {      const downX: number = event.pageX      const downY: number = event.pageY      switch (mouseMode) {        case MOVE_MODE:          handleMoveMode(downX, downY)          break        case LINE_MODE:          handleLineMode(downX, downY)          break        case ERASER_MODE:          handleEraserMode(downX, downY)          break        default:          break      }    }    wrap.onwheel = null    wrap.onwheel = (e: MouseWheelEvent) => {      const { deltaY } = e      const newScale: number = deltaY > 0        ? (canvasScale * 10 - 0.1 * 10) / 10        : (canvasScale * 10 + 0.1 * 10) / 10      if (newScale < 0.1 || newScale > 2) return      setCanvasScale(newScale)    }  }  const handleScaleChange = (value: number) => {    setCanvasScale(value)  }  const handleLineWidthChange = (value: number) => {    setLineWidth(value)  }  const handleColorChange = (color: string) => {    setLineColor(color)  }  const handleMouseModeChange = (event: RadioChangeEvent) => {    const { target: { value } } = event    const { current: canvas } = canvasRef    const { current: wrap } = wrapRef    setmouseMode(value)    if (!canvas || !wrap) return    switch (value) {      case MOVE_MODE:        canvas.style.cursor = 'move'        wrap.style.cursor = 'move'        break      case LINE_MODE:        canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`        wrap.style.cursor = 'default'        break      case ERASER_MODE:        message.warning('橡皮擦功能尚未完善,保存图片会出现错误')        canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`        wrap.style.cursor = 'default'        break      default:        canvas.style.cursor = 'default'        wrap.style.cursor = 'default'        break    }  }  const handleSaveClick = () => {    const { current: canvas } = canvasRef    // 可存入数据库或是直接生成图片    console.log(canvas?.toDataURL())  }  const handlePaperChange = (value: string) => {    const fillImageList = {      'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',      'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',      'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',    }    setFillImageSrc(fillImageList[value])  }  const handleRollBack = () => {    const isFirstHistory: boolean = canvasCurrentHistory === 1    if (isFirstHistory) return    setCanvasCurrentHistory(canvasCurrentHistory - 1)  }  const handleRollForward = () => {    const { current: canvasHistroyList } = canvasHistroyListRef    const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length    if (isLastHistory) return    setCanvasCurrentHistory(canvasCurrentHistory + 1)  }  const handleClearCanvasClick = () => {    const { current: canvas } = canvasRef    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')    if (!canvas || !context || canvasCurrentHistory === 0) return    // 清空画布历史    canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]    setCanvasCurrentHistory(1)    message.success('画布清除成功!')  }  return (    
className="mark-paper__mask" style={{ display: isLoading ? 'flex' : 'none' }} > tip="图片加载中..." indicator={ />} />
ref={canvasRef} className="mark-paper__canvas">

很可惜,这个东东与您的电脑不搭!

选择作业: defaultValue="xueshengjia" style={{ width: '100%', margin: '10px 0 20px 0' }} onChange={handlePaperChange} > 学生甲 学生乙 学生丙
画布操作:
className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`} onClick={handleRollBack} /> className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`} onClick={handleRollForward} /> onConfirm={handleClearCanvasClick} okText="确定" cancelText="取消" >
画布缩放: min={0.1} max={2.01} step={0.1} value={canvasScale} tipFormatter={(value) => `${(value).toFixed(2)}x`} onChange={handleScaleChange} />
画笔大小: min={1} max={9} value={lineWidth} tipFormatter={(value) => `${value}px`} onChange={handleLineWidthChange} />
模式选择: className="radio-group" onChange={handleMouseModeChange} value={mouseMode}> 移动 画笔 橡皮擦
颜色选择:
{['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => { return (
role="button" className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`} style={{ background: color }} onClick={() => handleColorChange(color)} /> ) })}
保存图片
)}export default MarkPaper as ComponentType

结语

如果这篇东东对大家有所帮助,希望大家可以给我点赞一下鼓励一下!

或者给俺的项目点个star支持支持吧!

github.com/zhcxk1998/School-Partners

菜鸡分析的不到位,还请各位大佬指出俺的不足!阿里嘎多~

adbd9ae4415323158c84f341e6752234.png

分享前端好文,点亮 在看 f1caa5ce59d2bc6021963063e6c1779f.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/366094.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

RMI强制Full GC每小时运行一次

在我职业生涯中进行的所有故障排除练习中&#xff0c;我感到随着时间的推移&#xff0c;我所追寻的错误在不断发展&#xff0c;变得越来越卑鄙和丑陋。 也许仅仅是我的年龄开始了。这个特别的Heisenbug –看起来像这篇帖子一样&#xff0c;再次让我清醒了很多&#xff0c;而不是…

oracle 安装ora 27102,ORA-27102 解决办法

因为在32位操作系统上执行了如下的操作alter system set sga_max_size2G scopespfile;之后再重启数据库就会失败报出错误:ORA-27102Out of Memory解决的方法也很简单:新建一个文本文件 如initaaa.ora内容&#xff1a;SPFILE../product/10.2.0&#xff0f;db_1/dbs/spfileorcl.o…

Codechef August Challenge 2018 : Modular GCD

传送门 一开始还手动拓欧找规律&#xff0c;发现好像玩不了。 然后想了想&#xff0c;A-B这个数比较小&#xff0c;枚举它的因子判断合不合法就行了。 需要特判AB的情况。 #include<cstdio> #include<algorithm> #define ll long long #define ld long double usin…

c语言 指针_C语言——指针

学习阶段&#xff1a;高中信竞、大学编程。前置知识&#xff1a;二进制与十六进制&#xff0c;C语言基础&#xff0c;数组。指针初学可能比较难理解&#xff0c;我这篇文章尽量用通俗易懂的方式来讲解。1. 指针概述为什么有指针这个东西&#xff1f;因为指针很贴近计算机内部的…

BZOJ 2141 排队(分块+树状数组)

题意 第一行为一个正整数n&#xff0c;表示小朋友的数量&#xff1b;第二行包含n个由空格分隔的正整数h1,h2,…,hn&#xff0c;依次表示初始队列中小朋友的身高&#xff1b;第三行为一个正整数m&#xff0c;表示交换操作的次数&#xff1b;以下m行每行包含两个正整数ai和bi&…

vant input框禁止调用手机键盘_【案例分享】适应网银等密码键盘的解决方案

在各种技术不断进步的大趋势下&#xff0c;对业务造成了极大影响&#xff0c;从大数据到云计算&#xff0c;再到人工智能&#xff0c;众多企业都开始步入技术改革&#xff0c;从而实现企业的创新。但与此同时&#xff0c;更多技术的引用&#xff0c;意味着更多的业务系统上线&a…

启动oracle数据库工具,Oracle数据库常用工具

SQL*Plus:SQL*Plus 是Oracle 数据库的一个基本工具&#xff0c;它允许用户使用SQL 命令交互式的访问数据库&#xff0c;也允许用户使用SQL*Plus 命令格式化输出参数。 通过SQL*Plus &#xff0c;可以完成数据库的启动和停止、创建和运行查询、更新数据、格式化输出数据报表、运…

Java 获取linux根目录下的文件夹_Windows支持直接访问Linux子系统文件:你的下一台Linux何必是Linux...

2020年第一波薅当当网羊毛的机会&#xff0c;别错过&#xff01;晓查 发自 凹非寺 量子位 报道 | 公众号 QbitAI 微软&#xff0c;致力于做最好的Linux发行版。今天&#xff0c;安装Windows 10测试版本号19603的用户发现&#xff0c;系统里WSL (Windows中的Linux子系统…

HTML5效果:实现树叶飘落

实现如图所示的东西效果&#xff08;落叶下落&#xff09;&#xff1a; html代码&#xff1a; <!DOCTYPE html><html><head><title>HTML5树叶飘落动画</title><meta charset"utf-8"><meta name"viewport" content&…

oracle 创交表,创建交叉报表(oracle)_oracle

创建交叉报表create table t1(goodid number(10) not null,saledate date not null,salesum number(10));要求生成本年度每个月的产品销售状况表m1 m2 m3 ... m12g1g2...gn下面是生成报表的sqlSELECT goodid,SUM(decode(to_char(saledate,mm),01,salesum)) "01"…

cass方格网数据excel_讨论|CASS怎么计算回字型土方? 124

大家好,欢迎来到我的专栏。这是我原创的第124篇CASS应用技术干货文章。希望对你有所帮助&#xff0c;写文不易&#xff0c;请点赞哦!回字型土方工程&#xff0c;就是计算区域内部&#xff0c;有一个或多个不参加计算的区域&#xff0c;这种区域也就是常说的“扣岛”。常见的有基…

垃圾收集器准则和提示

这些是我需要调整GC时通常会看到的一些准则和技巧。 主要由以下两本书组成&#xff0c;而根据我的经验却很少&#xff1a; Java性能 JBoss AS 5性能调优 希望它们对那里的其他人有用&#xff01; 垃圾收集器 XX:AggressiveOpts将HotSpot内部布尔变量设置为true以启用其他性…

MD5加密处理

MD5 加密后的位数一般为两种&#xff0c;16 位与 32 位。16 位实际上是从 32 位字符串中&#xff0c;取中间的第 9 位到第 24 位的部分 using System; using System.Security.Cryptography; using System.Text;namespace _04MD5加密 {internal class Program{private static vo…

移动前端—H5实现图片先压缩再上传

在做移动端图片上传的时候&#xff0c;用户传的都是手机本地图片&#xff0c;而本地图片一般都相对比较大&#xff0c;拿iphone6来说&#xff0c;平时拍很多图片都是一两M的&#xff0c;如果直接这样上传&#xff0c;那图片就太大了&#xff0c;如果用户用的是移动流量&#xf…

requests模块

python requests用法总结 本文全部来源于官方文档 http://docs.python-requests.org/en/master/ 1、安装 Requests 是一个第三方 Python 模块&#xff0c;其官网的介绍如下&#xff1a; Requests 唯一的一个非转基因的 Python HTTP 库&#xff0c;人类可以安全享用。 警告&…

linux子系统备份,使用LxRunOffline工具备份/还原Linux子系统(WSL)

安装WSL开启WSL组件首先需要打开WSL组件&#xff0c;可以使用以下命令&#xff1a;Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux也可以手动在【控制面板】-【程序】-【启用或关闭Windows功能】-【适用于Linux的Windows子系统】打勾&…

react打包后图片丢失_手搭一个 React,Typescript,Koa,GraphQL 环境

本文系原创&#xff0c;转载请附带作者信息&#xff1a;yhlben项目地址&#xff1a;https://github.com/yhlben/cdfang-spider前言在实际的开发过程中&#xff0c;从零开始初始化一个项目往往很麻烦&#xff0c;所以各种各样的脚手架工具应运而生。crea-react-app&#xff0c;v…

无需改动现有网络,企业高速远程访问内网Linux服务器

某企业为数据治理工具盒厂商&#xff0c;帮助客户摆脱数据问题困扰、轻松使用数据&#xff0c;使得客户可以把更多精力投入至数据应用及业务赋能&#xff0c;让数据充分发挥其作为生产要素的作用。 目前&#xff0c;该企业在北京、南京、西安、武汉等地均设有产研中心&#xff…

使用Hazelcast发布和订阅

几周前&#xff0c;我写了一篇有关Hazelcast入门的博客&#xff0c;描述了创建分布式地图&#xff0c;列表和队列是多么简单。 当时我提到Hazelcast还能做很多其他事情。 该博客快速浏览了Hazelcast的另一个功能&#xff1a;基于Publish / Subscribe模式的广播消息系统。 这采用…

接口自动化测试持续集成--Soapui接口功能测试参数化

按照自动化测试分层实现的原理&#xff0c;每一层的脚本实现都要进行参数化&#xff0c;自动化的目标就是要实现脚本代码与测试数据分离。当测试数据进行调整的时候不会对脚本的实现带来震荡&#xff0c;从而提高脚本的稳定性与灵活度&#xff0c;降低脚本的维护成本。Soapui最…