【实战】Canvas实现图片上标注、缩放、移动和保存历史状态
【实战】Canvas实现图片上标注、缩放、移动和保存历史状态
作者:BB小天使 https://juejin.im/post/5e717376e51d4526dd1ec2e6
前言
因为也是大三了,最近俺也在找实习,之前有一个自己的小项目:
https://github.com/zhcxk1998/School-Partners
面试官说可以往深层次思考一下,或许加一些新的功能来增加项目的难度,他提了几个建议,其中一个就是试卷在线批阅,老师可以在上面对作业进行批注,圈圈点点等俺当天晚上就开始研究这个东东哈哈哈,终于被我研究出来啦!
采用的是
canvas
绘制画笔,由css3的transform
属性来进行平移与缩放,因为呢考虑到如果用canvas的drawImage
或者scale
等属性进行变化,生成出来的图片也会有影响,想着直接css3变化,canvas用来做画笔等功能。
效果预览

动图是放cdn的,如果访问不了,可以登录在线尝试尝试:http://test.algbb.cn/#/admin/content/mark-paper
公式推导
如果不想看公式如何推导,可以直接跳过看后面的具体实现~
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'}

可能有人会问,为什么要减去这个offsetLeft
跟offsetTop
呢,因为我们上面反复强调,我们计算的是鼠标点击的坐标,而这个坐标还是相对于我们展示容器的坐标,所以我们要减去容器本身的偏移量才行。
组件设计
既然demo啥的都已经测试了ok了,我们接下来就逐一分析一下这个组件应该咋设计好呢(目前仍为低配版,之后再进行优化完善)
1. 基本的画布构成

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

大体就这样子啦!
<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画布设置尺寸与填充我们的图片
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<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. 实现画布缩放
画布缩放我主要通过右侧的滑动条以及鼠标滚轮来实现,首先我们再监听画布鼠标事件的函数中加一下监听滚轮的事件
总结一下:
- 监听鼠标滚轮的变化
- 更新缩放倍数,并改变样式
// 监听鼠标滚轮,更新画布缩放倍数
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的副作用函数,依赖于画布缩放倍数来进行样式的更新
//监听缩放画布
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
}