H5的Canvas绘图——使用fabricjs绘制一个可多选的随机9宫格

  • 📢欢迎点赞 :👍 收藏 ⭐留言 📝 如有错误敬请指正,赐人玫瑰,手留余香!
  • 📢本文作者:由webmote 原创
  • 📢作者格言:新的征程,最近一直被测试拿捏,痛苦的挣扎中... 我们面对的不仅仅是技术还有人心,人心不可测,海水不可量,唯有技术,才是深沉黑夜中的一座闪烁的灯塔

序言

多年之前,使用过HTML5的原生Canvas进行绘图,还记得当时使用const context = canvas.getContext('2d');时的兴奋劲,为此曾经制作过一份非常详细的Canvas使用指南。只可惜最后没有坚持下去,走向大前端之路,兜兜转转,误入了后端之门,再也没有了当时的愉悦体验。

最近由于兴趣,又一次对H5的Canvas产生了兴趣,突然发现世界已经进化,有个非常易用的封装类库fabric js,这使用起来,简直又是一番全新体验,如果你也有兴趣,不妨一看瞧瞧...

1. Fabricjs 介绍

Fabric.js 是一个强大的 JavaScript 库,专门用于在 HTML5 Canvas 上进行图形和图像处理。它为开发者提供了一个简单而直观的 API,使得创建和操作复杂的图形变得更加容易。Fabric.js 的核心特点包括对象模型、事件系统、以及丰富的图形绘制功能。

🚩首先,Fabric.js 的对象模型允许开发者将 Canvas 中的每个图形元素视为一个独立的对象。这些对象可以是矩形、圆形、线条、文本等,用户可以通过简单的代码对它们进行创建、修改和删除。每个对象都具有属性,如位置、大小、颜色等,可以通过 API 进行访问和更改,从而实现动态交互。这比直接操作Canvas的原生API省劲太多了。

🚩🚩其次,Fabric.js 提供了一个强大的事件系统,支持鼠标和触摸事件。这使得用户能够与 Canvas 上的元素进行交互,例如拖动、缩放和旋转图形。通过监听事件,开发者可以为应用程序增加更多的交互性,比如图形的选中、高亮显示等。

🚩🚩🚩此外,Fabric.js 支持多种图形转换,如组图形、克隆图形和合并图形等功能,这使得在复杂场景中进行图形处理变得更加灵活。它还支持 SVG 文件的导入和导出,使得在不同平台之间共享和重用图形变得方便。

Fabric.js 还具备优秀的性能,能够高效渲染大量对象,适合用于游戏开发、数据可视化以及在线设计工具等应用场景。

总的来说,Fabric.js 是一个功能全面、易于使用的 Canvas 图形处理库,非常适合希望快速构建富互动图形应用的开发者。无论是初学者还是经验丰富的开发者,都能从中受益,轻松实现复杂的图形效果。

看一个简单的例子:

js 复制代码
//生成fabric的canvas对象
const canvas = new fabric.Canvas('c');
//创建一个指定左上坐标,指定宽高,填充为蓝色的矩形框
const rect = new fabric.Rect({
     left: 100,
     top: 100,
     fill: 'blue',
     width: 150,
     height: 100
 });
 //创建一个指定左上,半径50的圆,并填充为红色
 const circle = new fabric.Circle({
     left: 150,
     top: 150,
     radius: 50,
     fill: 'red'
 });
 //把上面两个组合成一个组,并指定左上角
 const group = new fabric.Group([rect, circle], {
     left: 50,
     top: 50
 });
 
 canvas.add(group); 

上述代码绘制效果如下,默认可选择,可拖动:

2. 目标:多选9宫格

我们的绘制目标,理想的样子是这样的:

🦤目标 : 一个随机值的9宫格

🎏功能 :1.值随机出现,每次不一样; 2. 用户可以多选;3.选中时,选中的单元格围绕一个红色运动的线路。

🪄技术线路:作为一个练习,我们这里只选择fabricjs这条技术线路。

3. 解决随机问题

