Skip to content

课程 21 - Transformer

课程 14 中我们简单介绍过画布模式中的“选择模式”。在该模式下,选中图形后,在图形上覆盖一个操作层,可以通过拖拽行为移动它。在本节课中,我们将提供更多图形编辑能力,包括 resize 和旋转。

在 Konva 中把选中后图形上的操作层称作 Transformer,提供了以下例子:

我们也选择使用 Transformer 这个名字,它看起来和图形的 AABB 非常相似,事实上它被称为 OBB(oriented bounding box),是一个带有旋转角度的矩形。

序列化变换矩阵和尺寸信息

在 Figma 中图形的变换矩阵和尺寸信息如下。我们知道对于 2D 图形的变换矩阵 mat3 可以分解成 translation, scale 和 rotation 三部分。其中 X/Y 对应 translation,scale 我们放到翻转这一小节介绍。

source: https://help.figma.com/hc/en-us/articles/360039956914-Adjust-alignment-rotation-position-and-dimensions

因此我们选择修改 SerializedNode 结构,让它尽可能描述多种图形,同时移除一些图形表示位置的属性,例如 Circle 的 cx/cy,通过 x/ywidth/height 我们是可以计算出 cx/cy 的。

ts
export interface TransformAttributes {
    // Transform
    x: number;
    y: number;
    rotation: number;
    scaleX: number;
    scaleY: number;
    // Dimension
    width: number;
    height: number;
}

<circle cx="100" cy="100" r="50" /> 序列化后结构如下,这里使用 ellipse 表示是为了后续可以更灵活地 resize:

js
call(() => {
    const { createSVGElement, svgElementsToSerializedNodes } = ECS;
    const $circle = createSVGElement('circle');
    $circle.setAttribute('cx', '100');
    $circle.setAttribute('cy', '100');
    $circle.setAttribute('r', '50');
    const nodes = svgElementsToSerializedNodes([$circle], 0);
    return nodes[0];
});

对于 Polyline 和 Path 这种通过 pointd 属性定义的图形,我们无法删除这些属性,而需要计算出它们的 AABB 后,对这些属性进行重新计算。以 <polyline points="50,50 100,100, 100,50" /> 为例:

js
call(() => {
    const { createSVGElement, svgElementsToSerializedNodes } = ECS;
    const $polyline = createSVGElement('polyline');
    $polyline.setAttribute('points', '50,50 100,100, 100,50');
    const nodes = svgElementsToSerializedNodes([$polyline], 0);
    return nodes[0];
});

锚点

Transformer 的锚点分成 Resize 和旋转两类,在数目上有两种常见的组合。

一是 Excalidraw 和 Konva 采用的,用于 Resize 的 8 个锚点环绕四周,再加上一个独立的旋转锚点:

Source: https://csswolf.com/the-ultimate-excalidraw-tutorial-for-beginners/

二是 tldraw 和 Figma 采用的,使用 4 个锚点,从外侧靠近这 4 个锚点时变成旋转,而水平垂直的 Resize 通过拖拽四条边实现:

Source: https://wpdean.com/how-to-rotate-in-figma/

我们选择这种看起来更为简洁的方案:一个 Rect mask 作为父节点,以及四个子节点 Circle 锚点:

ts
const mask = this.commands.spawn(
    new UI(UIType.TRANSFORMER_MASK),
    new Transform(),
    new Renderable(),
    new Rect(), // 使用 Rect 组件
);
const tlAnchor = this.createAnchor(0, 0, AnchorName.TOP_LEFT); // 使用 Circle 组件
const trAnchor = this.createAnchor(width, 0, AnchorName.TOP_RIGHT);
const blAnchor = this.createAnchor(0, height, AnchorName.BOTTOM_LEFT);
const brAnchor = this.createAnchor(width, height, AnchorName.BOTTOM_RIGHT);

this.commands
    .entity(mask)
    .appendChild(this.commands.entity(tlAnchor))
    .appendChild(this.commands.entity(trAnchor))
    .appendChild(this.commands.entity(blAnchor))
    .appendChild(this.commands.entity(brAnchor));

Transformer 坐标系

课程 6 - 坐标系转换 中我们实现了 Viewport、Canvas、Client 这三个坐标系下的互相转换。这里我们需要引入一个新的坐标系,即 mask 的局部坐标系。例如当 mask 存在变换(例如旋转)时,作为子节点的锚点需要知道自身在世界坐标系下的位置。我们新增这一组转换方法:

ts
transformer2Canvas(camera: Entity, point: IPointData) {
    const { mask } = camera.read(Transformable);
    const matrix = Mat3.toGLMat3(mask.read(GlobalTransform).matrix);
    const [x, y] = vec2.transformMat3(
      vec2.create(),
      [point.x, point.y],
      matrix,
    );
    return {
      x,
      y,
    };
}
canvas2Transformer(camera: Entity, point: IPointData) {}

展示 CSS cursor

