Canvas:实现矩形元素的拖拽和伸缩

Canvas - drag and stretching of rectangle

nojsja 2022-03-04
字数:4.3k丨 阅读时间:18 分钟

目录

一、什么是 Canvas?

Canvas API 提供了一个通过 JavaScript 和 HTML 的 <canvas> 元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。

Canvas API 主要聚焦于 2D 图形。而同样使用 <canvas> 元素的 WebGL API 则用于绘制硬件加速的 2D 和 3D 图形。现在所有的主流浏览器都支持它。

之前做 Electron 进程监控管理工具 electron-re 的时候,使用过 Canvas 来绘制动态的折线图用于展示进程的 CPU/Memory 占用变化情况。

这次我们用 Canvas 基础绘制 API 来实现一个矩形元素的拖拽和伸缩。

二、前置知识

像素

关于屏幕像素的一些概念:

  • 物理像素(DP)

    物理像素也称设备像素,我们常听到的手机的分辨率及为物理像素,比如 iPhone 7 的物理分辨率为 750 * 1334。屏幕是由像素点组成的,也就是说屏幕的水平方向有 750 的像素点,垂直方向上有 1334 个像素点。

  • 设备独立像素(DIP)

    也称为逻辑像素,比如 Iphone4 和 Iphone3GS 的尺寸都是 3.5 寸,iphone4 的物理分辨率是 640 * 980,而 3gs 只有 320 * 480,假如我们按照真实布局取绘制一个 320px 宽度的图像时,在 iphone4 上只有一半有内容,剩下的一半则是一片空白,为了避免这种问题,我们引入了逻辑像素,将两种手机的逻辑像素都设置为 320px,方便绘制。

  • 设备像素比(DPR)

    上面的设备独立像素说到底是为了方便计算,我们统一了设备的逻辑像素,但是每个逻辑像素所代表的物理像素却不是确定的,为了确定在未缩放情况下,物理像素和逻辑像素的关系,我们引入了设备像素比 (DPR) 这个概念:设备像素比 = 设备像素 / 逻辑像素 (DPR = DP / DIP)。

Canvas 宽高和 CSS 宽高

Canvas 画布的默认大小为 300 像素 ×150 像素(宽 × 高,像素的单位是 px)。但是,可以使用 HTML 的高度和宽度属性来自定义 Canvas 的尺寸。

示例:

1
<canvas width="600" height="300" style="width: 300px; height: 150px"></canvas>
  • style 中的 width 和 height 分别代表 Canvas 这个元素在界面上所占据的宽高,即样式上的宽高,也就是设备独立像素(css 逻辑像素)。
  • attribute 中的 width 和 height 则代表 Canvas 实际像素的宽高,而 Canvas 绘制的图像是位图,也就是物理像素(1 个位图像素对应着 1 个物理像素)。

在设备 dpr = 2 的情况下,如果 Canvas 的宽高和 Css 宽高一致的话,也就是说 Canvas 画布中的物理像素数量更少,不能和屏幕的单个物理像素点一一对应。Canvas 此时会将图形绘制后进行硬放大以填充 Canvas 画布,因此绘制的图形就会模糊。

在设备 dpr = 1 的情况下不会出现这个问题,此时一个 Canvas 像素恰好对应一个物理像素,也对应一个 CSS 像素。

解决绘制模糊

原理:让 Canvas 像素和屏幕物理像素一一对应。

步骤:

  • 先让 canvas 的宽高等同于屏幕的物理像素宽高。
  • 缩放 canvas 让图形显示至正常尺寸。
1
2
3
4
5
6
7
8
9
10
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio; // 假设 dpr 为 2
// 获取 css 的宽高
const {width: cssWidth, height: cssHeight} = canvas.getBoundingClientRect();
// 根据 dpr,扩大 canvas 画布的像素,使 1 个 canvas 像素和 1 个物理像素相等
canvas.width = dpr * cssWidth;
canvas.height = dpr * cssHeight;
// 由于画布扩大,canvas 的坐标系也跟着扩大,如果按照原先的坐标系绘图内容会缩小,所以需要将绘制比例放大
ctx.scale(dpr,dpr);

Canvas 中的坐标系

在 2D 绘图环境中的坐标系统,默认情况下是与窗口坐标系统相同,它以 canvas 的左上角为坐标原点,沿 x 轴向右为正值,沿 y 轴向下为正值。其中 canvas 坐标的单位都是 “px”。

