通过画布实现多层画板的移动、缩放、状态保存、画笔绘制、图形绘制、设置背景图片和画布导出图像
继上次的大文件上传也过了一个月,这次又尝试了canvas。之前基本没尝试过使用canvas,只是听说过水印制作需要用到canvas。这次好好的学习了下,canvas能实现的远不止这些。这不就来实现了个简单的画板。
前言
现在也已经大三下了,大厂的实习找了一圈都寄了,八股文背的太难受了。就跑到掘金上找demo做做转换下心情,就看到了用canvs在图片上做标注的文章,于是就想着做一个画板玩玩。
项目地址
chenwing-gum/DrawBoard (github.com)
效果预览
设计思路
整个画板的实现主要依赖了canvas的绘制方法,不熟悉的同学可以先看看MDN上的Canvas 教程 - Web API 接口参考 | MDN (mozilla.org),基本api上面都有介绍
1. Canvas的初始化
这里提一嘴,我们的canvans需要一个外部元素包裹。然后设置overflow: hidden来隐藏溢出的元素,同时我们也可以通过外部元素来获取canvans的尺寸以及偏移量。
// 创建canvas的2d渲染上下文
ctx = canvas.value.getContext("2d");
// 设置画布中心为放大原点
canvas.value.style.transformOrigin = `${wrap.value.offsetWidth / 2}px ${
wrap.value.offsetHeight / 2
}px`;
imgCanvas.value.style.transformOrigin = `${wrap.value.offsetWidth / 2}px ${
wrap.value.offsetHeight / 2
}px`;
// 启动监听
handleCanvs();
2. 监听画布的鼠标事件
在画图的过程基本上就是鼠标第一下点击开始绘图、鼠标移动进行绘图、鼠标抬起结束绘图。所以我们只需要监听第一下点击,然后去触发对应模式的监听方法。这里我们多监听一个鼠标滚动事件来缩放我们的画布。
这里我们监听还能获取到鼠标的坐标即各种事件的开始坐标。
注意:
因为wrap设置了相对定位,获取的pageX/Y是以wrpa为基准的,要减去wrap左边和上边的偏移量。画布移动是根据我们鼠标为基准的,就不需要减了。
// 监听鼠标按下以及滚轮事件
const handleCanvs = () => {
wrap.value.onmousedown = null;
wrap.value.onmousedown = function (event) {
// 获取当前鼠标坐标
const downX = event.pageX;
const downY = event.pageY;
// 设置画出的线条样式
ctx.strokeStyle = lineColor.value;
ctx.lineWidth = lineWidth.value;
// 计算容器的位置
const offsetX = parseInt(wrap.value.offsetLeft);
const offsetY = parseInt(wrap.value.offsetTop);
// 根据鼠标状态判断执行什么方法
switch (MouseState.value) {
case "MOVE_MODE":
handleMoveMode(downX, downY);
break;
case "LINE_MODE":
handleLineMode(downX - offsetX, downY - offsetY, offsetX, offsetY);
break;
case "ERASER_MODE":
handleLineMode(downX - offsetX, downY - offsetY, offsetX, offsetY);
break;
case "SHAPE_MODE":
handleShapeMode(downX - offsetX, downY - offsetY, offsetX, offsetY);
break;
default:
break;
}
};
// 监听数鼠标滚动
wrap.value.onwheel = null;
wrap.value.onwheel = (event) => {
const { deltaY } = event;
const newScale =
deltaY > 0
? (canvasScale * 10 - 0.1 * 10) / 10
: (canvasScale * 10 + 0.1 * 10) / 10;
if (newScale >= 0.1 || newScale <= 2) {
canvasScale = newScale;
scaleControl.value =
deltaY > 0 ? scaleControl.value + 1 : scaleControl.value - 1;
// 设置缩放
setcanvasScale();
}
};
};
注意:
我给外部元素设置了相对定位,这会导致画布中监听事件的坐标获取是以外部元素为基准的,所以我们传入的初始坐标就需要减去外部元素的x轴以及y轴的偏移量。
3. 设置背景图
canvas中提供了drawImage方法,可以直接将img元素绘制到canvas上。
upDataCanvas()方法在后面有讲,可以先不管
const uploadImage = (url) => {
const image = new Image();
image.src = window.URL.createObjectURL(url);
// 实例准备完成后渲染到背景图画布上
image.onload = () => {
imgCtx.drawImage(image, 0, 0, canvas.value.width, canvas.value.height);
upDataCanvas();
};
4. 实现画布的移动与缩放
移动与缩放其实只需要设置transform的scale和translate就能实现。
// 移动模式下监听方法
const handleMoveMode = (downX, downY) => {
// 获取上次移动完成的偏移量
const fillStartPointx = fillStartPointX;
const fillStartPointy = fillStartPointY;
wrap.value.onmousemove = (event) => {
const moveX = event.pageX;
const moveY = event.pageY;
// 得出当前移动的偏移量
translatePointX = fillStartPointx + (moveX - downX);
translatePointY = fillStartPointy + (moveY - downY);
setcanvasScale();
};
wrap.value.onmouseup = (event) => {
const upX = event.pageX;
const upY = event.pageY;
wrap.value.onmousemove = null;
wrap.value.onmouseup = null;
// 设置最后的偏移量
fillStartPointX = fillStartPointx + (upX - downX);
fillStartPointY = fillStartPointy + (upY - downY);
};
};
// 设置画布位置及缩放
const setcanvasScale = () => {
canvas.value &&
(canvas.value.style.transform = `scale(${canvasScale}, ${canvasScale}) translate(${translatePointX}px, ${translatePointY}px)`);
};
5. 坐标转化
在开始实现画笔功能之前,我们需要知道一点。
当画布缩放的时候,此时我们的鼠标位置跟画布中的坐标已经对不上了。此时我们就需要将两者坐标进行一个转换。
不想看推导过程可以看完结论直接跳过。
坐标转换公式
(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
参数解释:
- transformOrigin :canvas的变化基点位置
- downX:鼠标点击位置
- scale:放大倍数
- translateX:canvas偏移量
这里我写了个demo来计算公式:
我们先画一个360 * 360的正方形并绘制一些点,并将变化基点设置到中心位置(180, 180)
我们把正方形分成了三份这样我们就很容易得出这些点的坐标了
我们将其放大三倍来看看这些点变化之后的坐标
我们来对比一下这些点的变化,放大两倍的点的坐标看的不是很明显就不放了
中心坐标(centerX, centerY): 180, 180
放大后->放大前
放大倍数(scale): 3
0, 0 -> 120, 120
120, 0 -> 160, 120
240, 0 -> 200, 120
360, 0 -> 240, 120
0, 120 -> 120, 160
120, 120 -> 160, 160
240, 120 -> 200, 160
360, 120 -> 240, 160
放大倍数(scale) 2
60, 60 -> 120, 120
140, 60 -> 160, 120
220, 60 -> 200, 120
300, 60 -> 240, 120
60, 140 -> 120, 160
140, 140 -> 160, 160
300, 140 -> 240, 160
220, 140 -> 200, 160
这个时候我在纸上推导了半天没想出来结果,我就想着把点的变化画出来试试。
这个时候就能看出问题了,两个点与中心点的连线是在同一条直线上的,并且两个点的距离正好就是 直线长度 * (1-1/放大倍数)
直线的长度我们就分摊到x轴与y轴。
于是我们就得到了上面的公式。
转化函数:
// 转化坐标,放大/缩小后坐标会有偏移
const getRealPoint = (x, y) => {
const pointX =
((wrap.value.offsetWidth / 2 - x) / canvasScale) * (canvasScale - 1) +
x -
translatePointX;
const pointY =
((wrap.value.offsetHeight / 2 - y) / canvasScale) * (canvasScale - 1) +
y -
translatePointY;
return { pointX, pointY };
};
这下主要问题就解决了
6. 实现画笔的绘制功能
画笔呢其实就是不停的画线段,画很小的线段。所以,我们监听鼠标的移动,每次移动我们都画一个线段,最后呈现的效果就是画笔跟随鼠标进行绘制。
// 画笔模式监听方法
const handleLineMode = (downX, downY, offsetX, offsetY) => {
const { pointX, pointY } = getRealPoint(downX, downY);
ctx.beginPath();
ctx.moveTo(pointX, pointY);
canvas.value.onmousemove = null;
canvas.value.onmousemove = (e) => {
// 因为wrap设置了相对定位,获取的pageX/Y是以wrpa为基准的,要减去wrap左边和上边的偏移量
const moveX = e.pageX - offsetX;
const moveY = e.pageY - offsetY;
const { pointX, pointY } = getRealPoint(moveX, moveY);
ctx.lineCap = "round";
ctx.lineTo(pointX, pointY);
ctx.stroke();
};
canvas.value.onmouseup = () => {
ctx.closePath();
canvas.value.onmousemove = null;
canvas.value.onmouseup = null;
upDataCanvas();
};
};
7. 实现橡皮擦功能
我一开始想直接用clearRect方法直接清除路径上的内容,这里就会导致背景也被擦除。
于是我就想,我们在主画布下再定义一个画布,用来放背景图然后用getImageData和putImageData将背景画布中的当前位置的图像信息覆盖到上层画布的当前位置上。
路径跟画笔是一样的,所以我就在同一个方法里分别处理
const handleLineMode = (downX, downY, offsetX, offsetY) => {
...
canvas.value.onmousemove = (e) => {
...
if (MouseState.value == "ERASER_MODE") {
let pxs = imgCtx.getImageData(
pointX - eraserWidth.value / 2,
pointY - eraserWidth.value,
eraserWidth.value,
eraserWidth.value
);
ctx.putImageData(
pxs,
pointX - eraserWidth.value / 2,
pointY - eraserWidth.value
);
} else {
...
}
};
canvas.value.onmouseup = () => {
...
};
};
这样就能达到效果
8. 多图层设计
当时实现橡皮擦功能时想到了使用多图层,但其实所有的操作基本都还是在主画布上进行的。多图层的使用也只是单纯的为了实现橡皮擦。后来突然想了下要是我只想换背景怎么办。因为我是使用drawImage,更换图片的时候会直接覆盖当前画布上的所有内容。
当时为了解决这个问题甚至想过把每次绘制的线段图形全部保存下来,更换背景的时候再全部放上去。现在想起来真是给我蠢哭了。
既然我们使用了多图层,为什么我们不将绘制图层跟背景图层分开使用,这样我们就可以更随心所欲的操控整个画布了。
所以我最后是让绘制图层为全透明,这样就能看到背景图层,并且在绘制图层的操作看起来就是在背景图上操作。
实现
在初始化时我们直接ctx.fillStyle = "rgba(255, 255, 255, 0)";
一句就让绘制图层变为透明。然后我们的橡皮操作只需要使用clearRect
清除路径上的线段即可。
结果只需要加两句话就能解决橡皮擦问题。
上面的获取底部图层区域内容再覆盖到上层图层的操作我还是写进来了,万一有些奇怪的场景可以用上。
9. 撤销恢复与清除
撤销恢复主要就是需要将我们画布的每一次操作都进行一个记录,然后根据对应的操作将保存的状态绘制到画布上。
首先我们来定义几个存储变量:
- currentCanvas:画布当前状态位置
- canvasHistory:存储画布状态
存储画布状态方法:
const upDataCanvas = () => {
// 获取整个画布的信息
const canvasData = ctx.getImageData(
0,
0,
canvas.value.width,
canvas.value.height
);
// 如果当前画布位置不是最后一个,我们更新画布就要去掉后面的状态
if (currentCanvas.value < canvasHistory.length - 1) {
canvasHistory = canvasHistory.slice(0, currentCanvas.value + 1);
}
// 存储当前画布状态并且更新画布位置
canvasHistory.push(canvasData);
currentCanvas.value += 1;
};
更新画布状态方法:
// canvasData 画布信息
const setCanvas = (canvasData) => {
ctx.putImageData(canvasData, 0, 0);
};
何时进行画布的存储
我们存储应该发生在一个操作完成的时候,而这不就是当我们抬起鼠标的时候吗。我们每个操作中都对鼠标抬起进行了监听,我们在这触发更新方法。
canvas.value.onmouseup = () => {
...
upDataCanvas();
};
撤销恢复与清除
撤销与恢复就很简单了嘛,获取下对应的画布信息然后调用一下更新方法,然后判断一下边界情况。清除那更简单,把相关参数重置一下然后取最开始的画布信息更新。
// 撤销方法
const revokeDraw = () => {
if (currentCanvas.value > 0) {
currentCanvas.value--;
const canvasData = canvasHistory[currentCanvas.value];
setCanvas(canvasData);
}
};
// 恢复方法
const restoreDraw = () => {
if (currentCanvas.value < canvasHistory.length - 1) {
currentCanvas.value++;
const canvasData = canvasHistory[currentCanvas.value];
setCanvas(canvasData);
}
};
// 重置画布方法
const resetDraw = () => {
currentCanvas.value = 0;
canvasHistory = canvasHistory.slice(0, 1);
const canvasData = canvasHistory[0];
canvasScale = 1;
translatePointX = 0;
translatePointY = 0;
fillStartPointX = 0;
fillStartPointY = 0;
scaleControl.value = 50;
setcanvasScale();
setCanvas(canvasData);
};
这里清除我们只清除主图层的内容,并没有清除背景图层的内容。我单独对背景图层的清除做了处理。
背景图层的清空直接把整个画布涂白就好了
// 清除背景
const delBg = () => {
// 将整个背景画布涂白
imgCtx.fillStyle = "#fff";
imgCtx.fillRect(0, 0, imgCanvas.value.width, imgCanvas.value.height);
upDataCanvas();
};
10. 图形的绘制
基础图形的绘制方法在MDN的canvas教程里面都有非常详细的介绍。这里就不多赘述了。
这里的图形绘制有两个点
- 鼠标拖动绘制
- 特殊图形监听shift的按下进行绘制
我们依次分析
鼠标拖动绘制:
这里拖动绘制的难点并不在拖动,拖动的实现其实很简单——监听鼠标移动计算坐标差绘制图形。
这里的问题是,我们想要根据拖动来绘制,那么跟画笔的操作类似。每次移动都进行绘制,但是问题就在这里。先看看这么做的后果。
可以看到,中间出现了很多的矩形,这就是每次都绘制的后果。
那么怎么解决呢?
我们从需求出发,我们是不要中间的矩形的。换句话说就是我们只要最后一次的结果。那么我们每次绘制的时候都清除前一次绘制的结果就行了。
我们之前也实现了画布撤销功能,那么我们每次拖动时都先进行一次撤销是不是就达到效果了。这里要注意一点,我们需要保留绘制之前的初始画布状态。不然我们第一次绘制不就多进行了一次撤销吗。
const handleRectangleMode = (downX, downY, offsetX, offsetY) => {
// 保存初始状态
upDataCanvas();
// 起始位置坐标
const { pointX: startX, pointY: startY } = getRealPoint(downX, downY);
canvas.value.onmousemove = null;
canvas.value.onmousemove = (e) => {
revokeDraw();
const moveX = e.pageX - offsetX;
const moveY = e.pageY - offsetY;
const { pointX: endX, pointY: endY } = getRealPoint(moveX, moveY);
// 计算矩形的左上角坐标以及长宽
const rectangleX = startX <= endX ? startX : endX;
const rectangleY = startY <= endY ? startY : endY;
const rectangleWidth = Math.abs(endX - startX);
const rectangleHeight = Math.abs(endY - startY);
ctx.strokeRect(rectangleX, rectangleY, rectangleWidth, rectangleHeight);
upDataCanvas();
};
canvas.value.onmouseup = () => {
canvas.value.onmousemove = null;
canvas.value.onmouseup = null;
};
};
完美解决
特殊图形监听shift的按下进行绘制:
这里的特殊图形也就是正方形跟圆形,做法也很简单。对键盘事件进行监听,按下了shift就根据正方形跟圆形的绘制方法进行绘制。没什么难点就直接放代码了。
矩形绘制
const handleRectangleMode = (downX, downY, offsetX, offsetY) => {
upDataCanvas();
const { pointX: startX, pointY: startY } = getRealPoint(downX, downY);
document.onkeydown = (event) => {
if (event.key == "Shift") {
shiftDown = true;
}
};
document.onkeyup = () => {
shiftDown = false;
};
canvas.value.onmousemove = null;
canvas.value.onmousemove = (e) => {
revokeDraw();
const moveX = e.pageX - offsetX;
const moveY = e.pageY - offsetY;
const { pointX: endX, pointY: endY } = getRealPoint(moveX, moveY);
const rectangleX = startX <= endX ? startX : endX;
const rectangleY = startY <= endY ? startY : endY;
if (shiftDown) {
const squaerWidth =
Math.abs(endX - startX) < Math.abs(endY - startY)
? Math.abs(endX - startX)
: Math.abs(endY - startY);
if (endX < startX) {
if (endY < startY)
ctx.strokeRect(startX, startY, -squaerWidth, -squaerWidth);
else ctx.strokeRect(startX, startY, -squaerWidth, squaerWidth);
} else ctx.strokeRect(rectangleX, rectangleY, squaerWidth, squaerWidth);
} else {
const rectangleWidth = Math.abs(endX - startX);
const rectangleHeight = Math.abs(endY - startY);
ctx.strokeRect(rectangleX, rectangleY, rectangleWidth, rectangleHeight);
}
upDataCanvas();
};
canvas.value.onmouseup = () => {
canvas.value.onmousemove = null;
canvas.value.onmouseup = null;
};
};
圆形绘制
const handleRoundMode = (downX, downY, offsetX, offsetY) => {
upDataCanvas();
const { pointX: startX, pointY: startY } = getRealPoint(downX, downY);
document.onkeydown = (event) => {
if (event.key == "Shift") {
shiftDown = true;
}
};
document.onkeyup = () => {
shiftDown = false;
};
canvas.value.onmousemove = null;
canvas.value.onmousemove = (e) => {
revokeDraw();
const moveX = e.pageX - offsetX;
const moveY = e.pageY - offsetY;
const { pointX: endX, pointY: endY } = getRealPoint(moveX, moveY);
ctx.beginPath();
const centerX = startX + (endX - startX) / 2;
const centerY = startY + (endY - startY) / 2;
if (shiftDown) {
const radius =
Math.abs(endX - startX) < Math.abs(endY - startY)
? Math.abs(endX - startX) / 2
: Math.abs(endY - startY) / 2;
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2, true);
} else {
const LongAxis = Math.abs(endX - startX) / 2;
const ShortAxis = Math.abs(endY - startY) / 2;
ctx.ellipse(centerX, centerY, LongAxis, ShortAxis, 0, 0, 2 * Math.PI);
}
ctx.stroke();
upDataCanvas();
};
canvas.value.onmouseup = () => {
canvas.value.onmousemove = null;
canvas.value.onmouseup = null;
};
};
11. 根据不同模式改变鼠标样式
这个就设置画布的cursor就行了,可以通过传入url()
来使用自定义的图片。
注意一点
图标的起始点可能会跟你想要的起始点不一样,会出现画图不跟鼠标的现象。记得设置偏移量,让起点跟图标的起点对齐。
const changeMouseState = (state, shapeState = "") => {
MouseState.value = state;
if (shapeState) {
ShapeState.value = shapeState;
}
switch (MouseState.value) {
case "MOVE_MODE":
canvas.value.style.cursor = `url(${getImageByPath(
"../assets/icon/move.svg"
)}) 6 26, auto`;
break;
case "LINE_MODE":
canvas.value.style.cursor = `url(${getImageByPath(
"../assets/icon/pen.svg"
)}) 6 26, pointer`;
break;
case "ERASER_MODE":
canvas.value.style.cursor = `url(${getImageByPath(
"../assets/icon/eraser.svg"
)}) 6 26, pointer`;
break;
case "SHAPE_MODE":
canvas.value.style.cursor = `url(${getImageByPath(
"../assets/icon/shape.svg"
)}) 16 16, pointer`;
break;
default:
canvas.style.cursor = "default";
wrap.value.style.cursor = "default";
break;
}
};
vite不能用require()
来请求图片,手动封装一个请求图片的方法。官网也有讲。
静态资源处理 | Vite 官方中文文档 (vitejs.dev)
const getImageByPath = (imgPath) => {
return new URL(
imgPath, import.meta.url
).href
}
export default getImageByPath
这里因为画图形的时候鼠标样式是同一个,所以我专门添加了个图形状态。在前面监听鼠标事件的时候触发图形的统一事件,然后再根据图形状态分别进行监听绘制。
// 形状模式监听方法,触发对应形状的方法
const handleShapeMode = (downX, downY, offsetX, offsetY) => {
switch (ShapeState.value) {
case "LINE_MODE":
handleShapeLineMode(downX, downY, offsetX, offsetY);
break;
case "RECTANGLE_MODE":
handleRectangleMode(downX, downY, offsetX, offsetY);
break;
case "ROUND_MODE":
handleRoundMode(downX, downY, offsetX, offsetY);
break;
default:
break;
}
};
12. 导出画布为图片
导出为图片主要的就是调用HTMLCanvasElement.toDataURL()将canvans元素上的内容导出为图片url。
所以我们需要做的只有一件事,就是合并主要图层与背景图层的内容,然后进行导出。
因为toDataURL
需要获取的是一个canvas元素上的内容,所以我们在创建一个保存图层,将合并的内容放到上面然后导出下载即可。
// 将画布导出为图片
const saveImage = () => {
// 将主画布和背景画布内容合并
const px = imgCtx.getImageData(
0,
0,
imgCanvas.value.width,
imgCanvas.value.width
);
saveCtx.putImageData(px, 0, 0);
// drawImage可以直接将canvas元素的内容绘制到另一个canvas
saveCtx.drawImage(
canvas.value,
0,
0,
canvas.value.width,
canvas.value.height
);
var image = new Image();
image.src = saveCanvas.value.toDataURL("image/png");
var url = image.src.replace(
/^data:image\/[^;]/,
"data:application/octet-stream"
);
downFile(url, "test.png");
};
drawImage()
可以绘制的图像源有很多种,可以去MDN上好好看看。
这里我封装了一个下载方法。下载方法有很多种,插件形式的也不少,这里就展示下代码不展开了。
const downFile = (url, fileName) => {
const x = new XMLHttpRequest()
x.open('GET', url, true)
x.responseType = 'blob'
x.onload = function() {
const url = window.URL.createObjectURL(x.response)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
}
x.send()
}
export default downFile
很多水印制作就是用这种方法在canvas上合成然后导出图片。
结语
一开始还以为没多少内容,没想到细分下来还是挺多的。canvas能实现的远不止这点东西,像是复杂的绘制啊,动画啥的太多了。这里我只是简单学习了下做了这个demo玩,也算缓解下最近的心情吧。