友情提示
- 请先看这里👉前言
- 相关代码👉地址,本文相关的代码在
interaction分支上,请切到这个分支查看代码 - 本系列文章提到的fabric均为v7版本,pixijs均为v8版本
- 本系列文章对应的内容已经发布到了npm,通过
npm i pixijs-fabric来安装,使用方式和fabric一样
1. 前言
前面我们已经实现了StaticCanvas,让我们能够看到各个元素出现在画布上的效果,然后我们实现了事件系统,让画布上的元素可以拖拽,但是,编辑器最重要的操作:编辑元素、框选元素等,我们还没有实现,而这一章,我们将逐步实现这些编辑功能,让这个画布的功能更加完善,进一步靠近fabric。
2. ActiveObject
ActiveObject代表当前要操作的单个元素,它可以是任意一个FabricObject的子类。fabric会将ActiveObject高亮出来(加上一个border、以及控制点)。
2.1 ActiveObject的border
ActiveObject的border是一个矩形,这个矩形的4个点分别是对象的的aCoords(代码)的4个点,aCoords的计算方式,是(-w, -h)、(w, -h)、(-w, h)、(w, h)4个点,乘以对象的localTransform(代码)。
但是,aCoords仅仅是基于对象的localTransform的,在编辑器里,对象可能层层嵌套,并且画布整体可能会有缩放,为了保证ActiveObject的border在视觉上一直跟随ActiveObject,我们还需要对aCoords叠加对象的parent的worldTransform,得到这4个点相对于视窗的全局坐标。
实际绘制代码,在InteractiveObject的_renderControls函数内,如下:
ts
_renderControls(ctx: CanvasRenderingContext2D, shouldDrawCps: boolean) {
// activeObjCpGrap是pixi的Graphics,专门用来绘制ActiveObject的border
const g = this.canvas.activeObjCpGrap;
// 获取对象的parent的worldTransform,如无parent,则取canvas的viewportTransform
const matrix =
this.group?.pixiContent?.getGlobalTransform() ||
this.canvas.getViewportTransform();
const { a, b, c, d, tx, ty } = matrix;
const { tl, tr, br, bl } = this.aCoords;
const tlX = tl.x * a + tl.y * c + tx;
const tlY = tl.x * b + tl.y * d + ty;
const trX = tr.x * a + tr.y * c + tx;
const trY = tr.x * b + tr.y * d + ty;
const brX = br.x * a + br.y * c + tx;
const brY = br.x * b + br.y * d + ty;
const blX = bl.x * a + bl.y * c + tx;
const blY = bl.x * b + bl.y * d + ty;
// 旋转控制点的坐标
const topMidX = (tlX + trX) / 2;
const topMidY = (tlY + trY) / 2;
const centerX = (tlX + trX + brX + blX) / 4;
const centerY = (tlY + trY + brY + blY) / 4;
const dirX = topMidX - centerX;
const dirY = topMidY - centerY;
const dirLen = Math.hypot(dirX, dirY) || 1;
const rotateCpX = topMidX + (dirX / dirLen) * 40;
const rotateCpY = topMidY + (dirY / dirLen) * 40;
const {
borderScaleFactor,
cornerColor,
cornerStrokeColor,
cornerStyle,
cornerSize,
transparentCorners,
borderColor,
} = this;
if (this.hasBorders) {
g.moveTo(tlX, tlY)
.lineTo(trX, trY)
.lineTo(brX, brY)
.lineTo(blX, blY)
.closePath();
if (shouldDrawCps) {
g.moveTo(topMidX, topMidY).lineTo(rotateCpX, rotateCpY);
}
g.stroke({
width: borderScaleFactor,
color: borderColor,
alpha: this.isMoving ? this.borderOpacityWhenMoving : 1,
});
}
// ...
}
上述代码除了绘制出了一个矩形,还绘制了一条线段,这条线段是为了旋转控制点而绘制的,将矩形的middle top和旋转控制点连接起来。
2.2 ActiveObject的控制点
2.2.1 ControlPoint类
这个类表示对象的控制点,它继承于pixi的Graphics类:
ts
export class ControlPoint extends Graphics{
corner: 'mt' | 'ml' | 'mr' | 'mb' | 'tl' | 'tr' | 'bl' | 'br' | 'mtr' = 'mt';
actionName: 'scale' | 'rotate' = 'scale';
// ...
}
corner表示控制点的方位,actionName表示控制点将对对象进行何种操作。
2.2.2 创建控制点
在StaticCanvas类上创建控制点,一共9个控制点:
ts
export class StaticCanvas{
// ...
private cpTl = new ControlPoint({
corner: 'tl',
cursor: 'nw-resize',
canvas: this,
});
private cpTr = new ControlPoint({
corner: 'tr',
cursor: 'ne-resize',
canvas: this,
});
private cpBr = new ControlPoint({
corner: 'br',
cursor: 'se-resize',
canvas: this,
});
private cpBl = new ControlPoint({
corner: 'bl',
cursor: 'sw-resize',
canvas: this,
});
private cpL = new ControlPoint({
corner: 'ml',
cursor: 'w-resize',
canvas: this,
});
private cpT = new ControlPoint({
corner: 'mt',
cursor: 'n-resize',
canvas: this,
});
private cpR = new ControlPoint({
corner: 'mr',
cursor: 'e-resize',
canvas: this,
});
private cpB = new ControlPoint({
corner: 'mb',
cursor: 's-resize',
canvas: this,
});
private cpAbove = new ControlPoint({
corner: 'mtr',
cursor: 'crosshair',
canvas: this,
});
// ...
}
2.2.3 绘制控制点
前面说了,ActiveObject的border是一个矩形,并且我们已经求出了这个矩形的4个顶点的全局坐标,而控制点处于这个矩形的4个顶点,以及这个矩形的4条边的中点上,以及,处于这个矩形正上方(旋转控制点),一共9个控制点。
基于前面的求ActiveObject的border的逻辑,我们只需要添加少量逻辑,就能完成控制点的绘制。
绘制控制点的逻辑,也在_renderControls函数里,用pixi的Graphics类来进行绘制:
ts
_renderControls(ctx: CanvasRenderingContext2D, shouldDrawCps: boolean) {
// ...
const { cps } = this.canvas;
if (this.hasControls) {
// 取到9个控制点,依次绘制
const { cpTl, cpTr, cpBr, cpBl, cpL, cpT, cpR, cpB, cpAbove } = this.canvas;
cpTl.position.set(tlX, tlY);
cpTr.position.set(trX, trY);
cpBr.position.set(brX, brY);
cpBl.position.set(blX, blY);
cpT.position.set(topMidX, topMidY);
cpL.position.set((tlX + blX) / 2, (tlY + blY) / 2);
cpB.position.set((blX + brX) / 2, (blY + brY) / 2);
cpR.position.set((trX + brX) / 2, (trY + brY) / 2);
cpAbove.position.set(rotateCpX, rotateCpY);
for (let i = 0; i < cps.length; i++) {
const cp = cps[i];
cp.angle = this.angle ?? 0;
if (cornerStyle === 'circle') {
cp.circle(0, 0, cornerSize / 2);
} else {
cp.rect(-cornerSize / 2, -cornerSize / 2, cornerSize, cornerSize);
}
cp.stroke({
width: borderScaleFactor,
color: cornerStrokeColor || cornerColor,
});
if (!transparentCorners) {
cp.fill({ color: cornerColor });
}
const h = cp.h;
h.x = -cornerSize / 2;
h.y = -cornerSize / 2;
h.width = cornerSize;
h.height = cornerSize;
}
}
}
3. ActiveSelection
ActiveSelection其实也是ActiveObject,和第2节的ActiveObject不同的是,第2节的ActiveObject代表单个元素,ActiveSelection代表一组元素。
ActiveSelection继承于Group
3.1 ActiveSelection的border
ActiveSelection不仅要绘制自己的border,还要绘制里面的子元素的border
ts
export class ActiveSelection extends Group {
// ...
_renderControls(ctx: CanvasRenderingContext2D, shouldDrawCps: boolean) {
for (let i = 0; i < this._objects.length; i++) {
const obj = this._objects[i];
obj._renderControls(ctx, false);
}
super._renderControls(ctx, true);
}
// ...
}
3.2 ActiveSelection的控制点
ActiveSelection的控制点和ActiveObject的逻辑一样,只需要直接用ActiveObject的逻辑就行了。
4. 框选
通过框选,来选中一组元素,选中的元素,会被设置为ActiveObject
4.1 记录选框起点
鼠标按下后,记录选框起点
ts
private _onMouseDown = (e: FederatedPointerEvent) => {
// ...
this._groupSelector = {
x: e.globalX,
y: e.globalY,
deltaY: 0,
deltaX: 0,
};
// ...
}
4.2 记录选框终点
鼠标移动的时候,记录选框的终点,并将选框绘制出来
ts
private __onMouseMove(e: FederatedPointerEvent) {
// ...
const groupSelector = this._groupSelector;
if (groupSelector) {
groupSelector.deltaX = e.globalX - groupSelector.x;
groupSelector.deltaY = e.globalY - groupSelector.y;
this.renderTop();
}
}
使用pixi的Graphics类来绘制选框
ts
protected _drawSelection(): void {
const { x, y, deltaX, deltaY } = this._groupSelector!;
const startX = x;
const startY = y;
const extentX = x + deltaX;
const extentY = y + deltaY;
const strokeOffset = this.selectionLineWidth / 2;
let minX = Math.min(startX, extentX),
minY = Math.min(startY, extentY),
maxX = Math.max(startX, extentX),
maxY = Math.max(startY, extentY);
const grah = this.selectionGrap;
grah.clear();
if (this.selectionColor) {
grah.rect(minX, minY, maxX - minX, maxY - minY).fill(this.selectionColor);
}
if (!this.selectionLineWidth || !this.selectionBorderColor) {
return;
}
minX += strokeOffset;
minY += strokeOffset;
maxX -= strokeOffset;
maxY -= strokeOffset;
grah.rect(minX, minY, maxX - minX, maxY - minY).stroke({
width: this.selectionLineWidth,
color: this.selectionBorderColor,
});
this.requestRenderAll();
}
4.3 完成框选
鼠标抬起时,完成框选
ts
private _onMouseUp = (e: FederatedPointerEvent) => {
// ...
this.handleSelection(e);
}
完成框选后,会根据框选的区域,将选中的元素设置为ActiveObject,如果是单个元素,则直接将该元素设置为ActiveObject,如果是多个元素,则将这一组元素放到一个ActiveSelection里,并将这个ActiveSelection设置为ActiveObject:
ts
protected handleSelection(e: FederatedPointerEvent) {
// ...
if (objects.length === 1) {
// set as active object
this.setActiveObject(objects[0], e);
} else if (objects.length > 1) {
// add to active selection and make it the active object
const klass =
classRegistry.getClass<typeof ActiveSelection>('ActiveSelection');
const as = new klass(objects, { canvas: this });
this.topLayer.addChild(as.pixiContent);
this.setActiveObject(as, e);
}
}
5. 旋转控制点
用旋转控制点来控制对象的旋转角度,无论对象的锚点处于什么位置,都要让对象以自身内容区域中心为旋转中心旋转。
5.1 mousedown时记录对象的初始状态
当在控制点上触发mousedown时,记录对象的初始状态,以便mousemove的时候做diff。
ts
protected _setupCurrentTransform(
e: FederatedPointerEvent,
target: FabricObject,
cp: ControlPoint,
): void {
// ...
cp.setTarget(target);
const {
localTransform,
mouseDownPoint,
scale,
fixedAnchor,
tl,
tr,
bl,
br,
} = cp.initAttr;
localTransform.copyFrom(target.pixiContent.localTransform);
mouseDownPoint.copyFrom(e.global);
scale.set(target.scaleX, target.scaleY);
const ax = resolveOrigin(origin.x) * target.width;
const ay = resolveOrigin(origin.y) * target.height;
fixedAnchor.set(ax, ay);
cp.initAttr.rotation = degreesToRadians(target.angle);
const { aCoords } = target;
tl.set(aCoords.tl.x, aCoords.tl.y);
tr.set(aCoords.tr.x, aCoords.tr.y);
bl.set(aCoords.bl.x, aCoords.bl.y);
br.set(aCoords.br.x, aCoords.br.y);
// ...
}
我们会:
- 记录初始localTransform
- 记录鼠标的视窗坐标
- 记录初始缩放(缩放控制点会用到)
- 记录旋转/缩放锚点(在进行旋转或缩放时,这个点保持不动)
- 记录初始旋转角度(旋转控制点会用到)
- 记录初始4个顶点的坐标(缩放控制点会用到,用来计算缩放因子)
5.2 mousemove时作diff
mousedown时,会记录控制对象的初始状态,并标记当前处于操作控制点的状态,mousemove时,如果有标记,则开始进行diff,每次触发mousemove,都会拿当前鼠标坐标和初始坐标作diff。
旋转控制点里要求的diff,是旋转角度,假设旋转控制点为P,旋转锚点为O,鼠标落点为Q,那么角POQ就是我们要求的diff,如图所示:

