CocaColf

庭前桃李满,院外小径芳

基于 Fabric.js 开发图形编辑器

2023-04-04


TL;DR

前段时间实现了一个简单的图形编辑器,它是 AI 识别视觉稿生成代码的中间一环,用于手动调整视觉稿中 AI 识别不到位的组件,同时也可以给识别到的组件进行相关配置让生成的代码更加完整。此前我对 Canvas 只是略知皮毛,对于编辑器总有一种这个需求不好做的印象。通过技术选型,我选择用 Fabric.js 来实现我的需求,不得不说 Fabric.js 非常适合简单编辑器的开发。本文忽略 Fabric.js 前置知识,主要关注我在使用 Fabric.js 开发出一个麻雀虽小五脏俱全的图形编辑器中遇到的一些问题。

编辑器整体

编辑器功能

从图片可以看出来,编辑器渲染的主要内容是一张设计稿图片,图片中每一个识别出的组件由一个含有左上角文本名称的矩形绘制于上。这个编辑器主要功能如下:

下面主要记录开发过程中遇到的主要的问题和解决方式。

自定义图形

Fabric.js 提供了 subclassing 扩展基本图形,比如矩形加文本就可以通过扩展矩形实现:

fabric.ComponentRect = fabric.util.createClass(fabric.Rect, {
	type: 'component-rect',
	initialize: function (initializeOptions: fabric.IObjectOptions) {
        const options = { ...initializeOptions };

        this.callSuper('initialize', options);

        // 取消旋转功能
        this.setControlsVisibility({ mtr: false });

        // 设置需要的整体样式
        this.set({
            id: options.id || '',
            // ...属性设置
        });
    },

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

    _render: function (ctx: CanvasRenderingContext2D) {
        this.callSuper('_render', ctx);

        ctx.font = `${LABEL_FONT_SIZE}px Helvetica`;
        ctx.fillStyle = this.labelColor;
        ctx.fillText(this.label, -this.width / 2, -this.height / 2);
    }
});

我们扩展 Rect 类创建了 ComponentRect 这个图形,所以我们只需要通过 new fabric.ComponentRect({}) 即可创建一个组件矩形。但是真实情况并非那么顺利,我们会遇到几个问题。

问题 1:无法获得自定义属性

可以通过 fabric.CanvastoObject方法获得所有节点的数据,但是发现缺少通过节点的 set 方法设置的自定义属性。对于自定义的属性,我们需要全部在节点的 toObject 方法内进行返回,如代码中 id 这个值所示。

问题 2:loadFromJson 时,自定义图形无法绘制

loadFromJsonfabric.Canvas的方法,给定一个 JSON 数据即可绘制画布内容,在历史记录功能中很有用。在实操中,可能会遇到下面几个与自定义图形相关的问题:

这些问题主要原因是在渲染时,fabric 不认识 ComponentRect 这个图形。解决方式为:

fabric.ComponentRect.fromObject = function (object, callback) {
	return fabric.Object._fromObject('ComponentRect', object, callback);
};

光标绘制图形

光标绘制图形

分析实现需求的几个关键点:

鼠标的行为可以通过监听画布的 mouse:downmouse:movemouse:up来捕获,而变化的矩形则是在按下鼠标时创建一个矩形,在 mouse:move事件中实时改变此矩形的形状。不过对于矩形来说,我们可以利用 fabric 的多选模式走一个捷径,这个多选的效果刚好是一个变化的矩形。如果把它默认的样式改为中部透明只保留边框就是期望的效果。

fabric多选

大概实现如下:

function setDrawMode (canvas: fabric.Canvas) {
    canvas.selection = true;
    canvas.selectionColor = 'transparent';
    canvas.selectionBorderColor = 'rgba(0, 0, 0, 0.2)';
    canvas.setCursor('crosshair');

    canvas.discardActiveObject().renderAll();

    // 此函数是遍历节点,禁用节点的交互功能,否则导致:
    // 1. 无法在某个元素内部绘制,看不到绘制的区域效果
    // 2.绘制时会拖拽外层大元素
    disableNodeInteractive();
}

let downPointer;
canvas.on('mouse:down', e => {
    let evt = e.e;
    if (e.absolutePointer) {
        downPointer = e.absolutePointer;
    }
});

// 因为可以利用 fabric 多选效果走捷径,所以 mouse:move 事件就不需要监听了
// canvas.on('mouse:move', ...

canvas.on('mouse:up', e => {
    if (downPointer && e.absolutePointer) {
        // 此函数功能为绘制自定义图形
        drawComponentRect(canvas, downPointer, e.absolutePointer);
        downPointer = undefined;
    }
});

图形区域限制

区域限制

区域限制的逻辑则很简单,无非是判断各个端点是否在可移动区域内。不过这里有几个需要注意的点:

节点穿透选中

Fabric.js 的节点可以通过 canvas.getObjects() 获取,得到的结果是一个节点数组,而这个数组同时也代表了节点之间的层级关系。数组的第零项代表最下层,最后一项为最上层。所以如果节点 A 的大小完全覆盖节点 B 且 B 的层级比 A 低,那么是无法直接选中 B 节点的。如下图所示,只能调整 form 节点的层级实现选中 input 节点。当同一个方向上节点众多的复杂的情况下,也许层级的调整还需要仔细的操作一番才能打到目的。因此有必要实现节点的穿透选中。

