前言
公司项目需求,实现在图片上框选多个多边形,获取多边形坐标点及其相对于图片的位置,本文React使用Hooks写法
一、环境
1、React 16.12.0 2、Fabrci.js 5.3.0 3、Antd design 4.20.0
二、实现步骤
1.引入Fabric.js
npm install fabric
2. 实现
我是在弹窗(Modal)中,如果大家只是默认渲染页,更简单
1、JSX 文件
import React, { forwardRef, useState, useImperativeHandle, useEffect, useRef } from "react";
import { Modal, Checkbox, Input, InputNumber, Form, message } from "antd";
import "./analysis.less";
import deleteIcon from "@/assets/img/delete-icon.svg"; //删除按钮svg
import defaultImg from "@/assets/img/test.jpg"; //底图图片
import { cloneDeep, uniqBy } from "lodash"; //深拷贝,去重 方法
import { fabric } from "fabric";
import { uuid } from "@/util"; //生成唯一ID,只是个前端本地方法
import { polygonPositionHandler, anchorWrapper, actionHandler, onMoving} from "./fabric";
import deviceDao from "./service"; //接口axios
const Analysis = (props, ref) => {
// 使用了forwardRef,事件需抛出给父组件,父组件才可以调用打开此弹窗页面
useImperativeHandle(ref, () => ({
getPage,
}));
const imgRef = useRef(null); //图片ref
const canvasBoxRef = useRef(null); //画布Ref,图片加载完毕后初始化此画布宽高盖在img上
const canvasRef = useRef(null); //fabric绑定canvas
const [open, setOpen] = useState(false); //弹窗是否打开
const [boxStyle, setBoxStyle] = useState({ width: 0, height: 0 }); // 画布宽高,canvasBoxRef 动态绑定此style用于设置宽高
// 框相关
const canvas = useRef(null); //画布控件
const canvasPolygon = useRef(null); //当前绘制polygon控件
const pointList = useRef(null); //最终框与点集合
useEffect(() => {
if (open && boxStyle.width && boxStyle.height) {
initCanvas(); //初始化
//回显接口多边形
generatePolygon([
{
id: "76022261",
points: [
{ x: 1375.5, y: 0.5 },
{ x: 1506.5, y: 115.5 },
{ x: 1507.5, y: 213.5 },
{ x: 1403.5, y: 181.5 },
],
},
{
id: "34343434",
points: [
{ x: 0.5, y: 634.5 },
{ x: 810, y: 472.6000061035156 },
{ x: 132.5, y: 847.5 },
{ x: 28.5, y: 815.5 },
],
},
]);
}
return () => {};
}, [open, boxStyle]);
//初始化画布
const initCanvas = () => {
const { width, height } = canvasBoxRef.current.getBoundingClientRect(); //拿到画布宽高
canvas.current = new fabric.Canvas(canvasRef.current, {
width,
height,
selectionColor: "transparent",
selectionBorderColor: "transparent",
hoverCursor: "transparent",
fireRightClick: true, // 启用右键
stopContextMenu: true, // 禁止默认右键菜单
hasBorders: false, //不需要边框
});
//画布监听事件
canvas.current.on("mouse:down", onMouseDown); //鼠标点击,打点开始绘制
canvas.current.on("mouse:move", onMouseMove); //鼠标移动,开始绘制
canvas.current.on("object:moving", onMoving); //边界处理,防止拖出去
canvas.current.on("mouse:dblclick", finishPolygon); //双击结束绘制
canvas.current.on("object:added", onObjectAdded); //监听canvas新增框
canvas.current.on("object:modified", dragPllygon); //拖动位置结束,更新数据
};
// 监听鼠标按下事件,打点或选中框
const onMouseDown = (opt) => {
var target = canvas.current.findTarget(opt.e);
// 在已有框点击而不是空白区域
if (target && target.id) {
opt.e.stopPropagation(); // 阻止事件冒泡
return;
}
if (canvasPolygon.current === null) {
createPolygon(opt); //新画框
} else {
changeCurrentPolygon(opt); //连续绘制(点过一个点了),而不是打第一个点
}
};
// 监听鼠标移动事件,开始绘制
const onMouseMove = (opt) => {
if (canvasPolygon.current) {
changePolygonBelt(opt);
}
};
// 创建多边形
const createPolygon = (opt) => {
//polygon为多边形控件,具体见官网
canvasPolygon.current = new fabric.Polygon(
[
{ x: opt.absolutePointer.x, y: opt.absolutePointer.y },
{ x: opt.absolutePointer.x, y: opt.absolutePointer.y },
],
{
fill: "rgba(255, 255, 255, 0.2)", //绘制时背景色
stroke: "#409eff", //绘制时边框色
objectCaching: false, //不使用缓存,此属性默认为true,如不为false,则打点无效果
hasControls: false, //默认绘制时不添加control控件,否则将出现九个点那个基准框
}
);
canvas.current.add(canvasPolygon.current); //将多边形添加到画布
};
// 连续打点
const changePolygonBelt = (opt) => {
let points = canvasPolygon.current.points; //此多边形点集合
points[points.length - 1].x = opt.absolutePointer.x;
points[points.length - 1].y = opt.absolutePointer.y;
canvas.current.requestRenderAll();
};
// 连续画线
const changeCurrentPolygon = (opt) => {
let points = canvasPolygon.current.points;
// 右键撤销上一个点
if (opt.button === 3) {
if (points.length) {
points.pop(); //从后边删一个
}
}
// 默认新增点
else {
points.push({
x: opt.absolutePointer.x,
y: opt.absolutePointer.y,
});
}
//更新画布
canvas.current.requestRenderAll();
};
// 绘制完成元素事件
const onObjectAdded = (opt) => {
const target = opt.target;
const mapList = cloneDeep(pointList.current || []);
const points = target.points;
// 至少三个点,否则是一条线或一个点,不push
if (points.length > 2) {
const newBox = {
id: target.id,
points,
};
mapList.push(newBox);
const unibyList = uniqBy(mapList, "id"); //以id 去重
pointList.current = unibyList;
}
};
// 双击完成多边形绘制
const finishPolygon = (opt) => {
let points = canvasPolygon.current.points;
points[points.length - 1].x = opt.absolutePointer.x;
points[points.length - 1].y = opt.absolutePointer.y;
points.pop();
points.pop();
canvas.current.remove(canvasPolygon.current); //删除此画布,为下一次做准备
//至少三个点,否则为一个点或线,不是多边形
if (points.length > 2) {
const data = [
{
id: uuid(8, 10),
points: points,
},
];
generatePolygon(data);
} else {
message.warning("请绘制至少三个点!!!");
}
canvasPolygon.current = null; //清除画布,未下一次绘制做准备
canvas.current.requestRenderAll(); //刷新画布
};
// 拿JS 数组生成多边形并绑定事件
const generatePolygon = (data) => {
if (!data || !data.length) {
return;
}
// 以数组为基础,渲染框
data.forEach((v) => {
const polygon = new fabric.Polygon(v.points, {
id: v.id,
stroke: "#00FF64", //渲染边框色
fill: "transparent", //背景色
selectable: false, //禁用位置及大小修改
hoverCursor: "move", //鼠标移入显示拖拽位置手势
transparentCorners: false, //操作顶点填充
padding: 0, // 多边形距离外侧基准框距离
objectCaching: false, //不使用缓存,此属性如为true,则拖拽顶点时边框不会实时更新边框位置
});
//多边形点击
polygon.on("mousedown", function (options) {
const poly = options.target;
canvas.current.setActiveObject(poly);
poly.edit = !poly.edit; //默认为不可编辑,点击选中后变为可编辑状态
if (poly.edit) {
const lastControl = poly.points.length - 1;
poly.cornerStyle = "circle";
poly.cornerColor = "#409eff";
poly.hasBorders = false;
// 拖拽大小控件
poly.controls = poly.points.reduce(function (acc, point, index) {
acc["p" + index] = new fabric.Control({
positionHandler: polygonPositionHandler,
actionHandler: anchorWrapper(index > 0 ? index - 1 : lastControl, actionHandler),
actionName: "modifyPolygon",
pointIndex: index,
});
return acc;
}, {});
// 删除控件
poly.controls.deleteControl = new fabric.Control({
x: 0.5, //横向偏移量
y: -0.5,
offsetY: -16,
offsetX: 16,
cornerSize: 24, //图标大小
cursorStyle: "pointer", // 移入鼠标样式
mouseUpHandler: deleteObject, // 点击鼠标抬起事件,用于删除操作
render: deleteRenderIcon, //渲染红色叉号
});
}
});
canvas.current.add(polygon); //添加多个多边形,因为是在foreach内
canvas.current.requestRenderAll(); //刷新画布
});
};
// 多边形位置拖动
const dragPllygon = (options) => {
const polygon = options.target;
const { id } = polygon;
// 获取最新点位值
const matrix = polygon.calcTransformMatrix();
const newPoints = polygon
.get("points")
.map(function (p) {
return new fabric.Point(p.x - polygon.pathOffset.x, p.y - polygon.pathOffset.y);
})
.map(function (p) {
return fabric.util.transformPoint(p, matrix);
});
// console.log("拖完了", newPoints);
// 修改对应数组中数据位置数据
const mapList = cloneDeep(pointList.current).map((v) => {
return {
...v,
points: v.id === id ? newPoints : v.points,
};
});
const uniByList = uniqBy(mapList, "id"); //以ID 去重
pointList.current = uniByList;
};
// 删除图标,官网方法,只是copy过来了,动态新增了img标签而已
const deleteRenderIcon = (ctx, left, top, styleOverride, fabricObject) => {
var img = document.createElement("img");
img.src = deleteIcon;
var size = 24;
ctx.save();
ctx.translate(left, top);
ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle));
ctx.drawImage(img, -size / 2, -size / 2, size, size);
ctx.restore();
};
// 以ID为唯一标识,删除框
const deleteObject = (eventData, transform) => {
let target = transform.target;
let canvas = target.canvas;
const { id } = target;
// 更新数据
const mapList = cloneDeep(pointList.current).filter((v) => v.id !== id);
pointList.current = mapList;
// 删除画布元素并刷新画布
canvas.remove(target);
canvas.requestRenderAll();
};
//打开弹窗
const getPage = (data) => {
setOpen(true);
};
// 图片加载完毕,设置画布大小
const imgLoad = (e) => {
e.persist();
const { offsetWidth, offsetHeight } = e.target;
setBoxStyle({
width: offsetWidth,
height: offsetHeight,
});
};
// 提交,拿到最终的点位数组,去请求接口啥的都行
const handleSubmit = async () => {
console.log("最终点位数据", pointList.current);
};
// 关闭弹窗
const handleCancel = () => {
// 数据初始化
setBoxStyle({ width: 0, height: 0 });
//将画布,绘制框都初始化,每次初始化fabric我是在每次打开弹窗事件中做的,如果各位是默认页面,在useEffeect return中做即可
imgRef.current = null;
canvasBoxRef.current = null;
canvasRef.current = null;
canvasPolygon.current = null;
setOpen(false); //关闭弹窗
};
return (
<Modal
title="分析内容"
className="analysis"
width="100vw"
style={{
maxWidth: "100vw",
top: 0,
paddingBottom: 0,
margin: 0,
}}
bodyStyle={{
height: "calc(100vh - 108px )",
overflowY: "auto",
}}
maskClosable={false}
keyboard={false}
visible={open}
destroyOnClose
okText="确定"
cancelText="取消"
onOk={handleSubmit}
onCancel={handleCancel}
>
<div className="content">
<div className="content-top">
<p>事件类型:</p>
<Checkbox.Group options={list} defaultValue={checked} onChange={checkboxChange} />
</div>
<div className="content-bottom">
<img src={defaultImg} ref={imgRef} alt="" onLoad={imgLoad} />
<div className="canvas-box" ref={canvasBoxRef} style={boxStyle}>
<canvas width={boxStyle.width} height={boxStyle.height} ref={canvasRef} />
</div>
</div>
</div>
</Modal>
);
};
export default forwardRef(Analysis);
2、fabric.js文件(本地文件,放官网方法,本地新建fabric.js直接copy代码放进去即可)
import { fabric } from "fabric";
// 大小修改,来自官网方法
export function polygonPositionHandler(dim, finalMatrix, fabricObject) {
var x = fabricObject.points[this.pointIndex].x - fabricObject.pathOffset.x,
y = fabricObject.points[this.pointIndex].y - fabricObject.pathOffset.y;
return fabric.util.transformPoint(
{ x: x, y: y },
fabric.util.multiplyTransformMatrices(fabricObject.canvas.viewportTransform, fabricObject.calcTransformMatrix())
);
}
export function actionHandler(eventData, transform, x, y) {
var polygon = transform.target,
currentControl = polygon.controls[polygon.__corner],
mouseLocalPosition = polygon.toLocalPoint(new fabric.Point(x, y), "center", "center"),
polygonBaseSize = getObjectSizeWithStroke(polygon),
size = polygon._getTransformedDimensions(0, 0),
finalPointPosition = {
x: (mouseLocalPosition.x * polygonBaseSize.x) / size.x + polygon.pathOffset.x,
y: (mouseLocalPosition.y * polygonBaseSize.y) / size.y + polygon.pathOffset.y,
};
polygon.points[currentControl.pointIndex] = finalPointPosition;
return true;
}
export function getObjectSizeWithStroke(object) {
var stroke = new fabric.Point(object.strokeUniform ? 1 / object.scaleX : 1, object.strokeUniform ? 1 / object.scaleY : 1).multiply(object.strokeWidth);
return new fabric.Point(object.width + stroke.x, object.height + stroke.y);
}
export function anchorWrapper(anchorIndex, fn) {
return function (eventData, transform, x, y) {
var fabricObject = transform.target,
absolutePoint = fabric.util.transformPoint(
{
x: fabricObject.points[anchorIndex].x - fabricObject.pathOffset.x,
y: fabricObject.points[anchorIndex].y - fabricObject.pathOffset.y,
},
fabricObject.calcTransformMatrix()
),
actionPerformed = fn(eventData, transform, x, y),
newDim = fabricObject._setPositionDimensions({}),
polygonBaseSize = getObjectSizeWithStroke(fabricObject),
newX = (fabricObject.points[anchorIndex].x - fabricObject.pathOffset.x) / polygonBaseSize.x,
newY = (fabricObject.points[anchorIndex].y - fabricObject.pathOffset.y) / polygonBaseSize.y;
fabricObject.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5);
return actionPerformed;
};
}
// 边界处理,防止拖出画布
export function onMoving(e) {
let padding = 0; // 内容距离画布的空白宽度,主动设置
var obj = e.target;
if (obj.currentHeight > obj.canvas.height - padding * 2 || obj.currentWidth > obj.canvas.width - padding * 2) {
return;
}
obj.setCoords();
if (obj.getBoundingRect().top < padding || obj.getBoundingRect().left < padding) {
obj.top = Math.max(obj.top, obj.top - obj.getBoundingRect().top + padding);
obj.left = Math.max(obj.left, obj.left - obj.getBoundingRect().left + padding);
}
if (
obj.getBoundingRect().top + obj.getBoundingRect().height > obj.canvas.height - padding ||
obj.getBoundingRect().left + obj.getBoundingRect().width > obj.canvas.width - padding
) {
obj.top = Math.min(obj.top, obj.canvas.height - obj.getBoundingRect().height + obj.top - obj.getBoundingRect().top - padding);
obj.left = Math.min(obj.left, obj.canvas.width - obj.getBoundingRect().width + obj.left - obj.getBoundingRect().left - padding);
}
}
3、less文件(注意下 .canvas-box 的z-index需大于img的z-index就行,盖在img上边)
.analysis {
-webkit-user-select: none; /* Safari 3.1+ */
-moz-user-select: none; /* Firefox 2+ */
-ms-user-select: none; /* IE10+ */
user-select: none; //不允许页面选中
.ant-modal-body {
padding: 12px;
.content {
width: 100%;
height: 100%;
.content-top {
width: 100%;
height: 50px;
border-bottom: 1px solid #ebeef5;
display: flex;
align-items: center;
p {
width: 100px;
height: 100%;
font-size: 16px;
font-weight: bold;
padding-left: 16px;
display: flex;
align-items: center;
}
.ant-checkbox-group {
margin-left: 14px;
}
}
.content-bottom {
width: 100%;
height: calc(100% - 50px);
position: relative;
img {
max-width: 100%;
}
.canvas-box {
position: absolute;
left: 0;
top: 0;
z-index: 10;
}
}
}
}
}
三、效果
[video(video-5MxvPEXb-1706254659784)(type-csdn)(url-https://live.csdn.net/v/embed/360382)(image-https://video-community.csdnimg.cn/vod-84deb4/b08111acb5c471ee99514531949c0102/snapshots/44761790976043458c75bb4efb12a0c3-00004.jpg?auth_key=4859156692-0-0-f34c1204892820b38b6eec9cb4a0f750)(title-React hooks 使用fabric.js 绘制多边形)]
总结
1、功能:手动画多边形,默认渲染回显多边形,获取到移动点和框位置后的最新坐标,点击编辑,删除,边界处理(不能拖到img/画布 外边)右键撤销上一次绘制,双击结束绘制等 2、代码都有通俗的注释,目的是为了大家都看得懂,官网是英文文档,看起来真难受 3、如果你是画圆或者矩形,思路差不多,只不过不用polygon,用Rect 4、fabric.js 使用思路 和openlayers 差不多,都是千层饼一层层摞上去的概念 5、如果需要手动拖动绘制矩形并框选区域,并获取坐标点,不想用fabric.js的话,请移步我另一个文章: React Hooks 手动绘制矩形 6、如有问题,欢迎评论区讨论,大家早下班,打游戏不好吗?