用pixi.js实现fabric.js(六):从线性代数的角度理解编辑器交互

友情提示

  • 阅读本文之前,需要了解一些线性代数的知识,可以去b站搜索3blue1brown的线性代数系列视频看一下
  • 本文内容不基于任何技术栈,而是一些通用理论

1. 前言

上一章中,我们已经实现了部分Canvas类的功能,但是仅仅是实现了事件系统相关的功能,还有很多交互功能,我们并没有实现,在实现这些交互功能之前,我们需要了解一些前置知识,以帮助理解如何实现这些交互功能。本文的目的,就是让大家来了解这些前置知识。

2. 几何的角度与代数的角度

2.1 缩放画布的例子

pixijs实现一个具有拖拽、旋转、缩放、多选、编辑的记事板这篇文章里的2.5节,我们实现了缩放画布的功能,我们的思路是这样的:

  1. 用wheel事件的deltaY来计算画布的新的zoom(scaleX、scaleY)
  2. 通过新旧两个zoom,会得到一个比例式,然后根据比例式计算出鼠标落点到新旧状态的画布左侧的距离,得出一个diff,这个diff就是画布的平移diff

得到了新的scale和新的translate,我们就得到了画布的新的状态,把这个新的状态设置给画布,就实现了画布的平移。

以上的实现方式,是从几何的角度来完成的,但是,如果从代数的角度来实现这个功能,会让过程变得更加简单一些。

2.2 从代数的角度来理解画布缩放

2.2.1 画布点和视窗点的互相转换

首先,我们要理解画布点(local)->视窗点(global)的转换逻辑。

画布的缩放,是通过scale+translate来实现的,这两个变换的叠加顺序是:先scale、再translate,所以,画布点->视窗点的转换逻辑有如下公式:
localX×scaleX+translateX=globalXlocalY×scaleY+translateY=globalYlocalX \times scaleX + translateX = globalX \\ localY \times scaleY + translateY = globalY localX×scaleX+translateX=globalXlocalY×scaleY+translateY=globalY

同样,我们可以根据上述公式,从global点反推local点。

2.2.2 translate的新的计算方式

scale的计算方式还是和以前一样,用e.deltaY,而translate的计算,则有了新的方式

画布缩放时,有一个特点,就是鼠标所在的点,是不动的,也就是说:localX、localY这个点,通过了新的变换后,出现在视窗上的位置是不变的。根据这个特点,加上上面的公式,我们可以得出一个简单的方程:
localX×新scaleX+新translateX=globalXlocalY×新scaleY+新translateY=globalYlocalX \times 新scaleX + 新translateX = globalX \\ localY \times 新scaleY + 新translateY = globalY localX×新scaleX+新translateX=globalXlocalY×新scaleY+新translateY=globalY

globalX、globalY是已知数,代表鼠标落点的视窗坐标,我们要求的,就是新localX、localY、新scaleX、新scaleY、新translateX、新translateY。

首先,我们根据鼠标所在点(globalX、globalY),反推出localX、localY,然后新scaleX、新scaleY用e.deltaY计算出来。剩下的未知数,就是画布的新translateX、新translateY

把这个方程左边的已知数挪到右边,就得到了新translateX、新translateY。

最后,把新scaleX、新scaleY、新translateX、新translateY设置给画布,就完成了这一次缩放。

这里我用html实现了一个例子:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    #box,
    #box2,
    #box3 {
      width: 100px;
      height: 100px;
      position: absolute;
      cursor: pointer;
    }

    #box {
      left: 100px;
      top: 200px;
      background-color: red;
    }

    #box2 {
      left: 250px;
      top: 100px;
      background-color: green;
    }

    #box3 {
      left: 400px;
      top: 300px;
      background-color: blue;
    }

    html,
    body {
      width: 100vw;
      height: 100vh;
      overflow: hidden;
    }

    #root{
      position: absolute;
      left: 0;
      top: 0;
      transform-origin: 0 0;
      touch-action: none;
      user-select: none;
    }
  </style>
</head>

<body>
  <div id="root">
    <div id="box"></div>
    <div id="box2"></div>
    <div id="box3"></div>
  </div>
</body>
<script>
  const root = document.getElementById('root');

  let scale = 1;
  let tx = 0;
  let ty = 0;

  const applyTransform = () => {
    root.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
  };

  document.addEventListener('wheel', (e) => {
    e.preventDefault();
    const zoom = e.deltaY < 0 ? 1.1 : 0.9; // 这里懒得用e.deltaY来算了,直接写死
    const nextScale = Math.max(0.1, Math.min(10, scale * zoom));
    const k = nextScale / scale;
    tx = e.clientX - (e.clientX - tx) * k;
    ty = e.clientY - (e.clientY - ty) * k;
    scale = nextScale;
    applyTransform();
  }, { passive: false });

  applyTransform();

</script>

</html>

2.3 线性代数

在2.2里,我们从代数的角度来实现了画布的缩放,但是这并不是我们的最终效果,因为缩放画布只是一个简单的例子:第一,画布的锚点固定在(0,0)点,第二,画布不会出现旋转的情况,基于这两个特性,我们很容易就能得出local点与global点之间的转换公式,但是,一旦加入旋转以及锚点,上面的这个公式就非常难得出了,这个时候,我们需要用更加通用的方式,来处理这种复杂的场景。

2.3.1 矩阵

矩阵包含了一个元素的所有变换属性,包括:旋转、缩放、平移、锚点,这些变化属性,都会被放到6个数字里(a、b、c、d、tx、ty)。

