pixijs实现一个具有拖拽、旋转、缩放、多选、编辑的记事板

7,481 阅读17分钟

2023.12更新:1. 把项目的pixijs升级到了v7,分支是chore/update-pixijs-7,现在不再在renderer上监听事件,而是在stage上监听事件。2. 由于不再在renderer上监听事件了,所以让stage代替以前的renderer,并加入了一个rootContainer,代替以前的stage,现在的缩放、平移操作,不再是操作stage了,而是操作rootContainer,stage处于不变的状态。3. 解决了一些小bug

1.前言

1.1 编写本文的目的

本文旨在分享一些前端常用的拖拽、旋转、缩放、框选等操作的实现方法,重心会放在核心思想上(当然也会有代码实现),虽然项目采用pixijs实现,但是核心思想并不仅仅局限于pixijs,读者了解了核心思想之后,也可以用其他方式来实现类似的效果。

1.2 项目介绍

本项目是一个基于pixijs的记事板,每一个重要功能的实现,都会有对应的核心思想阐述以及代码实现,整体代码在这里 -> 代码

整体效果如下:

拖拽&缩放画布 CPT2312230008-1704x750.gif

旋转&编辑节点 CPT2312230015-1706x750 (1).gif

框选&旋转多个节点 CPT2312230024-1524x670.gif

1.3前置知识

  • 了解pixijs的API以及思想(stage、Container等), 可以看一下pixijs的文档 -> pixijs官网,或者可以看一下这篇文章 -> pixijs教程
  • 有基本的线性代数知识,知道矩阵相乘的几何意义,知道旋转、缩放、平移对应的变换矩阵是怎样的。可以去b站看一下3Blue1Brown的这个视频 ->线性代数的本质

OK!我们可以开始了!

2.开始

2.1 随机生成Text

2.1.1生成Text

function createText() {
  const fontSize = 14;
  const text = new Text(
    '床前明月光\n疑是地上霜123\n举头望明月\n低头思故乡asdasd',
    {
      fill: 0x4ca486,
      fontFamily: `OpenSans, Arial, sans-serif, "Noto Sans Hebrew", "Noto Sans", "Noto Sans JP", "Noto Sans KR"`,
      fontSize,
      lineHeight: 1.2 * fontSize,
    }
  );
  text.cursor = 'pointer';
  text.interactive = true;
  return text;
}

2.1.2 将生成的Text添加到stage上,并指定一个随机位置

for (let i = 0; i < 500; i++) {
  const randomX = Math.random();
  const randomY = Math.random();
  const text = createText();
  pixiApp.stage.addChild(text);
  text.position.set(800 * 10 * randomX, 600 * 10 * randomY);
}

接下来我们就可以在屏幕上看到如下的效果:

截屏2023-04-23 16.02.55.png

2.2 拖拽单个Text对象

2.2.1 核心思想以及代码实现

我们会在pixi的renderer上监听pointerdown、pointermove、pointerup这3个事件(pixijs的事件有自己的一套叫法,这三个事件分别对应浏览器标准事件的mousedown、mousemove、mouseup)。

  1. 触发pointerdown时,我们会判断鼠标是否点到了画布上的Text,如果点到了Text,那么我们会用一个变量来标记当前点到了Text,并记录下鼠标down的位置信息:
pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    mouseDownPoint = event.target.position
    curDragTarget = event.target;
    curDragTargetOriginalPos = event.target.position;
  }
);
  1. 触发pointermove时,我们会根据第2步中设置的变量来判断pointerdown时是否点到了Text,如果是的话,则开始移动Text,具体做法是:计算出当前鼠标的位置和pointerdown时的鼠标位置的差值,然后设置Text的position:
