您现在的位置是:网站首页> 编程资料编程资料

Html5 Canvas实现图片标记、缩放、移动和保存历史状态功能 (附转换公式)详解如何用HTML5 Canvas API控制图片的缩放变换HTML5 Canvas实现图片缩放、翻转、颜色渐变的代码示例通过Canvas及File API缩放并上传图片完整示例

2021-08-30 1102人已围观

简介 这篇文章主要介绍了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倍

 // 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啦。我们测试一下

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

 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啊(手动滑稽)

 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' }

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

组件设计

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

1. 基本的画布构成

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

大体就这样子啦!

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

我们唯一需要的一点就是,容器需要设置属性 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画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作

简单的总结一下:

  • 传入鼠标按下的坐标
  • 计算当前位移距离,并更新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) } } // 监听滑动条来控制缩放  `${(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) s
                
                

相关内容

-六神源码网