Html5 Canvas实现图片标记、缩放、移动和保存历史状态功能 (附转换公式)
这篇文章主要介绍了Html5 Canvas实现图片标记、缩放、移动和保存历史状态功能 (附转换公式),本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
哈哈哈俺又来啦,这次带来的是canvas实现一些画布功能的文章,希望大家喜欢!
前言
因为也是大三了,最近俺也在找实习,之前有一个自己的小项目:
https://github.com/zhcxk1998/School-Partners
面试官说可以往深层次思考一下,或许加一些新的功能来增加项目的难度,他提了几个建议,其中一个就是 试卷在线批阅,老师可以在上面对作业进行批注,圈圈点点等 俺当天晚上就开始研究这个东东哈哈哈,终于被我研究出来啦!
采用的是 canvas
绘制画笔,由css3的 transform
属性来进行平移与缩放,之后再详细介绍介绍
(希望大家可以留下宝贵的赞与star嘻嘻)
效果预览
动图是放cdn的,如果访问不了,可以登录在线尝试尝试: test.algbb.cn/#/admin/con…
公式推导 如果不想看公式如何推导,可以直接跳过看后面的具体实现~ 1. 坐标转换公式 转换公式介绍
其实一开始也是想在网上找一下有没有相关的资料,但是可惜找不到,所以就自己慢慢的推出来了。我就举一下横坐标的例子吧!
通用公式
这个公式是表示,通过公式来将鼠标按下的坐标转换为画布中的相对坐标,这一点尤为重要
(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
参数解释
transformOrigin: transform变化的基点(通过这个属性来控制元素以哪里进行变化)
downX: 鼠标按下的坐标(注意,用的时候需要减去容器左偏移距离,因为我们要的是相对于容器的坐标)
scale: 缩放倍数,默认为1
translateX: 平移的距离
推导过程
这个公式的话,其实就比较通用,可以用在别的利用到 transform
属性的场景,至于怎么推导的话,我是用的笨办法
具体的测试代码,放在文末,需要自取~
1. 先做出两个相同的元素,然后标记上坐标,并且设置容器属性 overflow:hidden
来隐藏溢出内容
ok,现在就有两个一样的矩阵啦,我们为他标记上一些红点,然后我们对左边的进行css3的样式变化 transform
矩形的宽高是 360px * 360px
的,我们定义一下他的变化属性,变化基点选择正中心,放大3倍
1 2 3 | // css transform-origin: 180px 180px; transform: scale(3, 3); |
得到如下结果
ok,我们现在对比一下上面的结果,就会发现,放大3倍的时候,恰好是中间黑色方块占据了全部宽度。接下来我们就可以对这些点与原先没有进行变化(右边)的矩形进行对比就可以得到他们坐标的关系啦
2. 开始对两个坐标进行对比,然后推出公式
现在举一个简单的例子吧,例如我们算一下左上角的坐标(现在已经标记为黄色了)
其实我们其实就可以直接心算出来坐标的关系啦
( 这里左边计算坐标的值是我们鼠标按下的坐标 )
( 这里左边计算坐标的值是我们鼠标按下的坐标 )
( 这里左边计算坐标的值是我们鼠标按下的坐标 )
因为宽高是
360px
,所以分成3等份,每份宽度是120px
因为变化之后容器的宽高是不变的,变化的只有矩形本身
我们可以得出左边的黄色标记坐标是
x:120 y:0
,右边的黄色标记为x:160 y:120
(这个其实肉眼看应该就能看出来了,实在不行可以用纸笔算一算)
这个坐标可能有点特殊,我们再换几个来计算计算(根据特殊推一般)
蓝色标记:左边: x:120 y:120
,右边: x: 160 y:160
绿色标记:左边: x: 240 y:240
,右边: x: 200: y:200
好了,我们差不多已经可以拿到坐标之间的关系了,我们可以列一个表
还觉得不放心?我们可以换一下,缩放倍数与容器宽高等进行计算
不知道大家有没有感觉呢,然后我们就可以慢慢根据坐标推出通用的公式啦
(transformOrigin - downX) / scale * (scale-1) + down - translateX = point
当然,我们或许还有这个 translateX
没有尝试,这个就比较简单一点了,脑内模拟一下,就知道我们可以减去位移的距离就ok啦。我们测试一下
我们先修改一下样式,新增一下位移的距离
1 2 | transform-origin: 180px 180px; transform: scale(3, 3) translate(-40px,-40px); |
还是我们上面的状态,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啊(手动滑稽)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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' } |
可能有人会问,为什么要减去这个 offsetLeft
跟 offsetTop
呢,因为我们上面反复强调,我们计算的是鼠标点击的坐标,而这个坐标还是相对于我们展示容器的坐标,所以我们要减去容器本身的偏移量才行。
组件设计
既然demo啥的都已经测试了ok了,我们接下来就逐一分析一下这个组件应该咋设计好呢(目前仍为低配版,之后再进行优化完善)
1. 基本的画布构成
我们先简单分析一下这个构成吧,其实主要就是一个画布的容器,右边一个工具栏,仅此而已
大体就这样子啦!
1 2 3 4 5 6 7 8 | < div className = "mark-paper__wrap" ref={wrapRef}> < canvas ref={canvasRef} className = "mark-paper__canvas" > < p >很可惜,这个东东与您的电脑不搭!</ p > </ canvas > < div className = "mark-paper__sider" /> </ div > |
我们唯一需要的一点就是,容器需要设置属性 overflow: hidden
用来隐藏内部canvas画布溢出的内容,也就是说,我们要控制我们可视的区域。同时我们需要动态获取容器宽高来为canvas设置尺寸
2. 初始化canvas画布与填充图片
我们可以弄个方法来初始化并且填充画布,以下截取主要部分,其实就是为canvas画布设置尺寸与填充我们的图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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画布的各种鼠标事件
这个控制移动的话,我们首先可以弄一个方法来监听画布鼠标的各种事件,可以区分不同的模式来进行不同的事件处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | 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画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作
简单的总结一下:
传入鼠标按下的坐标
计算当前位移距离,并更新css变化效果
鼠标抬起时更新最新的位移状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | // 定义一些变量,来保存当前/最新的移动状态 // 当前位移的距离 const translatePointXRef: MutableRefObject< number > = useRef(0) const translatePointYRef: MutableRefObject< number > = useRef(0) // 上一次位移结束的位移距离 const fillStartPointXRef: MutableRefObject< number > = useRef(0) const fillStartPointYRef: MutableRefObject< number > = 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. 实现画布缩放
画布缩放我主要通过右侧的滑动条以及鼠标滚轮来实现,首先我们再监听画布鼠标事件的函数中加一下监听滚轮的事件
总结一下:
监听鼠标滚轮的变化
更新缩放倍数,并改变样式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // 监听鼠标滚轮,更新画布缩放倍数 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) } } // 监听滑动条来控制缩放 < Slider 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的副作用函数,依赖于画布缩放倍数来进行样式的更新
1 2 3 4 5 6 7 | //监听缩放画布 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. 实现画笔绘制
这个就需要用到我们之前推导出来的公式啦!因为呢,仔细想一下,如果我们缩放位移之后,我们鼠标按下的位置,他的坐标可能就相对于画布来说会有变化, 所以我们需要转换一下才能进行鼠标按下的位置与画布的位置一一对应的效果
稍微总结一下:
传入鼠标按下的坐标
通过公式转换,开始在对应坐标下绘制
鼠标抬起时,取消事件监听
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | // 利用公式转换一下坐标 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"
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | // 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色 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. 撤销与恢复的功能实现
这个的话,我们首先需要了解常见的撤销与恢复的功能的逻辑 分几种情况吧
若当前状态处于第一个位置,则不允许撤销
若当前状态处于最后一个位置,则不允许恢复
如果当前撤销了,然而更新了状态,则取当前状态为最新的状态(也就是说不允许恢复了,这个刚更新的状态就是最新的)
画布状态的更新
所以我们需要设置一些变量来存,状态列表,与当前画笔的状态下标
1 2 3 | // 定义参数存东东 const canvasHistroyListRef: MutableRefObject< ImageData []> = useRef([]) const [canvasCurrentHistory, setCanvasCurrentHistory] = useState< number >(0) |
我们还需要在初始化canvas的时候,我们就添加入当前的状态存入列表中,作为最先开始的空画布状态
1 2 3 4 5 6 7 8 9 10 11 | 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画布状态
添加进状态列表中,并且更新状态下标
如果当前处于撤销状态,若使用画笔更新状态,则将当前的最为最新的状态,原先位置之后的状态全部清空
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 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,其实现在关于画布状态的更新,我们已经完成了。接下来我们需要处理一下状态的撤销与恢复的功能啦
我们先定义一下这个工具栏吧
然后我们设置对应的事件,分别是撤销,恢复,与清空,其实都很容易看懂,最多就是处理一下边界情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | 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
当前状态下标,使用副作用函数进行处理
1 2 3 4 5 6 7 | 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. 实现鼠标图标的变化
我们简单的处理一下,画笔模式则是画笔的图标,橡皮擦模式下鼠标是橡皮擦,移动模式下就是普通的移动图标
切换模式时候,设置一下不同的图标
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | 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状态,通过点击选择框,切换不同的图片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // 重置变换参数,重新绘制图片 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的偏移量了,我们计算鼠标点击的坐标的时候就要减去这一部分的偏移量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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; } < div class = "container" > < div id = "test" ></ div > </ div > |
注意父组件使用relative相对布局的情况
假如我们现在有一种这种的布局,打印红色元素的偏移量,看起来都挺正常的
但是如果我们目标元素的父元素(也就是黄色部分)设置 relative
相对布局
1 2 3 4 5 6 7 8 9 10 11 12 13 | .wrap { position: relative; width: 400px; height: 300px; background: yellow; } < div class = "container" > < div class = "sider" ></ div > < div class = "wrap" > < div id = "test" ></ div > </ div > </ div > |
这时候我们打印出来的偏移量会是多少呢
两次答案不一样啊,因为我们的偏移量是根据相对位置来计算的,如果父容器使用相对布局,则会影响我们子元素的偏移量
组件代码(低配版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 | 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 & FormComponentProps const MarkPaper: FC< MarkPaperProps > = (props: MarkPaperProps) => { const MOVE_MODE: number = 0 const LINE_MODE: number = 1 const ERASER_MODE: number = 2 const canvasRef: RefObject< HTMLCanvasElement > = useRef(null) const containerRef: RefObject< HTMLDivElement > = useRef(null) const wrapRef: RefObject< HTMLDivElement > = useRef(null) const translatePointXRef: MutableRefObject< number > = useRef(0) const translatePointYRef: MutableRefObject< number > = useRef(0) const fillStartPointXRef: MutableRefObject< number > = useRef(0) const fillStartPointYRef: MutableRefObject< number > = useRef(0) const canvasHistroyListRef: MutableRefObject< ImageData []> = useRef([]) const [lineColor, setLineColor] = useState< string >('#fa4b2a') const [fillImageSrc, setFillImageSrc] = useState< string >('') const [mouseMode, setmouseMode] = useState< number >(MOVE_MODE) const [lineWidth, setLineWidth] = useState< number >(5) const [canvasScale, setCanvasScale] = useState< number >(1) const [isLoading, setIsLoading] = useState< boolean >(false) const [canvasCurrentHistory, setCanvasCurrentHistory] = useState< number >(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 ( < div > < CustomBreadcrumb list={['内容管理', '批阅作业']} /> < div className = "mark-paper__container" ref={containerRef}> < div className = "mark-paper__wrap" ref={wrapRef}> < div className = "mark-paper__mask" style={{ display: isLoading ? 'flex' : 'none' }} > < Spin tip = "图片加载中..." indicator={<Icon type = "loading" style={{ fontSize: 36 }} spin />} /> </ div > < canvas ref={canvasRef} className = "mark-paper__canvas" > < p >很可惜,这个东东与您的电脑不搭!</ p > </ canvas > </ div > < div className = "mark-paper__sider" > < div > 选择作业: < Select defaultValue = "xueshengjia" style={{ width: '100%', margin: '10px 0 20px 0' }} onChange={handlePaperChange} > < OptGroup label = "17软件一班" > < Option value = "xueshengjia" >学生甲</ Option > < Option value = "xueshengyi" >学生乙</ Option > </ OptGroup > < OptGroup label = "17软件二班" > < Option value = "xueshengbing" >学生丙</ Option > </ OptGroup > </ Select > </ div > < div > 画布操作:< br /> < div className = "mark-paper__action" > < Tooltip title = "撤销" > < i className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`} onClick={handleRollBack} /> </ Tooltip > < Tooltip title = "恢复" > < i className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`} onClick={handleRollForward} /> </ Tooltip > < Popconfirm title = "确定清空画布吗?" onConfirm={handleClearCanvasClick} okText = "确定" cancelText = "取消" > < Tooltip title = "清空" > < i className = "icon iconfont icon-qingchu" /> </ Tooltip > </ Popconfirm > </ div > </ div > < div > 画布缩放: < Tooltip placement = "top" title = '可用鼠标滚轮进行缩放' > < Icon type = "question-circle" /> </ Tooltip > < Slider min={0.1} max={2.01} step={0.1} value={canvasScale} tipFormatter={(value) => `${(value).toFixed(2)}x`} onChange={handleScaleChange} /> </ div > < div > 画笔大小: < Slider min={1} max={9} value={lineWidth} tipFormatter={(value) => `${value}px`} onChange={handleLineWidthChange} /> </ div > < div > 模式选择: < Radio.Group className = "radio-group" onChange={handleMouseModeChange} value={mouseMode}> < Radio value={0}>移动</ Radio > < Radio value={1}>画笔</ Radio > < Radio value={2}>橡皮擦</ Radio > </ Radio.Group > </ div > < div > 颜色选择: < div className = "color-picker__container" > {['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => { return ( < Tooltip placement = "top" title={color} key={color}> < div role = "button" className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`} style={{ background: color }} onClick={() => handleColorChange(color)} /> </ Tooltip > ) })} </ div > </ div > < Button onClick={handleSaveClick}>保存图片</ Button > </ div > </ div > </ div > ) } export default MarkPaper as ComponentType |
总结
到此这篇关于Html5 Canvas实现图片标记、缩放、移动和保存历史状态 (附转换公式)的文章就介绍到这了