前言
实现一个 canvas 拖拽、缩放、旋转的效果,如下效果图。
如何管理
统一使用 typescript 来编写,在面对状态操作复杂的流程, ts 提供了状态标注,类型提示等。便于我们维护和梳理流程思路。比如类型标注提供了类似注释的功能,将对象描述清楚的同时,在我们使用相关属性或方法都会有提示和类型判断,十分的方便。
为了统一管理,创建 Cavans 、State、Shape 来分别管理 canvas 图像操作,状态和不同类图像的对外的统一接口封装
canvas 类
- 用于创建 canvans ,初始化
- 事件监听 mousedown、mousemove、mouseup 来响应用户操作
- 提供基础的通用方法如 : initDraw(重新绘制画板图像)、windowLocToCanvas(获取鼠标位置相对于 canvas 画板的位置)更多可以查看 github 地址的代码。
State 类
- 记录当前选中图形的相关信息
- 将在其他地方需要重复用到的数据、方法封装好暴露给外部,如:
// 获取当前选中图形
get currentShape() {
return this.shapeList[this.index]
}
// 更新当前选中的图形序号
updateIndex(index: number){
this.index = index
}
图形类
这里分为 BaseShape 和 Rect 、 Circle 等自定义的类。我们绘制的图形可能有很多种类型,将他们抽离成类并且对外暴露的接口都相同,那我们使用方法的时候就不需要考虑这个图形到底属于什么类型,
比如下面的栗子,创建 Circle 类和 Rect 类,他们对外暴露的都是 drawShape 的方法都是相同的,只需要调用 drawShape 即可,而不需要考虑内部的实现逻辑。这样可以更好的抽离复杂的逻辑,维护性更好。
// Circle 类
drawShape(ctx: CanvasRenderingContext2D) {
const { x, y, fillStyle, r } = this.shape
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fillStyle = fillStyle
ctx.fill()
}
// Rect 类
drawShape(ctx: CanvasRenderingContext2D) {
const { x, y, fillStyle, w, h } = this.shape
ctx.beginPath()
ctx.rect(x, y, w, h)
ctx.fillStyle = fillStyle
ctx.fill()
}
BaseShape 类
将图形需要的基础方法和属性都统一抽离到该类中,例如 x,y 都表示图形绘制的起点的位置。这样减少重复代码,阅读体验更好
export default class BaseShape {
x!: number;
y!: number;
zIndex?: number;
rotateDeg = 0
// 旋转控制点距离边的距离
rotateY= 80
// 控制点的大小
point = { w:20,h:20 }
state: State
// 当前图形处于列表中的位置
index: number
constructor(shape: Shape,state: State,index: number) {
const { x,y,zIndex,rotateDeg } = shape
this.x = x
this.y = y
this.zIndex = zIndex
this.rotateDeg = rotateDeg || 0
this.state = state
this.index = index
}
}
Rect 类
由于不同 Shape 类提供外部的接口是相同的,那么这些 shape 类内部就是如何实现具体的操作,例如上面提到的 drawShape ,在不同类的实现方式是不一样的,rect 是使用 ctx.rect()来绘制图形,cicle 是使用 ctx.arc(),线段是通过 ctx.lineTo()。这样将相同类的操作聚合在一个同一个类中,同样也使代码更加有条理和可维护。
// Rect 类
drawShape(ctx: CanvasRenderingContext2D) {
const { x, y, fillStyle, w, h } = this.shape
ctx.beginPath()
ctx.rect(x, y, w, h)
ctx.fillStyle = fillStyle
ctx.fill()
}
操作
怎么给图形绑定事件
由于 canvas 中绘制的图形不像 html 中的 dom 一样可以绑定事件,如果我们想要给指定的图形添加事件需要代理到 canvas 元素上。
通过实例给矩形添加了 move 事件,在这个例子中做了哪些事?
- 开始绘制了一个矩形
- 当我们点击 canvas 画布的时候通过,canvas 来做事件代理,通过 ctx.isPointerInPath(clientX,clientY) 来判断点击位置是否落在图形中
- 如果落在图形中,当 mousemove 事件中重新绘制矩形,达到图形移动的效果,起始每次移动的矩形都是重新绘制的,而不是之前的那个。
- 例子中的 ctx.getImageData() 和 ctx.putImageData 后面会继续介绍,现在只需要知道 canvas 实现物体移动的大致流程是这样的。这一切看起来都挺顺利的。
多个图形时判断点中物体出错
当我们在 canvas 上绘制多个图形的时候会发现,使用 ctx.isPointerInPath()来判断是否点击最后一个图形是可以,但是前面绘制的图形无效。
这里需要介绍一下路径的概念,那画一条直线为例
- 可以将 ctx.moveTo(x,y) 看作是落笔点,就是画笔点笔头,用于设置画笔点起点位置
- ctx.lineTo(x,y) 将画笔从上一个起点开始,画一条线到 lineTo 设置的位置。
- 通过上面的操作,我们已经得到一条直线的路径,但是此时画板上没有出现一条直线,因为路径只是设置了绘制的路径,并没有实际的绘制线。如果我们想绘制出这条线使用 ctx.stroke() 来绘制。
- ctx.stroke()绘制当前或者已存在的路径。我们可以使用 ctx.lineTo() 、ctx.rect() 等绘制路径等方法绘制多条路径,然后使用 ctx.stroke() 将这些路径一次绘制出来。
有了上面的认识以后我们可以开始绘制我们想要的路径,假设我们想要绘制 (0,0) 到 (100,100) 的一条直线 为蓝色,再绘制一条路径(100,100) 到(200,200) 为绿色。写下如下代码
ctx.moveTo(0,0)
ctx.strokeStyle = 'blue'
ctx.lineTo(100,100)
ctx.strokeStyle = 'green'
ctx.lineTo(200,200)
ctx.stroke()
会很奇怪的发现,这两条线最终都变成了绿色,咋回事。原来 ctx.stroke()是对已有的所有路径都进行绘制,并且最后一次设置的绘制颜色 ctx.strokeStyle = 'green' 。所以所有路径都重新绘制并应用了绿色。 那我想要实现前面的需求该怎么办? 这里要使用 ctx.beginPath()
ctx.beginPath()
清空子路径开始一个新路径的方法。代码如下
// step1
ctx.beginPath()
ctx.moveTo(0,0)
ctx.strokeStyle = 'blue'
ctx.lineTo(100,100)
// step2 应用的样式只会作用在新的路径上
ctx.strokeStyle = 'green'
ctx.beginPath()
ctx.lineTo(200,200)
ctx.stroke()
有人可能会有疑问,我使用 ctx.beginPath()以后会不会影响到之前绘制的图形。答案是不会,因为路径是定义我们图形的形状,你在 step1 结束后,你已经得到了线段 1,他已经绘制在 canvas 上,你后序该路径并不会影响已经绘制的路径。
多物体时候绘制出错的原因
有了上面的认识以后,我们可以得到为什么 ctx.isPointerInPath(clientX,clientY) 无法判断前面绘制的图形了,因为每次绘制图形都会使用 ctx.beginPath()清除之前绘制的路径。 而 ctx.isPointerInPath 是根据路径来判断目标位置是否在路径中。
那我们有上面方法去解决呢? 开始我用的一种方法就是重新绘制所有路径,在每次绘制的时候判断当前点击位置是否存在在路径中。这样就可以判断我们点击位置在那个路径上了。
假设我们有一个
const pathList = [
{
type:'circle',
x:100,
y:100,
r:30
},
{
type:'rect',
x:200,
y:100,
w:40,
h:40
}
]
现在我们通过列表绘制了一个圆和一个矩形。当我们点击时候,我们遍历这个 pathList ,将这个数组里的图形都绘制一遍,然后使用 ctx.isPointerInPath(clientX,clientY) 来判断是否点击中了图形。如果有说明我们命中该图形。
点击查看多个图形选中判断方式 查看上面 demo 后解决了多个图形事件的问题,这一切看起来都挺顺利的。
绘制可缩放的图形
当我们绘制如图当形状当时候,当我们点击矩形四个角的 框时候移动可以拉伸矩形的大小。根据前面的知识,我们绘制多个 path 路径,然后使用一次 ctx.stroke() 将图形绘制出来,我们就可以根据 ctx.isPointerInPath(clientX,clientY) 来判断当前是否命中图形。
但是当我们需要判断是否点击中四个角的小矩形的时候就麻烦了。
- 可以通过计算的方式判断当前点击位置
- 将最后四个控制框分别绘制判断是否落在其中
- 使用 Path2D 创建绘制路径,然后通过 ctx.isPointerInPath(path,x,y) 来判断是否落在指定路径中。
const path = new Path2D()
ctx.beginPath()
path.rect(x, y, w, h)
ctx.fillStyle = fillStyle
ctx.fill(path)
绘制旋转图形
在 canvas 中使用 ctx.rotate(angle) 来实现图形旋转,有两点要注意:
- 这里是 angle 是弧度,弧度和角度的关系:rad = 45 * Math.PI / 180 45 度对应的弧度值
- 旋转的中心点默认是原点(0,0),所以一切旋转的都是以原点旋转。如果你想根据图形的中心点旋转要通过 ctx.translate(x,y) 来设置旋转中心点。
通过上图可以看出使用 ctx.translate(100,100) 就是将坐标系向右移动 100px 向下移动 100px,以后(100,100) 就相当于之前的原点,参考点就改变了。 旋转套用的公式如下:相当于我们将坐标原点移动到物体的中心点,完成旋转,自然达到了中心旋转的效果,但是后序的图形绘制参考点都以(100,100) 为原点这可不行,我们需要重置。这里需要介绍一下 canvas 上下文环境。
ctx.save()
ctx.translate(x + width / 2, y + height / 2)
ctx.rotate(angle * Math.PI / 180)
ctx.rect( -width / 2, -height / 2, width, height)
ctx.restore()
通过 ctx.save() 是将 canvas 2d 当前的环境状态放入栈中,保存 canvas 当前的状态。可保存的分为四类。
- 当前的变换矩阵。
- 当前的剪切区域。
- 当前的虚线列表.
- 以下属性当前的值: strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled.
当我们旋转完图形后需要恢复坐标系就需要使用 ctx.restore() 将之前保存最近一次的状态,例如坐标系(变换矩阵)等保存的属性。
如果根据当前鼠标移动判断旋转角度
Math.atan2(y,x)
Math.atan2() 返回从原点(0,0)到(x,y)点的线段与 x 轴正方向之间的平面角度(弧度值),也就是 Math.atan2(y,x),返回的夹角是与 X 轴的夹角 返回的是弧度值 弧度 = deg(角度) Math.PI / 180 由此推导出 deg(角度) = 弧度 180 / Math.PI
如上图,如果我们用角度来描述 (10,10) 和 (10,-10) 和(0,0) 都是 45 度。而使用 Math.atan2 可以描述当前位置相对于 (0,0)所在的象限(角度的正负)。
Math.atan2(10,10) = 0.7853981633974483
Math.atan2(-10,10) = -0.7853981633974483
具体实现
// 当鼠标点击的时候记录初始鼠标位置
mouseDown() {
const canvas = this.canvas
canvas.addEventListener('mousedown',e=>{
const {x,y} = this.windowLocToCanvas(e)
this.state.mouseDown = {x,y}
})
},
mouseMove() {
const canvas = this.canvas
canvas.addEventListener('mousemove',e=>{
const loc = this.windowLocToCanvas(e)
const { isRotating } = this.state
// 判断当前物体是否处于旋转控制点
if(isRotating) {
this.calcRotateAngle(loc)
}
})
}
calcRotateAngle(loc){
const { mouseDown:{x:mx,y:my} } = this.state
const shape = this.state.currentShape
// 取出当前图形的中心位置
const [cx,cy] = this.adaptShape(shape,this.state.index).centerPosition
const { x,y } = loc
// 计算鼠标点击的时候相对于中心点的旋转角度
const initDeg = Math.atan2(my-cy,mx-cx)
// 计算当前鼠标位置相对于中心点的旋转角度
const currentDeg = Math.atan2(y-cy,x-cx)
// 两者相减就是旋转的角度
this.state.updateAngle(currentDeg-initDeg)
this.initDraw()
}
说说 ctx.getImageData(sy,sy) ctx.putImageData(imageData,dx,dy)
CanvasRenderingContext2D.getImageData() 返回一个 ImageData 对象,用来描述 canvas 区域隐含的像素数据,这个区域通过矩形表示,起始点为(sx, sy)、宽为 sw、高为 sh。
getImageData 就是将当前 canvas 上的数据存储起来返回一个 ImageData 这样的对象来描述当前保存的 canvas 像素数据。
CanvasRenderingContext2D.putImageData() 是 Canvas 2D API 将数据从已有的 ImageData 对象绘制到位图的方法。 如果提供了一个绘制过的矩形,则只绘制该矩形的像素。此方法不受画布转换矩阵的影响。 putImageData 就是将存储的 ImageData 数据重新绘制到 canvas 上。
ctx.getImageData(sy,sy) 和 ctx.save() 的区别
- ctx.getImageData(sy,sy) 存储的是指定区域的像素,而 ctx.save() 存储的是状态,之前介绍的四种类型的状态。
- ctx.getImageData(sy,sy) 配合 ctx.putImageData(imageData,dx,dy) 来使用。ctx.save() 配合 ctx.restore() 使用。
ctx.putImageData() 和 ctx.clearRect() 的区别
- ctx.clearRect()是将指定区域的像素清除掉,会形成空白的画板。 ctx.putImageData() 是将之前存储的整个指定区域的像素重新绘制到指定区域的。如果存储的像素都是空白,那么两者看起来的效果是相同的。
- 但是 ctx.putImageData() 用途并不是清理的,而是重置画板像素区域的。例如我们绘制来一个网格背景,当我们重新绘制图形的时候,网格背景一直需要,那可以使用 imageData 来存储这个背景,而不需要调用绘制方法重新绘制一次。