通过矩阵,可以得出一种通用的解决方式,让我们只需要拿着6个数字(a、b、c、d、tx、ty),而不必考虑元素的任何变换形态,就可以得出各种转换公式,来实现编辑器的各种交互效果,这种实现方式,屏蔽了大部分的几何逻辑,让我们不需要拥有很强的空间想象能力,也能完成复杂的编辑器交互。

2.3.2 通用的解决问题方式

2.2节的画布缩放的实现,是矩阵的一个特例,接下来我们将用完整的思路,再次实现一次画布的缩放。

前面我们得出了画布点(local)->视窗点(global)的转换逻辑,是一个这样的公式:
localX×scaleX+translateX=globalXlocalY×scaleY+translateY=globalYlocalX \times scaleX + translateX = globalX \\ localY \times scaleY + translateY = globalY localX×scaleX+translateX=globalXlocalY×scaleY+translateY=globalY

现在,我们用矩阵来替换这个转换逻辑,它将会变成这样:
a c tx b d ty 0 0 1 × localX localY 1 = globalX globalY 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} localX \\ localY \\ 1 \end{bmatrix} = \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} ab0cd0txty1 × localXlocalY1 = globalXglobalY1

新的实现过程,整体实现思路和2.2节差不多,都是:建立方程->逐步求出各个量并填入方程以减少未知数的数量->最后方程里只剩下一个未知数->求出最后一个未知数然后把所有值set到对象上

具体实现方法:

先根据鼠标落点(globalX、globalY)求出localX和localY,上面的矩阵左乘一个逆矩阵,就可以得到localX和localY:
localX localY 1 = a c tx b d ty 0 0 1 −1 × globalX globalY 1 \begin{bmatrix} localX \\ localY \\ 1 \end{bmatrix} = \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ^{-1} \times \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} localXlocalY1 = ab0cd0txty1 −1× globalXglobalY1

在缩放画布画布后,虽然scale和translate变了,但是,我们的鼠标落点那个位置的画布,是不会动的,也就是说localX、localY经过了新的变换之后,依然会出现在globalX、globalY点,根据这一点,我们可以得出一个方程:
newA newC newTx newB newD newTy 0 0 1 × localX localY 1 = globalX globalY 1 \begin{bmatrix} newA & newC & newTx \\ newB & newD & newTy \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} localX \\ localY \\ 1 \end{bmatrix} = \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} newAnewB0newCnewD0newTxnewTy1 × localXlocalY1 = globalXglobalY1

newA、newB、newC、newD、newTx、newTy就是我们要求的新的矩阵值

localX、localY、globalX、globalY都是已知数,newA和newD代表新的scale,可以通过e.deltaY来得出,newB和newC固定是0,所以,等式中只剩下newTx、newTy要求了,把这个等式展开来,所有已知数移到右边,我们就可以得到newTx和newTy了,至此,缩放后的新的变换矩阵的6个数字全部求出。

这里我也用html实现了一个例子:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    #box,
    #box2,
    #box3 {
      width: 100px;
      height: 100px;
      position: absolute;
      cursor: pointer;
    }

    #box {
      left: 100px;
      top: 200px;
      background-color: red;
    }

    #box2 {
      left: 250px;
      top: 100px;
      background-color: green;
    }

    #box3 {
      left: 400px;
      top: 300px;
      background-color: blue;
    }

    html,
    body {
      width: 100vw;
      height: 100vh;
      overflow: hidden;
    }

    #root {
      position: absolute;
      left: 0;
      top: 0;
      transform-origin: 0 0;
      touch-action: none;
      user-select: none;
    }
  </style>
</head>

<body>
  <div id="root">
    <div id="box"></div>
    <div id="box2"></div>
    <div id="box3"></div>
  </div>
</body>
<script>
  const root = document.getElementById('root');

  let scale = 1;
  let tx = 0;
  let ty = 0;

  const applyTransform = () => {
    root.style.transform = `matrix(${scale}, 0, 0, ${scale}, ${tx}, ${ty})`;
  };

  // 获取逆矩阵
  const getInverted = (a, b, c, d, tx, ty) => {
    const det = a * d - b * c;
    if (det === 0) return null;
    const invA = d / det;
    const invB = -b / det;
    const invC = -c / det;
    const invD = a / det;
    const invTx = (c * ty - d * tx) / det;
    const invTy = (b * tx - a * ty) / det;
    return { a: invA, b: invB, c: invC, d: invD, tx: invTx, ty: invTy };
  }

  document.addEventListener('wheel', (e) => {
    e.preventDefault();
    const zoom = e.deltaY < 0 ? 1.1 : 0.9;
    const nextScale = Math.max(0.1, Math.min(10, scale * zoom));
    const inverted = getInverted(scale, 0, 0, scale, tx, ty);
    const globalX = e.clientX;
    const globalY = e.clientY;
    const localX = inverted.a * globalX + inverted.c * globalY + inverted.tx;
    const localY = inverted.b * globalX + inverted.d * globalY + inverted.ty;
    tx = globalX - localX * nextScale;
    ty = globalY - localY * nextScale;
    scale = nextScale;
    applyTransform();
  }, { passive: false });

  applyTransform();

</script>

</html>

2.4 矩阵的意义

看完2.3,很多读者肯定会觉得,2.3使用矩阵的方式,相较于2.2明显增加了复杂度,如果直接用2.2的方式来实现缩放画布,将会简单很多。

确实是这样的,因为,2.2里面的方式,只是一个特例,有很多值都已经是确定的状态了,剩余的未知数非常少,所以我们可以不用矩阵,但是不可能所有场景都像缩放画布这样,有很多值已经是固定的状态,这个时候,我们必须用矩阵来处理,当然,如果空间想象能力非常强,从纯几何的角度来实现,也不是不可以,只是用矩阵会让这个过程变得更简单一些。

