小程序Canvas如何实现撤销、笔迹回放操作

本文最后更新于 2 个月前

接上一篇文章。

轨迹自动播放实现

在画布上绘制笔迹的时候,通过touchmove事件将每一次绘制的点坐标都记录起来,同时也要将当前笔迹的颜色、宽度等数据记录起来。最终画布上所有的笔迹都转换成了一个数据列表,当播放的时候,再将列表里面的数据一条一条的在canvas上重新绘制出来,通过setTimeout进行自动循环不断的去绘制。

看代码:

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
class Paint {
// ...省略...
/**
* 从头绘制路径
* @param path 路径
* @param completed 完成回调
* @returns
*/
playPath(path: Path[], completed?: () => void) {
if (!path.length) return Promise.resolve();

this.path = path;

// 初始化
this.row = 0;
this.column = 0;
this.stop = false;
this.isComplete = false;

const { pos, color, width } = path[0];
this.start(pos[0], color, width);

this.run(completed);
if (completed) {
this.completed = completed;
}
}

private completed() {}

private run(completed = this.completed) {
// 结束绘制(下一次播放的时候要结束上一次播放)
if (this.stop) {
return;
}

if (this.column < this.path[this.row].pos.length) {
// 绘制第 n 条轨迹
this.drawLine(this.path[this.row].pos[this.column++]);
setTimeout(() => this.run(), 16.7);
} else {
// 一条轨迹制完成
if (++this.row < this.path.length) {
// 初始化下一条轨迹
this.column = 0;
const { pos, color, width } = this.path[this.row];
this.start(pos[0], color, width);

// 延时一会儿开始绘制下一条轨迹
setTimeout(() => {
setTimeout(() => this.run(), 16.7);
}, 240);
} else {
// 结束
this.isComplete = true;
completed();
}
}
}

/**
* 暂停播放
*/
pause() {
this.stop = true;
}

/**
* 继续播放
* @param completed 完成回调
*/
play(completed?: () => void) {
this.stop = false;
this.run(completed);
}
}
JS

有些命名可能有点不规范,逻辑也有一点复杂。代码中path数据列表保存的是每一次绘制的笔迹,每一条笔迹又包含了一组坐标点、颜色和宽度,类型定义如下:

1
2
3
4
5
6
7
8
9
10
export interface Dot {
x: number;
y: number;
}

export interface Path {
pos: Dot[];
color?: string;
width?: number;
}
TS

最终绘制的时候都是在处理点坐标,就相当于是在处理一个二维数组,所有就通过rowcolumn变量来进行区分。

撤销操作

主要思路就是:每在画布上进行一次绘制,在结束的时候touchend就将当前画布上的数据保存起来,通过ctx.getImageDataapi可以保存当前画布,然后放在一个列表中。同时再用一个变量作为指针来记录当前所在列表的位置,没点击一次撤销操作,指针就在列表中往前移动一步,同理点击恢复撤销时,指针往后移动一步。需要注意的一个点就是,当进行n次撤销操作后,指针不在列表最后的位置时,再进行绘制,此时应该清空列表中指针所在位置后面的数据,并保存当前画布数据。因为每次在画布上的操作,都应该是列表中的最后一条数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const handleTouchEnd = () => {
if (!painting) return;
painting = false;

// 步骤 +1
commit(TypeKeys.OPERATION_ADD);

// 保存路径
const path = state.path.slice(0, state.currentPathIndex);
commit(TypeKeys.SET_PATH, path.concat(currentLine));

// 生成记录
const list = state.historyStepList.slice(0, state.currentStepIndex >= MAX_HISTORY_COUNT - 1 ? state.currentStepIndex + 1 : state.currentStepIndex);
commit(TypeKeys.SET_HISTORY_STEP_LIST, list.concat(paint.value?.getImageData()));
};
TS

这里主要的难点就是对指针的控制,尤其是在列表的临界点时的处理,因为我这里还需要控制路径的列表,所有更加复杂一些,这一块差不多花了我一天的时间,处理不好在撤销的时候就很容易出bug。

由于每次保存的画布数据是比较大的,必须对历史记录列表做一个最大长度的限制,否则就会撑爆内存,小程序闪退。getImageData获取的画布数据大小取决于画布的width*height的大小,由于我这个小程序是全屏的,所以在大屏幕上的数据会非常的大,我目前限制了最大历史记录为10,后期考虑固定画布的大小。

不同大小的画布还会存在一个问题,就是在小屏幕手机上,可能会展示不全大屏幕手机分享的内容。

最后

再放出小程序码,欢迎大家进行体验。

如果觉得不错,可以关注我的公众号【末日码农】,我会将开发中遇到的实际问题和一些好的技术知识分享给大家。


小程序Canvas如何实现撤销、笔迹回放操作
https://codingmo.com/article/20220228/506c7491efc0/
作者
颜漠笑年
发布于
2022年2月28日
许可协议