📖 官网文档:pushu-wf.github.io/
🎉 在线体验地址:online-demo
🔗 仓库地址:gitee.com/wfeng0/avat…
前言
目前市面上已经有很多的图片裁剪工具了,例如:vue-cropper、vue-img-cutter 等,但是还是存在一些不足之处,例如没有 Typescript 支持,仍采用div渲染方案等。目前这方面的需求也日益增加,图片的裁剪功能在社交媒体、电商、证件照处理等多领域有着非常广泛的应用。
avatar-clipper 是一款基于 Konva 开发的轻量级头像裁剪工具,支持TypeScript 。其核心架构采用 Command 和 EventBus 模块,提供简洁 API 操作和灵活的事件回调机制。工具支持图片加载、裁剪框交互、水印添加、暗部效果等特色功能,并能导出多种格式的裁剪结果。相比现有方案,avatar-clipper 在保持功能完整的同时更加轻量化,不绑定任何 UI 组件,核心库打包结果仅 200 多kb,仅通过 API 实现核心裁剪功能,适用于社交媒体、电商等多场景需求。
整体架构如上图,裁剪工具的核心模块为 Command 及 EventBus,command提供必要的API操作,仅导出核心方法供用户使用,eventBus 则在必要时机,触发相应的事件回调,实现功能拓展。
初始化容器
选定的技术方案为 konva,因此,容器仅需要一个挂载节点即可,为了更好实现定位及内部元素样式的自我控制,以下结构作为容器框架:
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);
}
透明背景实现方案为添加图片:
初始化画布
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() 实现的:
// 创建新的图片实例
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 实现平移缩放会更简单:
// 创建裁剪框
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:
// 不然创建新的水印图层 - 设置不可相应事件
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 取消掉裁剪框部分:
/*绘画顺时针外部正方形*/
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实现:
/**
* @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 获取结果返回即可:
/**
* @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~