基于fabric的图片热区组件(React)

本文介绍了如何使用Fabric库在React中创建一个可自定义的图片热区组件,支持绘制长方形区域、设置链接、缩放调整大小、鼠标交互以及删除功能,确保热区始终位于图片范围内。
摘要由CSDN通过智能技术生成

描述:

react图片热区组件,基于fabric实现。可以在图片上绘制长方形区域,并设置链接。未设置链接的热区背景色为红色,已设置链接的热区背景色为蓝色。支持缩放热区调整大小、鼠标拖拽热区位移、鼠标移入移出热区样式、删除热区,还有限制热区不能超出图片区域。

效果:

实现代码:

import React, { useRef, useEffect, useMemo } from 'react';
import { fabric } from 'fabric';

import { isEmpty } from '@/utils/extend';

import { HotZoneDef, RecordMethod, HotZoneSetup } from '../constants';

import styles from '../index.less';

/** @name HotZoneCanvas */
export default props => {
  const { dataSource = [], src, size, onActive, onRemove, onReady } = props;

  const canvasRef = useRef({
    // requestRenderAll: () => {},
  });

  const attrs = useMemo(() => HotZoneSetup.init(), []);

  useEffect(() => {
    if (!isEmpty(size)) {
      const { scale, ...others } = size;
      const canvas = new fabric.Canvas(attrs.id, others);

      canvas.selection = false; // 禁止批量选择

      // 设置背景图片
      const callback = canvas.requestRenderAll.bind(canvas);
      canvas.setBackgroundImage(src, callback, {
        scaleX: scale,
        scaleY: scale,
      });

      // 点击热区元素
      canvas.on('selection:created', res => {
        RecordMethod.select(res?.selected);
        canvasRef.current.requestRenderAll();

        const id = res?.selected?.[0]?.id;

        if (id) {
          onActive(id);
        }
      });

      // 点击另一个热区元素
      canvas.on('selection:updated', res => {
        RecordMethod.deselect(res?.deselected);
        RecordMethod.select(res?.selected);
        canvasRef.current.requestRenderAll();

        const id = res?.selected?.[0]?.id;

        if (id) {
          onActive(id);
        }
      });

      // 取消点击热区元素
      canvas.on('selection:cleared', res => {
        RecordMethod.deselect(res?.deselected);
        canvasRef.current.requestRenderAll();

        onActive('');
      });

      // 鼠标hover
      canvas.on('mouse:over', ({ target }) => {
        if (target) RecordMethod.hover(target);
      });

      // 鼠标out
      canvas.on('mouse:out', ({ target }) => {
        if (target) RecordMethod.out(target);
      });

      // 删除热区元素
      canvas.on('object:removed', res => {
        const id = res?.target?.id;
        onRemove(id);
      });

      canvasRef.current = canvas;

      onReady(canvas);

      dataSource.forEach((ele, index) => {
        RecordMethod.add({
          canvas: canvasRef.current,
          options: { ...ele, ...HotZoneDef.rect },
          index,
        });
      });
    }
  }, [size, dataSource]);

  return (
    <div className={styles.canvas}>
      <canvas {...attrs} />
    </div>
  );
};

功能方法:

/* eslint-disable no-restricted-syntax */
import { fabric } from 'fabric';

import GlobalTheme from '@/utils/variable';
import { StrExtend, sleep } from '@/utils/extend';
import { isEmpty } from '@/utils/utils';

const ActionIcon = {
  remove: document.createElement('img'),
};

// 删除icon
Object.assign(ActionIcon.remove, {
  src:
    "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Ebene_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='595.275px' height='595.275px' viewBox='200 215 230 470' xml:space='preserve'%3E%3Ccircle style='fill:%23F44336;' cx='299.76' cy='439.067' r='218.516'/%3E%3Cg%3E%3Crect x='267.162' y='307.978' transform='matrix(0.7071 -0.7071 0.7071 0.7071 -222.6202 340.6915)' style='fill:white;' width='65.545' height='262.18'/%3E%3Crect x='266.988' y='308.153' transform='matrix(0.7071 0.7071 -0.7071 0.7071 398.3889 -83.3116)' style='fill:white;' width='65.544' height='262.179'/%3E%3C/g%3E%3C/svg%3E",
});