pixiApp.renderer.plugins.interaction.on(
  'pointermove',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;

    if (curDragTarget) {
      // 拖拽起始点位置在stage上的坐标
      const startPoint = pixiApp.stage.localTransform.applyInverse(mouseDownPoint);
      // 鼠标当前位置在stage上的坐标
      const curPoint = pixiApp.stage.localTransform.applyInverse(globalPos);
      const dx = curPoint.x - startPoint.x;
      const dy = curPoint.y - startPoint.y;
      const { x: originalX, y: originalY } = curDragTargetOriginalPos;
      curDragTarget.position.set(originalX + dx, originalY + dy);
    }
  }
);
  1. 触发pointerup时,将第2步设置的标记变量置为空值:
pixiApp.renderer.plugins.interaction.on(
  'pointerup',
  (event: InteractionEvent) => {
    curDragTarget = undefined;
  }
);

2.2.2 最终效果:

屏幕录制2023-04-23-21.18.30.gif

2.3 坐标映射

在上面的2.2.1第2步中,使用了pixiApp.stage.localTransform.applyInverse这个函数,将renderer上的坐标映射成了stage上的坐标,这里讲一下原理。

2.3.1 几个概念

  1. renderer

可以理解为canvas视窗,是固定不变的,从event对象上拿到的event.global,是事件在renderer上面的坐标,我们可以理解为全局坐标或global坐标,有点类似DOM Event的event.clientX/Y。

  1. stage

pixijs有一个Container的概念,用户可以自己创建Container,将一些元素放进去,这样的话,放进去的元素就会随着Container的移动而一起移动,相当于形成了一个‘组’,pixijs的stage也是一个Container,用户需要将内容放到stage上,才会被pixijs渲染出来。

2.3.2 不变的stage以及会变动的stage

上面说了,renderer是不会动的,这里说的动包括(平移、旋转、缩放),而stage是会动的,事实上,我们要实现对画布的缩放、平移,正是通过缩放、平移stage来实现的。

一开始,我们没有对stage进行缩放,这个时候,stage的原点和renderer的原点重合,鼠标落下的点在renderer上的坐标等于在stage上的坐标:

IMG_18924487393C-1.jpeg

如果我们了平移了stage:

IMG_60F13A21845C-1.jpeg

这样的话,鼠标落下点在renderer上的坐标和在stage上的坐标就不一样了

我们的Text对象是放在stage上的,所以,必须要将global坐标转换成stage坐标,才能正确的让Text对象在stage上移动

2.3.3 逆变换(逆矩阵)

上面说了,我们需要将global坐标映射成stage坐标,那么到底该怎么做呢?

其实,对stage进行平移或者缩放,其实就是进行了一个线性变换,也就是左乘了一个变换矩阵,这个变换矩阵,我们可以通过pixiApp.stage.localTransform拿到,我们可以输出看一下这个矩阵的样子:

image.png

这是一个齐次坐标,tx和ty是stage的水平移动量和垂直移动量

左乘这个变换矩阵之后,stage上的所有点都会被映射到另一个点,所以stage看起来就被缩放、平移了。现在,鼠标落下的点的global坐标是[a,b],a、b是已知数;鼠标落下的点的stage坐标是[x,y],x、y是未知数,令M=变换矩阵,可以得知,以下的等式是成立的:

IMG_5D73C77423E9-1.jpeg

进一步推出:

IMG_CE428308AEF2-1.jpeg

也就是说,我们让global坐标左乘stage的变换矩阵的逆矩阵就得到了stage坐标,pixiApp.stage.localTransform.applyInverse函数就相当于:让某个点左乘pixiApp.stage.localTransform这个矩阵的逆矩阵,所以,这也解释了为什么我们用下面这种方式进行坐标的映射:

// 拖拽起始点位置在stage上的坐标
const startPoint = pixiApp.stage.localTransform.applyInverse(mouseDownPoint);
// 鼠标当前位置在stage上的坐标
const curPoint = pixiApp.stage.localTransform.applyInverse(globalPos);

2.4 拖拽画布(stage)

其实理解了上面的拖拽Text对象之后,拖拽画布就不在话下了

2.4.1 核心思想

我们依然会在renderer上监听pointerdown、pointermove、pointerup,在触发pointerdown的时候,如果enent对象的target属性为空,说明点到了画布的空白区域,这个时候,如果再触发pointermove,就会开始移动画布了。