其中,绿色矩形是我们的控制对象,角θ就是我们要求的diff。
计算这个diff的方式,可以查看用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互的3.4节。
5.3 计算新的localTransform
在mousedown时,我们已经记录了控制对象的一些初始状态,包括localTransform,旋转锚点fixedAnchor,我们可以来算一下,fixedAnchor经过了旧的localTransform变换后,会出现在哪个点:
ts
const fixedAnchorLocal = localTransform.apply(
new PixiPoint(fixedAnchor.x, fixedAnchor.y),
);
fixedAnchor经过旧的localTransform变换,出现在了fixedAnchorLocal点,由于fixedAnchor是旋转锚点,所以它出现在屏幕上的位置是固定不动的,也就是说,fixedAnchor经过了新的localTransform之后,也会出现在fixedAnchorLocal点,也就是上面我们计算出来的点,通过这一点,我们可以得出一个矩阵方程:
假设新的localTransform为:
ab0cd0txty1
fixedAnchor为:
faXfaY1
fixedAnchorLocal为:
falXfalY1
那么有:
ab0cd0txty1 × faXfaY1 = falYfalY1
里面的faX、faY、falY、falY都是已知数,只有a、b、c、d、tx、ty是未知数。
在5.2中我们得到了diff值,也就是旋转角度θ,我们给旧的localTransform叠加一个旋转矩阵(旋转角度为θ),就可以得到a、b、c、d的值了:
ts
const newMatrix = localTransform.clone();
newMatrix.rotate(r); // r就是θ
接下来,方程中的未知数,只剩下tx、ty了,把方程展开,已知数全部移到右边,就可以得到tx、ty了:
ts
newMatrix.tx =
fixedAnchorLocal.x -
newMatrix.a * fixedAnchor.x -
newMatrix.c * fixedAnchor.y;
newMatrix.ty =
fixedAnchorLocal.y -
newMatrix.d * fixedAnchor.y -
newMatrix.b * fixedAnchor.x;
至此,我们就得到了新的localTransform。
5.4 把新的localTransform应用到元素上
5.4.1 不能直接setFromMatrix
如果我们在使用pixi直接写一个编辑器引擎的话,这一步的工作就非常简单了,只需要调用:
ts
target.transform.setFromMatrix(newMatrix)
pixi就会根据newMatrix帮我们计算出元素的x、y、scale、rotation等属性。
但是我们在实现fabric,所以我们要用fabric的模式,来计算元素的新属性。
5.4.2 要求的量
在用旋转控制点旋转元素时,我们要改的属性有3个:角度、x、y,对应到fabric的属性,就是angle、left、top。
5.4.3 求angle
angle就是元素的初始rotation加上前面求出来的diff(θ角):
ts
const newAngle = radiansToDegrees(rotation + θ); // fabric用角度制
5.4.4 求left、top
fabric的left、top,是锚点的left、top,锚点会出现在left、top这个位置上,所以,我们可以根据这一点来推算出left、top。
先用新的localTransform来计算出锚点会出现的位置:
ts
const originX = resolveOrigin(target.originX) * target.width;
const originY = resolveOrigin(target.originY) * target.height;
const { x, y } = newMatrix.apply(new PixiPoint(originX, originY));
锚点经过新的localTransform变换后,出现在了,所以,x、y就是元素的新left、top。
5.4.5 设置元素的新属性
把前面计算出来的angle、left、top直接set给元素,就完成了旋转:
ts
target.set({
angle: newAngle,
left: newLeft,
top: newTop,
});
6. 缩放控制点
用缩放控制点来控制对象的缩放,缩放时,要让对角线上的那个缩放控制点保持不动。
6.1 mousedown时记录对象的初始状态
和旋转控制点一样,在mousedown时,我们需要保存一些对象的初始信息,以便mousemove的时候做diff,这一块的代码(_setupCurrentTransform函数)是复用的。
6.2 mousemove时作diff
缩放控制点的diff,是一个缩放因子,用旧的scale乘以这个缩放因子,就是新的scale。
这里以右下角的缩放控制点为例,假设右下角控制点为T,左上角控制点为O,鼠标落点为P,从P点向OT所在直线作垂线,垂线与OT所在直线的交点为Q,那么OQ/OT就是这个缩放因子:

计算这个diff的方式,可以查看用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互的3.5节。
6.3 计算新的localTransform
在mousedown时,我们已经记录了控制对象的一些初始状态,包括localTransform,旋转锚点fixedAnchor,我们可以来算一下,fixedAnchor经过了旧的localTransform变换后,会出现在哪个点:
ts
const fixedAnchorLocal = localTransform.apply(
new PixiPoint(fixedAnchor.x, fixedAnchor.y),
);
fixedAnchor经过旧的localTransform变换,出现在了fixedAnchorLocal点,由于fixedAnchor是旋转锚点,所以它出现在屏幕上的位置是固定不动的,也就是说,fixedAnchor经过了新的localTransform之后,也会出现在fixedAnchorLocal点,也就是上面我们计算出来的点,通过这一点,我们可以得出一个矩阵方程:
假设新的localTransform为:
ab0cd0txty1
fixedAnchor为:
faXfaY1
fixedAnchorLocal为:
falXfalY1
那么有:
ab0cd0txty1 × faXfaY1 = falYfalY1
里面的faX、faY、falY、falY都是已知数,只有a、b、c、d、tx、ty是未知数。
在6.2中我们得到了diff值,也就是缩放因子,我们给旧的localTransform叠加一个缩放矩阵(缩放大小为缩放因子),就可以得到a、b、c、d的值了:
ts
const newMatrix = localTransform.clone().scale(factor, factor);
接下来,方程中的未知数,只剩下tx、ty了,把方程展开,已知数全部移到右边,就可以得到tx、ty了:
ts
newMatrix.tx =
fixedAnchorLocal.x -
newMatrix.a * fixedAnchor.x -
newMatrix.c * fixedAnchor.y;
newMatrix.ty =
fixedAnchorLocal.y -
newMatrix.d * fixedAnchor.y -
newMatrix.b * fixedAnchor.x;
至此,我们就得到了新的localTransform。
6.4 把新的localTransform应用到元素上
6.4.1 不能直接setFromMatrix
和前面的旋转控制点一样,我们不能直接用setFromMatrix,我们需要基于fabric的那套逻辑,计算出新的scaleX、scaleY、left、top。
6.4.2 要求的量
在用缩放控制点缩放元素时,我们要改的属性有2个:缩放值、坐标值,对应到fabric的属性,就是scaleX、scaleY、left、top。
6.4.3 计算scaleX、scaleY
这两个属性的计算,只需要用旧的scaleX、scaleY乘以缩放因子就可以了:
ts
const newScaleX = scale.x * factor;
const newScaleY = scale.y * factor;
6.4.4 计算left、top
和5.4.4一样,我们利用锚点和left、top的关系,来推算出新的left、top:
ts
const originX = resolveOrigin(target.originX) * target.width;
const originY = resolveOrigin(target.originY) * target.height;
const { x: newLeft, y: newTop } = newMatrix.apply(new PixiPoint(originX, originY));
6.4.5 设置元素的新属性
把计算出来的newScaleX、newScaleY、newLeft、newTop直接set给元素,就完成了缩放:
ts
target.set({
scaleX: newScaleX,
scaleY: newScaleY,
left: newLeft,
top: newTop,
});
7. 测试一下
7.1 测试框选画布上的元素
框住画布上的一些元素,看一下效果
测试代码地址👉codesandbox地址
pixijs-fabric效果

fabric效果

7.2 15000个矩形渲染的缩放测试
渲染15000个矩形在画布上,并保证它们全部出现在视窗内,然后缩放画布,查看性能

测试代码地址👉codesandbox地址
pixijs-fabric效果

可以看到,每个task的时间,在20ms左右,FPS维持在了50左右,属于流畅状态。
fabric效果

如同之前的测试一样,fabric的每个task来到了500多ms,FPS掉到了不足2,属于极度卡顿不可用状态。
7.2 框选15000个矩形的缩放测试
渲染15000个矩形在画布上,然后将它们全部框选住,并保证它们全部出现在视窗内,再缩放画布,查看性能

测试代码地址👉codesandbox地址
pixijs-fabric效果

由于框选住元素后,需要绘制大量的stroke,所以性能出现了急剧下降,每个task从20ms来到了80ms,FPS掉到了不到20,比较卡顿。
fabric效果

比较神奇的是,框选中15000个元素后,fabric的每个task还是维持在了500ms,不过,依然是处于不可用状态。
结尾
到这里,我们已经实现了fabric的核心交互功能了,但是有一些周边功能,由于时间原因,作者并没有完全实现,如果大家有需要或者有兴趣,可以去补足一下这部分功能,让这个'fabric'更加完善。