三、关键步骤说明

1. 调用和交互方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建画板
const drawer = new Drawer('#drawer');
// 创建几何图形
const rect = new DragableAndScalableRect({
x: 500, // 图形中心点 x 坐标,非左上角坐标
y: 300, // 图形中心点 y 坐标,非左上角坐标
width: 200, // 图形宽度
height: 200, // 图形高度
minWidth: 20, // 最小宽度
minHeight: 20, // 最小高度
cornerWidth: 20 // 用于伸缩的四个角小矩形宽度
});
// 将几何图形添加到画板
drawer.addPolygon(rect);

由上,我们采用面向对象方式,创建几个类,用于分离职责:

  • Drawer:画板类用于添加图形、调用图形绘制方法、监听窗口变化进行重绘、响应鼠标事件等等。
  • DrawHelper:辅助绘制类用于执行点阵绘制、获取鼠标在 Canvas 坐标系中的位置、清除矩形区域、检查图形参数是否合法等等。
  • Polygon:几何图形类,提供基本方法和模板方法,一些方法需要具体的图形子类进行实现。
  • DragableAndScalableRect:拖拽和缩放类,单个图形坐标的更新、几何位置计算、回执和销毁的具体逻辑、判断某个点是否位于图形内部等都是在这个类中实现的。

2. 创建画板类

1)职责

  • 添加图形。
  • 调用图形绘制方法。
  • 监听窗口变化进行重绘。
  • 响应鼠标事件。

2)功能实现点

  • 构造函数中我们通过 CSS 选择器来获取 Canvas 元素,并设置画板的宽高,同时通过 resize 方法缩放画板让屏幕像素和 Canvas 像素一一对应。
  • 画板可以添加多个图形元素,存放在 polygons 数组中,添加后 Canvas 的绘制上下文会和此元素进行绑定。图形也可以从画板中移除,对应的 ctx 与元素解绑。
  • 画板的 render 方法用于画布所有元素的绘制,会调用所有 polygon 对象的 draw 方法进行具体的内容绘制。
  • 画板类中需要绑定鼠标的按下、移动、抬起事件。当鼠标按下时遍历 polygons 数组选择第一个被点击的对象,判断当前 polygon 对象的事件响应情况,本例中仅有缩放和拖动两种事件。鼠标移动过程中如果当前有需要响应的 polygon 对象,则会实时向 polygon 对象发送当前的鼠标坐标信息用于坐标计算。
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
/* -------------- canvas 画板类 -------------- */
class Drawer {
constructor(selector) {
this.polygons = [];
this.me = document.querySelector(selector);
this.ctx = null;
this.target = null; // 单点操作目标

this.me.onmousedown = this.onMouseDown;
this.me.onmouseup = this.onMouseUp;
this.me.onmousemove = this.onMouseMove;

if (this.me.getContext) {
this.ctx = this.me.getContext('2d');
this.resize();
} else {
throw new Error('canvas context:2d is not available!');
}
}

onResize() {
this.resize();
this.clear();
this.render();
}

resize() {
const rect = this.me.getBoundingClientRect();
this.me.width = rect.width * window.devicePixelRatio;
this.me.height = rect.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}

clear() {
const rect = this.me.getBoundingClientRect();
this.ctx.clearRect(0, 0, this.me.width, this.me.height);
}

render() {
this.polygons.forEach(polygon => polygon.draw());
}

addPolygon(polygon) {
this.polygons.push(polygon);
polygon.attach(this.ctx);
polygon.draw();
}

removePolygon(polygon) {
const index = this.polygons.indexOf(polygon);
if (index !== -1) {
this.polygons[index].destroy();
this.polygons[index].detach();
this.polygons.splice(index, 1);
}
}

onMouseDown = (event) => {
const point = DrawHelper.getMousePosition(this.me, event);
for (let i = 0; i < this.polygons.length; i++) {
if (this.polygons[i].isInCornerPath(point) && this.polygons[i].scalable) {
this.polygons[i].scaleStart(point);
this.target = this.polygons[i];
break;
}
if (this.polygons[i].isInPath(point) && this.polygons[i].dragable) {
this.polygons[i].dragStart(point);
this.target = this.polygons[i];
break;
}
}
}

onMouseMove = (event) => {
const point = DrawHelper.getMousePosition(this.me, event);
if (!this.target) return;
switch (this.target.status) {
case 'draging':
this.target.drag(point)
break;
case 'scaling':
this.target.scale(point)
break;
default:
break;
}
}

onMouseUp = (event) => {
const point = DrawHelper.getMousePosition(this.me, event);
if (!this.target) return;
switch (this.target.status) {
case 'draging':
this.target.dragEnd(point)
break;
case 'scaling':
this.target.scaleEnd(point)
break;
default:
break;
}
this.target = null;
}
}