既然是9宫格,那么我们就按9来计算,分配9个数组对象,分别保存9个单元格的坐标,它们就是:

复制代码
[0,0], [0,1], [0,2]
[1,0], [1,1], [1,2]
[2,0], [2,1], [2,2]

如果我们把给定的值,随机分发给这些格子,那么是不是就实现了随机分布?🙃

...

当然,为了9宫格好看些,我决定如果在值不够9个时,只在前几个内随机,这样空白的格子可以很好的集中在一起,看起来舒服些!

随机分配有好多方式,最简单的应该是采用Math.random(),在使用后发现它的随机排列是伪随机,就是下一次随机位置不变化,这太尴尬了。😂😂😂

好吧,那让我们来个抽牌游戏算法吧:

js 复制代码
//洗牌
shuffle = function (arr) {
	for (let i = arr.length - 1; i >= 0; i--) {
		let temRandom = Math.floor(Math.random() * i);
		var tmp = arr[i];
		arr[i] = arr[temRandom];
		arr[temRandom] = tmp;
	}
	return arr;
};

有了这个算法,那么结合我们的思路,就可以狂敲代码,满地打滚了!

来来来,小兄弟,让我们在键盘上来区礼乐崩坏!🧚🧚🧚

js 复制代码
//来个坐标游戏类,我们让它完成随机单元格的输出
//原谅我,这里是原生js,并非ts!
class coordinateGame {
	size;
	positions;
	constructor(values, size) {
		this.size = size;
		this.positions = [];
		this.values = this.shuffle(values || []);
		console.log(this.values);
		for (var i = 0; i < 3; i++) {
			for (var j = 0; j < 3; j++) {
				var index = 3 * i + j;
				var val = index >= this.values.length ? '' : this.values[index];
				this.positions[index] = {
					coordinate: [j, i],
					selected: false,
					value: val.toString(),
					index: index,
				};
			}
		}
	}

	//洗牌
	shuffle = function (arr) {
		for (let i = arr.length - 1; i >= 0; i--) {
			let temRandom = Math.floor(Math.random() * i);
			var tmp = arr[i];
			arr[i] = arr[temRandom];
			arr[temRandom] = tmp;
		}
		return arr;
	};
	findNextPos = function () {
		var unSelectedPos = this.positions.filter((item) => !item.selected);
		if (unSelectedPos.length == 0) return null;
		var rtn = unSelectedPos[0];
		rtn.selected = true;
		return {
			coordinate: [...rtn.coordinate],
			value: rtn.value,
			index: rtn.index,
		};
	};
}

4. 画出小格子

分析下小格子,不过是一个矩形框 + 文字 + 选择图标 而已,如果是采用 H5+ CSS,相信难不倒大家的。

使用fabricjs,也是灰常简单的!

让我们直接开撸 🐈🐈🐈

