热区,一个让人有爱又恨的狗东西
。是时候搞定它了。
需求给了一周的时间,心想这玩意还用一周?直到...
任务需求
- React + Ts实现热区
- 热区可以拖拽移动
- 热区可以放大缩小
- 热区不允许重叠
- B端创建的热区要在移动端展示
- 不允许有误差
Day-01
浏览各个论坛,看看各位大佬们都用的什么插件,穿梭在github
,stackoverflow
,juejin
,zhihu
的知识海洋中...
搜索到很多很多看起来"完美
"的插件...
当然还有纯原生实现的...
2024年了,我最大的优点就是懒。造轮子?死也不可能造!
一杯茶,一根烟,一个Bug改一天,Day1就这么过去了。
Day-02-AM
打开掘金,和JYM吹吹牛逼,点上那么几个小赞,开始新一天的工(摸)作(鱼)生活。
继续畅游在知识的海洋中,寻找大佬造好的轮子。
突然,产品经理来袭,say:上线提前,最好明天下班前提测
心里一句国粹,腿开始不自觉抖动...
干饭先。
Day-02-PM
轮子?批轮!开造!
需求梳理
热区?
- 在一张图片上鼠标画个框。
- 框的信息可以获取这个框起点的坐标(x, y),宽高(w, h)
- 这个框可以移动,缩放
- 收集移动缩放后框的信息
- 移动端绘制这个框
思考?
- 这个框画在哪?
- 框肯定不能超出图片吧?(边界计算)
- 怎么让框移动,缩放?
- 如何返回框的信息?
- 多个框怎么计算重叠?
画个框
两种方案:
- 原生
- 使用fabric
原生
- div画个框放张背景图
- 绝对定位8个点 上下左右,左上左下,右上右下
- 使用cursor
arduino
[
{
style:
'position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;',
position: 'top',
},
{
style:
'position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; right: -5px; cursor: col-resize;',
position: 'right',
},
{
style:
'position: absolute; user-select: none; width: 100%; height: 10px; bottom: -5px; left: 0px; cursor: row-resize;',
position: 'bottom',
},
{
style:
'position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; left: -5px; cursor: col-resize;',
position: 'left',
},
{
style:
'position: absolute; user-select: none; width: 20px; height: 20px; right: -10px; top: -10px; cursor: ne-resize;',
position: 'topRight',
},
{
style:
'position: absolute; user-select: none; width: 20px; height: 20px; right: -10px; bottom: -10px; cursor: se-resize;',
position: 'bottomRight',
},
{
style:
'position: absolute; user-select: none; width: 20px; height: 20px; left: -10px; bottom: -10px; cursor: sw-resize;',
position: 'bottomLeft',
},
{
style:
'position: absolute; user-select: none; width: 20px; height: 20px; left: -10px; top: -10px; cursor: nw-resize;',
position: 'topLeft',
},
]
- 使用onmousemove,onmouseup实现画框缩放
- 使用drag实现移动
写了点,写不下去了。吐了, 什么狗东西。dogthing...
最后开始忍着恶心写完了,感兴趣私信我,我发你...
羞耻到不敢贴出...
fabricjs
我们先来看看官方的定义:
Fabric.js is a framework that makes it easy to work with HTML5 canvas element. It is an interactive object model on top of canvas element. It is also an SVG-to-canvas parser. Fabric.js 是一个可以让 HTML5 Canvas 开发变得简单的框架 。 它是一种基于 Canvas 元素的 可交互 对象模型,也是一个 SVG 到 Canvas 的解 析器(让SVG 渲染到 Canvas 上)。
从它的官方定义可以看出来,它是一个用 Canvas 实现的对象模型。如果你需要用 HTML Canvas 来绘制一些东西,并且这些东西可以响应用户的交互,比如:拖动、变形、旋转等 操作。 那用 fabric.js 是非常合适的,因为它内部不仅实现了 Canvas 对象模型,还将一 些常用的交互操作封装好了,可以说是开箱即用。
内部集成的主要功能如下:
- 几何图形绘制,如:形状(圆形、方形、三角形)、路径
- 位图加载、滤镜
- 自由画笔工具,笔刷
- 文本、富文本渲染
- 模式图像
- 对象动画
- Canvas 对象之间的序列化与反序列化
这不是撞枪口上了吗!这不是!
Canvas 开发原理
如果你之前没有过 Canvas 的相关开发经验(只有 JavaScript 网页开发经验),刚开始 入 门会觉得不好懂,不理解 Canvas 开发的逻辑。这个很正常,因为这表示你正在从传统 的 JavaScript 开发转到图形图像 GUI 图形图像、动画开发。 虽然语言都是 JavaScript 但是开发理念和用到的编程范式完全不同。
传统的客户端 JavaScript 开发一般可以认为是 事件驱动的编程模型 (Event-driven programming),这个时候你需要关注事件的触发者和监听者 Canvas 开发通常是 面向对象的编程模型,需要把绘制的物体抽象为对象,通过对 象的方法维护自身的属性,通常会使用一个全局的事件总线来处理对象之间的交互 这两种开发方式各有各的优势,比如:
有的功能在 HTML 里一行代码就能实现的功能放到 Canvas 中需要成千行的代码去实现。 比如:textarea, contenteditable 相反,有的功能在 Canvas 里面只需要一行代码实现的,使用 HTML 却几乎无法实现。比 如:截图、录制
愉快的一天结束了,还没开始写...
加班?批班!
Day-03
撸起袖子加油干
初始化和状态管理
js
const canvasRef = useRef<any>(null);
const [canvas, setCanvas] = useState<any>(null);
const imgWidth = useRef(0);
const imgHeight = useRef(0);
const canvasWidth = useRef(0);
const canvasHeight = useRef(0);
const currentSelection = useRef({
startX: 0,
startY: 0,
});
const [selectedRectId, setSelectedRectId] = useState('');
// 将热区配置数据映射为编辑组件的内部状态
const [rectConfig, setRectConfig] = useState(
data.hotZoneList.map((config) => ({
id: config.rectId,
url: config.url,
width: config.width,
height: config.height,
x: config.left,
y: config.top,
})),
);
让我们逐个介绍上述代码中的每个部分:
canvasRef 和 setCanvas:
js
const canvasRef = useRef<any>(null);
const [canvas, setCanvas] = useState<any>(null);
- canvasRef 是一个用于引用React组件中的canvas元素的useRef对象。
- setCanvas 是一个用于更新canvas状态的useState hook,该状态保存着fabric.js中的canvas实例。
imgWidth 和 imgHeight:
ini
const imgWidth = useRef(0);
const imgHeight = useRef(0);
- imgWidth 和 imgHeight 是用于存储图片的实际宽度和高度的useRef对象。 canvasWidth 和 canvasHeight:
ini
const canvasWidth = useRef(0);
const canvasHeight = useRef(0);
- canvasWidth 和 canvasHeight 是用于存储canvas元素的宽度和高度的useRef对象。
currentSelection:
ini
const currentSelection = useRef({
startX: 0,
startY: 0,
});
- currentSelection 是一个useRef对象,用于存储当前选择热区的起始坐标。
selectedRectId:
scss
const [selectedRectId, setSelectedRectId] = useState('');
- selectedRectId 是一个useState hook,用于存储当前选定的热区的ID。
rectConfig 和 setRectConfig:
arduino
const [rectConfig, setRectConfig] = useState(
data.hotZoneList.map((config) => ({
id: config.rectId,
url: config.url,
width: config.width,
height: config.height,
x: config.left,
y: config.top,
})),
);
- rectConfig 是一个存储热区配置的useState hook。它将data.hotZoneList中的热区配置数据映射为内部状态,包括ID、URL、宽度、高度、X和Y坐标。
Canvas初始化和背景图片加载
js
const initCanvas = () => {
// 创建背景图片对象
const bgImg = new fabric.Image(document.getElementById(`bgImg${dataIndex}`));
bgImg.scaleToWidth(canvasWidth.current);
// 创建新的fabric.Canvas实例
const newCanvas = new fabric.Canvas(canvasRef.current, {
backgroundImage: bgImg,
selectionColor: 'rgba(255, 255, 255, 0)',
selectionLineWidth: BORDER_WIDTH,
selectionBorderColor: ACTIVE_COLOR,
});
// 添加事件监听器
newCanvas.on('selection:cleared', (options) => onSelectRect(options, newCanvas));
newCanvas.on('selection:updated', (options) => onSelectRect(options, newCanvas));
newCanvas.on('selection:created', (options) => onSelectRect(options, newCanvas));
newCanvas.on('mouse:down', (options) => {
currentSelection.current.startX = options.e.offsetX;
currentSelection.current.startY = options.e.offsetY;
});
newCanvas.on('mouse:up', (options) => {
// 处理鼠标释放事件,添加热区
// ...
});
// 设置Canvas的大小
setCanvas(newCanvas);
// 如果已经存在热区数据,则初始化显示
if (data.hotZoneList.length) {
initDefaultConfig(data.hotZoneList, newCanvas);
}
};
// 图片加载完成后调用initCanvas
const onImgLoad = (e) => {
// 获取图片尺寸信息
const { width, height, naturalWidth, naturalHeight } = e.target;
// 存储图片尺寸和canvas尺寸
imgWidth.current = naturalWidth;
imgHeight.current = naturalHeight;
canvasWidth.current = width;
canvasHeight.current = height;
// 初始化Canvas
initCanvas();
};
该方法包含了两个主要的方法,initCanvas,onImageLoad
initCanvas
和 onImageLoad
这两个函数在这个文件中起着至关重要的作用,主要涉及初始化canvas以及处理图片加载的逻辑。
- onImageLoad 函数
作用
- 当图片加载完成时触发的事件处理函数。
- 获取图像的尺寸信息,包括原始尺寸和实际渲染尺寸。
- 存储图片尺寸信息和 canvas 尺寸信息。
- 调用 initCanvas 函数来初始化 canvas。
重要性
- 图片加载完成后,我们需要知道图像的尺寸以及 canvas - - 的尺寸,以便正确地进行后续的初始化操作。
- 将图片尺寸和 canvas 尺寸存储在 imgWidth, imgHeight, canvasWidth, canvasHeight 中,以备后续使用。
- initCanvas 函数
作用:
- 初始化 canvas,并设置其背景图像。
- 注册事件监听器,处理选择、拖拽等事件。
- 根据已存在的热区数据,初始化显示这些热区。
- 将新创建的 canvas 实例设置为组件的状态。
重要性:
- 创建 fabric.js 的 Image 和 Canvas - 实例,将背景图像设置到 canvas 中。
- 注册了一系列的事件监听器,用于处理选择、拖拽、热区添加等交互行为。
- 将初始化好的 canvas 实例通过 setCanvas 存储在组件的状态中,以便后续对 canvas 的操作。
这两个函数协同工作,确保在图像加载完成后,可以正确地初始化 canvas,并设置好事件监听器,使得用户能够通过鼠标进行交互操作,例如绘制热区。这对于实现一个交互式的编辑组件是至关重要的。
initCanvas
主要使用了fabricjs来进行初始化
fabric.Image:
- 用于创建一个fabric.js的图像实例。
- document.getElementById(bgImg${dataIndex}) 获取具有相应ID的HTML元素,其中dataIndex是从上下文中获取的。
- bgImg.scaleToWidth(canvasWidth.current) 将图像的宽度缩放到canvasWidth的当前值。
fabric.Canvas:
- 创建一个fabric.js的Canvas实例,将其附加到canvasRef.current上。
- backgroundImage: bgImg 将背景图片设置为之前创建的图像实例。
- selectionColor, selectionLineWidth, selectionBorderColor 用于设置选择热区时的样式。
事件监听器:
- on('selection:cleared', ...), on('selection:updated', ...), on('selection:created', ...) 监听Canvas上选择热区的事件,并分别调用onSelectRect函数。
on('mouse:down', ...)
- 监听鼠标按下事件,记录鼠标按下的坐标。
on('mouse:up', ...):
- 监听鼠标释放事件,处理热区的添加逻辑。
setCanvas:
- 使用setCanvas将新创建的Canvas实例设置为组件的状态。
initDefaultConfig:
如果存在热区数据,通过initDefaultConfig将热区配置初始化显示在Canvas上。
onImageLoad
获取图片尺寸信息:
- e.target 是触发加载事件的图像元素。
- naturalWidth, naturalHeight 是图片的原始尺寸。
- width, height 是图像在页面中的实际渲染尺寸。
存储图片尺寸和canvas尺寸:
- 使用useRef对象(imgWidth, imgHeight, canvasWidth, canvasHeight)存储图片和canvas的尺寸信息。
初始化Canvas:
- 调用之前定义的initCanvas 函数,根据图片尺寸和canvas尺寸创建并初始化Canvas。
绘制热区和热区事件处理
js
const createRect = ({ top, left, width, height, rectId }) => {
// 使用fabric.Rect创建热区对象
const rect = new fabric.Rect({
top,
left,
width,
height,
fill: 'rgba(255, 255, 255, 0)',
stroke: ACTIVE_COLOR,
strokeWidth: BORDER_WIDTH,
transparentCorners: false,
lockRotation: true,
}).setControlVisible('mtr', false);
// 设置热区ID并添加事件监听器
rect.rectId = rectId || `${Date.now()}`;
rect.on('moving', () => onRectMoving(rect));
rect.on('mouseup', () => onRectMouseup(rect));
return rect;
};
// 添加新热区
const addRect = ({ x, y }, newCanvas) => {
// 计算矩形宽高
// ...
// 创建热区对象
const rect = createRect({
top: height < 0 ? startY - Math.abs(height) : startY,
left: width < 0 ? startX - Math.abs(width) : startX,
width: Math.abs(width),
height: Math.abs(height),
rectId: `${Date.now()}`,
});
// 检查热区是否重叠
// ...
// 添加热区到Canvas并更新状态
newCanvas.add(rect);
newCanvas.setActiveObject(rect);
setRectConfig((prevRectConfig: any) => [...prevRectConfig, { id: rect.rectId, url: '', x, y, width, height }]);
};
createRect
new fabric.Rect:
- 使用 fabric.js 提供的 Rect 构造函数创建一个矩形对象。
矩形属性设置:
- top, left: 矩形的顶部和左侧位置。
- width, height: 矩形的宽度和高度。
- fill: 设置填充颜色为透明。
- stroke: 设置矩形边框颜色为 ACTIVE_COLOR。
- strokeWidth: 设置矩形边框宽度为 BORDER_WIDTH。
- transparentCorners: 设置矩形的角为透明。
- lockRotation: 锁定矩形的旋转。
setControlVisible('mtr', false):
- 使用 fabric.js 提供的方法,设置矩形的旋转控制器不可见。
rectId 和事件监听器:
- 设置热区的唯一标识 rectId,如果未提供,则使用当前时间戳。
- 添加 moving 和 mouseup 事件监听器,分别调用 onRectMoving 和 onRectMouseup 函数。
返回矩形对象:
- 返回创建好的矩形对象。
addRect
通过计算鼠标按下和释放的坐标,得到矩形的宽度和高度。 创建热区对象:
- 调用 createRect
- 函数创建热区对象,传递矩形的位置和大小。
检查热区是否重叠:
- 使用 getRectList 获取当前画布上的所有热区对象列表。
- 使用 isTwoRectOverlap 检查新创建的热区是否与已存在的热区重叠。
处理重叠情况:
- 如果热区重叠,通过 Message.error 提示用户,并释放当前创建的热区对象。
添加热区到Canvas并更新状态:
- 如果热区不重叠,将热区对象添加到画布上。
- 使用 setActiveObject 将新创建的热区设为活动对象。
- 更新组件状态,将新创建的热区信息添加到 rectConfig 中。
热区配置和状态更新
js
const onSelectRect = (options, newCanvas) => {
// 处理选择热区事件
// ...
};
const onRectMoving = (target) => {
// 处理热区移动事件
// ...
};
const onRectMouseup = (target) => {
// 处理热区释放鼠标事件
// ...
};
热区配置和数据保存
js
const clearAll = () => {
// 清空所有热区
rectConfig.forEach(() => {
deleteObject(0, canvas);
});
};
const saveDraw = () => {
// 保存绘制结果
// ...
};
填坑环节
绘制热区时不应该超出当前容器
onRectMoving 事件处理
js
const onRectMoving = (target) => {
const { top, left, lineCoords } = target;
const { br, tl } = lineCoords;
const width = br.x - tl.x;
const height = br.y - tl.y;
// 如果热区左侧位置小于0,则将其设置为0,防止热区超出画布左侧
if (left < 0) {
target.set('left', 0);
}
// 如果热区顶部位置小于0,则将其设置为0,防止热区超出画布顶部
if (top < 0) {
target.set('top', 0);
}
// 如果热区右侧位置加上宽度大于画布宽度,则将其左侧位置设置为合适的位置,防止热区超出画布右侧
if (left + width >= canvasWidth.current) {
target.set('left', canvasWidth.current - width);
}
// 如果热区底部位置加上高度大于画布高度,则将其顶部位置设置为合适的位置,防止热区超出画布底部
if (top + height >= canvasHeight.current) {
target.set('top', canvasHeight.current - height);
}
};
onRectMouseup 事件处理
js
const onRectMouseup = (target) => {
const { lineCoords } = target;
const { br, tl } = lineCoords;
const options = {
scaleX: 1,
scaleY: 1,
};
// 如果热区的左上角横坐标小于0,则调整热区的左侧位置和宽度
if (tl.x < 0) {
Object.assign(options, {
left: 0,
width: br.x - BORDER_WIDTH,
});
}
// 如果热区的右下角横坐标大于画布宽度,则调整热区的宽度
else if (br.x > canvasWidth.current) {
Object.assign(options, {
width: canvasWidth.current - tl.x - BORDER_WIDTH,
});
}
// 否则,按照原始计算方式设置热区的宽度
else {
Object.assign(options, {
width: br.x - tl.x - BORDER_WIDTH,
});
}
// 如果热区的左上角纵坐标小于0,则调整热区的顶部位置和高度
if (tl.y < 0) {
Object.assign(options, {
top: 0,
height: br.y - BORDER_WIDTH,
});
}
// 如果热区的右下角纵坐标大于画布高度,则调整热区的高度
else if (br.y > canvasHeight.current) {
Object.assign(options, {
height: canvasHeight.current - tl.y - BORDER_WIDTH,
});
}
// 否则,按照原始计算方式设置热区的高度
else {
Object.assign(options, {
height: br.y - tl.y - BORDER_WIDTH,
});
}
// 应用计算后的选项,调整热区的位置和大小
target.set(options);
};
控制原理:
- 在 onRectMoving 中,监听热区移动事件,通过判断热区的位置,防止热区超出画布的边界,包括左侧、顶部、右侧和底部。
- 在 onRectMouseup 中,监听热区释放鼠标事件,根据热区的左上角和右下角的坐标,调整热区的位置和大小,确保热区不会超出画布边界。
绘制热区重叠计算
通过 addRect 函数中的以下代码来控制创建热区时的重叠情况:
js
// 检查热区是否重叠
const hasOverlap = getRectList(newCanvas).some((rect2) =>
isTwoRectOverlap(rect.getBoundingRect(), rect2.getBoundingRect()),
);
if (hasOverlap) {
Message.error('热区之间不可重叠框选,请调整热区!');
rect.dispose();
return;
}
在这段代码中,主要利用了 getRectList 函数获取当前画布上的所有热区对象列表,以及 isTwoRectOverlap 函数判断两个矩形是否重叠。
以下是相关的函数和步骤的详细解释:
getRectList 函数
js
// 获取画布上的热区对象列表
const getRectList = (newCanvas?) => {
const rectList: any[] = [];
const result = canvas || newCanvas;
if (!result) return rectList;
result.forEachObject((obj: any) => {
if (obj.rectId) {
rectList.push(obj);
}
});
return rectList;
};
解释:
- getRectList 函数用于获取当前画布上的所有热区对象列表。
- 通过 forEachObject 方法遍历画布上的所有对象,筛选出带有 rectId 属性的对象,即热区对象。
- 返回热区对象列表。
isTwoRectOverlap 函数
js
// 判断两个矩形是否重叠
const isTwoRectOverlap = (rect1, rect2) => {
return (
rect1.left < rect2.left + rect2.width &&
rect1.left + rect1.width > rect2.left &&
rect1.top < rect2.top + rect2.height &&
rect1.top + rect1.height > rect2.top
);
};
解释:
- isTwoRectOverlap 函数用于判断两个矩形是否重叠。
- 判断的逻辑是通过比较两个矩形的左侧、右侧、顶部和底部的坐标关系,如果有重叠则返回 true,否则返回 false。
addRect 函数中的检查重叠逻辑
js
// 检查热区是否重叠
const hasOverlap = getRectList(newCanvas).some((rect2) =>
isTwoRectOverlap(rect.getBoundingRect(), rect2.getBoundingRect()),
);
if (hasOverlap) {
Message.error('热区之间不可重叠框选,请调整热区!');
rect.dispose();
return;
}
解释:
- 在 addRect 函数中,通过调用 getRectList 获取当前画布上的所有热区对象列表。
- 使用 some 方法遍历热区列表,判断新创建的热区和已存在的热区是否有重叠,如果有重叠则提示错误消息,并释放当前创建的热区对象。
总结
整体实现流程如下
- 响应图片加载事件:
- 当图片加载完成后,获取图片和画布的尺寸。
- 根据图片和画布尺寸初始化画布和设置背景。
- 初始化画布和背景图片:
- 使用 fabric.Canvas 创建一个画布实例。
- 监听画布上的鼠标事件,例如鼠标点击和释放。
- 设置画布的背景图片,确保背景图片与画布大小匹配。
- 处理鼠标点击和释放事件:
- 在鼠标点击事件中记录起始位置坐标。
- 在鼠标释放事件中,根据起始和结束位置创建热区对象。
- 检查新创建的热区是否与现有热区重叠。
- 控制热区移动和调整大小:
- 使用 fabric.Rect 创建热区对象。
- 设置热区的样式、边框等属性。
- 添加热区对象到画布,并更新组件状态。
- 监听热区的移动和释放鼠标事件,在移动过程中控制热区- - 不超出画布边界,释放鼠标后调整热区的位置和大小。
- 处理热区的删除和编辑:
- 在编辑状态下,可以选择热区并编辑链接。
- 提供删除热区的功能,删除时同时从画布和组件状态中移除。
- 验证和保存:
- 对用户输入的链接进行验证。
- 确认保存前检查热区之间是否有重叠,若有则提示错误。
- 将热区数据保存到组件状态中,并通过回调函数传递给父组件。
看看效果
添加热区
移动端
低头一看,PM 5:00 喝了一口浓茶,发布代码,悠闲的等待着我的bug...
哦,不对,移动端代码忘写了。
pc端预览
html
<div key={dataIndex} className={styles.previewItem}>
{item.hotZoneList.map((hotZone, index) => (
<div
key={index}
className={styles.hotZone}
style={{
left: `${hotZone.left}px`,
top: `${hotZone.top}px`,
width: `${hotZone.width}px`,
height: `${hotZone.height}px`,
}}
onClick={() => {
console.log(hotZone.url);
}}
>
热区0{index + 1}
</div>
))}
</div>
移动端预览
html
<View key={dataIndex} className={styles.previewItem}>
{item.hotZoneList.length > 0 && item.hotZoneList.map((hotZone: any, index: number) => (
<View
key={index}
className={styles.hotZone}
style={{
left: `${hotZone.left * screenWidth / 375}px`,
top: `${hotZone.top * screenWidth / 375}px`,
width: `${hotZone.width * screenWidth / 375}px`,
height: `${hotZone.height * screenWidth / 375}px`,
}}
onClick={() => onClickLink(hotZone.url)}
/>
))}
</View>
screenWidth:
当前屏幕宽度
误差问题
- canvas的宽我定的375
- pc预览宽375
- 移动端根据375计算
- hotZone.left 是热区左侧相对于设计稿(或其他基准尺寸)的距离。
- screenWidth 是当前屏幕宽度。
- 375 是设计稿(或其他基准尺寸)的宽度。
这段代码的目的是将热区左侧的距离按比例适配到当前屏幕上。通常,screenWidth / 375 这个比例表示当前屏幕宽度与设计稿宽度的比值。
例如,如果热区在设计稿上的左侧距离是 100,而当前屏幕宽度是 750,那么通过这个公式计算后,热区在当前屏幕上的左侧距离将会是 200。
这种做法是为了在不同屏幕宽度的设备上保持一定的排版一致性,实现屏幕的适配。这样,无论设备宽度是多少,热区相对于屏幕的位置都会按比例进行缩放
最后
如果你看到这里,还不能自己手撸,点赞评论加收藏~ 我会将完成版本代码私信发送哦~
小透明唯一的骄傲。