一、背景
在零售、商超、仓储等行业的日常运营中,商品价签的批量制作与打印是一项高频且核心的工作。传统价签制作模式存在诸多痛点:依赖专业设计软件(如 Photoshop、CorelDRAW)手动排版,效率低下;价签模板格式不统一,修改商品名称、价格、条码等信息时需逐份调整,易出错;普通打印方案无法精准匹配价签纸尺寸,导致内容偏移、浪费耗材。
对于中小型商家或门店而言,搭建一套专业的价签管理系统成本过高,而市面上的通用模板工具又难以满足自定义拖拽排版的灵活需求 —— 比如根据不同商品类型调整价签内元素位置(价格字体放大、条码靠左对齐)、批量替换商品数据、一键适配热敏 / 针式打印机的打印参数。
基于此,我们可以借助 Fabric.js 轻量级 Canvas 图形库,快速构建一套低成本、高灵活的商品价签套打工具。它既支持前端可视化拖拽编辑价签模板(自由摆放文字、条码、图片等元素),又能对接商品数据源批量生成价签内容,同时解决 Canvas 尺寸适配等核心问题,帮助零售场景实现价签制作的自动化与标准化。
二、项目初期
初期时,已经实现基本打印功能,但是用户反馈不是很好,主要原因是,系统内的商品信息的一些内容,是硬编码写死在纸张的位置上的,这样就会导致,在不同大小的价签中,呈现出的效果不同,打印出来的效果也会有很大的偏差,为了解决这一痛点,我们借助 Fabric.js 打造一款可视化价签编辑器。
三、技术方案
graph TD
A[可视化价签编辑器] --> B[技术栈选择]
A --> C[核心功能]
B --> D[React + Fabricjs]
D --> E1[创建画布]
D --> E2[设置临界点]
D --> E3[设置背景图]
D --> E4[对象之前拖拽/添加时不能重复]
C --> F1[纸张大小选择]
C --> F2[上传模版]
C --> F3[页边距-需要设置为画布的临界点]
C --> F4[批量设置-字体大小]
需要实现的页面
价签类型字段是选择价签大小的,单位为mm,选择完成后,自动回填高度和宽度,同时设置画布的宽高为选择价签的宽高
上、下、左、右的边距选择价签类型后,可以输入,输入后自动设置为画布的临界点
模版上传,根据选择的纸张大小进行等比裁剪,同时将裁剪后的图片设置为画布的背景图,此字段主要是方便用户在创建的时候,能够更精确的拖拽字段的位置
批量设置,目前仅支持设置画布中元素的字体大小
单位转换
// 基于word文档 96dpi来算
const width = renderSize.width * 3.779527559; // mm 转 px
const height = renderSize.height * 3.779527559;
const width = px / 3.779527559; // px 转 mm
const height = px / 3.779527559;
四、代码实现
4.1 创建画布
// mm转px的转换比例
const MM_TO_PX = 3.779527559;
// 价签的宽高
const [tagSize, setTagSize] = useState<{ width: number; height: number }>();
// 首次创建画布
const width = tagSize.width * MM_TO_PX;
const height = tagSize.height * MM_TO_PX;
const canvas = new fabric.Canvas('fabric-canvas-tag', {
width,
height,
backgroundColor: '#ffffff',
selection: false,
});
4.2 添加字段到画布
// 展示的价签字段
const [droppedFields, setDroppedFields] = useState([]);
// 添加字段到画布
const addFieldToCanvas = useCallback(
(field: FieldOptionItem) => {
if (!paperSize) {
message.warning('请先选择价签类型');
return;
}
// 如果画布未初始化,等待一下再试
if (!canvasRef.current) {
setTimeout(() => {
if (canvasRef.current) {
setDroppedFields((prev) => {
if (prev.some((f) => f.value === field.value)) {
return prev;
}
const existingFields = prev.length;
const x = 10;
const y = 10 + existingFields * 30;
return [...prev, {
...field,
x,
y,
}];
});
} else {
message.warning('画布未初始化,请稍后再试');
}
}, 100);
return;
}
setDroppedFields((prev) => {
if (prev.some((f) => f.value === field.value)) {
return prev;
}
const existingFields = prev.length;
const x = 10;
const y = 10 + existingFields * 30;
return [...prev, {
...field,
x,
y,
}];
});
},
[paperSize]
);
4.2 创建字段对象(容器+文本覆盖层+删除图标)
// 创建删除图标
const createDeleteIcon = useCallback((containerLeft: number, containerTop: number, containerWidth: number): fabric.Group => {
const iconSize = 16;
const iconRadius = iconSize / 2;
// 创建 × 符号
const cross = new fabric.Text('×', {
fontSize: 24,
fontFamily: 'Arial',
fill: '#1890ff',
originX: 'center',
originY: 'center',
textAlign: 'center',
});
// 组合成 Group
const deleteIcon = new fabric.Group([cross], {
left: containerLeft + containerWidth - iconRadius - 2,
top: containerTop + iconRadius + 2,
selectable: false,
evented: true,
hasControls: false,
hasBorders: true,
hoverCursor: 'pointer',
moveCursor: 'pointer',
});
// 存储类型标识
(deleteIcon as any).data = {
type: 'deleteIcon',
};
return deleteIcon;
}, []);
const createFieldObject = useCallback((field: FieldOptionItem): { container: fabric.Rect; text: fabric.Text; deleteIcon: fabric.Group } => {
const fontSize = 14;
const showLabel = field.showLabel || '';
// 创建文本对象(作为覆盖层,不参与缩放)
const textObj = new fabric.Textbox(`${showLabel}:`, {
left: (field.x), // 文本位置 = 容器位置 + 内边距
top: (field.y),
fontSize,
fill: '#202d40',
fontWeight: '400',
lockRotation: true,
selectable: false, // 文本不可单独选择
evented: false, // 文本不响应事件
});
// 计算容器大小(基于文本尺寸 + 内边距)
const containerWidth = (textObj.width || 0) + 10;
const containerHeight = (textObj.height || 0) + 10;
// 创建容器(矩形,可缩放和移动)
const container = new fabric.Rect({
left: field.x || 10,
top: field.y || 10,
width: containerWidth,
height: containerHeight,
fill: 'transparent', // 透明背景
stroke: '#202d40', // 边框颜色
strokeWidth: 1,
strokeDashArray: [5, 5], // 虚线边框
lockRotation: true,
rx: 2, // 圆角
ry: 2,
});
// 移除旋转控制点
container.setControlsVisibility({
mtr: false, // 移除中间顶部的旋转控制点
});
// 创建删除图标
const deleteIcon = createDeleteIcon(container.left || 0, container.top || 0, containerWidth);
// 存储字段信息到容器
(container as any).data = {
fieldValue: field.value,
};
// 存储字段信息到文本(用于关联)
(textObj as any).data = {
fieldValue: field.value,
};
// 存储字段信息到删除图标(用于关联)
(deleteIcon as any).data = {
type: 'deleteIcon',
fieldValue: field.value,
};
return { container, text: textObj, deleteIcon };
}, [createDeleteIcon]);
trips: 文本对象和容器需要区分开,目的是,如果直接拖拽文本对象的容器,文本字体大小也会跟着缩放,这样的话字体大小会不可控,所以需要文本对象和拖放的容器需要区分开,保持实际字体大小不变
4.3 事件处理函数
4.3.1 删除事件
// 存储容器和文本的关联:key 是 fieldValue,value 是 { container: fabric.Rect, text: fabric.Text, deleteIcon: fabric.Group }
const fieldObjectsMap = useRef<Map<string, { container: fabric.Rect; text: fabric.Text; deleteIcon: fabric.Group }>>(new Map());
const handleDeleteField = (fieldValue: string) => {
const fieldObj = fieldObjectsMap.current.get(fieldValue);
if (fieldObj && canvasRef.current) {
// 从画布移除对象(容器、文本和删除图标)
canvasRef.current.remove(fieldObj.container);
canvasRef.current.remove(fieldObj.text);
canvasRef.current.remove(fieldObj.deleteIcon);
fieldObjectsMap.current.delete(fieldValue);
// 更新状态
setDroppedFields((prev) => {
return [...prev].filter((i) => i.value !== fieldValue);
});
canvasRef.current.discardActiveObject();
canvasRef.current.renderAll();
}
};
// 删除图标点击事件处理
const handleDeleteIconClick = (e: fabric.IEvent) => {
if (!e.target) return;
const obj = e.target as fabric.Group;
const data = (obj as any)?.data;
// 是否是删除图标
if (data && data.type === 'deleteIcon' && data.fieldValue) {
e.e?.stopPropagation();
handleDeleteField(data.fieldValue);
}
};
4.3.2 对象修改完成后
const handleObjectModified = (e: fabric.IEvent) => {
const obj = e.target as fabric.Rect;
if (!obj || !(obj as any).data) return;
const fieldValue = (obj as any).data.fieldValue;
const fieldObj = fieldObjectsMap.current.get(fieldValue);
if (fieldObj) {
const { text, deleteIcon } = fieldObj;
// 更新文本位置(跟随容器,保持内边距)
text.set({
left: (obj.left || 0) + 5,
top: (obj.top || 0) + 5,
});
text.setCoords();
// 更新删除图标位置
const containerWidth = obj.getScaledWidth();
deleteIcon.set({
left: (obj.left || 0) + containerWidth + 5,
top: (obj.top || 0),
});
deleteIcon.setCoords();
}
// 获取容器的实际宽高(包括缩放)
const containerWidth = obj.getScaledWidth();
const containerHeight = obj.getScaledHeight();
setDroppedFields((prev) =>
prev.map((item) => {
if (item.value === fieldValue) {
return {
...item,
x: obj.left || 0,
y: obj.top || 0,
width: obj.width || 0,
height: obj.height || 0,
containerWidth,
containerHeight,
};
}
return item;
})
);
};
trips: 这里需要获取容器缩放后的宽高存储,目的是解决用户可以通过拖拽容器来实现内容的可控,比如如果需要展示的内容较长,可以通过把容器拉长,来避免换行
4.3.3 对象移动事件处理
const handleObjectMoving = (e: fabric.IEvent) => {
const obj = e.target as fabric.Rect;
if (!obj || !(obj as any).data) return;
obj.setCoords();
const fieldValue = (obj as any).data.fieldValue;
// 获取边距边界
const bounds = getCanvasBounds();
// 获取当前对象的实际尺寸(包括缩放)
const objScaledWidth = obj.getScaledWidth();
const objScaledHeight = obj.getScaledHeight();
// 同步更新文本位置和删除图标位置
const fieldObj = fieldObjectsMap.current.get(fieldValue);
if (fieldObj) {
const { text, deleteIcon } = fieldObj;
text.set({
left: (obj.left || 0) + 5,
top: (obj.top || 0) + 5,
});
text.setCoords();
// 更新删除图标位置
const containerWidth = obj.getScaledWidth();
deleteIcon.set({
left: (obj.left || 0) + containerWidth + 5,
top: (obj.top || 0),
});
deleteIcon.setCoords();
}
// 限制在边距范围内
if (obj.left! < bounds.minX) obj.set('left', bounds.minX);
if (obj.top! < bounds.minY) obj.set('top', bounds.minY);
if (obj.left! + objScaledWidth > bounds.maxX) obj.set('left', bounds.maxX - objScaledWidth);
if (obj.top! + objScaledHeight > bounds.maxY) obj.set('top', bounds.maxY - objScaledHeight);
// 检测与其他元素的重叠
const objLeft = obj.left || 0;
const objTop = obj.top || 0;
const padding = 5; // 元素之间的最小间距
// 检查当前位置是否与其他元素重叠
const checkOverlap = (x: number, y: number): { hasOverlap: boolean; overlappedObj?: fabric.Rect } => {
const entries = Array.from(fieldObjectsMap.current.entries());
for (const [value, otherFieldObj] of entries) {
if (value === fieldValue) continue; // 排除自己
const otherContainer = otherFieldObj.container;
const otherLeft = otherContainer.left || 0;
const otherTop = otherContainer.top || 0;
// 获取其他容器的实际尺寸(包括缩放)
const otherScaledWidth = otherContainer.getScaledWidth();
const otherScaledHeight = otherContainer.getScaledHeight();
if (
!(x + objScaledWidth + padding <= otherLeft ||
otherLeft + otherScaledWidth + padding <= x ||
y + objScaledHeight + padding <= otherTop ||
otherTop + otherScaledHeight + padding <= y)
) {
return { hasOverlap: true, overlappedObj: otherContainer };
}
}
return { hasOverlap: false };
};
// 如果当前位置重叠,找到最近的不重叠位置
const overlapResult = checkOverlap(objLeft, objTop);
if (overlapResult.hasOverlap) {
// 找到一个不重叠的位置
const step = 5; // 每次移动的步长
const maxAttempts = 500; // 最大尝试次数
let found = false;
let newX = objLeft;
let newY = objTop;
// 先尝试向下移动
for (let offset = step; offset < maxAttempts && !found; offset += step) {
const tryY = objTop + offset;
if (tryY + objScaledHeight <= bounds.maxY) {
const check = checkOverlap(objLeft, tryY);
if (!check.hasOverlap) {
newY = tryY;
found = true;
break;
}
}
}
// 如果向下不行,尝试向右移动
if (!found) {
for (let offset = step; offset < maxAttempts && !found; offset += step) {
const tryX = objLeft + offset;
if (tryX + objScaledWidth <= bounds.maxX) {
const check = checkOverlap(tryX, objTop);
if (!check.hasOverlap) {
newX = tryX;
found = true;
break;
}
}
}
}
// 如果还不行,尝试向右下角移动
if (!found) {
for (let offset = step; offset < maxAttempts && !found; offset += step) {
const tryX = objLeft + offset;
const tryY = objTop + offset;
if (tryX + objScaledWidth <= bounds.maxX && tryY + objScaledHeight <= bounds.maxY) {
const check = checkOverlap(tryX, tryY);
if (!check.hasOverlap) {
newX = tryX;
newY = tryY;
found = true;
break;
}
}
}
}
// 如果找到了新位置,更新对象位置
if (found && (newX !== objLeft || newY !== objTop)) {
obj.set({
left: newX,
top: newY,
});
obj.setCoords();
}
}
};
trips: 这里需要添加页编剧和重叠的校验,文章下面会对这部分重点讲解
4.3.4 对象缩放事件处理
const handleObjectScaling = (e: fabric.IEvent) => {
const obj = e.target as fabric.Rect;
if (!obj || !(obj as any).data) return;
obj.setCoords();
const fieldValue = (obj as any).data.fieldValue;
const fieldObj = fieldObjectsMap.current.get(fieldValue);
// 获取边距边界
const bounds = getCanvasBounds();
// 计算最大可用宽度和高度(画布宽度/高度减去左右/上下边距)
const maxAvailableWidth = bounds.maxX - bounds.minX;
const maxAvailableHeight = bounds.maxY - bounds.minY;
// 获取当前对象的实际尺寸(包括缩放)
const objScaledWidth = obj.getScaledWidth();
const objScaledHeight = obj.getScaledHeight();
// 根据缩放方向,只更新对应的尺寸,让边框线跟随变化
const currentScaleX = obj.scaleX || 1;
const currentScaleY = obj.scaleY || 1;
const updateObj: any = {};
// 如果横向缩放(scaleX 不等于 1),只更新宽度
if (Math.abs(currentScaleX - 1) > 0.01) {
// 如果缩放后的宽度超过最大可用宽度,直接设置为最大可用宽度
if (objScaledWidth > maxAvailableWidth) {
updateObj.width = maxAvailableWidth;
} else {
updateObj.width = objScaledWidth;
}
updateObj.scaleX = 1;
}
// 如果竖向缩放(scaleY 不等于 1),只更新高度
if (Math.abs(currentScaleY - 1) > 0.01) {
// 如果缩放后的高度超过最大可用高度,直接设置为最大可用高度
if (objScaledHeight > maxAvailableHeight) {
updateObj.height = maxAvailableHeight;
} else {
updateObj.height = objScaledHeight;
}
updateObj.scaleY = 1;
}
// 如果有需要更新的属性,则更新
if (Object.keys(updateObj).length > 0) {
obj.set(updateObj);
obj.setCoords();
}
// 更新后重新获取实际尺寸
const finalScaledWidth = obj.getScaledWidth();
const finalScaledHeight = obj.getScaledHeight();
const currentLeft = obj.left || 0;
const currentTop = obj.top || 0;
// 确保位置在边界内
let newLeft = currentLeft;
let newTop = currentTop;
let needPositionUpdate = false;
// 限制左边界
if (currentLeft < bounds.minX) {
newLeft = bounds.minX;
needPositionUpdate = true;
}
// 限制上边界
if (currentTop < bounds.minY) {
newTop = bounds.minY;
needPositionUpdate = true;
}
// 限制右边界
if (newLeft + finalScaledWidth > bounds.maxX) {
newLeft = bounds.maxX - finalScaledWidth;
needPositionUpdate = true;
}
// 限制下边界
if (newTop + finalScaledHeight > bounds.maxY) {
newTop = bounds.maxY - finalScaledHeight;
needPositionUpdate = true;
}
// 如果位置需要调整,更新对象位置
if (needPositionUpdate) {
obj.set({
left: newLeft,
top: newTop,
});
obj.setCoords();
}
if (fieldObj) {
const { text, deleteIcon } = fieldObj;
// 文本位置跟随容器,保持内边距(文本不缩放,只跟随位置)
text.set({
left: (obj.left || 0) + 5,
top: (obj.top || 0) + 5,
});
text.setCoords();
// 更新删除图标位置
const containerWidth = obj.getScaledWidth();
deleteIcon.set({
left: (obj.left || 0) + containerWidth + 5,
top: (obj.top || 0),
});
deleteIcon.setCoords();
}
canvas.renderAll();
};
trips: 在缩放容器时,也需要考虑临界点以及重叠的校验
4.3.5 绑定事件
canvas.on('object:modified', handleObjectModified); // 编辑
canvas.on('object:moving', handleObjectMoving); // 移动完成
canvas.on('object:scaling', handleObjectScaling); // 缩放
canvas.on('mouse:down', handleDeleteIconClick); // 鼠标按下删除
4.4 页面边距(临界点)校验
4.4.1 先定义状态,拿到用户输入的边距
// 存储边距值
const marginsRef = useRef({ topMargin: 0, bottomMargin: 0, leftMargin: 0, rightMargin: 0 });
trips: 这里的单位是mm,需要转换成px
4.4.2 获取画布的有效区域
// 单位转换
// 获取边距的px值
const getMarginPx = useCallback((marginMm: number) => {
return (marginMm || 0) * MM_TO_PX;
}, []);
// 获取画布的有效区域
const getCanvasBounds = useCallback(() => {
if (!canvasRef.current) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
}
const canvasWidth = canvasRef.current.getWidth();
const canvasHeight = canvasRef.current.getHeight();
const margins = marginsRef.current;
const leftPx = getMarginPx(margins.leftMargin);
const topPx = getMarginPx(margins.topMargin);
const rightPx = getMarginPx(margins.rightMargin);
const bottomPx = getMarginPx(margins.bottomMargin);
return {
minX: leftPx,
minY: topPx,
maxX: canvasWidth - rightPx,
maxY: canvasHeight - bottomPx,
};
}, [getMarginPx]);
4.4.3 限制在边距范围内
// 限制在边距范围内
if (obj.left! < bounds.minX) obj.set('left', bounds.minX);
if (obj.top! < bounds.minY) obj.set('top', bounds.minY);
if (obj.left! + objScaledWidth > bounds.maxX) obj.set('left', bounds.maxX - objScaledWidth);
if (obj.top! + objScaledHeight > bounds.maxY) obj.set('top', bounds.maxY - objScaledHeight);
trips: 在文章上面,对象移动和缩放事件中,需要计算最大可用的宽度和高度,需要减去输入的左右/上下的边距
4.5 对象重叠校验
-
原理: 轴对齐包围盒(AABB)碰撞检测
-
逻辑: 两个矩形不重叠的条件是:一个在另一个的左侧、右侧、上方或下方
x1 + w1 <= x2 矩形 1 在矩形 2 的左侧 x2 + w2 <= y2 矩形 1 在矩形 2 的右侧
y1 + h1 <= y2 矩形 1 在矩形 2 的上方 y2 + h2 <= y1 矩形 1 在矩形 2 的下方
只要 4 个不重叠条件有一个不成立 → 矩形重叠,返回 true 所有不重叠条件都成立 → 矩形不重叠,返回 false。
// 检测两个矩形是否重叠
const isOverlapping = useCallback(
(x1: number, y1: number, w1: number, h1: number, x2: number, y2: number, w2: number, h2: number): boolean => {
return !(x1 + w1 <= x2 || x2 + w2 <= x1 || y1 + h1 <= y2 || y2 + h2 <= y1);
},
[]
);
// 找到一个不重叠的位置
const findNonOverlappingPosition = useCallback(
(
targetX: number,
targetY: number,
targetWidth: number,
targetHeight: number,
excludeValue: string
): { x: number; y: number } => {
if (!canvasRef.current) {
return { x: targetX, y: targetY };
}
const padding = 3; // 元素之间的最小间距
// 获取边距边界
const bounds = getCanvasBounds();
// 检查当前位置是否与其他元素重叠
const checkOverlap = (x: number, y: number): boolean => {
// 确保在边距范围内
if (x < bounds.minX || y < bounds.minY || x + targetWidth > bounds.maxX || y + targetHeight > bounds.maxY) {
return true; // 超出边距边界也算重叠
}
// 检查与其他所有元素是否重叠
const entries = Array.from(fieldObjectsMap.current.entries());
for (const [value, fieldObj] of entries) {
if (value === excludeValue) continue; // 排除自己
const container = fieldObj.container;
const objLeft = container.left || 0;
const objTop = container.top || 0;
const objWidth = container.width || 0;
const objHeight = container.height || 0;
if (
isOverlapping(
x,
y,
targetWidth + padding,
targetHeight + padding,
objLeft,
objTop,
objWidth + padding,
objHeight + padding
)
) {
return true;
}
}
return false;
};
// 如果当前位置不重叠,直接返回
if (!checkOverlap(targetX, targetY)) {
return { x: targetX, y: targetY };
}
// 尝试在当前位置周围寻找不重叠的位置
const step = 10; // 每次移动的步长
const maxOffset = Math.max(
bounds.maxX - bounds.minX,
bounds.maxY - bounds.minY
); // 最大搜索偏移量
// 定义搜索方向:包括所有8个方向,优先考虑常用方向
const directions = [
{ dx: 0, dy: 1 }, // 向下
{ dx: 1, dy: 0 }, // 向右
{ dx: -1, dy: 0 }, // 向左
{ dx: 0, dy: -1 }, // 向上
{ dx: 1, dy: 1 }, // 右下
{ dx: -1, dy: 1 }, // 左下
{ dx: 1, dy: -1 }, // 右上
{ dx: -1, dy: -1 }, // 左上
];
// 从目标位置开始,逐步扩大搜索范围
// 对每个方向,逐步增加偏移量
for (let offset = step; offset <= maxOffset; offset += step) {
for (const dir of directions) {
const newX = targetX + dir.dx * offset;
const newY = targetY + dir.dy * offset;
if (!checkOverlap(newX, newY)) {
return { x: newX, y: newY };
}
}
}
// 如果所有尝试都失败,返回一个默认位置(右上角)
return { x: bounds.maxX - targetWidth - 30, y: bounds.minY + 8 };
},
[isOverlapping, getCanvasBounds]
);
4.6 编辑回显
回显的逻辑比较简单,主要注意的是以下几点
- 正在被用户操作的容器需要跳过位置和大小更新
- 避免在用户拖动或缩放时被回显逻辑覆盖。
- containerWidth/containerHeight 与 width/height 的区别处理
-
containerWidth/containerHeight:用户手动缩放后的尺寸,需用 scaleX/scaleY 设置,比较时用 getScaledWidth() / getScaledHeight()
-
width/height:原始尺寸,直接设置 width/height,比较时用 container.width/height
- 重叠检测和位置自动调整
- 更新位置或大小后,使用实际尺寸进行重叠检测,并自动调整到不重叠位置,同时同步更新状态。
4.6.1 正在被用户操作的容器需要跳过位置和大小更新
// 检查容器是否正在被用户操作(拖动或缩放)
const isActive = canvasRef.current?.getActiveObject() === container;
// 如果容器正在被操作,跳过更新,避免干扰用户操作
if (isActive) {
// 只更新文本样式,不更新位置和大小
const calculatedFontSize = getFontSize(field);
if (text.fontSize !== calculatedFontSize) {
text.set({
fontSize: calculatedFontSize,
});
text.setCoords();
}
return;
}
4.6.2 containerWidth/containerHeight 与 width/height 的区别处理
// 更新现有对象的位置和大小
const targetWidth = field.containerWidth || field.width;
const targetHeight = field.containerHeight || field.height;
// 计算当前尺寸
// 如果 field.containerWidth 存在,说明是缩放后的宽度,应该和 getScaledWidth() 比较
// 如果 field.width 存在,说明是原始宽度,应该和 container.width 比较
const currentScaledWidth = container.getScaledWidth();
const currentScaledHeight = container.getScaledHeight();
const currentWidth = container.width || 0;
const currentHeight = container.height || 0;
// 检查是否需要更新位置或大小
const needsPositionUpdate =
(field.x !== undefined && Math.abs(container.left! - field.x) > 0.1) ||
(field.y !== undefined && Math.abs(container.top! - field.y) > 0.1);
// 如果 field.containerWidth 存在,和缩放后的宽度比较;否则和原始宽度比较
const needsSizeUpdate = field.containerWidth !== undefined || field.containerHeight !== undefined
? (field.containerWidth !== undefined && Math.abs(currentScaledWidth - field.containerWidth) > 0.1) ||
(field.containerHeight !== undefined && Math.abs(currentScaledHeight - field.containerHeight) > 0.1)
: (targetWidth !== undefined && Math.abs(currentWidth - targetWidth) > 0.1) ||
(targetHeight !== undefined && Math.abs(currentHeight - targetHeight) > 0.1);
// 只有在确实需要更新时才执行
if (needsPositionUpdate || needsSizeUpdate) {
let newX = field.x !== undefined ? field.x : container.left!;
let newY = field.y !== undefined ? field.y : container.top!;
// 如果容器正在被操作,保持当前尺寸,不更新
let newWidth = container.width!;
let newHeight = container.height!;
let hasScaled = false; // 标记是否通过缩放设置了尺寸
if (!isActive) {
// 只有在容器未被操作时,才更新尺寸
// 如果 field.containerWidth 存在,说明用户手动调整过,应该通过缩放来设置
// 如果 field.width 存在,直接设置宽度
if (field.containerWidth !== undefined && needsSizeUpdate) {
// 用户手动调整过宽度,通过缩放来设置
const scaleX = field.containerWidth / (container.width || 1);
container.set('scaleX', scaleX);
hasScaled = true;
newWidth = container.width!; // 保持原始宽度,缩放由 scaleX 控制
} else if (targetWidth !== undefined) {
newWidth = targetWidth;
}
if (field.containerHeight !== undefined && needsSizeUpdate) {
// 用户手动调整过高度,通过缩放来设置
const scaleY = field.containerHeight / (container.height || 1);
container.set('scaleY', scaleY);
hasScaled = true;
newHeight = container.height!; // 保持原始高度,缩放由 scaleY 控制
} else if (targetHeight !== undefined) {
newHeight = targetHeight;
}
}
4.6.3 重叠检测和位置自动调整
// 获取实际尺寸用于重叠检测
const actualWidth = hasScaled ? container.getScaledWidth() : newWidth;
const actualHeight = hasScaled ? container.getScaledHeight() : newHeight;
// 检查新位置是否与其他元素重叠
const nonOverlappingPos = findNonOverlappingPosition(
newX,
newY,
actualWidth,
actualHeight,
field.value
);
// 如果位置被调整,使用调整后的位置
if (nonOverlappingPos.x !== newX || nonOverlappingPos.y !== newY) {
newX = nonOverlappingPos.x;
newY = nonOverlappingPos.y;
setDroppedFields((prev) =>
prev.map((f) =>
f.value === field.value ? { ...f, x: newX, y: newY } : f
)
);
}
4.7 设置背景图
主要通过使用 Fabricjs Image对象的方法来实现,因为在我的实际需求中,用到的地方较多,所以这里是写了一个工具函数。
/**
* @description: 设置fabric画布的背景图
* @param imageUrl - 图片URL字符串或图片URL数组,如果为null则清除背景图
*/
export const setCanvasBackgroundImage = (canvasRef: React.RefObject<fabric.Canvas>, imageUrl: string | string[] | null) => {
if (!canvasRef.current) {
return;
}
const canvas = canvasRef.current;
// 如果传入null,清除背景图
if (imageUrl === null) {
canvas.setBackgroundImage(null as any, canvas.renderAll.bind(canvas));
return;
}
// 处理图片URL
let url: string | null = null;
if (typeof imageUrl === 'string') {
url = imageUrl;
} else if (Array.isArray(imageUrl) && imageUrl.length > 0) {
url = imageUrl[0];
}
if (!url) {
return;
}
// 获取画布尺寸
const canvasWidth = canvas.getWidth();
const canvasHeight = canvas.getHeight();
// 使用fabric加载图片并设置为背景
fabric.Image.fromURL(
url,
(img) => {
if (!canvasRef.current) return;
// 计算缩放比例,保持宽高比,填充整个画布
const scaleX = canvasWidth / img.width!;
const scaleY = canvasHeight / img.height!;
const scale = Math.min(scaleX, scaleY); // 使用较小的缩放比例以确保填充
// 设置背景图
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), {
scaleX: scale,
scaleY: scale,
originX: 'left',
originY: 'top',
});
},
{ crossOrigin: 'anonymous' } // 避免跨域
);
};
4.8 批量设置
只需要更新字段状态,画布会重新渲染
setDroppedFields((prev) =>
prev.map((item) => {
const fieldData = setAllValues[item.value] || {};
return {
...item,
fontSize: fieldData.fontSize,
};
})
);
五、实现效果
- 新增价签
- 预览价签
经过以上,可以看到位置基本是准确的了,如果有偏差,只需要用户手动微调整即可
实测使用价签机打印时,用户拍照,并且上传模版后,在添加字段到对应位置,位置可以达到90%以上的准度
六、总结
本次基于 Fabric.js 做商品价签套打编辑器的开发,核心就是搞定「拖拽编辑 + 内容生成 + 精准打印」这三件事
~用 Fabric.js 轻松实现了价签内商品信息的自由拖拽排版,不用复杂配置就能做自定义价签模板,还能对接商品数据批量生成内容,告别手动改价签的麻烦。最终落地了轻量化的价签套打方案。
如有不正确的地方,欢迎评论区各位大佬指正