3. 从线性代数的角度理解编辑器的各种操作

3.1 拖拽画布

拖拽画布可以直接用mousedown和mousemove时的鼠标落点做diff,然后给画布的x、y加上这个diff,就可以了。

拖拽画布的时候,我们似乎并没有用到什么转换操作,也没用到矩阵,这是因为,画布在将自身的各个属性叠加成一个矩阵时,是最后才处理x、y的,这意味着:矩阵的tx、ty等于元素的x、y,并且tx、ty并不会受到scale、rotate的影响。

有:
a c tx b d ty 0 0 1 = 1 0 tx 0 1 ty 0 0 1 × a c 0 b d 0 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} 1 & 0 & tx \\ 0 & 1 & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} a & c & 0 \\ b & d & 0 \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1 = 100010txty1 × ab0cd0001

如果先处理x、y,再处理其它值,得出来的结果就不相等了:
a c tx b d ty 0 0 1 a c 0 b d 0 0 0 1 × 1 0 tx 0 1 ty 0 0 1 = a c a×tx+c×ty b d b×tx+d×ty 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \neq \begin{bmatrix} a & c & 0 \\ b & d & 0 \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} 1 & 0 & tx \\ 0 & 1 & ty \\ 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} a & c & a \times tx + c \times ty \\ b & d & b \times tx + d \times ty \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1 = ab0cd0001 × 100010txty1 = ab0cd0a×tx+c×tyb×tx+d×ty1

3.2 缩放画布

见2.3

3.3 拖拽元素

拖拽元素的时候,我们不能直接通过鼠标的视窗坐标做diff,我们还需要考虑画布的缩放带来的影响,更准确的来说,要考虑拖拽元素的父元素的缩放所带来的影响,如果父元素处于放大状态,那么直接使用鼠标的视窗坐标做diff,这个diff也会被放大,同样,如果父元素处于缩小状态,那么这个diff也会被缩小,最终所表现出来的,就是拖拽的元素不跟随鼠标,可能飘到离鼠标很远的地方去,这并不符合我们的直觉。

3.3.1 将鼠标的视窗坐标(global坐标)转换成拖拽元素的父平面坐标

我们不能直接用鼠标的视窗坐标做diff,而是先将这个视窗坐标,转换成和拖拽元素同一平面的坐标,再做diff,这样的话,算出来的diff,再经过父元素的缩放或者其他属性的叠加后,就会跟随鼠标了。

假设父元素的变化矩阵为:
a c tx b d ty 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1

鼠标的视窗坐标为:
globalX globalY 1 \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} globalXglobalY1

我们要求的坐标为:
localX localY 1 \begin{bmatrix} localX \\ localY \\ 1 \end{bmatrix} localXlocalY1

那么有:
a c tx b d ty 0 0 1 × localX localY 1 = globalX globalY 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} localX \\ localY \\ 1 \end{bmatrix} = \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} ab0cd0txty1 × localXlocalY1 = globalXglobalY1

左右两边左乘一个父元素的变换矩阵的逆矩阵:
localX localY 1 = a c tx b d ty 0 0 1 −1 × globalX globalY 1 \begin{bmatrix} localX \\ localY \\ 1 \end{bmatrix} = \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ^{-1} \times \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} localXlocalY1 = ab0cd0txty1 −1× globalXglobalY1

就可以得到localX和localY了

3.3.2 diff

mousedown的时候记录一个localX、localY,mousemove的时候,计算出一组新的localX、localY,然后做diff,把这个diff应用到被拖拽的元素上。

3.3.3 例子

这里是一个html实现的例子:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    #box,
    #box2,
    #box3 {
      width: 100px;
      height: 100px;
      position: absolute;
      cursor: pointer;
    }

    #box {
      left: 100px;
      top: 200px;
      background-color: red;
    }

    #box2 {
      left: 250px;
      top: 100px;
      background-color: green;
    }

    #box3 {
      left: 400px;
      top: 300px;
      background-color: blue;
    }

    html,
    body {
      width: 100vw;
      height: 100vh;
      overflow: hidden;
    }

    #root {
      position: absolute;
      left: 0;
      top: 0;
      transform-origin: 0 0;
      touch-action: none;
      user-select: none;
    }
  </style>
</head>

<body>
  <div id="root">
    <div id="box"></div>
    <div id="box2"></div>
    <div id="box3"></div>
  </div>
