用pixi.js实现fabric.js(五):事件系统

3 阅读9分钟

友情提示

  • 请先看这里👉前言
  • 相关代码👉地址,本文相关的代码在events-system分支上,请切到这个分支查看代码
  • 本系列文章提到的fabric均为v7版本,pixijs均为v8版本
  • 本系列文章对应的内容已经发布到了npm,通过npm i pixijs-fabric来安装,这个包的使用方式和fabric一样

1. 前言

事件系统是一个渲染引擎最重要的功能之一,如果没有事件系统,这个渲染引擎将不可用。

上一章里,我们已经实现了静态画布,用来绘制需要展示的元素,接下来我们将给这些元素绑定上事件,让这些元素可交互

2. fabric的事件系统

2.1 实现方式

和pixi一样,fabric也是基于射线法来完成碰撞检测的,如果不知道什么是射线法,可以看这篇文章:如何实现一个Canvas渲染引擎(三):碰撞检测(射线法和像素标记法)的2.2节。

fabric的findTarget函数被用来检测碰撞对象,它会递归遍历对象树,寻找可能的碰撞对象,最后用射线法来判断是否与对象产生了碰撞,射线法的核心函数为:isPointInPolygon函数

2.2 特点

pixi的事件系统,完全参照了DOM的实现,有冒泡阶段、捕获阶段,区分了mouseenter、mouseover事件等,而fabric并没有完全参照DOM来实现事件系统,它有着自己的一套逻辑。还有一个特点就是,fabric在进行碰撞检测的时候,不仅会判断是否命中了对象的碰撞区域,还会判断鼠标命中的点的像素是否是透明像素,如果是透明像素,则判断为未命中。

2.3 如何替代?

pixijs提供的事件系统已经非常完备了,包含了:视窗坐标到canvas坐标的转换、抵消分辨率的影响、射线法进行碰撞检测等等,所以,我们可以直接用pixijs的事件系统来实现fabric的事件系统。

3. 准备工作

3.1 pixi对象和fabric对象双向绑定

因为我们要用pixi的事件系统来实现fabric的事件系统,所以,我们取到的event.target是pixi的对象,但是我们在做的事情是:实现fabric,我们最终还是要取到fabric的对象,所以,我们可以让这两者进行一个双向绑定,以便在不同的对象之间切换。

declare module 'pixi.js' {
  interface Container {
    fabricContent: FabricObject;
  }
}

class InteractiveFabricObject extends InnerObject{
  // ...
  public pixiContent = new Container({ children: [new Graphics()] });
  // ...
  constructor(options?: Props) {
    // ...
    this.pixiContent.fabricContent = this;
  }
}

3.2 对象的碰撞区域(hitArea)

既然要触发事件,那么就要告诉计算机,命中了对象的哪个区域,才算命中了这个对象,这个区域就是对象的碰撞区域(hitArea)。

fabric的hitArea,就是以一个宽为this.width,高为this.height的矩形,这个矩形的中心点,在fabric对象的(0,0)点。

知道了hitArea的位置以及大小后,我们可以用pixi的hitArea属性来替换fabric的碰撞区域:

export class ObjectGeometry {
  // ...
  protected hitArea = new Rectangle();
  // ...
  public setCoords(): void {
    // ...
    this.hitArea.set(
      -this.width / 2,
      -this.height / 2,
      this.width,
      this.height,
    );
    this.pixiContent.hitArea = this.hitArea;
  }
}

在对象的setCoords函数里,计算碰撞区域。

4. 各个事件的具体实现

4.1 MouseDown

在pixi的stage上监听mousedown事件:

const stage = this.pixiApp.stage;
stage.on('pointerdown', this._onMouseDown)

触发mousedown之后,需要记录一些状态,供后续的其他事件处理,主要是click事件和drag事件。这里的处理是,记录元素的初始状态,以及触发mosuedown时的鼠标位置,代码:


private handlePotentialClick(e: FederatedPointerEvent) {
  const fabricTarget = e.target.fabricContent;
  if (!fabricTarget) return;

  this.clickInfo.mouseDownTime = performance.now();
  this.clickInfo.mouseDownTarget = fabricTarget;
  this.clickInfo.mouseDownGlobalX = e.globalX;
  this.clickInfo.mouseDownGlobalY = e.globalY;
}
private handleDragStart(e: FederatedPointerEvent) {
  const fabricTarget = e.target?.fabricContent;
  if (!fabricTarget) return;

  this._dragSource = fabricTarget;
  this.hasFiredDragStart = false;
  this.dragInitInfo.targetX = this._dragSource.left;
  this.dragInitInfo.targetY = this._dragSource.top;
  const matrix =
    this._dragSource.parent?.pixiContent.worldTransform ||
    this.root.worldTransform;
  matrix.applyInverse(e.global, tempPoint);
  this.dragInitInfo.mouseX = tempPoint.x;
  this.dragInitInfo.mouseY = tempPoint.y;
}
private _onMouseDown = (e: FederatedPointerEvent) => {
  this.mouseDown = true;
  this.handlePotentialClick(e);
  this.handleDragStart(e);
  // ...
}