3. 创建绘制辅助类

1)职责

  • 执行点阵绘制。
  • 清除矩形区域。
  • 检查图形参数是否合法。

2)功能实现点

  • drawPoints 方法会拿到一个点阵数组,使用 Canvas 的 context.beginPath 方法开始绘制, ctx.moveTo 移动到某个点ctx.lineTo 连接点为线段,ctx.stoke 绘制路径。注意:本例中绘制矩形时没有直接使用内置API strokeRect(x, y, width, height),而直接使用此方法进行线段绘制。
  • getMousePosition 用于获取鼠标相对于 canvas 的位置信息,也就是在 canvas 中的坐标,注意 CSS 像素需要乘 canvas 缩放比例:rect.top * (canvas.height / rect.height)
  • clearRect 清除指定坐标和宽高的矩形区域。
  • checkGeometry 检查图形参数是否合法,参数需要为正值。
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
/* -------------- 绘制辅助类 -------------- */
class DrawHelper {
// 执行绘制
static drawPoints(ctx, points) {
const firstPoint = points[0];
ctx.strokeStyle = 'black';
ctx.beginPath();

ctx.moveTo(firstPoint.x, firstPoint.y);
points.forEach(point => {
ctx.lineTo(point.x, point.y);
});

ctx.lineTo(firstPoint.x, firstPoint.y);
ctx.stroke();
}

// 获取鼠标位置
static getMousePosition(canvas, event) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left * (canvas.width / rect.width);
const y = event.clientY - rect.top * (canvas.height / rect.height);

return {x, y};
}

// 清除矩形区域
static clearRect(ctx, x, y, width, height) {
ctx.clearRect(x, y, width, height);
}

// 检查参数 geometry
static checkGeometry(geometry) {
const keys = Object.keys(geometry);
for (let i = 0; i < keys.length; i++) {
if (geometry[keys[i]] < 0) {
throw new Error(`geometry: value of ${keys[i]} is no less than 0!`);
}
}
return geometry;
}

static drawRect() {}
}

4. 创建通用几何图形类

1)职责

  • 提供子类的模板方法和属性。
  • 提供外部接口供画板类调用以响应用户操作。

2)功能实现点

  • scaleStart 开始缩放。
  • scale 缩放中。
  • scaleEnd 缩放结束。
  • dragStart 开始拖拽。
  • drag 拖拽中。
  • dragEnd 拖拽结束。
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
/* -------------- 几何图形类 -------------- */
class Polygon {
dragable=false
scalable=false
status='pending'
prePoint=null
constructor() {
this.ctx = null;
}

draw() {}
destroy() {}

attach(ctx) {
this.ctx = ctx;
}
detach() {
this.ctx = null;
}

isInPath(point) { return false }
isInCornerPath(point) { return false }

scaleStart(point) {
this.status = 'scaling';
this.prePoint = point;
}
scale(point) {
this.destroy();
this.update(point);
this.draw();
}
scaleEnd(point) {
this.status = 'pending';
this.destroy();
this.update(point);
this.draw();
this.prePoint = null;
}

dragStart(point) {
this.status = 'draging';
this.prePoint = point;
}
drag(point) {
this.destroy();
this.update(point);
this.draw();
}
dragEnd(point) {
this.status = 'pending';
this.destroy();
this.update(point);
this.draw();
this.prePoint = null;
}

}

5. 创建拖动和伸缩矩形类

1)职责

  • 图形坐标的更新
  • 几何位置计算
  • 绘制和销毁的具体逻辑实现
  • 判断某个点是否位于图形响应区域