</body>
<script>
  const root = document.getElementById('root');
  const draggableSelector = '#box, #box2, #box3';

  let scale = 1;
  let tx = 0;
  let ty = 0;
  let draggingEl = null;
  let dragStartLocalX = 0;
  let dragStartLocalY = 0;
  let dragStartLeft = 0;
  let dragStartTop = 0;

  const applyTransform = () => {
    root.style.transform = `matrix(${scale}, 0, 0, ${scale}, ${tx}, ${ty})`;
  };

  // 获取逆矩阵
  const getInverted = (a, b, c, d, tx, ty) => {
    const det = a * d - b * c;
    if (det === 0) return null;
    const invA = d / det;
    const invB = -b / det;
    const invC = -c / det;
    const invD = a / det;
    const invTx = (c * ty - d * tx) / det;
    const invTy = (b * tx - a * ty) / det;
    return { a: invA, b: invB, c: invC, d: invD, tx: invTx, ty: invTy };
  }

  const screenToLocal = (x, y) => {
    const inverted = getInverted(scale, 0, 0, scale, tx, ty);
    if (!inverted) return { x: 0, y: 0 };
    return {
      x: inverted.a * x + inverted.c * y + inverted.tx,
      y: inverted.b * x + inverted.d * y + inverted.ty,
    };
  };

  root.addEventListener('mousedown', (e) => {
    const target = e.target.closest(draggableSelector);
    if (!target) return;

    e.preventDefault();
    draggingEl = target;

    const localPoint = screenToLocal(e.clientX, e.clientY);
    dragStartLocalX = localPoint.x;
    dragStartLocalY = localPoint.y;
    dragStartLeft = draggingEl.offsetLeft;
    dragStartTop = draggingEl.offsetTop;
  });

  document.addEventListener('mousemove', (e) => {
    if (!draggingEl) return;

    const localPoint = screenToLocal(e.clientX, e.clientY);
    const dx = localPoint.x - dragStartLocalX;
    const dy = localPoint.y - dragStartLocalY;

    draggingEl.style.left = dragStartLeft + dx + 'px';
    draggingEl.style.top = dragStartTop + dy + 'px';
  });

  document.addEventListener('mouseup', () => {
    draggingEl = null;
  });

  document.addEventListener('wheel', (e) => {
    e.preventDefault();
    const zoom = e.deltaY < 0 ? 1.1 : 0.9;
    const nextScale = Math.max(0.1, Math.min(10, scale * zoom));
    const inverted = getInverted(scale, 0, 0, scale, tx, ty);
    const globalX = e.clientX;
    const globalY = e.clientY;
    const localX = inverted.a * globalX + inverted.c * globalY + inverted.tx;
    const localY = inverted.b * globalX + inverted.d * globalY + inverted.ty;
    tx = globalX - localX * nextScale;
    ty = globalY - localY * nextScale;
    scale = nextScale;
    applyTransform();
  }, { passive: false });

  applyTransform();

</script>

</html>

3.4 用控制点旋转对象

通过旋转控制点,来控制对象的旋转角度

3.4.1 向量的点积公式求旋转夹角

如图所示,绿色矩形是我们的旋转对象,O点是该对象的中心点,P点是该对象的旋转控制点,Q点是拖拽过程中的鼠标落点。角POQ(θ)就是我们要求的旋转夹角,P、O、Q这3个点都是已知的,所以我们可以得到2个向量:OP、OQ,然后根据向量的点积公式,求出这两个向量的夹角,有:
cos⁡θ= a⃗⋅b⃗ ∣a⃗∣∣b⃗∣ \cos\theta = \frac{\vec{a} \cdot \vec{b}}{|\vec{a}| |\vec{b}|} cosθ=∣a ∣∣b ∣a ⋅b

计算出cos θ,就可以用反三角函数计算出θ的值。

3.4.2 通过叉积来计算旋转方向

还是上面的OP、OQ向量,可以计算它们的叉积,判断它们的夹角是否在180度之内,然后判断是顺时针旋转还是逆时针旋转。

至此,我们就得到了旋转diff,也就是±θ。

3.4.3 锚点的影响

前面,虽然我们得到了旋转diff值,但是到这里并没有结束,并不是说,我们直接把这个diff值应用到旋转对象的rotation上,就OK了,因为,旋转对象的锚点,不一定是中心点,而一般编辑器的旋转对象操作,都是以对象的中心点为旋转中心点,这一点大家体验过fabric就知道了。

旋转对象操作,也需要加入平移效果,这样才能让对象的旋转看起来像围绕自身的中心点旋转。也就是说:旋转对象=旋转+平移。

3.4.4 如何计算平移?

在3.4.2中我们已经可以得到了旋转diff,但是在3.4.3中我们又遇到了新的问题,就是锚点的问题,为此,我们还需要给旋转对象加上一个平移,以消除锚点的影响,让对象的旋转中心始终为对象的内容区域的中心。

我们还是用矩阵方程来解决这个问题。

假设,经过了旋转之后,对象的新的变换矩阵为:
a c tx b d ty 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1

对象的内容区域的中心点的local坐标为:
centerX centerY 1 \begin{bmatrix} centerX \\ centerY \\ 1 \end{bmatrix} centerXcenterY1

对象的内容区域的中心点的视窗坐标为:
globalX globalY 1 \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} globalXglobalY1

有:
a c tx b d ty 0 0 1 × centerX centerY 1 = globalX globalY 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} centerX \\ centerY \\ 1 \end{bmatrix} = \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} ab0cd0txty1 × centerXcenterY1 = globalXglobalY1

接下来,就让我们逐步求出新的变换矩阵的a、b、c、d、tx、ty值。

前面我们已经求出了旋转diff值,给旧的变换矩阵叠加一个旋转矩阵(旋转角度为diff值),我们就可以求出a、b、c、d的值。

centerX、centerY代表旋转对象的内容区域的中心的坐标,这个点会受到锚点的影响,所以它并不一定等于(0,0),我们可以实现一个计算内容区域的bounds的函数,来求出这个值。

globalX和globalY可以通过centerX、centerY叠加旋转对象自身的世界矩阵来得到。

至此,上述矩阵方程中的未知数,只剩下tx、ty了,我们把方程展开,把所有已知数移到右边,就可以得出tx、ty的值了,所以新的变换矩阵的a、b、c、d、tx、ty都已经求出来了。

最后,我们把新的变换矩阵应用到旋转对象上,就完成了对象的旋转。

3.4.5 例子

这里用html实现了一个例子,其中,红色方块是我们的旋转对象,方块上方的黑点是旋转控制点:

代码如下:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    #box {
      width: 100px;
      height: 100px;
      background-color: red;
      position: absolute;
      left: 700px;
      top: 300px;
      cursor: pointer;
      /* transform-origin: center top; */
    }
  </style>
</head>

<body>
  <div id="box"></div>
</body>
<script>
  const box = document.getElementById('box');

  const tracker = document.createElement('div');
  tracker.style.position = 'absolute';
  tracker.style.left = '50%';
  tracker.style.top = '0';
  tracker.style.width = '0';
  tracker.style.height = '0';
  tracker.style.marginTop = '-12px';
  tracker.style.pointerEvents = 'none';
  box.appendChild(tracker);

  const followDot = document.createElement('div');
  followDot.id = 'followDot';
  followDot.style.position = 'fixed';
  followDot.style.width = '10px';
  followDot.style.height = '10px';
  followDot.style.borderRadius = '50%';
  followDot.style.backgroundColor = '#000';
  followDot.style.zIndex = '9999';
  followDot.style.cursor = 'pointer';
  document.body.appendChild(followDot);

  const originalOutline = box.style.outline;

  followDot.addEventListener('mouseenter', () => {
    box.style.outline = '3px solid pink';
  });

  followDot.addEventListener('mouseleave', () => {
    box.style.outline = originalOutline;
  });

  let isRotating = false;
  let startVector = null;
  let startA = 1;
  let startB = 0;
  let startC = 0;
  let startD = 1;
  let startTx = 0;
  let startTy = 0;

  function createMatrix(a = 1, b = 0, c = 0, d = 1, tx = 0, ty = 0) {
    return { a, b, c, d, tx, ty };
  }

  function multiplyMatrix(m1, m2) {
    return {
      a: m1.a * m2.a + m1.c * m2.b,
      b: m1.b * m2.a + m1.d * m2.b,
      c: m1.a * m2.c + m1.c * m2.d,
      d: m1.b * m2.c + m1.d * m2.d,
      tx: m1.a * m2.tx + m1.c * m2.ty + m1.tx,
      ty: m1.b * m2.tx + m1.d * m2.ty + m1.ty,
    };
  }

  function translateMatrix(x, y) {
    return createMatrix(1, 0, 0, 1, x, y);
  }

  function rotateMatrix(rad) {
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);
    return createMatrix(cos, sin, -sin, cos, 0, 0);
  }

  function applyMatrix(m, point) {
    return {
      x: m.a * point.x + m.c * point.y + m.tx,
      y: m.b * point.x + m.d * point.y + m.ty,
    };
  }

  function matrixToCss(m) {
    return `matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.tx}, ${m.ty})`;
  }

  function parseMatrix(transform) {
    if (!transform || transform === 'none') return createMatrix();

    const match = transform.match(/^matrix\((.+)\)$/);
    if (!match) return createMatrix();

    const [a, b, c, d, tx, ty] = match[1].split(',').map(Number);
    return createMatrix(a, b, c, d, tx, ty);
  }

  function getTransformOrigin() {
    const [x, y] = getComputedStyle(box).transformOrigin.split(' ').map(parseFloat);
    return { x, y };
  }

  function getContentCenter() {
    return {
      x: box.offsetWidth / 2,
      y: box.offsetHeight / 2,
    };
  }

  function getEffectiveMatrix(matrix) {
    const origin = getTransformOrigin();
    return multiplyMatrix(
      multiplyMatrix(translateMatrix(origin.x, origin.y), matrix),
      translateMatrix(-origin.x, -origin.y)
    );
  }

  function getStyleMatrixFromEffectiveMatrix(effectiveMatrix) {
    const origin = getTransformOrigin();
    return multiplyMatrix(
      multiplyMatrix(translateMatrix(-origin.x, -origin.y), effectiveMatrix),
      translateMatrix(origin.x, origin.y)
    );
  }

  function getBoxCenter() {
    const matrix = parseMatrix(getComputedStyle(box).transform);
    const center = applyMatrix(getEffectiveMatrix(matrix), getContentCenter());

    return {
      x: box.offsetLeft + center.x,
      y: box.offsetTop + center.y,
    };
  }

  followDot.addEventListener('mousedown', (e) => {
    e.preventDefault();
    const center = getBoxCenter();
    startVector = {
      x: e.clientX - center.x,
      y: e.clientY - center.y,
    };
    const matrix = parseMatrix(getComputedStyle(box).transform);
    startA = matrix.a;
    startB = matrix.b;
    startC = matrix.c;
    startD = matrix.d;
    startTx = matrix.tx;
    startTy = matrix.ty;
    isRotating = true;
  });

  window.addEventListener('mousemove', (e) => {
    if (!isRotating || !startVector) return;

    const center = getBoxCenter();
    const currentVector = {
      x: e.clientX - center.x,
      y: e.clientY - center.y,
    };

    const len1 = Math.sqrt(startVector.x * startVector.x + startVector.y * startVector.y);
    const len2 = Math.sqrt(currentVector.x * currentVector.x + currentVector.y * currentVector.y);
    if (!len1 || !len2) return;

    const dot = startVector.x * currentVector.x + startVector.y * currentVector.y;
    const cos = dot / (len1 * len2);
    const angle = Math.acos(Math.min(1, Math.max(-1, cos)));
    const cross = startVector.x * currentVector.y - startVector.y * currentVector.x;
    const direction = cross >= 0 ? 1 : -1;
    const diffRotate = direction * angle;

    const startMatrix = createMatrix(startA, startB, startC, startD, startTx, startTy);
    const startEffectiveMatrix = getEffectiveMatrix(startMatrix);
    const centerPoint = applyMatrix(startEffectiveMatrix, getContentCenter());
    const nextEffectiveMatrix = multiplyMatrix(
      multiplyMatrix(
        multiplyMatrix(translateMatrix(centerPoint.x, centerPoint.y), rotateMatrix(diffRotate)),
        translateMatrix(-centerPoint.x, -centerPoint.y)
      ),
      startEffectiveMatrix
    );

    box.style.transform = matrixToCss(getStyleMatrixFromEffectiveMatrix(nextEffectiveMatrix));
  });

  window.addEventListener('mouseup', () => {
    isRotating = false;
    startVector = null;
  });

  function updateDotPosition() {
    const rect = tracker.getBoundingClientRect();
    const x = rect.left + rect.width / 2;
    const y = rect.top + rect.height / 2;

    followDot.style.left = `${x - 5}px`;
    followDot.style.top = `${y - 5}px`;

    requestAnimationFrame(updateDotPosition);
  }

  requestAnimationFrame(updateDotPosition);
