使用 canvas 和 svg 实现神奇的涂抹消除

231 阅读7分钟

在刷短视频或者玩一些小游戏的时候,你可能见过这样一个有趣的效果:
用户用手指在屏幕上随意涂抹,原本被遮挡的内容逐渐显露出来,像是“刮刮乐”一样的交互。

这个效果看似神奇,其实用前端就能实现。无论是 <canvas> 还是 <svg>,只要结合合适的绘制方式和混合模式,就能轻松做出这种“涂抹消除”的体验。

本文会带你从 基础原理完整实现,逐步拆解这个效果:

  • 如何用 canvas 监听手指/鼠标轨迹并绘制透明区域

  • 如何用 svg 的 mask来实现类似效果

看完后,你也能快速写出一个“神奇的涂抹消除”小 demo,用在活动页、小游戏或者有趣的交互场景里。

一 前置,存储数据的设计

对于普通的功能来说,其实可以不用考虑到对应的数据的结构设计,但是我们可以考虑相应的扩展

例如我们可以基于绘制的路径,实现后退前进

当然我一样可以自定义画笔的大小

所以针对这方面的功能,我们需要设计一个可维护的有效数据结构


// 一个基础的
type BasePoint = {
	left: number
	top: number
}

// 一个线段是由多个点构成的
type BaseLine = {
	points: BasePoint[],
	
	size: number // 画笔大小
	mode: 'brush' | 'eraser' // 涂抹还是消除
}

// 线段的类型
type Lines = BaseLine[]

以上就是先简单定义的 线段类型,便于后续扩展,可以往里面添加一系列属性。

二 前置,对应的方法了解

对于我们涂抹消除,想的还是尽可能让 pc 和 h5 端都可以使用

所以采用的绑定事件使用的是 Pointer events 指针事件

pointerdown 当某指针得以激活时触发。

pointermove 当某指针改变其坐标时触发。

pointerup 当某指针不再活跃时触发。

接下来就开始我们具体的实现吧!

三 神奇的 canvas

1、准备画布

首先,准备一张图片和一个 canvas 画布,画布的大小和 canvas 大小统一

<!doctype html>

<html lang="en">

<head>

<meta charset="UTF-8" />

<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>Document</title>

<style>

* {

	margin: 0;
	
	padding: 0;
	
	box-sizing: border-box;

}


.container {

	user-select: none;
	
	position: relative;

}

  
.container img {
	
	touch-action: none;
	
	pointer-events: none;
	
	user-select: none;

}

  
.container canvas {

	position: absolute;
	
	left: 0px;
	
	top: 0px;
	
	z-index: 1;
	
	opacity: 0.6;

}

  
.active {

	color: blue;

}

</style>

</head>

<body>
	
	<div class="container">
	
		<img
		
		src="https://x-design-release.stariicloud.com/poster/7545732a3f987e398c9337020f344335.png"
		
		alt=""
		
		width="511px"
		
		height="511px"
		
		/>
		
		<canvas id="canvas" />
	
	</div>

<div>

	<button id="brush" class="active">涂抹</button>
	
	<button id="eraser">消除</button>

</div>

  
<script>

</script>

</body>

</html>

页面效果如下图

1.png

2、处理事件

核心逻辑:

  1. 定义核心数据 let lines = [] 存储总的线段数据 let currrentLines = { points: [] } 存储当前操作的数据
  2. 鼠标在滑动操作时,记录当前的鼠标点位,并把其放入 currrentLines 的 points 中,开启绘制
  3. 把 currrentLines 数据推入 lines
<script>
	const canvas = document.querySelector('#canvas');
	const ctx = canvas.getContext('2d');

	let isDown = false;

	canvas.width = 511;

	canvas.height = 511;

	const BRUSH_SIZE = 40; // 画笔大小

	let mode = 'brush' // 刷子类型

	let lines = []
	
	let currentLines = {
		points: []
	}
	
	const pointerDown = () => {
		isDown = true;
		currentLines.mode = mode
		currentLines.size = BRUSH_SIZE
	};

	const pointerMove = (e) => {
		e.stopPropagation();
		e.preventDefault();
		if (!isDown) return;
		const { clientX, clientY } = e;
	
		curentLines = {
			...currentLines,
			points: [
				...currentLines.points, 
				{
					left: clientX,
					top: clientY
				}
			]
		}
	

		draw([...lines, curentLines]); // 暂未实现
	};

	const pointerUp = () => {
		if (!isDown) return
		isDown = false;
		lines = [...lines, curentLines]
		currentLines = {
			points: []
		}
	};

	const bindEvent = () => {
		canvas.addEventListener('pointerdown', pointerDown);
		canvas.addEventListener('pointermove', pointerMove);
		window.addEventListener('pointerup', pointerUp);
		brush.addEventListener('click', (e) => {
			mode = 'brush'
			e.target.classList.add('active')
			eraser.classList.remove('active')
		})

		eraser.addEventListener('click', (e) => {
			mode = 'erase'
			e.target.classList.add('active')
			brush.classList.remove('active')
		})
	};

	bindEvent();