const LabeledRect = fabric.util.createClass(fabric.Rect, {
  type: 'labeledRect',
  // initialize can be of type function(options) or function(property, options), like for text.
  // no other signatures allowed.
  initialize(options) {
    this.callSuper('initialize', options || {});
    this.set('label', options?.label || '');
  },

  toObject() {
    return fabric.util.object.extend(this.callSuper('toObject'), {
      label: this.get('label'),
    });
  },

  // eslint-disable-next-line no-underscore-dangle
  _render(ctx, options) {
    if (!ctx) {
      return;
    }

    if (!options) {
      this.callSuper('_render', ctx);
    }

    ctx.beginPath();
    ctx.save();
    // 最大限制
    const maxX = 16;
    const maxY = 16;
    const maxScaleX = 1 / (maxX / this.width);
    const maxScaleY = 1 / (maxY / this.height);
    const scaleX = 1 / this.scaleX > maxScaleX ? maxScaleX : 1 / this.scaleX;
    const scaleY = 1 / this.scaleY > maxScaleY ? maxScaleY : 1 / this.scaleY;
    ctx.translate(-this.width / 2, -this.height / 2);
    ctx.scale(scaleX, scaleY);

    ctx.font = '12px Helvetica';

    ctx.fillStyle = this.stroke;
    ctx.fillRect(0, 0, 16, 16);

    ctx.fillStyle = '#fff';
    ctx.textAlign = 'center';
    ctx.fillText(this.label, 8, 12);

    ctx.restore();
    ctx.closePath();
  },
});

export const HotZoneColor = {
  actived: GlobalTheme.vRed,
  def: GlobalTheme.vThemeColor,
  linked: 'rgb(13,126,255,0.2)',
  unLink: 'rgb(227,46,46,0.2)',
  unLinkBorder: 'rgb(227,46,46)',
  linkedBorder: 'rgb(13,126,255)',
};

export const HotZoneDef = {
  width: 600,

  color: HotZoneColor,

  item: {
    top: 0,
    left: 0,
    width: 100,
    height: 100,
  },

  rect: {
    cornerSize: 8, // 设置触点的尺寸
    transparentCorners: false, // 设置触点的样式: true=描边; false=填充
    cornerStyle: 'circle',
    hasBorders: true,
    lockScalingFlip: true, // 锁定缩放翻转
    lockUniScaling: true, // 锁定非均匀缩放
    strokeUniform: true, // 缩放时固定边框宽度
    borderScaleFactor: 2, // 控制器边框宽度
    // 设置缓存更新项
    cacheProperties: [
      'top',
      'left',
      'width',
      'height',
      'scaleX',
      'scaleY',
      'flipX',
      'flipY',
      'originX',
      'originY',
      'transformMatrix',
      'stroke',
      'strokeWidth',
      'strokeDashArray',
      'strokeLineCap',
      'strokeDashOffset',
      'strokeLineJoin',
      'strokeMiterLimit',
      'angle',
      'opacity',
      'fill',
      'globalCompositeOperation',
      'shadow',
      'visible',
      'backgroundColor',
      'skewX',
      'skewY',
      'fillRule',
      'paintFirst',
      'clipPath',
      'strokeUniform',
      'rx',
      'ry',
      'label',
    ],
  },
};

export const HotZoneSetup = {
  init: () => {
    const tempid = StrExtend.random();

    return {
      id: ['canvas', tempid].join('-'),
    };
  },

  item: () => {
    const id = StrExtend.random();
    return { id, ...HotZoneDef.item };
  },

  rect: () => ({ ...HotZoneSetup.item(), ...HotZoneDef.rect }),

  /**
   * @method HotZoneSetup.remove
   * @description 为热区覆盖面设置删除按钮
   * @param item fabric.Object | fabric.Rect
   * */

  remove: item => {
    const remove = new fabric.Control({
      x: 0.5,
      y: -0.5,

      offsetY: -10,
      offsetX: 10,
      cornerSize: 16,
      actionName: 'remove',
      cursorStyle: 'pointer',
      mouseDownHandler: (res, control) => {
        const canvas = control.target.canvas;
        canvas.remove(control.target);
        canvas.requestRenderAll();
        return true;
      },
      render: (ctx, left, top) => {
        ctx.save();
        ctx.translate(left, top);
        ctx.drawImage(ActionIcon.remove, -8, -8, 16, 16);
        ctx.restore();
      },
    });

    Object.assign(item.controls, { remove });
  },
};