</script>

</html>

3.5 用控制点缩放对象

通过控制点,来修改对象的scale,缩放时,对角线上的控制点保持不动,这一点大家体验过了fabric就知道是什么效果了。

3.5.1 计算新的scale

拖拽控制点的时候,需要不断计算出最新的scale,那么这个最新的scale是根据什么得出的呢?

这里以右下角控制点为例:

上图中,绿色矩形,是我们的缩放对象,O点是矩形左上方顶点,T点是矩形右下方顶点(也是右下角控制点所在位置),P点是鼠标落点,从P点出发,向线段OT所在直线作垂线,这条垂线与OT所在直线的交点,就是Q点。

我们得出新的scale的方式,就是计算线段OQ与OT的长度的比例,用这个比例乘以初始scale,就得到了新的scale。

假设线段OQ与OT的长度的比例为factor,那么有:
newScale=factor×oldScalenewScale = factor \times oldScale newScale=factor×oldScale

3.5.2 新问题

目前,我们已经得到了新的scale,但是,正如本小节开头提到的,在拖拽缩放控制点时,对角线上的控制点应该是固定不动的,如果我们仅仅只是把这个新的scale设置给控制对象,那么你将会发现,新scale影响了控制对象的bounds,导致对角线上的控制点的位置发生了更改,这并不是我们要的效果。

到了这里,结合3.4中的内容,我们其实也可以得出,用控制点来缩放对象,不仅仅需要给控制对象设置一个新的scale,还需要给控制对象加上一段平移,让它的左上角保持不动。

3.5.3 矩阵方程

从3.5.2中我们又一次发现了一个不变的量,那就是控制对象左上角的坐标,根据这个不变的量,我们又可以得出一个矩阵方程。

假设控制对象经过一次缩放后,其新的变换矩阵为:
a c tx b d ty 0 0 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} ab0cd0txty1

控制对象的左上角local坐标为:
ltX ltY 1 \begin{bmatrix} ltX \\ ltY \\ 1 \end{bmatrix} ltXltY1

控制对象的左上角的视窗坐标为:
globalX globalY 1 \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} globalXglobalY1

那么有:
a c tx b d ty 0 0 1 × ltX ltY 1 = globalX globalY 1 \begin{bmatrix} a & c & tx \\ b & d & ty \\ 0 & 0 & 1 \end{bmatrix} \times \begin{bmatrix} ltX \\ ltY \\ 1 \end{bmatrix} = \begin{bmatrix} globalX \\ globalY \\ 1 \end{bmatrix} ab0cd0txty1 × ltXltY1 = globalXglobalY1

我们来逐步求出新的变换矩阵的a、b、c、d、tx、ty。

a、b、c、d可以通过在旧的变换矩阵上叠加一个scale矩阵来求出,scale我们已经算出来了。

ltX和ltY就是对象的左上角坐标,但是我们并不能直接用-width/2,-height/2来代表这个坐标,因为锚点不一定在中心点,我们可以实现一个求内容区域bounds的函数,来得到这个坐标。

globalX、globalY可以通过ltX、ltY叠加旧的变换矩阵来求出。

至此,矩阵方程中的未知数,只有tx、ty,我们将方程展开,已知数全挪到右侧,就可以得到tx、ty,所以新的变换矩阵的a、b、c、d、tx、ty都求出来了。

3.5.4 例子

下面用html实现了一个缩放控制点的例子,在这个例子里,红绿渐变色的矩形是我们的控制对象,上方黑色点是旋转控制点,右下方蓝色点是缩放控制点

代码:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    #box {
      width: 100px;
      height: 100px;
      background: linear-gradient(to right, red, green);
      position: absolute;
      left: 700px;
      top: 300px;
      cursor: pointer;
      /* transform-origin: center top; */
    }
  </style>
</head>

<body>
  <div id="box"></div>