js 复制代码
draw() {
    //这里的canvas是fabricjs的canvas对象
    var canvas = this.canvas;	
    //使用坐标游戏类获取位置
	var rndPos = this.#game.findNextPos();
	//如果需要给定选中值,那么我们浅浅的克隆下
	const tmpAnswer = [...this.answer];
	//9宫格绘制开始了,我们循环循环
	while (rndPos) {
	   //根据返回的坐标,计算将要防止单元格的x坐标,y坐标
		var x =
			rndPos.coordinate[0] * this.#sw + span * (rndPos.coordinate[0] + 1);
		var y =
			rndPos.coordinate[1] * this.#sh + span * (rndPos.coordinate[1] + 1);
		var selected = false;
		//如果这个格子被选中,那么把选中集合内该值删除下(因为值可以重复...)
		tmpAnswer.forEach((item, i) => {
			if (item && item == rndPos.value) {
				selected = true;
				delete tmpAnswer[i]; //set undefined.
			}
		});
		//颜色配置下,可以配置选中,未选中
		var color = selected ? colors[2] : colors[1];

        //绘制矩形框了, 因为按照分组来绘制的,所以坐标按照组的中心点
		const rect = new fabric.Rect({
			width: this.#sw,
			height: this.#sh,
			originX: 'center',
			originY: 'center',
			fill: rndPos.value != '' ? colors[1] : colors[0],
		});

       //绘制值,就在正中间
		var text = new fabric.FabricText(rndPos.value, {
			fontSize: 30,
			originX: 'center',
			originY: 'center',
			fill: textColor,
		});

        //把矩形框和文字组合到一个组内
		var group = new fabric.Group([rect, text], {
			left: x,
			top: y,
			angle: 0,
		});

        //组的坐标可以进行设置的,我们按有无值配置为手,或者一般光标
		group.hoverCursor = rndPos.value != '' ? 'pointer' : 'default';
		//默认的选择框不需要,都配置下吧
		group.lockMovementX = true;
		group.lockMovementY = true;
		group.selectable = false;
		group.hasControls = false;
		group.subTargetCheck = false;

        //组可以设置任意属性,给它增加点额外的标签
        //这里把上面的矩形都加进去了,因为我没找到可以方便遍历儿子的属性...
		group.set({
			data: rndPos.value,
			selected: selected,
			index: rndPos.index,
			rect: rect,
		});
		//如果是选中,那么重新填充矩形框颜色,并且设定选定的图片
		if (selected) {
			rect.set({ fill: color });
			if (this.#images.length > rndPos.index) {
				var newImg = this.#images[rndPos.index];					
				newImg.set({
					originX: 'left',
					originY: 'top',
					left: group.left + this.#sw - 20,
					top: group.top,
					angle: 20,
				});
				group.add(newImg);					
				console.log('group=',group.data, rndPos.Index, newImg,group)
			}
		}
        //组对象增加到画布
		canvas.add(group);
		canvas.selectionColor = 'rgb(0,200,0)';
		canvas.selection = false;
		canvas.multiSelet = false;
		canvas.defaultCursor = 'pointer';

        //保持循环...        
		rndPos = this.#game.findNextPos();
	}
    //绘制
	canvas.renderAll();
}

选择的事件需要单独增加,才有效果!

为了实现效果,我们为组对象定义了几个自定义属性,在fabricjs内,为对象定义属性非常简单,

直接使用set, get即可。

  • selected: 标识组是否被用户选中
  • data:组的值(即矩形上的文字)
  • index:组的单元格索引

我们为整个画布增加鼠标按下事件。

js 复制代码
this.canvas.on('mouse:down', (options) => {
	//如果点击是组,那么我们就进行处理
	if (options.target && options.target.type == 'group') {
		var group = options.target;
		//selected属性是我们自定义属性,不是对象固有的。
		group.set({ selected: !group.get('selected') });
		var selected = group.get('selected');
		var val = group.get('data');
		var index = group.get('index');
		this.canvas.setActiveObject(group);
		this.#refreshValue();
		if (val != '') {
			var color = selected ? colors[2] : colors[1];
			group.rect.set({ fill: color });
			if (this.#images.length > index) {
				if (selected) {
					var newImg = this.#images[index];
					newImg.set({
						originX: 'left',
						originY: 'top',
						left: group.left + this.#sw - 20,
						top: group.top,
						angle: 20,
					});
					group.add(newImg);
					this.#animateDashedLine([
						group.left + 2.5,
						group.top - 2,
						group.width - 1,
						group.height + 2,
					]);
				} else {
					group.remove(this.#images[index]);
					this.#removeFlagLine();
				}
			}
		}
	}
});

选择图片我们采用了fabricjs内置的载入图片函数,由于是网络操作,因此该操作是个异步函数,加载时也会有延迟。

图片加载完成后,我们复制9份进行保存。 这里感觉fabricjs类库怪怪的,为啥不能复用图片对象呢?有大神知道吗?可以来告知下。

