魔法笔开发
文档创建人 | 创建日期 | 文档内容 | 更新时间 |
---|---|---|---|
adsionli | 2023-07-27 | 涂抹笔绘制 | 2023-07-27 |
上周在公司项目中了做了一个很有意思的东西,借助公司的aigc提供的能力,做了一个可以通过画布涂抹商品水印的功能。其中俺负责开发使用的组件,然后在开发组件过程中做了一些很有意思的事情,就是这支魔法笔的使用,来分享一些开发中遇到的有意思的点还有一些困难的地方,还有就是一些功能的实现。
别指望全部代码放出,哈哈哈,就是一些好玩的地方,不涉及到公司的代码进行分享
功能需求
- 这支神奇的魔法笔,可以自由地变化大小。变大的时候,涂抹路径变宽;变小的时候,涂抹路径变窄。
- 可以丝滑流畅的进行绘制,指哪打哪,中间不会中断。
- 可以在图片上进行操作,且不会影响原图片内容,同时颜色不会混合变深。
- 可以随着图片的放缩、移动,准确找到对应的位置。 没啦,就这四点,看着挺少的,实现起来真的挺费事的,花了2天才很好的完成出来,中间踩了很多的小坑,用了两种方案,可算是弄出来可以使用啦。
开发经历
这次开发真的是一个惨痛的经历,历经了好几种方案,重写了两三次,才算是比较完美的完成了,后期还有很多的优化空间,大家可以自由发挥,这里就分享一下自己的开发过程
画直线
第一直觉,画直线呗,然后设置线宽呗,这也太简单了吧(噗嗤,当时的自己太天真了)。
实现过程
画直线,需要些啥,就是画线呗,在鼠标每一次移动的时候,都记录一下位置,然后进行直线的绘制,实现代码如下
js
const MouseEvent = (props) => {
const isDraw = false;
const draw = draw();
const startPos = {
x: 0,
y: 0
};
const lastPos = {
x: 0,
y: 0
}
const offsetPos = {
x: 0,
y: 0
};
let width = 10;
let scale = 1;
let imgScale = 1;
let color = `rgb(0, 0, 0)`;
const {getScale, getWidth, getCanvasRef} = props;
const setLastPos = (x, y) => lastPos = {x, y};
const setStartPos = (x, y) => startPos = {x, y};
/** 设置canvas偏移位置 **/
const setOffsetPos = () => {
const canvas = getCanvasRef();
const position = canvas.current.getBoundingClientRect();
offsetPos = {x: position.x, y: position.y};
}
/** 计算鼠标实际中心位置 **/
const calMousePosition = (x, y) => {
const centerX = x + (offsetPos.x > 0 ? (-1 * offsetPos.x) : Math.abs(offsetPos.x));
const centerY = y + (offsetPos.y > 0 ? (-1 * offsetPos.y) : Math.abs(offsetPos.y)) ;
return {
x: centerX / imgScale,
y: centerY / imgScale
}
}
/** 设置canvas2dContext **/
setContext = () => {
const canvas = getCanvasRef();
const ctx = canvas.current.getContext('2d');
draw.setCtx(ctx);
}
resetData = () => {
isDraw = false;
setLastPos(0, 0);
setStartPos(0, 0);
width = 10;
scale = 1;
}
const onMouseDown = (event) => {
scale = getScale();
width = (getWidth() || width) * scale;
const {clientX, clientY, button} = event;
//NOTE: 一个小细节,只允许鼠标右键点击有效
if(button !== 0) {
return;
}
isDraw = true;
setStartPos(clientX, clientY);
setLastPos(clientX, clientY);
setOffsetPos();
setContext();
}
const onMouseUp = (event) => {
const {button} = event;
if(button !== 0) return;
if(!isDraw) return;
//NOTE: 重置数据
reset();
const {clientX, clientY} = event;
draw.drawLine(lastPos, {x: clientX, y: clientY});
}
const onMouseMove = (event) => {
if(!isDrag) return;
const {clientX, clientY} = event;
requestAnimationFrame(() => {
//NOTE: 鼠标实际位置,用于给鼠标translate赋值用
const center = calMousePosition(clientX, clientY);
draw.drawLine(lastPos, {x: clientX, y:clientY});
setLastPos(clientX, clientY);
})
}
const onMouseLeave = (event) => {
reset();
}
return {
onMouseDown,
onMouseUp,
onMouseMove,
onMouseEnter,
onMouseLeave
}
}
js
const draw = () => {
let ctx = null;
let lineWidth = 1;
setCtx = (context) => ctx = context;
setWidth = (width) => lineWidth = width;
drawLine = (startPos, endPos, color = 'rgb(0, 0, 0)') => {
ctx.lineWidth = lineWidth;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
ctx.lineTo(endPos.x, endPos.y);
ctx.stroke();
}
return {
setCtx,
setWidth,
drawLine
}
}
好啦,最简单的画线操作写完啦,这里有几个细节需要注意一下:
- 为了确保画线时候的流畅性,我们需要在
mousemove
的时候进行处理,我在这里添加了requestAnimationFrame
,这样的话对于mousemove
的处理就会跟着浏览器的刷新率来。还有一种处理方式就是加节流,这里其实在另外一篇分析element-plus的image组件中也有提到过,其中说的是关于处理鼠标滚轮事件,这里也是一样的。 - 计算鼠标中心位置。这里我们需要知道,实际鼠标在的位置不是我们获得位置,它实际需要重新进行计算的,我们需要获取到canvas的位置(因为event绑定在canvas上的),这样获取到的鼠标位置才是在我们画笔中心位置的。
- 画线问题。画线需要一个起始位置和一个结束位置,所以我们每一次都需要保存上一次
mousemove
时的位置,为下一次画线做准备。
上面几个细节注意之后,我们就可以进行画线了,下面是结果图:
但是,很可惜,我们可以看到画出来的效果惨不忍睹,然后这时候我就在想是不是我哪里没处理好,然后我就去查询相关的文章,我看到有个老哥用了贝塞尔曲线去处理,然后我就对代码进行了如下的改造:
js
const draw = () => {
......
/** 获取贝塞尔曲线目标点 **/
const calTargetPoint = (controlPoint, endPoint) => {
return {
x: (controlPoint.x + endPoint.x) / 2,
y: (controlPoint.y + endPoint.y) / 2
}
}
const drawLine = (startPos, controlPos, endPos) => {
const targetPos = calTargetPoint(controlPos, endPos);
ctx.beginPath();
ctx.moveTo(...startPos);
ctx.quadraticCurveTo(...targetPos, ...endPos);
ctx.stroke();
ctx.closePath();
}
......
}
然后event
中的部分内容也需要对应修改
js
const MouseEvent = () => {
const pointList = [];
const onMouseDown = () => {
// NOTE: 取消原先的startPos赋值,改为下面的
pointList.push({x: clientX, y:clientY});
}
const onMouseMove = () => {
......
const isDraw = pointList.length === 3;
if(isDraw) {
pointList.unShift();
}
pointList.push({x: clientX, y: clientY});
isDraw && draw.drawLine(...pointList);
......
}
const onMouseUp = () => {
......
//NOTE: 置空数组
pointList.length = 0;
......
}
}
这样,代码层面就差不多修改好了,结果,我试了一下,在线宽为1的时候,确实还可以,但是,还是老问题,在线宽变化之后,仍然是存在不连续的问题,而且锯齿严重。 到这里,我算是放弃了使用了画线的方式来做这个功能,于是就开始思考究竟该怎么才能画出连续且平滑的涂抹痕迹呢。
画圆
为什么会突然想到画圆来替代画线呢,这就得好好感谢我的发呆了。发呆的时候,在ipad上用apple pen在GoodNotes上乱涂乱画,然后把笔迹加粗之后,我突然发现,在每一次笔记结束的时候,这玩意都有点像圆,然后我有观察它开始的位置,好像也是一个圆形,就突然来了灵感,想是不是可以画圆来替代画线呢?说干就干,然后开始了下面的代码改造与编写。
MouseEvent改造
首先就是需要对MouseEvent又要进行改造了,不过这和画线差不多,只有以下几个点需要注意
- 画圆时半径的考虑,与线宽不同,画圆需要考虑半径问题,同时需要考虑用户控制粗细时,半径的放缩。
- 圆心位置的考虑,圆心的选定也会变得重要,不过我们之前处理过了鼠标的位置,鼠标的位置就是我们选定圆心的位置。 好了,可以开始动手改造啦
js
const MouseEvent = (props) => {
/** width换成了circleRadius **/
let circleRadius = 10;
const {getRadius} = handle;
const color = "rgba(255, 204, 0, 0.4)";
......
const onMouseDown = (event) => {
......
const { clientX, clientY } = event;
circleRadius = radius / scale;
const centerPoint = calMousePosition(clientX, clientY);
draw.circle(drawCenter, circleRadius, color);
......
}
const onMouseMove = (event) => {
const { clientX, clientY } = event;
requestAnimationFrame(() => {
//NOTE: 这个位置不是鼠标绘制位置,只是鼠标出现在屏幕上的位置
const center = getMousePosition(clientX, clientY);
const drawPosition = getInterpolation(lastPos, center);
for (let value of drawPosition) {
const drawCenter = { x: value.x, y: value.y };
draw.circle(drawCenter, circleRadius, color);
}
lastPos = center;
})
}
......
}
draw改造
js
const draw = () => {
......
const circle = (center, radius, color) => {
const { x, y } = center;
ctx.beginPath();
//NOTE: 这里设置一下防止颜色叠加了
ctx.globalCompositeOperation = 'xor';
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.closePath();
}
......
}
改造完之后看一下结果图:
好吧,还是存在问题,鼠标移动之间不连续,会出现明显的空隙。 但是!!!这里就是我最擅长的插值操作了,哈哈哈,于是,决定在中间进行线性插值,来结束这一切罪恶的问题。
插值拯救一切
插值代码如下,以及对onMouseMove的一些改造
js
const MouseMove = (props) => {
......
/**
* @description 进行线性插值,保证路径连贯
*/
const getInterpolation = (a, b) => {
/** 设置插值补偿的步长 */
const step = 1;
const distance = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
let returnData = [];
for (let i = 0; i <= distance; i += step) {
const t = i / distance;
const x = a.x + t * (b.x - a.x);
const y = a.y + t * (b.y - a.y);
returnData.push({ x, y });
}
return returnData;
}
const onMouseMove = (event) => {
......
requestAnimationFrame(() => {
const center = calMousePosition(clientX, clientY);
const drawPosition = getInterpolation(lastPos, center);
for (let value of drawPosition) {
const drawCenter = { x: value.x, y: value.y };
draw.setCtx(getCanvasContext(canvasRef));
draw.circle(drawCenter, circleRadius, color);
draw.setCtx(getCanvasContext(realDrawCanvasRef));
draw.circle(drawCenter, circleRadius, color, true);
}
lastPos = center;
})
}
......
}
over,到这里为止,我很高兴的说,结束,当然,看一下下面的结果
ok,非常完美,再来一张结果图,验证不同粗细下是不是ok
ok,非常到位。终于结束啦。
当然,这里只是做一个画笔,项目里面还有更多的复杂交互放在一起,比如滚轮放大缩小图片、长按空格配合鼠标左键可以抓取图片并移动、画布实际覆盖在图片上,等等等,这里大家可以根据自己的需求继续修改。
结束语
最近很久都没有怎么写文章啦,因为在上班中有许多需要去进行学习的地方,所以时间不多,也没有什么很多很明确要写的,也有一些想要分享的,比如说前端项目化,自动化打包机等等,但是我发现这些内容有很多大佬都有分享过(毕竟俺也是看大佬们写的分享来一步步学习的),在写的话就有可能知识冗余,不过有一些好玩的组件的实现的话,会发出来和大家一起分享。
加油加油,永远保持写代码的激情,冲冲冲!!!