Avatar-Clipper 轻量级图片裁剪工具

📖 官网文档:pushu-wf.github.io/

🎉 在线体验地址:online-demo

🔗 仓库地址:gitee.com/wfeng0/avat...

前言

目前市面上已经有很多的图片裁剪工具了,例如:vue-croppervue-img-cutter 等,但是还是存在一些不足之处,例如没有 Typescript 支持 ,仍采用div渲染方案等。目前这方面的需求也日益增加,图片的裁剪功能在社交媒体、电商、证件照处理等多领域有着非常广泛的应用。

avatar-clipper 是一款基于 Konva 开发的轻量级头像裁剪工具,支持TypeScript 。其核心架构采用 Command 和 EventBus 模块,提供简洁 API 操作和灵活的事件回调机制。工具支持图片加载、裁剪框交互、水印添加、暗部效果等特色功能,并能导出多种格式的裁剪结果。相比现有方案,avatar-clipper 在保持功能完整的同时更加轻量化,不绑定任何 UI 组件,核心库打包结果仅 200 多kb,仅通过 API 实现核心裁剪功能,适用于社交媒体、电商等多场景需求。

整体架构如上图,裁剪工具的核心模块为 Command 及 EventBus,command提供必要的API操作,仅导出核心方法供用户使用,eventBus 则在必要时机,触发相应的事件回调,实现功能拓展。

初始化容器

选定的技术方案为 konva,因此,容器仅需要一个挂载节点即可,为了更好实现定位及内部元素样式的自我控制,以下结构作为容器框架:

ts 复制代码
class ImageClipper{
	// 解析 container
	const container = parseContainer(options.container);
		
    // add class
	container.classList.add("image-clipper-container");

	// 添加一个容器,作为 konva 挂载节点,并设置宽高为 options 的宽高
	const konvaContainer = document.createElement("div");
	konvaContainer.id = "image-clipper-stage";
	konvaContainer.classList.add("image-clipper-stage");

	const { width, height } = this.getOptions();

	if (width) konvaContainer.style.width = `${width}px`;
	if (height) konvaContainer.style.height = `${height}px`;
	
    container.appendChild(konvaContainer);

}

透明背景实现方案为添加图片:

初始化画布

ts 复制代码
constructor(private imageClipper: ImageClipper, private event: EventBus<EventBusMap>) {
	const root = this.imageClipper.getContainer();
	const container = root.querySelector("#image-clipper-konva-container");

	if (!container) {
		throw new Error("container is not exist");
	}

	// 确保 container 是 HTMLDivElement 类型
	if (!(container instanceof HTMLDivElement)) {
		throw new Error("container is not a HTMLDivElement");
	}

	// konva stage 的宽高与容器一致
	const { width, height } = container.getBoundingClientRect();

	// 创建 stage
	this.stage = new Konva.Stage({ container, width, height });

	// 创建 layer
	this.layer = new Konva.Layer({ id: "mainLayer" });

	// 添加到 stage 上
	this.stage.add(this.layer);

	// 更新视图
	this.render();
}

添加图片

添加图片的核心,就是通过 new Image() 实现的:

ts 复制代码
// 创建新的图片实例
const imageNode = new Image();

// 解析 source 资源
const source = await parseImageSource(image);
imageNode.src = source;

// 基于 load 事件实现 konva image 创建
imageNode.onload = () => {
	// 创建 Konva.Image
	const konvaImage = new Konva.Image({
		id: "image",
		image: imageNode,
		x: 0,
		y: 0,
		width: imageNode.width,
		height: imageNode.height,
		draggable: true,
		listening: true,
	});

	this.layer.add(konvaImage);

	this.render();

	// patch image loaded event
	imageClipper.dispatchEvent("imageLoaded");
};

添加裁剪框

裁剪框的核心思想,是通过形变控制器添加的透明矩形实现的,直接利用 Transformer 实现平移缩放会更简单:

ts 复制代码
// 创建裁剪框
const crop = new Konva.Rect({
	x: 0,
	y: 0,
	width: cropAttr?.width ?? width * 0.6,
	height: cropAttr?.height ?? height * 0.6,
	strokeWidth: 0,
	fill: "transparent",
	stroke: "transparent",
	draggable: true,
	listening: true,
});

// 实现居中显示
const x = cropAttr?.x ?? (width - crop.width()) / 2;
const y = cropAttr?.y ?? (height - crop.height()) / 2;
crop.position({ x, y });

// 创建型变控制器
const transformer = new Konva.Transformer({
	rotateEnabled: false,
	anchorStroke: cropAttr?.stroke ?? "#299CF5",
	anchorFill: cropAttr?.fill ?? "#299CF5",
	anchorSize: 8,
	anchorCornerRadius: 8,
	borderStroke: cropAttr?.stroke ?? "#299CF5",
	borderDash: [8, 10],
	borderStrokeWidth: 2,
});

添加水印

为了不影响底层拖拽,水印应该单独为一个 layer:

ts 复制代码
// 不然创建新的水印图层 - 设置不可相应事件
const watermarkLayer = new Konva.Layer({ id: "watermarkLayer", listening: false, rotation: -45 });