js 复制代码
fabric.FabricImage.fromURL(this.#loadImage)
.then((newImg) => {
	newImg.scale(0.1);
	//图片进行简单矩阵过滤,使之变红
	newImg.filters = [
		new fabric.filters.ColorMatrix({
			matrix: [
				0, 0, 0, 0, 255, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0,
			],
		}),
	];
	newImg.applyFilters();
	this.#img = newImg;
	for (var i = 0; i < 9; i++) {
		this.#images[i] = newImg.cloneAsImage();
	}
})
.catch((err) => {
	console.log(err);
});

5. 选中效果

这个纯属挑战自我了,就这么人任性。🐓🐓🐓

先说说实现思路, 短线使用矩形框来绘制,然后我们让矩形框的左上坐标发生移动,那么就实现了动画。

fabricjs很轻松帮助我们设定左上的坐标,我们只需要控制四个角的移动方向即可。

有些复杂的是,在矩形到角顶点时,需要进行旋转,然后继续运动。

动画的控制采用fabricjs封装的requestAnimFrame,其应该就是window对象的 requestAnimFrame,其按照显示器刷新率进行调用下一次的函数执行,记住,仅调用一次,特性和setTimeout一样。

来吧,展示:

js 复制代码
// 定义动画函数
	#animateDashedLine(rect) {
		var canvas = this.canvas;

		var l = rect[0];
		var t = rect[1];
		var w = rect[2];
		var h = rect[3];
		var step = 3;
        
        //清理之前的线段,加了自定义属性 flag
		var arr = canvas.getObjects();
		for (var i = 0; i < arr.length; i++) {
			if (arr[i].id == 'flag') {
				canvas.remove(arr[i]);
				break;
			}
		}
       //这里之前是圆,不好看被替换后未改名称。
		var circle = new fabric.Rect({
			left: l,
			top: t,
			width: 10,
			height: 2,
			stroke: 'red',
			fill: 'red',
			id: 'flag',
		});
		canvas.add(circle);
		canvas.bringObjectToFront(circle);

		var currX = circle.left;
		var currY = circle.top;

       //运动算法实现,
		var animate = () => {
			currX = circle.left;
			currY = circle.top;
			if (currY <= t) {
				if (currX >= l + w - circle.width) {
					circle.set({
						left: l + w,
						top: currY + step,
						angle: 90,
					});
				} else {
					circle.set({
						left: currX + step,
						angle: 0,
					});
				}
			} else if (currX >= l + w) {
				if (currY >= t + h - circle.width) {
					circle.set({
						left: currX - step,
						angle: 180,
						top: t + h,
					});
				} else {
					circle.set({
						top: currY + step,
					});
				}
			} else if (currY >= t + h) {
				if (currX <= l + circle.width) {
					circle.set({
						top: t + h - circle.width,
						left: l,
						angle: 90,
					});
				} else {
					circle.set({
						left: currX - step,
					});
				}
			} else {
				if (currY <= t + circle.width) {
					circle.set({
						left: currX + step,
						angle: 0,
						top: t,
					});
				} else {
					circle.set({
						top: currY - step,
					});
				}
			}
			// 重新渲染画布
			canvas.renderAll();
			// 循环调用动画函数
			this.#animateId = fabric.util.requestAnimFrame(animate);
		};

		// 启动动画
		animate();
	}

移除动画非常重要,要不然清理后会有残留。

js 复制代码
//移除动画
	#removeFlagLine() {
		fabric.util.cancelAnimFrame(this.#animateId);
		var arr = this.canvas.getObjects();
		for (var i = 0; i < arr.length; i++) {
			if (arr[i].id == 'flag') {
				this.canvas.remove(arr[i]);
				break;
			}
		}
	}

6. 汇总定义为游戏类

上述零零散散的,不太好调用,我们采用类进行封装。

按照 randomWordGame命名,算作一个随机文字游戏吧,哈哈。

一些私有方法,我们采用#前缀,进行隐藏,避免外部调用。大致结构如下:

