likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 10

魔法笔开发

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

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

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

功能需求

  1. 这支神奇的魔法笔,可以自由地变化大小。变大的时候,涂抹路径变宽;变小的时候,涂抹路径变窄。
  2. 可以丝滑流畅的进行绘制,指哪打哪,中间不会中断。
  3. 可以在图片上进行操作,且不会影响原图片内容,同时颜色不会混合变深。
  4. 可以随着图片的放缩、移动,准确找到对应的位置。 没啦,就这四点,看着挺少的,实现起来真的挺费事的,花了2天才很好的完成出来,中间踩了很多的小坑,用了两种方案,可算是弄出来可以使用啦。

开发经历

这次开发真的是一个惨痛的经历,历经了好几种方案,重写了两三次,才算是比较完美的完成了,后期还有很多的优化空间,大家可以自由发挥,这里就分享一下自己的开发过程

画直线

第一直觉,画直线呗,然后设置线宽呗,这也太简单了吧(噗嗤,当时的自己太天真了)。

实现过程

画直线,需要些啥,就是画线呗,在鼠标每一次移动的时候,都记录一下位置,然后进行直线的绘制,实现代码如下

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
    }
}
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时的位置,为下一次画线做准备。

上面几个细节注意之后,我们就可以进行画线了,下面是结果图:

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

但是,很可惜,我们可以看到画出来的效果惨不忍睹,然后这时候我就在想是不是我哪里没处理好,然后我就去查询相关的文章,我看到有个老哥用了贝塞尔曲线去处理,然后我就对代码进行了如下的改造:

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中的部分内容也需要对应修改

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. 圆心位置的考虑,圆心的选定也会变得重要,不过我们之前处理过了鼠标的位置,鼠标的位置就是我们选定圆心的位置。 好了,可以开始动手改造啦
	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改造

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();
	}
	......
}

改造完之后看一下结果图:

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

好吧,还是存在问题,鼠标移动之间不连续,会出现明显的空隙。 但是!!!这里就是我最擅长的插值操作了,哈哈哈,于是,决定在中间进行线性插值,来结束这一切罪恶的问题。

插值拯救一切

插值代码如下,以及对onMouseMove的一些改造

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,到这里为止,我很高兴的说,结束,当然,看一下下面的结果

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

ok,非常完美,再来一张结果图,验证不同粗细下是不是ok

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

ok,非常到位。终于结束啦。

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

结束语

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

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