无法穿透选中

这里有两种思考的视角。

一种是从节点出发。当鼠标悬浮或者点击在节点 A 上时,如果目的是穿透选中底部的 B 节点,那么需要对页面上的所有节点同 A 进行位置和大小的计算,算出来哪些节点被 A 完全覆盖且位于 A 的下方,然后对其进行选中操作。

一种是从鼠标事件的位置出发。仔细感受节点穿透选中操作,不难发现其实最终应该被操作的节点应该是包含了鼠标位置的最小面积节点。那么只需要对所有节点做一次遍历判断找到满足要求的节点即可。

显然从鼠标位置来入手更简单。

function minAreaNodeContainPointer (canvas: fabric.Canvas, pointer: fabric.Point) {
    const objects = canvas.getObjects();
    let minArea = Infinity;
    let targetNode: fabric.Object | null = null;

    for (let object of objects) {
        const objectArea = object.width! * object.height!;
        if (object.containsPoint(pointer, null, true) && objectArea < minArea) {
            targetNode = object;
            minArea = objectArea;
        }
    }

    // 将该节点置顶,否则会导致拖拽时聚焦的是该节点但移动的是上层节点
    if (targetNode) {
        canvas.moveTo(targetNode, objects.length);
    }

    return targetNode;
}

canvas.on('mouse:down', e => {
    let evt = e.e;

    // 穿透选中内层节点
    if (/* e.e.ctrlKey && */ e.absolutePointer) {
        const innerNode = minAreaNodeContainPointer(canvas, e.absolutePointer);
        innerNode && canvas.setActiveObject(innerNode);
    }
});

穿透选中

自适应布局

默认情况下,画布以左上角为原点进行节点渲染,当节点的坐标超出当前视口范围内画布大小时,这些节点是不可见的,因此需要提供画布的自适应布局的功能,让所有的节点都可以在当前视口范围内可见。下面是一个 demo 示例,黑色线框区域为 800*800 大小的画布,画布内实际上存在 6 个节点,有两个是在视口范围之外。当我们点击 fit view 按钮后,所有节点都可以在视口区域内可视。

fitview

这个功能的实现思路为:

我将这部分代码封装为 fabric-fitView

画布缩放和拖拽

这个在 文档 上已经非常详细

历史记录

历史记录的实现原理非常简单,将所有的状态存放在数组内,通过改变指向当前状态的指针来加载不同时候的画布内容即可。

右键菜单

右键菜单

分析这个需求,有下面几个点:

第一个问题 Fabric 已经给予了帮助,通过设置 stopContextMenufireRightClick即可;第二个问题则需要监听鼠标的事件和捕获坐标,将我们的菜单在 DOM 中的位置移动至鼠标附近。

canvas.on('mouse:down', e => {
  	// button: 1-左键;2-中键;3-右键
    // target 为 null 则是发生在 canvas 上
    if (e.button === 3) {
        if (e.target) {
            canvas.setActiveObject(e.target);
        }
        showMenu(canvas, e);
    } else {
        hideMenu();
    }
});

function showMenu (canvas, opt) {
  const menu = document.getElementById('menu');  // 获取菜单容器

  // 设置右键菜单位置
  // 1. 获取菜单组件的宽高
  const menuWidth = menu.offsetWidth;
  const menuHeight = menu.offsetHeight;

  // 当前鼠标位置
  let pointX = opt.pointer.x;
  let pointY = opt.pointer.y;

  if (canvas.width && canvas.width - pointX <= menuWidth) {
      pointX -= menuWidth;
  }
  if (canvas.height && canvas.height - pointY <= menuHeight) {
      pointY -= menuHeight;
  }

  menu.style.left = `${pointX}px`;
  menu.style.top = `${pointY}px`;
}

右侧面板联动

改变右侧面板的值调整画布上内容呈现或者是将画布上内容的调整同步至右侧面板,本质上是数据通信,对于数据通信有很多可以选择的方式,在 Vue 中个人觉得最简单的还是 Vue EventBus。

_render 中使用 ctx 绘制图形和缩放有关

如果通过覆写 _render 函数,在其中通过 ctx 绘制图形,这个绘制的图形会和节点自身的缩放有关系。比如图形缩放了 3 倍,此时绘制出来的文本也会显示成 3 倍大,而我期望这个文本大小适中是保持同等大小的字号。解决这个问题可以监听元素的 modified 事件,在 modified 结束后设置元素宽高,且把缩放重置为 1

canvas.on('object:modified', e => {
	if (
		e.action === 'scale' &&
		e.target &&
		e.target.width &&
		e.target.height
	) {
		e.target.set({
			width: Math.round(e.target.width * (e.target.scaleX || 1)),
			height: Math.round(e.target.height * (e.target.scaleY || 1)),
			scaleX: 1,
			scaleY: 1,
			dirty: true,
		});
	}
})

Comments: