最近在搞一个类似于微信截图的图片审核工具。具体的功能能支持Canvas放大缩小、移动、画框,箭头,自由画笔和输入文字,且最后能导出一张标注后的截图。功能操作区如图所示
安装fabric.js
npm install fabric --save
项目中如何使用
import { fabric } from 'fabric';
初始化
const canvas = new fabric.Canvas(canvasRef.current, options)// 如果需要Canvas上的元素可以拖动,则需要设置canvas.selection = true
加载背景图
我们的场景是对图片进行标注,所以第一步必须先加载一张图片。我加载的图片可能会比Canvas大,初始时先对图片做了缩放校验。
css
fabric.Image.fromURL(
'图片路径',
(img: any) => {
// 图片的宽或者高超过了画布,因此缩放图片,以适应当前画布
if (img?.width > canvas.width || img?.height > canvas.height) {
const scaleX = canvas.width / img.width;
const scaleY = canvas.height / img.height;
const scale = Math.min(scaleX, scaleY);
img.scale(scale);
}
// 图片居中展示
const oImg = img.set({ left: 0, top: 0, angle: 0 });
canvas.centerObject(oImg);
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas)); // 设置为背景图
canvas.renderAll();
},
// 添加属性,支持图片跨域,不然使用导出截图功能时会报错
{ crossOrigin: 'anonymous' },
);
形状、文字绘制
获取当前坐标:const pointer = canvas.getPointer(event.e); 在我场景中允许Canvas缩放,所以需要获取转换后的坐标,getPointer返回的就是转换后的x, y。
矩形绘制
less
origX,origY在我的代码中是mouse:down的鼠标位置
const activeRect = new fabric.Rect({
left: origX,
top: origY,
fill: 'transparent', // 创建线条rect
stroke: 'red',
width: pointer.x - origX,
height: pointer.y - origY,
originX: 'left',
originY: 'top',
selectable: true, // 是否可以移动
hasControls: true, // 是否显示句柄
strokeWidth: 'red',
rx: 4, // 设置半角弧度
ry: 4,
});
canvas.add(activeRect);
箭头绘制
用的是Path绘制的箭头,也可以用Line和Triangle组合来绘制。
为啥最后用Path来绘制呢??是因为绘制出来的箭头需要支持移动,使用Line和Triangle组合绘制的箭头主体和头分离,拖动的时候不能一起拖。。
如果想要一起拖动Line和Triangle,可以使用new fabric.Group([line, triangle])把它俩组合起来。但是遇到了一个问题:在mouse:move事件中实时改变箭头位置时不会添加到Canvas上。
ini
// 利用Path来绘制箭头,参考:https://github.com/Couy69/vue-fabric-drawingboard
const drawArrowPath = (x1: number, y1: number, x2: number, y2: number) => {
const w = x2 - x1;
const h = y2 - y1;
const sh = Math.cos(Math.PI / 4) * 16;
// eslint-disable-next-line no-restricted-properties
const sin = h / Math.sqrt(Math.pow(w, 2) + Math.pow(h, 2));
// eslint-disable-next-line no-restricted-properties
const cos = w / Math.sqrt(Math.pow(w, 2) + Math.pow(h, 2));
const w1 = (16 * sin) / 4;
const h1 = (16 * cos) / 4;
const centerx = sh * cos;
const centery = sh * sin;
/**
* centerx,centery 表示起始点,终点连线与箭头尖端等边三角形交点相对x,y
* w1 ,h1用于确定四个点
*/
let path = ' M ' + x1 + ' ' + y1;
path += ' L ' + (x2 - centerx + w1) + ' ' + (y2 - centery - h1);
path += ' L ' + (x2 - centerx + w1 * 2) + ' ' + (y2 - centery - h1 * 2);
path += ' L ' + x2 + ' ' + y2;
path += ' L ' + (x2 - centerx - w1 * 2) + ' ' + (y2 - centery + h1 * 2);
path += ' L ' + (x2 - centerx - w1) + ' ' + (y2 - centery + h1);
path += ' Z';
return path;
};
自由画笔
设置Canvas上的一些参数就可以了。
canvas.isDrawingMode = true;
canvas.freeDrawingBrush.color = 'red';//设置画笔颜色
canvas.freeDrawingBrush.width = 4;//设置画笔宽度
可编辑文字
php
const iText = new fabric.IText('要插入的文字', {
fill: 'red',
fontSize: '16px',
top: pointer.y,
left: pointer.x,
hasControls: false,
});
canvas.add(iText);
// 设置为活动对象并进入编辑模式
canvas.setActiveObject(iText);
iText.enterEditing();
// 可监听的失焦事件
iText.on('editing:exited', () => {})
canvas支持缩放、移动操作
ini
canvas.on('mouse:wheel', (opt: any) => {
const delta = opt.e.deltaY;
// 缩放
if (opt.e.ctrlKey) {
let zoom = canvas.getZoom();
zoom *= 0.99 ** delta;
if (zoom > 20) zoom = 20;
if (zoom < 0.01) zoom = 0.01;
// 获取鼠标当前的位置
const pointer = canvas.getPointer(opt.e);
const pos = new fabric.Point(pointer.x, pointer.y);
// 设置缩放的中心点为鼠标的位置
canvas.zoomToPoint(pos, zoom);
} else {
// 移动
const activeObject = canvas.getActiveObject();
const horizontalScroll = -opt.e.deltaX;
const verticalScroll = -opt.e.deltaY;
const newLeft = Number(canvas.viewportTransform[4]) + horizontalScroll;
const newTop = Number(canvas.viewportTransform[5]) + verticalScroll;
// 更新画布的位置
canvas.viewportTransform[4] = newLeft;
canvas.viewportTransform[5] = newTop; // 更新画布的top位置
// 渲染画布
canvas.requestRenderAll();
}
// 阻止默认事件
opt.e.preventDefault();
opt.e.stopPropagation();
});
撤销
在图片上涂涂画画之后,还允许撤销(Cmd+Z)操作。主要实现:把每一步的数据(canvas.toObject)保存到stateStack中,执行撤销操作时加载(loadFromJSON)上一步的所有内容。
fabric.js提供了 loadFromJSON 方法,支持一次性加载所有的数据。但是!!在有渲染图片的场景中使用loadFromJSON方法,可能页面会有闪烁的情况,因为有加载图片过程。
解决办法:1、获取canvas.getObjects()清空当前场景中的所有内容。2、获取到保存的上一场景内容的objects,利用canvas.add()循环加载一遍。
ini
// 编辑状态记录,用于撤销
const canvasState = useRef<CanvasState>({
currentState: null,
stateStack: [], // 所有操作的集合,可以添加一个空白数据,可在加载image后添加,不然图片不会出现在里面
}).current;
// 1、添加操作:在add完形状之后把当前场景的内容添加进去 (我是在mouse:up中添加的)
// 2、更新操作:对图形移动,文字编辑之后把当前场景的内容添加进去,监听object:modified事件
// 添加当前场景的方法 (在初始化canvas时要在stateStack中添加一个空数据,不然撤退不到最原始状态)
function pushStateIfChanged() {
const state = canvas.toObject();
const { currentState, stateStack } = canvasState;
if (!isEqual(state, currentState)) {
stateStack.length = currentState ? stateStack.indexOf(currentState) + 1 : 0;
stateStack.push(state);
canvasState.currentState = state;
}
}
// 监听键盘事件
function handleUndo() {
const { currentState, stateStack } = canvasState;
const i = currentState ? stateStack.indexOf(currentState) : -1;
if (stateStack[i - 1]) {
const data = stateStack[i - 1];
const { objects } = data;
// 先清空所有的内容(除背景图)不要使用clear(), 它会清空画布所有内容
toolCanvas.current.getObjects().forEach((o: any) => {
toolCanvas.current.remove(o);
});
// 再重绘一遍所有的图形,这样就不会发生闪烁情况
for (let n = 0; n < objects.length; n++) {
fabric.util.enlivenObjects(
[objects[n]],
(enlivenedObjects: any) => {
enlivenedObjects.forEach((obj: any) => {
toolCanvas.current.add(obj);
});
toolCanvas.current.renderAll();
},
'fabric',
);
}
canvasState.currentState = stateStack[i - 1] || null;
}
updateOperateBtnStates();
}
导出截图
fabric.js提供了 canvas.toDataURL() 方法,可直接导出当前canvas的内容。
但是,又有个问题!
如果我当前的canvas是处于放大、移动的状态,上面👆🏻的方法会导出canvas视图里面的内容。。但这不是我想要的效果。
我的解决办法是:把当前canvas的数据导出,再新建一个copyCanvas加载所有内容,再利用 toDataURL 导出截图就行啦。
但是,还有一个问题!
如果你当前截图是在小屏幕操作的,后面再去到大屏幕看刚才的截图,图片就会很小(我希望的效果是大小屏应该同等大小)。
- 如果用 原canvas 的宽高作为 copyCanvas 的宽高的话会出现图片大小不一致问题。
- 如果用图片的真实宽高作为 copyCanvas 的宽高的话,一些标注在图片之前的框就不会被导出。
如何解决上面👆🏻的问题呢? 欢迎给出不同的答案。