前言

公司项目需求,实现在图片上框选多个多边形,获取多边形坐标点及其相对于图片的位置,本文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、如有问题,欢迎评论区讨论,大家早下班,打游戏不好吗?