2)功能实现点

  • isInPath 判断某个坐标点是否位于当前矩形的四个顶点以内。
  • isInCornerPath 判断某个坐标点是否位于当前矩形的四个可伸缩顶点的四个矩形以内。
  • draw 方法调用 DrawHelper 将组成当前图形的各个部分的点阵坐标数组绘制到画布上。
  • destroy 根据矩阵坐标和宽高进行局部画布清除,注意的是需要将传入宽高和位置进行一定的调整让实际清除区域大于矩形区域,否则可能会残留一部分矩形的边线区域在画布上。
  • updateWhenDraging 拖动时,更新图形的矩阵坐标。首先需要记录 prePoint 也就是上个触发坐标点,然后将当前坐标 point 和 prePoint 相减获得距离差值。最后将当前图形的 坐标 + 差值 就可以得到最新的图形中心点的位置,而图形的宽高保持不变。
  • updateWhenScaling 缩放时,更新图形的矩阵坐标。首先需要记录 prePoint 也就是上个触发坐标点,然后将当前坐标 point 和 prePoint 相减获得 距离差值。注意这里图形的坐标算法和拖动时不相同,缩放时,坐标点 x,y 和 图形的宽高 width,height 都需要进行调整。
    • 注意缩放时检查是否到达 width 和 height 的最小临界点,如果到达则不再进行坐标更新。
    • x,y 的坐标位移为之前获得的距离差值的一半,因为 width 和 height 同时也在改变。
    • 而 width 和 height 的计算在各个顶点的计算情况也是不同的。
      • 左上角:距离差值对 width 和 height 都产生 负增益 效果。可以想象我们从 左上角往右下角 拉时,width 和 height 都会 缩小,而左上角到右下角这个方向即坐标系的 正方向
      • 右上角:距离差值对 width 产生 正增益 效果,对 height 产生 负增益 效果。
      • 右下角:距离差值对 width 和 height 都产生 正增益 效果。
      • 左下角:距离差值对 width 产生 负增益 效果,对 height 产生 正增益 效果。
  • getPoints 根据图形属性 x, y, width, height 等计算出图形的各个坐标顶点。
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
class DragableAndScalableRect extends Polygon {
minWidth = 0
minHeight = 0
constructor(geometry) {
super();
this.geometry = DrawHelper.checkGeometry(geometry);
this.minWidth = 'minWidth' in geometry ? geometry.minWidth : this.minWidth;
this.minHeight = 'minHeight' in geometry ? geometry.minHeight : this.minHeight;
this.points = this.getPoints();
this.cornerPoint = null;
this.dragable = true;
this.scalable = true;
}

// 判断点击位置是否在图形内部
isInPath(point, geometry) {
const {x, y, width, height} = geometry || this.geometry;
return (point.x>= x - width/2) &&
(point.x <= x + width/2) &&
(point.y>= y - height/2) &&
(point.y <= y + height/2);
}

// 判断点击位置是否在四个角
isInCornerPath(point) {
const [rectPoints, ...cornerPoints] = this.points;
const {cornerWidth} = this.geometry;
for (let i = 0; i < rectPoints.length; i++) {
if (
this.isInPath(
point,
{...rectPoints[i], width: cornerWidth, height: cornerWidth})
) {
this.cornerPoint = i;
return true;
}
}
this.cornerPoint = null;
return false;
}

// 根据点阵绘制图形
draw() {
this.points.forEach(pointArray => {
if (Array.isArray(pointArray)) {
DrawHelper.drawPoints(this.ctx, pointArray);
}
});
}

// 销毁图形
destroy() {
const {width, height, cornerWidth} = this.geometry;
const [rectPoints, ...cornerPoints] = this.points;
const leftTopPoint = rectPoints[0];
DrawHelper.clearRect(this.ctx, leftTopPoint.x - 1, leftTopPoint.y - 1, width + 2, height + 2);
cornerPoints.forEach((cPoint) => {
DrawHelper.clearRect(this.ctx, cPoint[0].x - 1, cPoint[0].y - 1, cornerWidth + 2, cornerWidth + 2);
});
}

updateWhenDraging(point) {
const {prePoint} = this;
this.geometry.x = this.geometry.x + (point.x - prePoint.x);
this.geometry.y = this.geometry.y + (point.y - prePoint.y);
this.points = this.getPoints();
this.prePoint = point;
}

updateWhenScaling(point) {
const {prePoint} = this;
const xDistance = (point.x - prePoint.x);
const yDistance = (point.y - prePoint.y);
const newGeometry = {...this.geometry};

switch (this.cornerPoint) {
case 0:
newGeometry.x = this.geometry.x + (xDistance) / 2;
newGeometry.y = this.geometry.y + (yDistance) / 2;
newGeometry.width = this.geometry.width - (xDistance);
newGeometry.height = this.geometry.height - (yDistance);
break;
case 1:
newGeometry.x = this.geometry.x + (xDistance) / 2;
newGeometry.y = this.geometry.y + (yDistance) / 2;
newGeometry.width = this.geometry.width + (xDistance);
newGeometry.height = this.geometry.height - (yDistance);
break;
case 2:
newGeometry.x = this.geometry.x + (xDistance) / 2;
newGeometry.y = this.geometry.y + (yDistance) / 2;
newGeometry.width = this.geometry.width + (xDistance);
newGeometry.height = this.geometry.height + (yDistance);
break;
case 3:
newGeometry.x = this.geometry.x + (xDistance) / 2;
newGeometry.y = this.geometry.y + (yDistance) / 2;
newGeometry.width = this.geometry.width - (xDistance);
newGeometry.height = this.geometry.height + (yDistance);
break;
default:
return;
}

if (
newGeometry.width < this.minWidth ||
newGeometry.height < this.minHeight
) {
return;
}
this.geometry = newGeometry;
this.points = this.getPoints();
this.prePoint = point;
}

// 实时更新点阵坐标
update(point) {
switch (this.status) {
case 'draging':
this.updateWhenDraging(point);
break;
case 'scaling':
this.updateWhenScaling(point);
break;
default:
break;
}
}

// 获取矩形四个角
getPointFromGeometry(x, y, width, height) {
return {
leftTopPoint: {
x: x - width / 2,
y: y - height / 2
},
rightTopPoint: {
x: x + width / 2,
y: y - height / 2
},
leftBottomPoint: {
x: x - width / 2,
y: y + height / 2
},
rightBottomPoint: {
x: x + width / 2,
y: y + height / 2
}
};
}

// 获取几何图形点阵
getPoints() {
const {x, y, width, height, cornerWidth} = this.geometry;
const rectPosition = this.getPointFromGeometry(x, y, width, height);
const leftTopPoint = rectPosition.leftTopPoint;
const rightTopPoint = rectPosition.rightTopPoint;
const leftBottomPoint = rectPosition.leftBottomPoint;
const rightBottomPoint = rectPosition.rightBottomPoint;

const leftTopRectPosition = this.getPointFromGeometry(leftTopPoint.x, leftTopPoint.y, cornerWidth, cornerWidth);
const rightTopRectPosition = this.getPointFromGeometry(rightTopPoint.x, rightTopPoint.y, cornerWidth, cornerWidth);
const rightBottomRectPosition = this.getPointFromGeometry(rightBottomPoint.x, rightBottomPoint.y, cornerWidth, cornerWidth);
const leftBottomRectPosition = this.getPointFromGeometry(leftBottomPoint.x, leftBottomPoint.y, cornerWidth, cornerWidth);

const leftTopRect = [
leftTopRectPosition.leftTopPoint,
leftTopRectPosition.rightTopPoint,
leftTopRectPosition.rightBottomPoint,
leftTopRectPosition.leftBottomPoint
];
const rightTopRect = [
rightTopRectPosition.leftTopPoint,
rightTopRectPosition.rightTopPoint,
rightTopRectPosition.rightBottomPoint,
rightTopRectPosition.leftBottomPoint
];
const rightBottomRect = [
rightBottomRectPosition.leftTopPoint,
rightBottomRectPosition.rightTopPoint,
rightBottomRectPosition.rightBottomPoint,
rightBottomRectPosition.leftBottomPoint
];
const leftBottomRect = [
leftBottomRectPosition.leftTopPoint,
leftBottomRectPosition.rightTopPoint,
leftBottomRectPosition.rightBottomPoint,
leftBottomRectPosition.leftBottomPoint
];

return [
[
leftTopPoint, rightTopPoint, rightBottomPoint, leftBottomPoint
],
leftTopRect,
rightTopRect,
rightBottomRect,
leftBottomRect
];
}
}

6. 处理窗口大小变化

监听窗口 resize 事件,然后调用画布的 onResize 方法清除画布并重新绘制各个已经添加到画布的图形对象。进阶需要考虑的是使用节流和去抖函数进行性能优化。

1
2
3
window.onresize = () => {
drawer.onResize();
}

四、完整源码

>> github 地址

五、结语

Canvas 画布为前端增加了无限的图形操作能力和创造性,应用场景挺多,比如:图像编辑、动画渲染、配合 WebGL 的 3D 场景、视频帧处理、游戏开发等等。挺好玩的东西,有精力和兴趣的可以深入研究一下。

[ loading ]⇷⇷