之后,需要emit对应的事件(mousedown:before和mousedown),事件名称与fabric保持一致:

private _onMouseDown = (e: FederatedPointerEvent) => {
  // ...
  this._handleEvent(e, 'down:before');
  // ...
  if (button) {
    ((this.fireMiddleClick && button === 1) ||
      (this.fireRightClick && button === 2)) &&
      this._handleEvent(e, 'down', {
        alreadySelected,
      });
    return;
  }
  // ...
}

4.2 MouseMove

由于需要处理把元素拖拽到canvas之外的场景,所以不能直接监听mousemove了,而是监听globalmousemove

在stage上监听globalmousemove事件:

const stage = this.pixiApp.stage;
stage.on('globalpointermove', this._onMouseMove);

触发mousemove的时候,emit对应的事件:mousemove:before、mousemove:

__onMouseMove(e: FederatedPointerEvent) {
  //...
  this._handleEvent(e, 'move:before');
  //...
  this._handleEvent(e, 'move');
  //...
}

判断命中的对象绑定的cursor,如cursor:pointer:

__onMouseMove(e: FederatedPointerEvent) {
  //...
  const fabricTarget = e.target?.fabricContent;
  this._setCursorFromEvent(e, fabricTarget);
  //...
}

如果两次mousemove触发的对象不一样,则分别触发mouseover和mouseout事件:

__onMouseMove(e: FederatedPointerEvent) {
  //...
  const fabricTarget = e.target?.fabricContent;
  this._fireOverOutEvents(e, fabricTarget);
  //...
}

4.3 MouseUp

这个事件的处理比较关键,在这里我们会处理click、dbclick、tripleclick

fabric.js虽然提供了双击、三击事件,但是并没有提供单击事件,在这里,作者打算给这个fabric加上一个click事件,弥补这个空缺。

在stage上监听mouseup事件:

stage.on('pointerup', this._onMouseUp);

首先,按照fabric的逻辑,依然是emit mouseup:before事件和mouseup事件:

private _onMouseUp = (e: FederatedPointerEvent) => {
  // ...
  this._handleEvent(e, 'up:before');
  // ...
  this._handleEvent(e, 'up');
  // ...
}

然后,处理click事件,在这个fabric里(pixijs-fabric),click事件的判定将会更加严格,首先,mousedown和mouseup必须在同一个元素上触发;其次,mousedown和mouseup的鼠标距离,不能超过6个像素,以及,时间间隔不能超过150ms,才算一次click,处理click事件的逻辑如下:

private handleClick(e: FederatedPointerEvent) {
  const fabricTarget = e.target?.fabricContent;
  if (!fabricTarget) return;

  const clickInfo = this.clickInfo;
  clickInfo.mouseUpTime = performance.now();
  const diffX = e.globalX - clickInfo.mouseDownGlobalX;
  const diffY = e.globalY - clickInfo.mouseDownGlobalY;
  const diff = Math.sqrt(diffX * diffX + diffY * diffY);
  if (
    clickInfo.mouseUpTime - clickInfo.mouseDownTime < 150 &&
    fabricTarget === clickInfo.mouseDownTarget &&
    diff < 6
  ) {
    this._handleEvent(e, 'click');

    // 处理双击
    this.handleDbClick(e);

    clickInfo.clickTime = performance.now();
    clickInfo.clickTarget = fabricTarget;
    clickInfo.clickGlobalX = e.globalX;
    clickInfo.clickGlobalY = e.globalY;
  }
}
private _onMouseUp = (e: FederatedPointerEvent) => {
  // ...
  this.handleClick(e);
  // ...
}

处理完了click事件之后,接着就要处理dbclick事件了,和click事件一样,dbclick事件的判定也会更加严格:

