Fabric.js 实战:拖拽元素排版 + 内容生成 + 打印功能全实现

43 阅读15分钟

一、背景

在零售、商超、仓储等行业的日常运营中,商品价签的批量制作与打印是一项高频且核心的工作。传统价签制作模式存在诸多痛点:依赖专业设计软件(如 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[批量设置-字体大小]

需要实现的页面

image.png

价签类型字段是选择价签大小的,单位为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 编辑回显

回显的逻辑比较简单,主要注意的是以下几点

  1. 正在被用户操作的容器需要跳过位置和大小更新
  • 避免在用户拖动或缩放时被回显逻辑覆盖。
  1. containerWidth/containerHeight 与 width/height 的区别处理
  • containerWidth/containerHeight:用户手动缩放后的尺寸,需用 scaleX/scaleY 设置,比较时用 getScaledWidth() / getScaledHeight()

  • width/height:原始尺寸,直接设置 width/height,比较时用 container.width/height

  1. 重叠检测和位置自动调整
  • 更新位置或大小后,使用实际尺寸进行重叠检测,并自动调整到不重叠位置,同时同步更新状态。

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,
       };
   })
);

五、实现效果

  1. 新增价签 image.png
  2. 预览价签
image.png

经过以上,可以看到位置基本是准确的了,如果有偏差,只需要用户手动微调整即可

实测使用价签机打印时,用户拍照,并且上传模版后,在添加字段到对应位置,位置可以达到90%以上的准度

六、总结

本次基于 Fabric.js 做商品价签套打编辑器的开发,核心就是搞定「拖拽编辑 + 内容生成 + 精准打印」这三件事

~用 Fabric.js 轻松实现了价签内商品信息的自由拖拽排版,不用复杂配置就能做自定义价签模板,还能对接商品数据批量生成内容,告别手动改价签的麻烦。最终落地了轻量化的价签套打方案。

如有不正确的地方,欢迎评论区各位大佬指正