</body>
<script>
  const box = document.getElementById('box');

  const tracker = document.createElement('div');
  tracker.style.position = 'absolute';
  tracker.style.left = '50%';
  tracker.style.top = '0';
  tracker.style.width = '0';
  tracker.style.height = '0';
  tracker.style.marginTop = '-12px';
  tracker.style.pointerEvents = 'none';
  box.appendChild(tracker);

  const followDot = document.createElement('div');
  followDot.id = 'followDot';
  followDot.style.position = 'fixed';
  followDot.style.width = '10px';
  followDot.style.height = '10px';
  followDot.style.borderRadius = '50%';
  followDot.style.backgroundColor = '#000';
  followDot.style.zIndex = '9999';
  followDot.style.cursor = 'pointer';
  document.body.appendChild(followDot);

  const scaleTracker = document.createElement('div');
  scaleTracker.style.position = 'absolute';
  scaleTracker.style.left = '100%';
  scaleTracker.style.top = '100%';
  scaleTracker.style.width = '0';
  scaleTracker.style.height = '0';
  scaleTracker.style.pointerEvents = 'none';
  box.appendChild(scaleTracker);

  const scaleDot = document.createElement('div');
  scaleDot.id = 'scaleDot';
  scaleDot.style.position = 'fixed';
  scaleDot.style.width = '10px';
  scaleDot.style.height = '10px';
  scaleDot.style.borderRadius = '50%';
  scaleDot.style.backgroundColor = 'blue';
  scaleDot.style.zIndex = '9999';
  scaleDot.style.cursor = 'nwse-resize';
  document.body.appendChild(scaleDot);

  const originalOutline = box.style.outline;

  followDot.addEventListener('mouseenter', () => {
    box.style.outline = '3px solid pink';
  });

  followDot.addEventListener('mouseleave', () => {
    box.style.outline = originalOutline;
  });

  scaleDot.addEventListener('mouseenter', () => {
    box.style.outline = '3px solid pink';
  });

  scaleDot.addEventListener('mouseleave', () => {
    box.style.outline = originalOutline;
  });

  let isRotating = false;
  let startVector = null;
  let startA = 1;
  let startB = 0;
  let startC = 0;
  let startD = 1;
  let startTx = 0;
  let startTy = 0;

  let isScaling = false;
  let scaleStartA = 1;
  let scaleStartB = 0;
  let scaleStartC = 0;
  let scaleStartD = 1;
  let scaleStartTx = 0;
  let scaleStartTy = 0;
  let scaleStartPoint = null;
  let scaleEndPoint = null;

  function createMatrix(a = 1, b = 0, c = 0, d = 1, tx = 0, ty = 0) {
    return { a, b, c, d, tx, ty };
  }

  function multiplyMatrix(m1, m2) {
    return {
      a: m1.a * m2.a + m1.c * m2.b,
      b: m1.b * m2.a + m1.d * m2.b,
      c: m1.a * m2.c + m1.c * m2.d,
      d: m1.b * m2.c + m1.d * m2.d,
      tx: m1.a * m2.tx + m1.c * m2.ty + m1.tx,
      ty: m1.b * m2.tx + m1.d * m2.ty + m1.ty,
    };
  }

  function translateMatrix(x, y) {
    return createMatrix(1, 0, 0, 1, x, y);
  }

  function rotateMatrix(rad) {
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);
    return createMatrix(cos, sin, -sin, cos, 0, 0);
  }

  function scaleMatrix(scale) {
    return createMatrix(scale, 0, 0, scale, 0, 0);
  }

  function applyMatrix(m, point) {
    return {
      x: m.a * point.x + m.c * point.y + m.tx,
      y: m.b * point.x + m.d * point.y + m.ty,
    };
  }

  function matrixToCss(m) {
    return `matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.tx}, ${m.ty})`;
  }

  function parseMatrix(transform) {
    if (!transform || transform === 'none') return createMatrix();

    const match = transform.match(/^matrix\((.+)\)$/);
    if (!match) return createMatrix();

    const [a, b, c, d, tx, ty] = match[1].split(',').map(Number);
    return createMatrix(a, b, c, d, tx, ty);
  }

  function getTransformOrigin() {
    const [x, y] = getComputedStyle(box).transformOrigin.split(' ').map(parseFloat);
    return { x, y };
  }

  function getContentCenter() {
    return {
      x: box.offsetWidth / 2,
      y: box.offsetHeight / 2,
    };
  }

  function getEffectiveMatrix(matrix) {
    const origin = getTransformOrigin();
    return multiplyMatrix(
      multiplyMatrix(translateMatrix(origin.x, origin.y), matrix),
      translateMatrix(-origin.x, -origin.y)
    );
  }

  function getStyleMatrixFromEffectiveMatrix(effectiveMatrix) {
    const origin = getTransformOrigin();
    return multiplyMatrix(
      multiplyMatrix(translateMatrix(-origin.x, -origin.y), effectiveMatrix),
      translateMatrix(origin.x, origin.y)
    );
  }

  function getBoxCenter() {
    const matrix = parseMatrix(getComputedStyle(box).transform);
    const center = applyMatrix(getEffectiveMatrix(matrix), getContentCenter());

    return {
      x: box.offsetLeft + center.x,
      y: box.offsetTop + center.y,
    };
  }

  followDot.addEventListener('mousedown', (e) => {
    e.preventDefault();
    const center = getBoxCenter();
    startVector = {
      x: e.clientX - center.x,
      y: e.clientY - center.y,
    };
    const matrix = parseMatrix(getComputedStyle(box).transform);
    startA = matrix.a;
    startB = matrix.b;
    startC = matrix.c;
    startD = matrix.d;
    startTx = matrix.tx;
    startTy = matrix.ty;
    isRotating = true;
  });

  window.addEventListener('mousemove', (e) => {
    if (!isRotating || !startVector) return;

    const center = getBoxCenter();
    const currentVector = {
      x: e.clientX - center.x,
      y: e.clientY - center.y,
    };

    const len1 = Math.sqrt(startVector.x * startVector.x + startVector.y * startVector.y);
    const len2 = Math.sqrt(currentVector.x * currentVector.x + currentVector.y * currentVector.y);
    if (!len1 || !len2) return;

    const dot = startVector.x * currentVector.x + startVector.y * currentVector.y;
    const cos = dot / (len1 * len2);
    const angle = Math.acos(Math.min(1, Math.max(-1, cos)));
    const cross = startVector.x * currentVector.y - startVector.y * currentVector.x;
    const direction = cross >= 0 ? 1 : -1;
    const diffRotate = direction * angle;

    const startMatrix = createMatrix(startA, startB, startC, startD, startTx, startTy);
    const startEffectiveMatrix = getEffectiveMatrix(startMatrix);
    const centerPoint = applyMatrix(startEffectiveMatrix, getContentCenter());
    const nextEffectiveMatrix = multiplyMatrix(
      multiplyMatrix(
        multiplyMatrix(translateMatrix(centerPoint.x, centerPoint.y), rotateMatrix(diffRotate)),
        translateMatrix(-centerPoint.x, -centerPoint.y)
      ),
      startEffectiveMatrix
    );

    box.style.transform = matrixToCss(getStyleMatrixFromEffectiveMatrix(nextEffectiveMatrix));
  });

  scaleDot.addEventListener('mousedown', (e) => {
    e.preventDefault();
    const matrix = parseMatrix(getComputedStyle(box).transform);
    const effectiveMatrix = getEffectiveMatrix(matrix);
    scaleStartA = matrix.a;
    scaleStartB = matrix.b;
    scaleStartC = matrix.c;
    scaleStartD = matrix.d;
    scaleStartTx = matrix.tx;
    scaleStartTy = matrix.ty;
    scaleStartPoint = applyMatrix(effectiveMatrix, { x: 0, y: 0 });
    scaleEndPoint = applyMatrix(effectiveMatrix, { x: box.offsetWidth, y: box.offsetHeight });
    isScaling = true;
  });

  window.addEventListener('mousemove', (e) => {
    if (!isScaling || !scaleStartPoint || !scaleEndPoint) return;

    const ot = {
      x: scaleEndPoint.x - scaleStartPoint.x,
      y: scaleEndPoint.y - scaleStartPoint.y,
    };
    const op = {
      x: e.clientX - box.offsetLeft - scaleStartPoint.x,
      y: e.clientY - box.offsetTop - scaleStartPoint.y,
    };
    const otLenSquare = ot.x * ot.x + ot.y * ot.y;
    if (!otLenSquare) return;

    const scale = (op.x * ot.x + op.y * ot.y) / otLenSquare;
    const nextA = scaleStartA * scale;
    const nextB = scaleStartB * scale;
    const nextC = scaleStartC * scale;
    const nextD = scaleStartD * scale;
    const origin = getTransformOrigin();
    const fixedLocalPoint = { x: 0, y: 0 };
    const fixedPoint = scaleStartPoint;

    // fixedPoint = origin + matrix * (fixedLocalPoint - origin) + translate
    const nextTx = fixedPoint.x - origin.x - nextA * (fixedLocalPoint.x - origin.x) - nextC * (fixedLocalPoint.y - origin.y);
    const nextTy = fixedPoint.y - origin.y - nextB * (fixedLocalPoint.x - origin.x) - nextD * (fixedLocalPoint.y - origin.y);

    box.style.transform = matrixToCss(createMatrix(nextA, nextB, nextC, nextD, nextTx, nextTy));
  });

  window.addEventListener('mouseup', () => {
    isRotating = false;
    startVector = null;
    isScaling = false;
    scaleStartPoint = null;
    scaleEndPoint = null;
  });

  function updateDotPosition() {
    const rect = tracker.getBoundingClientRect();
    const x = rect.left + rect.width / 2;
    const y = rect.top + rect.height / 2;

    followDot.style.left = `${x - 5}px`;
    followDot.style.top = `${y - 5}px`;

    const scaleRect = scaleTracker.getBoundingClientRect();
    scaleDot.style.left = `${scaleRect.left + scaleRect.width / 2 - 5}px`;
    scaleDot.style.top = `${scaleRect.top + scaleRect.height / 2 - 5}px`;

    requestAnimationFrame(updateDotPosition);
  }

  requestAnimationFrame(updateDotPosition);