2.4.2 代码实现

  1. 触发pointerdown时,用touchBlank来标记点中了画布的空白区域:
pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;
    // 记录下stage原来的位置
    stageOriginalPos = copyPoint(pixiApp.stage.position);
    // 记录下mouse down的位置
    mouseDownPoint = copyPoint(globalPos);

    if (!event.target) {
      // 点到了画布的空白位置
      touchBlank = true;
    }
  }
);
  1. 触发pointermove时,给stage设置新的position:
pixiApp.renderer.plugins.interaction.on(
  'pointermove',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;

    if (touchBlank) {
      // 拖拽画布
      const dx = globalPos.x - mouseDownPoint.x;
      const dy = globalPos.y - mouseDownPoint.y;
      pixiApp.stage.position.set(
        stageOriginalPos.x + dx,
        stageOriginalPos.y + dy
      );
    }
  }
);

这里就不需要将global坐标转成stage坐标了,因为stage是相对renderer定位的,所以直接用global坐标就好

  1. 触发pointerup时,将touchBlank变量设置为false:
pixiApp.renderer.plugins.interaction.on(
  'pointerup',
  (event: InteractionEvent) => {
    touchBlank = false;
  }
);

2.4.3 最终效果

屏幕录制2023-04-23-22.36.03.gif

2.5 缩放画布(stage)

2.5.1 几个注意点

  1. 画布是x/y等比例缩放的
  2. 我们不会旋转画布
  3. 要以鼠标的位置为锚点进行画布的缩放

综合1、2点,我们可以很容易写出stage的变换矩阵,假设我们将画布放大了2倍,并且向右平移了100px,向下平移了200px,那么变换矩阵是Matrix(2, 0, 0, 2, 100, 200)。

这里的Matrix和CSS3的transform的matrix属性一样,也是6个参数

2.5.2 核心思想

先来思考一个问题,我们现在要对stage做一些变换,变换前的stage的状态是:放大了1.2倍,向右平移了100px,向下平移了200px,如下:

IMG_A470A85CDDC0-1.jpeg

我们很容易写出变换前的stage的变换矩阵:Matrix(1.2, 0, 0, 1.2, 100, 200)

我们对stage做的变换是:以鼠标所在的点[a,b]为锚点,缩小成最初的大小的0.8倍,下图中,蓝色的框框代表缩小后的stage:

IMG_D8EAE1DC6965-1.jpeg

这个时候,stage的变换矩阵是什么呢?

首先,前4位很容易写出来,因为现在的缩放倍数是0.8,所以前4位我们是知道的:Matrix(0.8, 0, 0, 0.8, ?, ?),那么后面2位该怎么得到呢?我们先来求后面2位的第一位,我们给它取个名字,就叫x吧,另外,我加了几根辅助线,方便理解:

IMG_64B96D171E53-1.jpeg

先来捋一下:红色框框是变换前的stage;蓝色框框是变换后的stage;x(蓝色虚线的长度)就是我们要求的那个x,它是变换后的stage的左边框到y轴的距离;p(绿色虚线的长度)是mouse point到变换前的stage的左边框的距离;q(橙色虚线)是mouse point到变换后的stage的左边框的距离

显然,这个等式是成立的: x = 100 + (p - q)

p就是mouse point在stage(before)上的横坐标,stage(before)的变换矩阵是已知的,通过应用它的逆矩阵,我们可以得到p的值,但是得到的值是相对于stage的值,并不是相对于renderer的值,我们现在要求的x是相对于renderer的值,所以p也要要转化成相对于renderer的值,具体做法是我们需要将其乘以1.2,得到的就是相对于renderer的值,还记得吗,stage(before)是放大了1.2倍的。

现在我们求出了p,只剩下q了,我们的变换是以mouse point为锚点的变换,所以p和q是成比例的,可以得知这个等式是成立的:p/q = 1.2/0.8,这样的话,q也求出来了,那么x的值就求出来了,我们得到了stage(after)的横向移动的距离,同理,可以得到stage(after)的纵向移动距离。