</script>

3、绘制

在绘制之前,先了解下 canvas 的 globalCompositeOperation 融合属性

globalCompositeOperation  属性设置要在绘制新形状时应用的合成操作的类型。  

实现消除效果属性值: "destination-out" 仅保留现有画布内容和新形状不重叠的部分。

const draw = (lines) => {
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	
	lines.forEach(({ points, mode, size }) => {
		ctx.save();
		ctx.beginPath();
		
		points.forEach(({ left, top }, index) => {
			if (!index) {
				ctx.moveTo(left, top);
				ctx.lineTo(left, top);
			} else {
				ctx.lineTo(left, top);
			}
		})
		
		if (mode === 'erase') {
			ctx.globalCompositeOperation = 'destination-out'; // 擦除模式
		}
			
		ctx.strokeStyle = '#3388FF'; // 线条颜色
		ctx.lineWidth = size; // 线条宽度
		ctx.stroke();
		ctx.closePath();
		ctx.restore();
	})
}

效果如下

2.png

但是目前看到,绘制的线段还不太完美,我们可以再优化下


ctx.lineCap = 'round'; // 设置线段的末端的样式
ctx.lineJoin = 'round'; // 设置线段连接的样式

3.png

好了 完美!

四 神奇的 svg

与 svg 的差别,就是绘制层的区别,逻辑层可复用,所以我们只需要对上面的 html 内容进行稍许的改造,就可以让 绘制逻辑 从 canvas 替换到 svg

在进行具体的实现之前,先介绍一些会用到标签

1、defs: SVG 允许我们定义以后需要重复使用的图形元素。建议把所有需要再次使用的引用元素定义在defs元素里面。这样做可以增加 SVG 内容的易读性和无障碍。在defs元素中定义的图形元素不会直接呈现。你可以在你的视口的任意地方利用 <use>元素呈现这些元素。

简洁的说,就是可以通过 defs 用来定义一些不在页面直接呈现的一些元素,后续可以通过一些其他的方式来使用 2、g: 元素是一个容器,用于将其他 SVG 元素进行分组。设置的样式会同步到组内的元素当中

3、image: 用于展示图片

好了,介绍完一些标签之后,我们按照老规矩,先把静态的 html 写出来

1、准备画布 & 处理事件

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />
	<title>Document</title>
	<style>
	* {
		margin: 0;
		padding: 0;
		box-sizing: border-box;
	}

	.container {
		height: 511px;
		user-select: none;
		position: relative;
	}

	.container .svg-main {
		position: absolute;
		left: 0px;
		top: 0px;
		z-index: 1;
	}

	#blueLayer {
		opacity: 0.6;
	}

	.active {
		color: blue;
	}

	</style>
</head>
<body>
	<div class="container">
		<svg
			class="svg-main"
			width="511px"
			height="511px"
			xmlns="http://www.w3.org/2000/svg"
			viewbox="0 0 511 511"
	 .  >		
			<defs>
			<!-- mask 用于消除蓝色路径 -->
				<mask id="eraseMask" maskUnits="userSpaceOnUse">
					<rect x="0" y="0" width="511" height="511" fill="white" />
				<!-- 黑色区域表示要隐藏的路径 -->
				</mask>
			</defs>
		
			<image
				href="https://x-design-release.stariicloud.com/poster/7545732a3f987e398c9337020f344335.png"
				x="0"
				y="0"
				width="511px"
				height="511px"
			/>
		
			<!-- 蓝色路径图层 -->
			<g id="blueLayer" mask="url(#eraseMask)"></g>
		</svg>
	</div>
	<div>
		<button id="brush" class="active">涂抹</button>
		<button id="eraser">消除</button>
	</div>