</script>

</html>

4. 总结

用解矩阵方程的方式,可以解决很多编辑器交互的问题,具体思路就是:找出某些量之间的关系,得出一个等式,接下来,慢慢求出这个等式里的值,就可以得到新的变换矩阵,最终解决了问题。

这种方式,在遇到多种状态综合时,非常有效,我们可以不用操心锚点、旋转等状态带来的影响,而专注于某些比较容易求出的值,然后通过代数的方式,求出一些不好求的值。

但是,在某些情况比如缩放画布里,大多数状态是固定的,我们没有必要用矩阵方程的形式来计算新的变换矩阵,只需要用一些简单的换算公式就能解决问题了,所以,要不要用矩阵方程也需要看情况,有的时候可用可不用。

相关推荐
卷帘依旧2 小时前
useImperativeHandle的作用
前端
卷帘依旧2 小时前
Hooks在Fiber上的存储原理
前端
you45802 小时前
学成在线--day02 CMS前端开发(含Vue基础知识得回顾)
前端·javascript·vue.js
想吃火锅10052 小时前
【leetcode】1.两数之和js版
javascript·算法·leetcode
xiaofeichaichai2 小时前
虚拟 DOM
前端·javascript·vue.js
2401_878454532 小时前
前端高频得手写题
前端
初一初十2 小时前
vue3实现的纯前端护肤品商城网站
前端·javascript·vue.js·前端框架
卷帘依旧2 小时前
React状态管理方案怎么选
前端
zeqinjie2 小时前
Flutter 折叠屏 iPad / 宽屏适配实践
android·前端·flutter