综上所述,我们求出了变换后的stage的变换矩阵。

2.5.3 代码实现

获取当前的缩放比例

getZoom(): number {
    // stage是宽高等比例缩放的,所以取x或者取y是一样的
    return pixiApp.stage.scale.x;
}

在canvas元素上监听鼠标滚轮事件

pixiApp.view.addEventListener('wheel', (event) => {
  // 因为画布是充满视窗的,所以clientX等于mouse point在renderer上的x坐标
  const globalPos = new Point(event.clientX, event.clientY);
  const delta = event.deltaY;
  const oldZoom = getZoom();
  let newZoom = zoom * 0.999 ** delta;
  applyZoom(oldZoom, newZoom, globalPos);
});

applyZoom函数

applyZoom = (oldZoom: number, newZoom: number, pointerGlobalPos: Point) => {
    const oldStageMatrix = pixiApp.stage.localTransform.clone();
    const oldStagePos = oldStageMatrix.applyInverse(pointerGlobalPos);
    const dx = oldStagePos.x * oldZoom - oldStagePos.x * newZoom;
    const dy = oldStagePos.y * oldZoom - oldStagePos.y * newZoom;

    pixiApp.stage.setTransform(
      pixiApp.stage.position.x + dx,
      pixiApp.stage.position.y + dy,
      newZoom,
      newZoom,
      0,
      0,
      0,
      0,
      0
    );
}

2.5.4 最终效果

缩放画布.gif

2.6 给点击的对象添加一个选中效果

2.6.1 核心思想以及代码实现

  1. 当我们点击了一个Text对象之后,将该对象设置为activeObj,然后获取该Text对象的4个顶点,然后根据这4个顶点画一个矩形,添加到stage上

设置activeObj:

pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    activeObject = event.target;
    addActiveTargetBorder()
  }
);

获取Text的4个顶点:

getObjectStageBound(obj: DisplayObject) {
    const localBounds = obj.getLocalBounds();
    const tl = new Point(localBounds.x, localBounds.y);
    const tr = new Point(localBounds.x + localBounds.width, localBounds.y);
    const br = new Point(
      localBounds.x + localBounds.width,
      localBounds.y + localBounds.height
    );
    const bl = new Point(localBounds.x, localBounds.y + localBounds.height);
    const localPoints = [tl, tr, br, bl];
    return localPoints.map((p) => obj.localTransform.apply(p));
}

根据4个顶点画一个矩形,并添加到stage上:

addActiveTargetBorder() {
    const bound = getObjectStageBound(activeObject);
    const border = new Graphics();
    border.lineStyle(3 / getZoom(), 0x5b97fc);
    border.drawPolygon(bound);
    pixiApp.stage.addChild(border);
}
  1. 现在,选中效果也就是border已经添加到了stage上,但是,我们在对Text对象进行拖拽时,Text对象相对于stage的位置就改变了,这个时候,border也要随着Text对象的位置的改变而改变自己的位置

在添加border后,要将其引用记录下来,我们稍微修改一下addActiveTargetBorder函数:

addActiveTargetBorder() {
    const bound = getObjectStageBound(activeObject);
    const border = new Graphics();
    border.lineStyle(3 / getZoom(), 0x5b97fc);
    border.drawPolygon(bound);
    pixiApp.stage.addChild(border);
    activeObjBorder = border;
}

不断更新border,让其跟随activeObj移动

updateActiveTargetBorder = () => {
    if (activeObject && activeObjBorder) {
      const bound = getObjectStageBound(activeObject);
      activeObjBorder.clear(); // 清除border
      activeObjBorder.lineStyle(3 / getZoom(), 0x5b97fc);
      activeObjBorder.drawPolygon(bound); // 重新画draw border
    }
};

updateActiveTargetBorder函数要添加到ticker列表中,这样的话,pixi就会不断执行这个函数

pixiApp.ticker.add(updateActiveTargetBorder);