<script>

	const svgMain = document.querySelector(".svg-main");
	
	const gLayer = document.querySelector("#blueLayer");
	
	const maskLayers = document.querySelector("#eraseMask");
	
	  
	let isDown = false;
	
	
	const BRUSH_SIZE = 40;
	
	let mode = "brush";
	  
	
	let lines = [];
	
	let currentLines = {
	
		points: [],
	
	};
	
	const draw = (lines) => {};

	const pointerMove = (e) => {
		e.stopPropagation();
		e.preventDefault();
		if (!isDown) return;
		const { clientX, clientY } = e;
	
		curentLines = {
			...currentLines,
			points: [
				...currentLines.points, 
				{
					left: clientX,
					top: clientY
				}
			]
		}
	

		draw([...lines, curentLines]); // 暂未实现
	};

	const pointerUp = () => {
		if (!isDown) return
		isDown = false;
		lines = [...lines, curentLines]
		currentLines = {
			points: []
		}
	};

	const bindEvent = () => {
		canvas.addEventListener('pointerdown', pointerDown);
		canvas.addEventListener('pointermove', pointerMove);
		window.addEventListener('pointerup', pointerUp);
		brush.addEventListener('click', (e) => {
			mode = 'brush'
			e.target.classList.add('active')
			eraser.classList.remove('active')
		})

		eraser.addEventListener('click', (e) => {
			mode = 'erase'
			e.target.classList.add('active')
			brush.classList.remove('active')
		})
	};

	bindEvent();
</script>
</body>
</html>

页面的效果就呈现成这样啦

4.png

2、绘制

梳理下核心逻辑

1、在按下绘制时,我们可以往 g 标签中插入一个 path 路径节点,这样就可以展现涂抹的部分

2、当切换消除时,我们利用 svg mask 的能力,往 mask 标签中也插入一个 path 标签,因为 mask 本生就应用到了 g 标签组上,所以“消除”也有了,

消除 mask 核心原理:mask 是一张灰度图,白色区域显示,黑色区域隐藏,灰色半透明。 应用在元素上时,相当于把“目标元素”用这张灰度图重新裁剪、混合

接下来看下具体的代码

const createPath = (points, color, width) => {
	
	const path = document.createElementNS(
	
		"http://www.w3.org/2000/svg",
	
		"path"
	
	);
	
	const d = points
		.map((p, i) =>
			i === 0
		? `M ${p.left} ${p.top} L ${p.left} ${p.top}`
		: `L ${p.left} ${p.top}`
	).join(" ");
	
	path.setAttribute("d", d);
	path.setAttribute("stroke", color);
	path.setAttribute("stroke-width", width);
	path.setAttribute("stroke-linecap", "round");
	path.setAttribute("stroke-linejoin", "round");
	path.setAttribute("fill", "none");
	
	return path;
};

const draw = (lines) => {
	gLayer.innerHTML = "";
	maskLayers.innerHTML =
	'<rect x="0" y="0" width="511" height="511" fill="white" />';

	lines.forEach(({ points, mode, size }) => {
	
		const path = createPath(points, "#3388FF", size);
	
		if (mode === "brush") {
	
			gLayer.appendChild(path);
	
		} else {
			maskPath.setAttribute("stroke", "black");
			maskLayers.appendChild(path)
		}
	});
};

效果展示

5.png

但是此时发现了一个问题,我消除过的区域,如果想要再次涂抹呢,发现是不行的。

原因是:mask 是「不可逆」的 —— 一旦挖掉,就不会因为后续 brush 再恢复。

所以接下来我们换个方式优化下

3、优化持续涂抹消除

上面讲到,我们 mask 是不可逆的,又知道 mask 区域 黑色表示是消除的部分,白色表示是显示的部分

那这样 我们是不是可以,在重新涂抹的时候,一样也往 mask 里面添加一个 填充色为白色的 path,来把 黑的消除的部分给盖住呢??

直接来试试,具体看代码

const draw = (lines) => {
	gLayer.innerHTML = "";
	maskLayers.innerHTML =
	'<rect x="0" y="0" width="511" height="511" fill="white" />';

	lines.forEach(({ points, mode, size }) => {
	
		const path = createPath(points, "#3388FF", size);
	
		if (mode === "brush") {
			gLayer.appendChild(path);
		}
		
		// 首先克隆出一个节点
		const maskPath = path.cloneNode(true);
		// 根据是消除还是涂抹进行变化颜色
		maskPath.setAttribute("stroke", mode === "brush" ? "white" : "black");
		// 添加到 mask 中
		maskLayers.appendChild(maskPath);
	});
};

具体效果

6.png

ok 可行很完美

至此我们就完成了 svg 的涂抹和消除效果

五,历史记录的回退和撤销

还记得我们之前用来存储 lines 的数据不,

所以由此我们扩展回退和撤销就很简单了,只要去操作 lines 即可

大家可以试试看!!

六、结语

距离上一篇文章期间间隔属实有点久了,最近也是因为换了公司,所以一直在忙,接下来,我会继续给大家更新一些好玩的东西,

最后感谢各位的观看

祝大家生活如意,工作如意!