export const RecordMethod = {
  /**
   * @method RecordMethod.add
   * @param params.canvas fabric.Canvas
   * @param params.options fabric.IRectOptions
   * */
  add: params => {
    const { canvas, options, index } = params;
    const { link } = options;

    // console.log('options :>> ', options);
    // console.log('index :>> ', index);

    const fill = link?.fulllink ? HotZoneColor.linked : HotZoneColor.unLink;
    const stroke = link?.fulllink ? HotZoneColor.linkedBorder : HotZoneColor.unLinkBorder;

    const rect = new LabeledRect({
      ...options,
      stroke,
      strokeDashArray: [5],
      strokeWidth: 1, // 设置边框宽度
      fill,
      borderColor: stroke, // 设置控制器线的颜色
      cornerColor: stroke, // 设置控制器触点的颜色
      label: `${index + 1}`,
    });

    rect.setControlVisible('mtr', false); // 隐藏旋转触点

    HotZoneSetup.remove(rect);

    canvas.add(rect);

    // 作用在矩形的事件:松开鼠标
    rect.on('mouseup', e => {
      // 获取画布视口边界
      const canvasBoundaries = canvas.calcViewportBoundaries();
      // 矩形本身
      const obj = e.target;

      // 矩形的边界
      const objBoundingRect = e.target.getBoundingRect();

      const { left, top } = objBoundingRect;
      const width = objBoundingRect.width;
      const height = objBoundingRect.height;

      const { br, tl } = canvasBoundaries;

      // 超出画布左边:图形左上方x坐标 < 画布左上方x坐标,将图形的 left 设置成画布左上方x坐标的值
      if (left < tl.x) {
        obj.left = tl.x;
      }

      // 超出画布右边:图形左上方y坐标 < 画布左上方y坐标,将图形的 top 设置成画布左上方y坐标的值
      if (left + width > br.x) {
        obj.left = width > br.x ? tl.x : br.x - width;
      }

      // 超出画布上边: 图形左上方x坐标 + 图形宽度 > 画布右下方x坐标,将图形的 left 设置成画布右下方x坐标 - 图形宽度
      if (top < tl.y) {
        obj.top = tl.y;
      }

      // 超出画布下边:图形左上方y坐标 + 图形高度 > 画布右下方y坐标,将图形的 top 设置成画布右下方y坐标 - 图形高度
      if (top + height > br.y) {
        obj.top = height > br.y ? tl.y : br.y - height;
      }

      // 限制大小
      const maxScaleX = br.x / e.target.width;
      const maxScaleY = br.y / e.target.height;
      const minX = 16;
      const minY = 16;
      const minScaleX = minX / e.target.width;
      const minScaleY = minY / e.target.height;

      if (obj.scaleX > maxScaleX) obj.scaleX = maxScaleX;
      if (obj.scaleX < minScaleX) obj.scaleX = minScaleX;
      if (obj.scaleY > maxScaleY) obj.scaleY = maxScaleY;
      if (obj.scaleY < minScaleY) obj.scaleY = minScaleY;

      // 刷新画布
      canvas.requestRenderAll();
    });
  },

  // 点击
  active: (canvas, id) => {
    const target = RecordMethod.getItem(canvas, id);
    canvas.setActiveObject(target);
    canvas.requestRenderAll();
  },

  // 删除
  remove: (canvas, id) => {
    const target = RecordMethod.getItem(canvas, id);
    canvas.remove(target);
    canvas.requestRenderAll();
  },

  /**
   * @param list fabric.Object[]
   * */
  select: list => {
    (list || []).forEach(ele => {
      ele.set('strokeWidth', 0); // 设置边框宽度
      ele.set('strokeDashArray', undefined); // 边框虚线
    });
  },

  /**
   * @param list fabric.Object[]
   * */
  deselect: list => {
    (list || []).forEach(ele => {
      ele.set('strokeWidth', 1); // 设置边框宽度
      ele.set('strokeDashArray', [5]); // 边框虚线
    });
  },

  // hover
  hover: target => {
    const item = target.canvas.getActiveObject();
    if (item?.id !== target.id) {
      RecordMethod.setItem(target, {
        strokeWidth: 1, // 设置边框宽度
        strokeDashArray: undefined, // 边框虚线
      });
    }
  },

  // out
  out: target => {
    const item = target.canvas.getActiveObject();
    if (item?.id !== target.id) {
      RecordMethod.setItem(target, {
        strokeWidth: 1, // 设置边框宽度
        strokeDashArray: [5], // 边框虚线
      });
    }
  },

  // 添加link
  addLink: (canvas, id, link) => {
    const target = RecordMethod.getItem(canvas, id);
    const fill = link?.fulllink ? HotZoneColor.linked : HotZoneColor.unLink;
    const stroke = link?.fulllink ? HotZoneColor.linkedBorder : HotZoneColor.unLinkBorder;

    RecordMethod.setItem(target, {
      stroke, // 设置边框颜色
      fill, // 设置填充颜色
      cornerColor: stroke, // 设置控制器触点的颜色
      borderColor: stroke, // 设置控制器线的颜色
    });
  },

  // 排序
  sort: (canvas, maps) => {
    const items = canvas.getObjects();
    const newList = [...items];
    newList.forEach(item => {
      RecordMethod.setItem(item, {
        label: maps[item.id],
      });
    });
  },

  // 根据id获取元素
  getItem: (canvas, id) => {
    const items = canvas.getObjects();
    const ele = items.find(obj => obj.id === id);
    return ele;
  },

  // 设置元素
  setItem: (target, attrs) => {
    for (const item of Object.keys(attrs)) {
      target.set(item, attrs[item]);
    }
    target.canvas.requestRenderAll();
  },
};

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值