描述:
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();
},
};