当鼠标悬停到锚点上时,鼠标样式需要直观地展示对应的功能,在 Web 端通过修改 <canvas> 的样式实现。默认的 CSS cursor 支持的图标比较有限,例如表示旋转语义的图标是不存在的,在 Excalidraw 和 Konva 中只能使用 grab 代替。再比如表示 Resize 的图标确实有 8 个,但由于图形存在旋转情况,当旋转角度不为 45 的整数倍时,即便像 Konva 一样计算选择合适的图标,也无法精确表示:

ts
function getCursor(anchorName: string, rad: number) {
    rad += DEG_TO_RAD * (ANGLES[anchorName] || 0);
    const angle = (((RAD_TO_DEG * rad) % 360) + 360) % 360;

    if (inRange(angle, 315 + 22.5, 360) || inRange(angle, 0, 22.5)) {
        return 'ns-resize';
    }
}

因此我们需要使用自定义鼠标样式,还需要能根据旋转角度动态调整。How can I rotate a css cursor 中提供了一种使用 SVG 的方式,而 tldraw 在此基础上增加了动态计算角度的逻辑,详见:useCursor。以右上角锚点为例:

Rotate anchor

将旋转变换应用在 SVG 图标上,得到此时 Cursor 的值:

ts
`url("data:image/svg+xml,<svg height='32' width='32'>...
    <g fill='none' transform='rotate(${
      r + tr // 旋转角度
    } 16 16)>

而当鼠标进一步靠近锚点时,会从旋转变成 Resize 交互:

Resize anchor

如何在远远地靠近锚点时就触发拾取呢?

扩大拾取面积

首先想到的是让图形可以扩大甚至是自定义拾取面积,例如 Pixi.js 就提供了 hitArea。我们也可以为 Renderable 组件也增加这个字段:

ts
export class Renderable {
    @field({ type: Type.object, default: null }) declare hitArea: Circle | Rect;
}

在 ComputeBounds System 计算包围盒时考虑这个属性,这样我们就可以设置一个比锚点大一圈的圆形判定区域:

ts
if (hitArea instanceof Circle) {
    renderBounds = Circle.getRenderBounds(hitArea);
}

但这种方式存在一个明显的问题:即使我们把拾取面积设置成锚点的 5 倍大,当相机缩放时,仍需要悬停到锚点上才能触发拾取。因此我们需要跳出 Canvas 世界坐标系下考虑拾取问题。

在 Viewport 坐标系下拾取

我们需要在 Viewport 坐标系下进行拾取判定,这样才可以无视相机缩放。

首先我们需要计算四个锚点在 Canvas 世界坐标系下的位置,而不是直接使用锚点的 cx/cy,否则当 Transformer 本身存在旋转时(我们马上就会看到这一点)就会出错。

ts
hitTest(api: API, { x, y }: IPointData) {
    const { tlAnchor, trAnchor, blAnchor, brAnchor } = camera.read(Transformable);

    const { x: tlX, y: tlY } = api.canvas2Viewport(
        // 需要考虑 Transformer 本身的变换,例如旋转
        api.transformer2Canvas(camera, {
            x: tlAnchor.read(Circle).cx,
            y: tlAnchor.read(Circle).cy,
        }),
    );
    // 省略其余锚点位置计算

    const distanceToTL = distanceBetweenPoints(x, y, tlX, tlY);
}

然后优先判定离四个锚点的最小距离是否满足 Resize 交互的阈值,如果满足就返回对应的鼠标样式图标名称,加上旋转角度得到旋转后的 SVG:

ts
if (minDistanceToAnchors <= TRANSFORMER_ANCHOR_RESIZE_RADIUS) {
    if (minDistanceToAnchors === distanceToTL) {
        return {
            anchor: AnchorName.TOP_LEFT,
            cursor: 'nwse-resize',
        };
    }
}

接下来进入旋转交互的判定。此时检测点不能在 Transformer 内,可以使用 Check if Point Is Inside A Polygon 中介绍的判定方法:

ts
else if (
    !isInside &&
    minDistanceToAnchors <= TRANSFORMER_ANCHOR_ROTATE_RADIUS
) {
    if (minDistanceToAnchors === distanceToTL) {
        return {
            anchor: AnchorName.TOP_LEFT,
            cursor: 'nwse-rotate',
        };
    }
}

最后来到 Transformer 四条边的 Resize 判定,这里需要计算检测点到线段的距离,可参考 Gist - point to line 2d

ts
import distanceBetweenPointAndLineSegment from 'point-to-segment-2d';

const distanceToTopEdge = distanceBetweenPointAndLineSegment(
    point,
    [tlX, tlY],
    [trX, trY],
);
// 省略计算到其余3条边的距离

if (minDistanceToEdges <= TRANSFORMER_ANCHOR_RESIZE_RADIUS) {
    if (minDistanceToEdges === distanceToTopEdge) {
        return {
            anchor: AnchorName.TOP_CENTER,
            cursor: 'ns-resize',
        };
    }
}

单个图形 Resize

在 Figma / FigJam 中,除了可以通过拖拽四个角落的锚点以及四条边进行自由改变大小之外,还可以:

  • Option 或者 Alt 拖拽时,以几何中心缩放
  • Shift 拖拽时,固定对角、对边不动,沿水平垂直方向等比例缩放
  • 组合按下

效果如下,来自:Resize, rotate, and flip objects in FigJam

Resizing in FigJam

先来看自由改变大小如何实现。以左上角锚点为例,拖拽时右下角锚点是固定不动的:

ts
private handleSelectedResizing(
    api: API,
    canvasX: number,
    canvasY: number,
    anchorName: AnchorName,
) {
    const { x, y } = api.canvas2Transformer({
      x: canvasX,
      y: canvasY,
    });
    if (anchorName === AnchorName.TOP_LEFT) {
        // 设置左上角锚点位置
        Object.assign(tlAnchor.write(Circle), {
            cx: x,
            cy: y,
        });
    }
    // 省略其他锚点处理逻辑
    {
        const { cx: tlCx, cy: tlCy } = tlAnchor.read(Circle);
        const { cx: brCx, cy: brCy } = brAnchor.read(Circle);
        const width = brCx - tlCx;
        const height = brCy - tlCy;
        const { x, y } = api.transformer2Canvas({ x: tlCx, y: tlCy });
        // 重新计算被选中图形位置和尺寸
        this.fitSelected(api, {
            x,
            y,
            width,
            height,
            rotation: this.#rotation,
        });
    }
}

最后根据左上和右下两个锚点,对选中图形重新进行变换操作。

变换图形

现在我们知道了发生 resize 前后的属性(变换和尺寸信息)。

锁定长宽比

仍然以拖拽左上角锚点为例,锁定长宽比时就不能直接设置它的位置,需要在固定住右下角锚点位置不变的情况下,根据拖拽开始时图形的长宽比重新计算左上角锚点的位置。

首先记录拖拽锚点开始时选中图形的 OBB 和长宽比,等价于对角线的斜率:

ts
if (input.pointerDownTrigger) {
    if (type === UIType.TRANSFORMER_ANCHOR) {
        this.#obb = this.getSelectedOBB();
        const { width, height } = this.#obb;
        const hypotenuse = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2));
        this.#sin = Math.abs(height / hypotenuse);
        this.#cos = Math.abs(width / hypotenuse);
    }
}

拖拽过程中:

  1. 固定右下角锚点位置不变
  2. 计算此时从左上到右下的对角线距离
  3. 根据之前保存的长宽比,重新计算左上角锚点位置
ts
if (lockAspectRatio) {
    // 1.
    const comparePoint = {
        x: brAnchor.read(Circle).cx,
        y: brAnchor.read(Circle).cy,
    };
    // 2.
    newHypotenuse = Math.sqrt(
        Math.pow(comparePoint.x - x, 2) + Math.pow(comparePoint.y - y, 2),
    );
    const { cx, cy } = tlAnchor.read(Circle);
    const reverseX = cx > comparePoint.x ? -1 : 1;
    const reverseY = cy > comparePoint.y ? -1 : 1;
    // 3.
    Object.assign(tlAnchor.write(Circle), {
        cx: comparePoint.x - newHypotenuse * this.#cos * reverseX,
        cy: comparePoint.y - newHypotenuse * this.#sin * reverseY,
    });
}

在拖拽过程中可以实时展示出对角线,给用户明显的提示(一般是虚线)。

中心缩放

还是以左上角锚点为例,此时固定参考点从右下角锚点改成几何中心点,同样是在拖拽行为开始时记录:

ts
const comparePoint = centeredScaling
    ? {
          x: this.#obb.width / 2, 
          y: this.#obb.height / 2, 
      }
    : {
          x: brAnchor.read(Circle).cx,
          y: brAnchor.read(Circle).cy,
      };

然后重新计算右下角锚点位置,和左上角锚点对称于中心点:

ts
if (centeredScaling) {
    const tlOffsetX = tlAnchor.read(Circle).cx - prevTlAnchorX;
    const tlOffsetY = tlAnchor.read(Circle).cy - prevTlAnchorY;
    Object.assign(brAnchor.write(Circle), {
        cx: brAnchor.read(Circle).cx - tlOffsetX,
        cy: brAnchor.read(Circle).cy - tlOffsetY,
    });
}

翻转

当拖拽锚点或者边到反方向时,会出现翻转现象,下图为 Figma 中的效果,注意 Rotation 的变化:

Rotate 180 deg when flipped

旋转

Figma

Hover just outside one of the layer's bounds until the icon appears. Click and drag to rotate your selection: Drag clockwise to create a negative angle (towards -180° ). Drag counterclockwise to create a positive angle (towards 180° ) Hold down Shift to snap rotation values to increments of 15.

调整旋转中心

下图是 Figma Change the rotation origin 的效果:

Change the rotation origin

使用方向键移动图形

Figma 提供了 Nudge layers 特性,可以使用上下左右方向键移动图形,还可以配合 Shift 进行更大距离的移动。在我们的实现中就使用固定距离了:

ts
if (e.key === 'ArrowUp') {
    e.preventDefault();
    this.api.updateNodeOBB(selected, { y: selected.y - 10 });
    this.api.record();
}

扩展阅读

Released under the MIT License.