基于 Fabric.js 开发图形编辑器
2023-04-04
TL;DR
前段时间实现了一个简单的图形编辑器,它是 AI 识别视觉稿生成代码的中间一环,用于手动调整视觉稿中 AI 识别不到位的组件,同时也可以给识别到的组件进行相关配置让生成的代码更加完整。此前我对 Canvas 只是略知皮毛,对于编辑器总有一种这个需求不好做的印象。通过技术选型,我选择用 Fabric.js 来实现我的需求,不得不说 Fabric.js 非常适合简单编辑器的开发。本文忽略 Fabric.js 前置知识,主要关注我在使用 Fabric.js 开发出一个麻雀虽小五脏俱全的图形编辑器中遇到的一些问题。
编辑器功能
从图片可以看出来,编辑器渲染的主要内容是一张设计稿图片,图片中每一个识别出的组件由一个含有左上角文本名称的矩形绘制于上。这个编辑器主要功能如下:
画布
- 图形渲染
- 画布内容自适应画布大小
- 缩放:Ctrl + 滚轮
- 画布移动:SPACE + 鼠标
- 右键菜单
- 顶部工具栏:选择模式、鼠标绘制组件、撤销、重做、拖拽、帮助中心
- 快捷键操作
- 历史记录
图形
- 拖拽移动
- 拉伸缩放
- 节点删除
- 节点变化信息和右侧面板联动
- 区域限制:节点的缩放、拖拽、绘制操作限制在底部图片区域内
- 节点穿透选中
右侧面板
- 节点的操作和右侧面板可以双向联动
下面主要记录开发过程中遇到的主要的问题和解决方式。
自定义图形
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.Canvas
的 toObject
方法获得所有节点的数据,但是发现缺少通过节点的 set
方法设置的自定义属性。对于自定义的属性,我们需要全部在节点的 toObject
方法内进行返回,如代码中 id 这个值所示。
问题 2:loadFromJson 时,自定义图形无法绘制
loadFromJson
是 fabric.Canvas
的方法,给定一个 JSON 数据即可绘制画布内容,在历史记录功能中很有用。在实操中,可能会遇到下面几个与自定义图形相关的问题:
- Cannot read properties of undefined (reading ‘fromObject’)
- 自定义图形无法渲染
这些问题主要原因是在渲染时,fabric 不认识 ComponentRect 这个图形。解决方式为:
- subClassing 使用 fabric 成员变量的方式定义。也就是说不能使用
const ComponentRect = fabric.util.createClass(fabric.Rect, {})
来定义自定义图形,必须使用fabric.ComponentRect = ...
的方式 - 扩展定义 fromObject 方法
fabric.ComponentRect.fromObject = function (object, callback) {
return fabric.Object._fromObject('ComponentRect', object, callback);
};
光标绘制图形
分析实现需求的几个关键点:
- 光标从默认形状变为十字架形
- 鼠标按下的坐标
- 鼠标拖动时跟随变化的矩形
- 鼠标抬起的坐标
鼠标的行为可以通过监听画布的 mouse:down
、mouse:move
、mouse:up
来捕获,而变化的矩形则是在按下鼠标时创建一个矩形,在 mouse:move
事件中实时改变此矩形的形状。不过对于矩形来说,我们可以利用 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;
}
});
图形区域限制
区域限制的逻辑则很简单,无非是判断各个端点是否在可移动区域内。不过这里有几个需要注意的点:
- 节点自身属性中有 left 和 top ,这个点代表的是左上角的坐标。那么右上角的 x 坐标则是
left + width
。这里需要注意,这个 width 只是原始的宽度,缩放的情况下实际的宽度应当是width * scaleX
;其他点也是同理 - 判断是否超出范围应该是将各端点都判断一遍,而不是一个端点不正确就立即返回。应当以一个数组收集超出的边界。比如超出左边和上边
['left', 'top']
- 在图形拖动或者拖拽超出边界时,如果有修正位置的需求,则应该是遍历上面收集的超界数组,对每一条超出的边进行修正
节点穿透选中
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 按钮后,所有节点都可以在视口区域内可视。
这个功能的实现思路为:
- 如果没有这个按钮提供的功能,我们使用习惯是通过画布的缩放和拖拽来让所有节点位于视口范围。因此这个功能的实现则是通过改变视口中心位置和画布缩放来实现
- 我们将所有的节点包裹在一个虚拟的盒子里,实际上就是将视口移到这个盒子中心以及缩放这个盒子大小
- 这个盒子的大小取决于最远和最近的节点坐标
我将这部分代码封装为 fabric-fitView
画布缩放和拖拽
这个在 文档 上已经非常详细
历史记录
历史记录的实现原理非常简单,将所有的状态存放在数组内,通过改变指向当前状态的指针来加载不同时候的画布内容即可。
右键菜单
分析这个需求,有下面几个点:
- 按下右键时,浏览器的菜单不要弹出
- 自己实现的右键菜单需要在鼠标按下的位置附近弹出
第一个问题 Fabric 已经给予了帮助,通过设置 stopContextMenu
和 fireRightClick
即可;第二个问题则需要监听鼠标的事件和捕获坐标,将我们的菜单在 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,
});
}
})