private handleDbClick(e: FederatedPointerEvent) {
  const fabricTarget = e.target.fabricContent;
  const clickInfo = this.clickInfo;
  const { clickTime, clickTarget, clickGlobalX, clickGlobalY } = clickInfo;
  const diffX = e.globalX - clickGlobalX;
  const diffY = e.globalY - clickGlobalY;
  const diff = Math.sqrt(diffX * diffX + diffY * diffY);
  const now = performance.now();
  if (now - clickTime < 250 && fabricTarget === clickTarget && diff < 6) {
    if (now - clickInfo.dbClickTime >= 300) {
      this._handleEvent(e, 'dblclick');
    }
    this.handleTripleClick(e);
    clickInfo.dbClickTarget = fabricTarget;
    clickInfo.dbClickGlobalX = e.globalX;
    clickInfo.dbClickGlobalY = e.globalY;
    clickInfo.dbClickTime = now;
  }
}

处理完了dbclick,接下来处理tripleclick事件:

private handleTripleClick(e: FederatedPointerEvent) {
  const fabricTarget = e.target.fabricContent;
  const clickInfo = this.clickInfo;
  const { dbClickGlobalX, dbClickGlobalY, dbClickTarget, dbClickTime } =
    clickInfo;
  const diffX = e.globalX - dbClickGlobalX;
  const diffY = e.globalY - dbClickGlobalY;
  const diff = Math.sqrt(diffX * diffX + diffY * diffY);
  const now = performance.now();
  if (now - dbClickTime < 250 && fabricTarget === dbClickTarget && diff < 6) {
    if (now - clickInfo.triClickTime >= 300) {
      this._handleEvent(e, 'tripleclick');
    }
    clickInfo.triClickTime = now;
  }
}

这里有个点需要注意,dbclick和tripleclick是需要防抖的,不能让它们无限次数地连续触发。

4.4 MouseOut

在stage上监听mouseout事件:

stage.on('pointerout', this._onMouseOut);

触发mouseout后,emit对应的事件(mouseout):

private _onMouseOut = (e: FederatedPointerEvent) => {
  // ...
  const target = this._hoveredTarget;
  // ...
  this.fire('mouse:out', { ...shared, target });
  target && target.fire('mouseout', { ...shared, target });
}

4.5 Wheel

在stage上监听wheel事件,然后把对应的参数信息包装成fabric事件对象的形式,就OK了:

private bindEvents(){
  // ...
  stage.on('wheel', this._onMouseWheel);
}
private _onMouseWheel = (e: FederatedWheelEvent) => {
  const fabricTarget = e.target?.fabricContent;
  const viewportPoint = new Point(e.globalX, e.globalY);
  const scenePoint = this.root.worldTransform.applyInverse(
    viewportPoint,
    new Point(),
  );
  const options: CanvasEvents[`mouse:wheel`] = {
    e: e.nativeEvent as TPointerEvent,
    target: fabricTarget,
    subTargets: [],
    scenePoint,
    viewportPoint,
    transform: this._currentTransform,
  } as CanvasEvents[`mouse:wheel`];
  this.fire(`mouse:wheel`, options);
  let t = fabricTarget;
  while (t) {
    t.fire(`mousewheel`, options);
    t = t.parent!;
  }
};

4.6 dragstart

fabric提供了一系列drag相关的事件,用来将外部元素拖动到画布区域,与画布产生联动,但是在这里,作者并不想按照fabric的drag逻辑来做,而是把drag做的更简单实用一些。这个版本的dragstart将不再是用来和画布外的元素联动了,而是专注于处理画布内的联动。

dragstart新定义:当鼠标在元素上触发mousedown事件,并开始拖动(触发mousemove)时,就会触发dragstart事件。

在mousedown的时候,记录一些被拖拽元素的基本信息:

// ...
private dragInitInfo = {
  targetX: 0,
  targetY: 0,
  mouseX: 0,
  mouseY: 0,
};
// ...
private _onMouseDown = (e: FederatedPointerEvent) => {
  // ...
  this.handleDragStart(e);
  // ...
}
private handleDragStart(e: FederatedPointerEvent) {
  const fabricTarget = e.target?.fabricContent;
  if (!fabricTarget) return;

  this._dragSource = fabricTarget;
  this.hasFiredDragStart = false;
  this.dragInitInfo.targetX = this._dragSource.left;
  this.dragInitInfo.targetY = this._dragSource.top;
  const matrix =
    this._dragSource.parent?.pixiContent.worldTransform ||
    this.root.worldTransform;
  matrix.applyInverse(e.global, tempPoint);
  this.dragInitInfo.mouseX = tempPoint.x;
  this.dragInitInfo.mouseY = tempPoint.y;
}
// ...

在mousemove的时候,判断当前是否有drag对象,如果有,就emit dragstart事件,并且,还要修改drag对象的位置信息,达到拖拽的效果:

private handleDragMove(e: FederatedPointerEvent) {
  if (!this._dragSource) return;

  // fire drag start
  if (!this.hasFiredDragStart) {
    const viewportPoint = new Point(e.globalX, e.globalY);
    const scenePoint = this.root.worldTransform.applyInverse(
      viewportPoint,
      new Point(),
    );
    const options: CanvasEvents['dragstart'] = {
      e: e.nativeEvent as TPointerEvent,
      target: this._dragSource,
      subTargets: [],
      scenePoint,
      viewportPoint,
      transform: this._currentTransform,
    };
    this.fire('dragstart', options);
    this._dragSource.fire('dragstart', options);

    this.hasFiredDragStart = true;
  }

  const matrix =
    this._dragSource.parent?.pixiContent.worldTransform ||
    this.root.worldTransform;
  matrix.applyInverse(e.global, tempPoint);
  const diffX = tempPoint.x - this.dragInitInfo.mouseX;
  const diffY = tempPoint.y - this.dragInitInfo.mouseY;
  this._dragSource.set({
    left: this.dragInitInfo.targetX + diffX,
    top: this.dragInitInfo.targetY + diffY,
  });
  this.requestRenderAll();
}

4.7 dragend

和dragstart一样,这个事件将不会参照fabric的逻辑来做,而是会做成专注于画布内元素的交互的形式。

鼠标抬起后,如果判断有拖拽对象,则触发dragend事件,并且,在emit对应的事件后,需要删除drag对象,避免状态污染:

private handleDragEnd = (e: FederatedPointerEvent) => {
  if (!this._dragSource) return;

  if (this.hasFiredDragStart) {
    const viewportPoint = new Point(e.globalX, e.globalY);
    const scenePoint = this.root.worldTransform.applyInverse(
      viewportPoint,
      new Point(),
    );
    const options: CanvasEvents['dragend'] = {
      e: e.nativeEvent as TPointerEvent,
      target: this._dragSource,
      subTargets: [],
      scenePoint,
      viewportPoint,
      transform: this._currentTransform,
    };
    this.fire('dragend', options);
    this._dragSource.fire('dragend', options);
  }

  delete this._dragSource;
};

5. 其他事件

由于时间原因,这里并没有实现fabric的所有事件,而是只实现了常用的那些事件,还有诸如contextmenu、dragover等等的事件没有实现,如果大家有兴趣,可以自己去实现一下。

6. 测试一下

测试方式:沿用上一章里的fabric官方demo和性能测试demo,来看一下加入了事件系统后的鼠标hover效果,以及拖拽元素的效果。

6.1 fabric官方demo

测试代码地址👉codesandbox地址

为了方便大家查看,我在canvas元素上绑定了一系列的事件,让用户能够缩放、平移画布;我还在底部加入了一个radio,让用户可以在pixi、fabric两者之间切换,以对比效果。

pixijs-fabric效果:

image.png

fabric效果:

image.png

可以看到,两者的绘制效果是一样的。

6.2 性能测试

在画布上绘制15000个矩形,对比fabric的性能和pixijs-fabric的性能,这个测试主要是强调pixijs强大的渲染性能。

测试代码:

const canvas = new Canvas(undefined, {
  width: 1200,
  height: 700,
  backgroundColor: '#85C8F2',
});
enableCanvasZoom(canvas);

const wrapper = document.getElementById('wrapper');
wrapper?.appendChild(canvas.lowerCanvasEl);
for (let i = 0; i < 15000; i++) {
  const rect = new Rect({
    left: Math.random() * 1200,
    top: Math.random() * 700,
    width: 30 + Math.random() * 20,
    height: 15 + Math.random() * 20,
    fill: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(
      Math.random() * 256,
    )}, ${Math.floor(Math.random() * 256)})`,
  });
  canvas.add(rect);
}

和上面一样,我在canvas元素上绑定了一系列的事件,让用户能够缩放、平移画布;并且在底部加入了一个radio,让用户可以在pixi、fabric两者之间切换,以对比效果。

测试代码地址👉codesandbox地址

通过chrome的提供的性能分析工具来对比两者的渲染性能。

pixijs-fabric效果:

image.png

可以看到,加入了事件系统后,画布性能有所下降,每个task来到了22ms左右,FPS来到了40多帧。

fabric效果:

image.png

fabric和之前一样,每个依然保持在600ms,FPS不到2,处于ppt的状态。

6.3 拖拽元素测试

拖拽元素是fabric的可交互画布(Canvas类)自带的功能,并不需要任何配置就可以开启,可以通过上面两节里的任意一个测试demo来体验拖拽元素的效果:

codesandbox地址

codesandbox地址