在canvas上做一个可以丝滑绘制不同粗细的魔法笔

魔法笔开发

文档创建人 创建日期 文档内容 更新时间
adsionli 2023-07-27 涂抹笔绘制 2023-07-27

上周在公司项目中了做了一个很有意思的东西,借助公司的aigc提供的能力,做了一个可以通过画布涂抹商品水印的功能。其中俺负责开发使用的组件,然后在开发组件过程中做了一些很有意思的事情,就是这支魔法笔的使用,来分享一些开发中遇到的有意思的点还有一些困难的地方,还有就是一些功能的实现。

别指望全部代码放出,哈哈哈,就是一些好玩的地方,不涉及到公司的代码进行分享

功能需求

  1. 这支神奇的魔法笔,可以自由地变化大小。变大的时候,涂抹路径变宽;变小的时候,涂抹路径变窄。
  2. 可以丝滑流畅的进行绘制,指哪打哪,中间不会中断。
  3. 可以在图片上进行操作,且不会影响原图片内容,同时颜色不会混合变深。
  4. 可以随着图片的放缩、移动,准确找到对应的位置。 没啦,就这四点,看着挺少的,实现起来真的挺费事的,花了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
	}
}

好啦,最简单的画线操作写完啦,这里有几个细节需要注意一下:

  1. 为了确保画线时候的流畅性,我们需要在mousemove的时候进行处理,我在这里添加了requestAnimationFrame,这样的话对于mousemove的处理就会跟着浏览器的刷新率来。还有一种处理方式就是加节流,这里其实在另外一篇分析element-plus的image组件中也有提到过,其中说的是关于处理鼠标滚轮事件,这里也是一样的。
  2. 计算鼠标中心位置。这里我们需要知道,实际鼠标在的位置不是我们获得位置,它实际需要重新进行计算的,我们需要获取到canvas的位置(因为event绑定在canvas上的),这样获取到的鼠标位置才是在我们画笔中心位置的。
  3. 画线问题。画线需要一个起始位置和一个结束位置,所以我们每一次都需要保存上一次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又要进行改造了,不过这和画线差不多,只有以下几个点需要注意

  1. 画圆时半径的考虑,与线宽不同,画圆需要考虑半径问题,同时需要考虑用户控制粗细时,半径的放缩。
  2. 圆心位置的考虑,圆心的选定也会变得重要,不过我们之前处理过了鼠标的位置,鼠标的位置就是我们选定圆心的位置。 好了,可以开始动手改造啦
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,非常到位。终于结束啦。

当然,这里只是做一个画笔,项目里面还有更多的复杂交互放在一起,比如滚轮放大缩小图片、长按空格配合鼠标左键可以抓取图片并移动、画布实际覆盖在图片上,等等等,这里大家可以根据自己的需求继续修改。

结束语

最近很久都没有怎么写文章啦,因为在上班中有许多需要去进行学习的地方,所以时间不多,也没有什么很多很明确要写的,也有一些想要分享的,比如说前端项目化,自动化打包机等等,但是我发现这些内容有很多大佬都有分享过(毕竟俺也是看大佬们写的分享来一步步学习的),在写的话就有可能知识冗余,不过有一些好玩的组件的实现的话,会发出来和大家一起分享。

加油加油,永远保持写代码的激情,冲冲冲!!!

相关推荐
万少3 分钟前
万少用9个AI工具,帮朋友完成了一个"不可能"的项目
前端
小小小小宇5 分钟前
Vue `import` 为什么可以异步加载
前端
WMYeah11 分钟前
【无标题】
前端·rust·抽奖程序·跨平台抽奖程序
Unbelievabletobe11 分钟前
免费外汇api的响应时间在不同时段下的波动分析
大数据·开发语言·前端·python
大哥,带带弟弟21 分钟前
Grafana 前端嵌入与 JWT 鉴权实战
前端·grafana
小小小小宇22 分钟前
前端 V8 引擎垃圾回收机制与内存问题排查
前端
前端老石人33 分钟前
CSS 值定义语法
前端·css
sheeta199843 分钟前
Vue 前端基础笔记
前端·vue.js·笔记
小小小小宇43 分钟前
GitLab + GitLab Runner + Qiankun 微前端 + Nginx + Node 中间件 前端开发机从零搭建 CI/CD 全流程
前端
前端那点事1 小时前
别再写垃圾组件!Vue3 如何设计「真正可复用」的高质量通用组件
前端·vue.js