写在前面
刚开始学习前端那段时间吧,在第一次接触canvas的时候做过一个demo,内容就是一张画布,很简陋,只能画线,也没有其他的功能。
想着马上到年底了也许应该例行更新一下简历,偶然看到了这个画布的demo,点开乱涂一通之后,感觉是时候把简历上的“canvas画板”变成真正的画板了(不然到时候人家看预览不得觉得我捞的淌口水)。
使用vue3 + ts
功能确认
首先来列一下所需要的功能吧,一个看起来能正常使用的画板应该有什么样的功能呢
- 能画(废话)。
- 能擦。
- 能撤销。
- 都能撤销了那肯定也得能前进。
- 笔刷可以设置,需要一个配置区域。
- 画是铅笔擦是橡皮,那就需要一个工具箱,可以进行工具的切换。
- 能清空画布。
- 可以保存为图片。
有这些就差不多了,至于其他工具(比如矩形工具),目前顾不上搞,就先弄这些。
关于布局
开始的想法是左右布局,左边为选择工具和配置工具的区域,右侧为画布。
但考虑到这样的布局压缩了画板的空间,于是改成了弹出式,并且将工具栏和配置区域分为两部分。
需要的差不多都有了,那么我直接进行一个工的开。
过程
前期准备
创建画布和布局比较简单,能说的不多,在这里就不详细展开了。
跳过前期工作,得到了这样一个效果: 收起时:
悬浮弹出
工具栏和配置区域的显示状态通过v-model
绑定状态变量,在子组件中外层容器中使用鼠标移入移出事件,触发update:modelValue
来进行实时更新。
为了优化操作体验,弹出的组件不会在鼠标移出的同时收起,当鼠标移出时,会添加一个定时器,当定时器结束时再去更新显示状态。
// 鼠标移出500ms后收起组件
setTimeout(() => {
showState.value = state;
emit("update:modelValue", showState.value);
}, 500);
这里出现了一个问题,如果鼠标在移出组件到组件收回的期间重新把鼠标移回去,因为设置的计时器没有被清除,导致即便鼠标在组件中,组件还是会在计时器结束后收回,所以还需要一个记录计时器id的变量,如果在计时器结束之前又将鼠标移入,则停止计时,再移出则重新计时。
let timer = 0;
const changeShowState = (state: boolean) => {
// 鼠标移出时开始计时,500ms后改变显示状态。如果期间鼠标再次移入,则清空计时器,再次离开时重新计时
if (!state) {
timer = setTimeout(() => {
showState.value = state;
emit("update:modelValue", showState.value);
}, 500);
} else {
clearTimeout(timer);
showState.value = state;
emit("update:modelValue", showState.value);
}
};
看看效果:
工具切换
因为不同工具需要支持不同的配置方式,通过emit
和props
,将工具栏选中工具信息传给画板,画板再传给配置组件,就可以实现动态的配置项.
绘制
首先需要获取到canvas的context:
context = canvas.value.getContext("2d");
要实现鼠标绘画,需要给canvas添加鼠标事件,鼠标按下时切换绘制状态并记录点击位置,鼠标移动时执行绘制方法,松开按键时再次切换绘制状态。
铅笔
首先需要一个画线方法:
// 绘制线条
const drawLine = (
context: CanvasRenderingContext2D,
x1: number,
y1: number,
x2: number,
y2: number
) => {
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.stroke();
};
在鼠标移动时执行画线方法,每次绘制时,因为画线实际展示的效果是绘制了一个矩形,所以会发现在拐弯处存在缺口:
我的解决方法是先绘制一个圆点,再去画线,这样就可以补上拐点处的缺失。
// 绘制圆点
const drawCircle = (
context: CanvasRenderingContext2D,
x: number,
y: number,
radius: number
) => {
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.closePath();
};
鼠标移动时先画点再画线,就完成了画笔的基本需求,最起码不会中断了。
橡皮
橡皮的做法,首先可以借鉴大佬们提到的使用clip()
方法,步骤大致为
- 保存当前画布:
canvas.save()
- 绘制圆形裁切区
- 清空绘制的裁切区中的内容
- 创建两圆形裁切区之间的矩形裁切区
- 再次清空裁切区内容
- 取出画布:
canvas.restore()
但是也提到了一些问题,清空区域时连带画布的背景色也会被清空(变回透明)。
考虑到这点,我琢磨了一个丐版橡皮的实现方式:
- 画板生成后,保存背景颜色
- 切换橡皮时保存笔刷颜色
- 将fillStyle和strokeStyle切换为保存的背景色,鼠标移动时使用和画笔相同的绘制逻辑
- 切换回画笔时再改回为笔刷颜色
对于简单画板来说,实际需要执行的方法更少,而且还不用担心橡皮会用力过猛擦掉背景(- =)。
撤销、前进
撤销的核心逻辑是每次绘制结束时(即松开按键时)保存canvas内容作为历史,并记录步骤。
// 保存历史记录方法
const saveHistory = () => {
step.value++;
if (step.value < canvasHistory.length) {
canvasHistory.length = step.value;
}
// 保存为base64
canvasHistory.push(canvas.value.toDataURL());
};
撤销时清空画板,后退一步并取出对应步骤的历史记录重新绘制到画布上,前进时同理,改为前进一步即可。
// canvas历史记录
let canvasHistory: string[] = [];
// 当前步骤数
const step = ref(0);
const undo = () => {
if (step.value > 0) {
step.value--;
let canvasPic = new Image();
canvasPic.src = canvasHistory[step.value];
canvasPic.onload = () => {
// 清理画布
context.value?.clearRect(0, 0, canvas.value.width, canvas.value.height);
// 将缓存复制到画布上,通过这种方式可以解决画面闪动的问题
context.value?.drawImage(canvasPic, 0, 0);
};
}
};
const redo = () => {
if (step.value < canvasHistory.length - 1) {
step.value++;
let canvasPic = new Image();
canvasPic.src = canvasHistory[step.value];
canvasPic.onload = () => {
context.value?.clearRect(0, 0, canvas.value.width, canvas.value.height);
context.value?.drawImage(canvasPic, 0, 0);
};
}
};
这里又出现了一个问题,撤销时,在清空画布和重绘内容期间有一小段空白,导致撤销时会出现画布闪烁。 我们可以创建另一个canvas作为缓存,在清空画布后,直接将cache的内容搬到画布上来解决闪烁的问题。
const undo = () => {
if (step.value > 0) {
step.value--;
let canvasPic = new Image();
canvasPic.src = canvasHistory[step.value];
canvasPic.onload = () => {
// 设置缓存
setCache(canvasPic);
// 清理画布
context.value?.clearRect(0, 0, canvas.value.width, canvas.value.height);
// 将缓存复制到画布上,通过这种方式可以解决画面闪动的问题
context.value?.drawImage(cache.value, 0, 0);
};
}
};
const setCache = (imgSrc: HTMLImageElement) => {
cache.value.width = canvas.value.width;
cache.value.height = canvas.value.height;
cacheContext.value = cache.value.getContext("2d");
cacheContext.value?.drawImage(imgSrc, 0, 0);
};
清空画布
没啥好说的,就是跑一下清空= =,需要注意的是清空之后还要保存一下历史记录,不然撤销的时候无法返回清空之前的那一步。
保存
这个也没啥好说的,直接上码
const savePicture = () => {
// 创建一个链接,提供href和下载时的文件名
let a: HTMLAnchorElement = document.createElement("a");
a.href = canvas.value.toDataURL("image/png");
a.download = "picture" + new Date().getTime();
// 模拟点击链接
a.click();
};
写在最后
通篇码下来,突出一个表达和总结能力的欠缺,文化水平有限,语言不通顺之处还望多多包含 附上源码链接(使用vite重新创建了一次项目,后续更新都会在该项目中):画板一张