watermarkLayer.offsetX(width / 2);
watermarkLayer.offsetY(height / 2);

// 创建水印 - 循环创建,并将 layer 进行旋转即可
const simpleText = new Konva.Text({
	x: 10,
	y: 15,
	text: wortermarkAttr?.text ?? "Simple Text",
	fontSize: wortermarkAttr?.fontSize ?? 20,
	fontFamily: "Calibri",
	fill: wortermarkAttr?.color ?? "rgba(0,0,0,.35)",
	// opacity: 0.5,
});

// 定义间隔
const [gapX, gapY] = wortermarkAttr?.gap ?? [10, 10];

// 循环创建水印
for (let i = 0; i < width * 2; i += simpleText.width() + gapX) {
	for (let j = 0; j < height * 2; j += simpleText.height() + gapY) {
		// 判断当前是否为偶数行
		const row = Math.floor(j / (simpleText.height() + gapY));
		const isEvenRow = row % 2 === 0;
		const text = simpleText.clone();
		text.x(i + (isEvenRow ? simpleText.width() / 2 : 0));
		text.y(j);
		watermarkLayer.add(text);
	}
}

watermarkLayer.batchDraw();
this.stage.add(watermarkLayer);
this.render();

实现暗部效果

为了突出裁剪范围,通常会给裁剪框外部添加暗部效果,使用技巧实现,就是绘制满屏的矩形,然后 clearRect 取消掉裁剪框部分:

ts 复制代码
/*绘画顺时针外部正方形*/
ctx.save();
ctx.moveTo(0, 0); // 起点
ctx.lineTo(width, 0); // 第一条线
ctx.lineTo(width, height); // 第二条线
ctx.lineTo(0, height); // 第三条线
ctx.closePath(); // 结束路径,自动闭合

/*填充颜色*/
ctx.fillStyle = "rgba(0, 0, 0, 0.35)";
ctx.fill();

ctx.restore();

// 清空 crop 区域
ctx.clearRect(x, y, cropRectInfo.width, cropRectInfo.height);

获取裁剪结果

获取裁剪结果则通过konva原生 toCanvas toDataURL实现:

ts 复制代码
/**
	* @description 获取裁剪结果
	* @param { "string" | "blob" | "canvas" } type 裁剪结果类型
	* @param { number } [pixelRatio] pixelRatio
	* @param { "png" | "jpeg" } [mimeType] mimeType
	*/
public getResult(type: "string" | "blob" | "canvas", pixelRatio = 1, mimeType: "png" | "jpeg" = "png") {
	if (!this.stage) return "Stage is not exist.";

	// 通过复制图层实现
	const stageClone = this.stage.clone();
	// 删除 transformer
	const mainLayer = <Layer>stageClone.findOne("#mainLayer");
	mainLayer.findOne("Transformer")?.remove();

	const cropAttrs = this.getCropAttr();

	if (type === "canvas") {
		return stageClone.toCanvas({ ...cropAttrs, pixelRatio });
	}

	const base64String = stageClone.toDataURL({ ...cropAttrs, pixelRatio, mimeType: `image/${mimeType}` });

	if (type === "string") {
		return base64String;
	} else if (type === "blob") {
		return base64ToBlob(base64String);
	}
}

实现预览事件

预览就是能触发更新的地方,手动调用getResult 获取结果返回即可:

ts 复制代码
/**
* @description 工具函数 - 触发 preview 事件 节流触发!
*/
public patchPreviewEvent() {
	throttle(
		() =>
			requestAnimationFrame(() => {
				const imageClipper = this.draw.getImageClipper();
				if (!imageClipper) return;

				const base64 = <string>this.getResult("string");
				if (!base64) return;

				imageClipper.event.dispatchEvent("preview", base64);
			}),
		10
	)();
}

总结

欢迎大家试用,若在使用过程中有什么问题,或有可优化的功能,欢迎大家提 ISSUES~

相关推荐
蓝胖子的多啦A梦22 分钟前
搭建前端项目 Vue+element UI引入 步骤 (超详细)
前端·vue.js·ui
TE-茶叶蛋24 分钟前
WebSocket 前端断连原因与检测方法
前端·websocket·网络协议
骆驼Lara34 分钟前
前端跨域解决方案(1):什么是跨域?
前端·javascript
离岸听风37 分钟前
学生端前端用户操作手册
前端
onebyte8bits39 分钟前
CSS Houdini 解锁前端动画的下一个时代!
前端·javascript·css·html·houdini
yxc_inspire43 分钟前
基于Qt的app开发第十四天
前端·c++·qt·app·面向对象·qss
一_个前端1 小时前
Konva 获取鼠标在画布中的位置通用方法
前端
[email protected]2 小时前
Asp.Net Core SignalR导入数据
前端·后端·asp.net·.netcore
小满zs7 小时前
Zustand 第五章(订阅)
前端·react.js
涵信8 小时前
第一节 基础核心概念-TypeScript与JavaScript的核心区别
前端·javascript·typescript