js 复制代码
const colors = ['#e4e4e4', '#999', '#3a5985'];
const textColor = '#fff';
const bgColor = '#d5deef';
const span = 10;
class randomWordGame {
	#h = 0;
	#w = 0;
	#sw = 0;
	#sh = 0;
	#images = [];
	#loadImage = '';
	#game = null;
	#animateId = null;	
	#img = null;
	constructor(divId, data, selectedValue, loadImage = '/imgs/selected.png') {
		this.data = data || [];
		this.answer = selectedValue || [];
		this.canvas = new fabric.Canvas(divId, {
			preserveObjectStacking: true,
			backgroundColor: bgColor,
			selectionColor: '#89AFE0',
			selectionLineWidth: 1,
		});
		this.#h = this.canvas.getHeight() - span * 4;
		this.#w = this.canvas.getWidth() - span * 4;
		this.#sh = this.#h / 3.0;
		this.#sw = this.#w / 3.0;
		this.#images = [];
		this.#loadImage = loadImage;
		this.#game = new coordinateGame(data, 9);
		this.#animateId = null;
		this.#img = null;
		this.canvas.on('mouse:down', (options) => {
			//...			
		});

		//load images
		//fabric.FabricImage.fromURL(this.#loadImage)
	}

	init() {
		if (this.#img) {
			for (var i = 0; i < 9; i++) {
				this.#images[i] = this.#img.cloneAsImage();
			}
		}
		this.#game = new coordinateGame(this.data, 9);	
		this.canvas.remove(...this.canvas.getObjects())		
	}

	draw() {
		//...
	}

	//获取值
	#refreshValue() {		
	}
	// 定义动画函数
	#animateDashedLine(rect) {		
	}
	//移除动画
	#removeFlagLine() {		
	}
}

7. 应用和效果

我们增加几个按钮,来测试下随机效果,以及点击看看。

调用变得异常简单了:

  1. 声明: game = new randomWordGame('canvas', data, answer, '/imgs/selected.png');
  2. 初始化: game.init();
  3. 绘制: game.draw();
  4. 重新初始化: 设置属性,调用init和draw
js 复制代码
import * as fabric from 'fabric';
import randomWordGame from './randomWordGame';


const data = ['1', 2, 2, '中华', '天', 15];
const answer = ['1', '2', '中华'];
var game = new randomWordGame('canvas', data, answer, '/imgs/selected.png');

game.init();
setTimeout(() => {
    game.draw();
}, 1000);
//game.draw();

document.getElementById ("btn").addEventListener ("click", clickme, false);
function clickme() {
    console.log(game.answer);
    
}
document.getElementById("btn2").addEventListener("click", clickme2, false);
function clickme2() {
    game.data = ['我', 20, 2, '神么', '神', 15];
    game.answer = ['我'];
    game.init();
    game.draw();
    
}

总结

确实,很久没有动手前端的东西了,都手生了,如果代码有哪里不规范的地方,希望大家不吝指教,我这里不胜感激!!!

当然也希望这些介绍能帮助到大家,让新手们对Fabricjs有所了解,产生兴趣,想动手,做一个有意思的开发者。

你学废了吗?

👓都看到这了,还在乎点个赞吗?

👓都点赞了,还在乎一个收藏吗?

👓都收藏了,还在乎一个评论吗?

相关推荐
疾风铸境10 分钟前
Qt5.14.2+mingw64编译OpenCV3.4.14一次成功记录
前端·webpack·node.js
晓风伴月14 分钟前
Css:overflow: hidden截断条件‌及如何避免截断
前端·css·overflow截断条件
最新资讯动态17 分钟前
使用“一次开发,多端部署”,实现Pura X阔折叠的全新设计
前端
爱泡脚的鸡腿32 分钟前
HTML CSS 第二次笔记
前端·css
灯火不休ᝰ1 小时前
前端处理pdf文件流,展示pdf
前端·pdf
智践行1 小时前
Trae开发实战之转盘小程序
前端·trae
最新资讯动态1 小时前
DialogHub上线OpenHarmony开源社区,高效开发鸿蒙应用弹窗
前端
lvbb661 小时前
框架修改思路
前端·javascript·vue.js
树上有只程序猿1 小时前
Java程序员需要掌握的技术
前端
从零开始学安卓1 小时前
Kotlin(三) 协程
前端