2.6.2 最终效果

添加选中效果.gif

2.7 旋转Text对象

2.7.1 添加控制点

在实现旋转之前,先给activeObj添加一个控制点

控制点对象继承于pixijs的Graphics对象:

class ControlPoint extends Graphics {
  controlTarget: DisplayObject;
  constructor(target: DisplayObject) {
    super();
    this.controlTarget = target;
  }
}

添加activeObj的border时,一并添加控制点:

addActiveTargetControlPoint(activeObj: DisplayObject) {
    const controlPoint = new ControlPoint(activeObj);
    activeObjControlPoint = controlPoint;
    controlPoint.interactive = true;
    controlPoint.cursor = 'pointer';
    pixiApp.stage.addChild(controlPoint);
    controlPoint.lineStyle(2 / getZoom(), 0xc66965);
    const radius = 5 / getZoom();
    controlPoint.beginFill(0xffffff);
    controlPoint.drawCircle(0, 0, radius);
    controlPoint.endFill();
    const bound = getObjectStageBound(activeObject!);
    const [tl, tr] = bound;
    controlPoint.position.set((tl.x + tr.x) / 2, (tl.y + tr.y) / 2);
}
pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    activeObject = event.target;
    addActiveTargetBorder()
    addActiveTargetControlPoint(activeObject)
  }
);

更新activeObj的border时,一并更新控制点的位置信息

updateActiveTargetControlPoint = () => {
    if (activeObject && activeObjControlPoint) {
      activeObjControlPoint.clear();
      activeObjControlPoint.lineStyle(2 / getZoom(), 0xc66965);
      const radius = 5 / getZoom();
      activeObjControlPoint.beginFill(0xffffff);
      activeObjControlPoint.drawCircle(0, 0, radius);
      activeObjControlPoint.endFill();
      const bound = getObjectStageBound(activeObject);
      const [tl, tr] = bound;
      activeObjControlPoint.position.set(
        (tl.x + tr.x) / 2,
        (tl.y + tr.y) / 2
      );
    }
};
pixiApp.ticker.add(updateActiveTargetControlPoint);

控制点的效果如下:

控制点效果.gif

2.7.2拖拽控制点以旋转Text对象

2.7.2.1核心思想

先思考一个问题,现在有2个向量:向量v1和向量v2,我们要对向量v1做什么样的操作,才能让v1向量和v2向量共线呢?

IMG_8C052A663373-1.jpeg

想必各位已经有了答案,就是旋转一个角度θ就行了,这个角度θ可以这样求:根据公式v1⋅v2=||v1||||v2||cosθ,可以得到:θ=acos(v1⋅v2/||v1||||v2||),这样我们就求出了这个角度。

求出了这个角度还不够,还有个问题就是:v1向量应该加这个角度,还是减这个角度,才能达到与v2共线呢?

这里可以通过两个向量的叉积来判断:v1和v2的叉积为正,那么v1在v2的顺时针方向,但是有个问题,现在我们的坐标系跟数学里的那个坐标系是不同的,数学里的那个坐标系,y轴是朝上的,现在我们的坐标系的y轴是朝下的,所以真正的结论应该是:v1和v2的叉积为正,那么v1在v2的逆时针方向。

2.7.2.2代码实现

鼠标点到了控制点时,我们要记录一些信息,为拖拽做准备:

pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;
    mouseDownPoint = new Point(globalPos.x, globalPos.y);

    if (event.target instanceof ControlPoint) {
      rotatingActiveObject = true;
      originalAngle = event.target.controlTarget.angle;
      const bound = getObjectStageBound(activeObject);
      const [tl, _tr, br, _bl] = bound;
      originalCenter = new Point((tl.x + br.x) / 2, (tl.y + br.y) / 2);
    }
  }
);

拖拽控制点时,以Text对象中心点为锚点旋转Text对象:

setActiveObjAngle(angle: number) {
    activeObject.angle = angle;
    const newBound = getObjectStageBound(activeObject);
    const [tl, _tr, br, _bl] = newBound;
    const newCenter = new Point((tl.x + br.x) / 2, (tl.y + br.y) / 2);
    const dx = newCenter.x - originalCenter.x;
    const dy = newCenter.y - originalCenter.y;
    activeObject.position.set(
      activeObject.position.x - dx,
      activeObject.position.y - dy
    );
}
pixiApp.renderer.plugins.interaction.on(
  'pointermove',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;

    if (rotatingActiveObject) {
      // 拖拽控制点
      const pointerDownStagePos =
        pixiApp.stage.localTransform.applyInverse(mouseDownPoint);
      const curPointerStagePos =
        pixiApp.stage.localTransform.applyInverse(globalPos);

      const v1 = new Point(
        pointerDownStagePos.x - originalCenter.x,
        pointerDownStagePos.y - originalCenter.y
      );
      const v2 = new Point(
        curPointerStagePos.x - originalCenter.x,
        curPointerStagePos.y - originalCenter.y
      );

      // 计算v1向量和v2向量的夹角
      const v1mv2 = v1.x * v2.x + v1.y * v2.y; // v1和v2的点积
      const modV1V2 =
        Math.sqrt(Math.pow(v1.x, 2) + Math.pow(v1.y, 2)) *
        Math.sqrt(Math.pow(v2.x, 2) + Math.pow(v2.y, 2));
      const cos = v1mv2 / modV1V2; // v1向量和v2向量的夹角的cos值
      const angle = (180 * Math.acos(cos)) / Math.PI;

      // 判断应该顺时针 旋转还是逆时针旋转(这里注意:坐标系是倒过来的)
      const v2xv1 = v2.x * v1.y - v2.y * v1.x; // v1向量和v2向量的叉积
      const dAngle = v2xv1 > 0 ? -angle : angle; // 叉积为正说明v1向量在v2向量的顺时针方向

      setActiveObjAngle((originalAngle + dAngle) % 360);
    }
  }
);

2.7.3 最终效果

旋转Text对象.gif

2.8 多选

2.8.1 实现一个类似Windows桌面上画矩形框的效果

2.8.1.1 核心思想

当触发pointerdown事件时,我们会在stage上添加一个Graphics对象,并记录下pointerdown时鼠标的坐标,作为矩形框的左上角(或者右下角);接下来,随着pointermove事件的触发,我们会不断地重新绘制这个Graphics,以达到矩形框的右下角(或者左上角)的顶点追随鼠标的效果;最后,触发pointerup事件时,将这个Graphics对象从stage上移除掉。

2.8.1.2 代码实现

SelectorTool类:

class SelectorTool {
  private pixiApp: Application;
  private p1: Point;
  private p2: Point;
  private rect: Graphics;
  constructor(pixiApp: Application, startPoint: Point) {
    this.pixiApp = pixiApp;
    this.p1 = startPoint;
    this.p2 = startPoint;
    this.rect = new Graphics();
    this.pixiApp.stage.addChild(this.rect);
  }
  getRect(): Box {
    const xMin = Math.min(this.p1.x, this.p2.x);
    const yMin = Math.min(this.p1.y, this.p2.y);
    const xMax = Math.max(this.p1.x, this.p2.x);
    const yMax = Math.max(this.p1.y, this.p2.y);
    return {
      tl: new Point(xMin, yMin),
      br: new Point(xMax, yMax),
    };
  }
  drawRect() {
    const rect = this.getRect();
    this.rect.clear();
    this.rect.beginFill(0x8888ff, 0.5);
    this.rect.drawRect(
      rect.tl.x,
      rect.tl.y,
      rect.br.x - rect.tl.x,
      rect.br.y - rect.tl.y
    );
    this.rect.endFill();
  }
  move(point: Point) {
    this.p2 = point;
    this.drawRect();
  }
  end() {
    this.pixiApp.stage.removeChild(this.rect);
  }
}

pointerdown时new一个SelectorTool对象:

pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;
    
    if (!event.target) {
      // 点到了画布的空白位置
      touchBlank = true;
      
      if (curTool === Tool.Selector) {
        const stagePos = pixiApp.stage.localTransform
          .clone()
          .applyInverse(globalPos);
        selectorTool = new SelectorTool(pixiApp, stagePos);
      }
    }
  }
);

pointermove时,执行selectorTool对象的move函数:

pixiApp.renderer.plugins.interaction.on(
  'pointermove',
  (event: InteractionEvent) => {
    const globalPos = event.data.global;
    const stagePos = pixiApp.stage.localTransform.applyInverse(globalPos);

    if (touchBlank && curTool === Tool.Selector) {
      selectorTool.move(stagePos);
    }
  }
);

pointerup时执行selectorTool对象的end函数:

pixiApp.renderer.plugins.interaction.on(
  'pointerup',
  (event: InteractionEvent) => {
    touchBlank = false;

    if (selectorTool) {
      selectorTool.end();
      selectorTool = undefined;
    }
  }
);

2.8.1.3 最终效果

画矩形框.gif

2.8.2 在pointerup时,选中矩形框内的元素

2.8.2.1 核心思想

在pointerup时,我们将遍历stage上的所有元素,判断是否有元素在矩形框内,如果有,我们就会新建一个Container,将这些元素放到Container里面,再将这个Container放到stage上,并将这个Container设置成activeObj(在第5步中,我们实现了给activeObj添加border和控制点的效果,这个效果在这里也适用)

2.8.2.2 注意

在将选中的元素放到container里时,这些元素就会相对于container进行定位了,这个时候,我们要重新设置这些元素的位置,让其相对于stage的位置不变。

2.8.2.3 代码实现

给SelectorTool类的end函数添加一些逻辑:

end() {
    this.pixiApp.stage.removeChild(this.rect);

    const selected: DisplayObject[] = [];
    this.pixiApp.stage.children
      .filter((child) => {
        if (child instanceof Text) {
          return true;
        }
      })
      .forEach((child) => {
        const objRect = getObjectStageAABB(child);
        if (AABBRectTest(this.getRect(), objRect)) {
          selected.push(child);
        }
      });

    if (selected.length > 1) {
      // 创建一个GroupSelector对象,并将选中的元素放到这个对象里
      createGroupSelector(selected);
    }
}

将选中的Text元素放入GroupSelector对象里:

addChildrenFromStage(objList: DisplayObject[]) {
    this.addChild(...objList);
    
    // 重新设置这些Text元素的位置
    objList.forEach((obj) => {
      obj.position.set(
        obj.position.x - this.position.x,
        obj.position.y - this.position.y
      );
    });
    
    const localBounds = this.getLocalBounds();
    this.hitArea = new Polygon([
      0,
      0,
      localBounds.width,
      0,
      localBounds.width,
      localBounds.height,
      0,
      localBounds.height,
    ]);
}

2.8.2.4 最终效果

屏幕录制2023-04-24 17.37.38.GIF

2.8.3 在鼠标点击activeObj之外的区域时,移除GroupSelector,并将其子元素放回到stage上

2.8.3.1 核心思想

当Text对象从stage移到GroupSelector对象上时,就相对于GroupSelector对象定位了,如果要将Text对象再从GroupSelector对象上放回stage上,那么需要让他的position加上一个值,因为,将Text对象从stage移到GroupSelector对象上时,我们让它的position减少了一个值,就是这一段代码:

// 重新设置这些Text元素的位置
objList.forEach((obj) => {
  obj.position.set(
    obj.position.x - this.position.x,
    obj.position.y - this.position.y
  );
});

但是,仅仅只是这样做就可以了吗?显然不是的,因为这样做的话,没有考虑到GroupSelector对象的旋转!,如果仅仅只是这样做的话,那么实际效果看起来就会很诡异:

屏幕录制2023-04-24-19.03.44.gif

所以,上面的方法是行不通的,得另辟蹊径。

既然我们要将Text对象放回到stage上,那么我们就直接计算出Text对象相对于stage的变换矩阵。可以得出,以下等式是成立的:

Text对象相对于stage的变换矩阵 = GroupSelector对象相对于stage的变换矩阵 x Text对象相对于GroupSelector对象的变换矩阵

这样的话,我们就算出了Text对象相对于stage的变换矩阵

2.8.3.2 代码实现

GroupSelector对象的putChildrenBackToStage函数:

putChildrenBackToStage(stage: Container) {
    const groupSelectorMatrix = this.localTransform.clone();

    const children = [...this.children];
    children.forEach((obj) => {
      stage.addChild(obj);

      // (每个child相对container的变换矩阵)左乘(container相对stage的变换矩阵)就得到了(每个child相对于stage的变换矩阵)
      const finalMatrix = groupSelectorMatrix
        .clone()
        .append(obj.localTransform);
      obj.transform.setFromMatrix(finalMatrix);
    });
}

点击了画布的空白区域,就从stage上移除GroupSelector对象,并将其子元素放回到stage上:

pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    if (!event.target) {
      // 点到了画布的空白位置

      if (groupSelector) {
        groupSelector.putChildrenBackToStage(pixiApp.stage);
        pixiApp.stage.removeChild(groupSelector);
      }
    }
  }
);

2.8.3.3 实际效果

屏幕录制2023-04-24-19.28.55.gif

2.9 编辑Text对象的内容

2.9.1 核心思想

仅凭pixijs的API,想要实现一个编辑框是非常困难的,所以,我们可以借用浏览器的DOM元素的能力,来实现一个编辑框。具体步骤如下:

  1. 双击Text节点时,拿到这个节点的位置信息以及里面的文本内容,然后将这个Text节点隐藏
  2. 根据Text节点相对于renderer的位置信息,在页面上创建一个可编辑的div元素
  3. 在编辑的时候,将div里的文本信息同步给Text对象
  4. 点击编辑区域之外后,移除div元素,并让Text节点变得可见

在第2步中,我们如何拿到Text节点相对于renderer的位置信息呢,这里我们就不用使用2.8.3.1中那样的矩阵相乘了,通过worldTransform属性,能够直接拿到Text对象相对于renderer的定位。

2.9.2 代码实现

创建TextEditor:

createTextEditor(textTarget: Text) {
    // 隐藏Text对象
    textTarget.visible = false;

    const editor = document.createElement('div');
    editor.contentEditable = 'plaintext-only';
    editor.style.position = 'fixed';
    editor.classList.add('whiteboard-editor');

    // 从Text对象上取到一些字体的属性
    editor.style.color = `${textTarget.style.fill}`;
    editor.style.fontFamily = `OpenSans, Arial, sans-serif, "Noto Sans Hebrew", "Noto Sans", "Noto Sans JP", "Noto Sans KR"`;
    editor.style.transformOrigin = 'left top';
    editor.style.fontWeight = '400';
    editor.innerText = textTarget.text;

    document.body.appendChild(editor);
    
    // 编辑的时候,将内容同步给Text对象
    editor.oninput = () => {
      textTarget.text = editor.innerText;
      textTarget.updateTransform();
    };
    editor.focus();

    textEditor = editor;
}

现在我们创建了编辑框(div),我们还要让这个编辑框随着画布的缩放而一起缩放:

updateTextEditor = () => {
  const { a, b, c, d, tx, ty } = text.worldTransform;
  textEditor.style.transform = `matrix(${a},${b},${c},${d},${tx},${ty})`;
};
pixiApp.ticker.add(updateTextEditor);

点击了编辑区域外的地方,则移除编辑框

deleteTextEditor = () => {
    document.body.removeChild(textEditor);
    textObj.visible = true;
    textEditor = undefined;
};
pixiApp.renderer.plugins.interaction.on(
  'pointerdown',
  (event: InteractionEvent) => {
    if (textEditor) {
      deleteTextEditor();
    }
  }
);

2.9.3 最终效果

屏幕录制2023-04-24-20.20.38.gif

完结

谢谢大家的观看!😊