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 我们放到翻转这一小节介绍。fig-file-parser

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

因此我们选择修改 SerializedNode 结构,让它尽可能描述多种图形,同时移除一些图形表示位置的属性,例如 Circle 的 cx/cy,通过 x/y 和 width/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]);
    return nodes[0];
});

对于 Polyline 和 Path 这种通过 point 和 d 属性定义的图形,我们无法删除这些属性,而需要计算出它们的 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]);
    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
// SVG angles are in degrees; both reflections are in the object's local axes.
const sx = scaleX < 0 ? -1 : 1;
const sy = scaleY < 0 ? -1 : 1;
const r = (rotation - cameraRotation) * RAD_TO_DEG;
const transform = `translate(16 16) rotate(${r}) scale(${sx} ${sy}) rotate(${tr}) translate(-16 -16)`;

翻转必须分别保留 X/Y 轴的符号。SVG 变换从右向左应用:先按锚点类型设置图标方向,再沿图形的局部轴翻转,最后转到视口方向。只判断行列式正负无法区分水平、垂直和双轴翻转;在旋转之后做翻转也会使光标指向错误。

而当鼠标进一步靠近锚点时,会从旋转变成 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 前后的属性(变换和尺寸信息),很容易计算出两个状态间的过渡矩阵:

plaintext
// @see https://github.com/konvajs/konva/blob/master/src/shapes/Transformer.ts#L1106

[delta transform] = [new transform] * [old transform inverted]
ts
const baseSize = 10000000;
const oldTr = mat3.create();
mat3.translate(oldTr, oldTr, [oldAttrs.x, oldAttrs.y]);
mat3.rotate(oldTr, oldTr, oldAttrs.rotation);
mat3.scale(oldTr, oldTr, [
    oldAttrs.width / baseSize,
    oldAttrs.height / baseSize,
]);
const newTr = mat3.create();
mat3.translate(newTr, newTr, [newAttrs.x, newAttrs.y]);
mat3.rotate(newTr, newTr, newAttrs.rotation);
mat3.scale(newTr, newTr, [
    newAttrs.width / baseSize,
    newAttrs.height / baseSize,
]);

const delta = mat3.multiply(newTr, newTr, mat3.invert(mat3.create(), oldTr));

但是我们并不能直接将这个矩阵应用到目标图形上,还需要考虑场景图中父节点在世界坐标系下的变换:

plaintext
[delta transform] * [parent transform] * [old local transform] = [parent transform] * [new local transform]

左乘父节点在世界坐标系下的变换的逆变换,可以得到目标图形局部坐标系下的变换:

plaintext
[new local] = [parent inverted] * [delta] * [parent] * [old local]

最后将新的局部坐标系下矩阵应用到目标图形上,不同类型的图形会作用于不同的属性。例如我们不希望对 Text 进行拉伸导致形变,而是通过修改 fontSize 实现缩放效果;如果是 Polyline / Path 则需要对每个控制点应用变换得到新的 points 或者 d 属性值。另外这样也可以确保描边不会被变换影响,毕竟每次都视作产生一个新的图形。

锁定长宽比 ​

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

首先记录拖拽锚点开始时选中图形的 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,
    });
}

值得一提的是在 Konva.js 中,并不支持拖拽四条边保持长宽比,此时需要禁止掉这四个锚点,详见:Is there a way to keep the image aspect ratio on transform?。

但我们可以很轻松地实现,以拖拽右侧边的锚点为例:

  1. 先设置右下锚点的 X 坐标
  2. 由于左侧锚点的 X 坐标不变,这样可以计算出变换后的宽度
  3. 由于长宽比被锁定,变换后的高度也就确定了,这样可以计算出变换后的高度
  4. 根据变换前后的高度差,设置左上和右下锚点的 Y 坐标
ts
if (anchorName === AnchorName.MIDDLE_RIGHT) {
    // 1.
    brAnchor.write(Circle).cx = x;

    if (lockAspectRatio) {
        // 2.
        const newWidth = brAnchor.read(Circle).cx - tlAnchor.read(Circle).cx;
        const tan = sin / cos;
        const newHeight = newWidth * tan; // 3.
        const deltaY = newHeight - (prevBrAnchorY - prevTlAnchorY);
        // 4.
        brAnchor.write(Circle).cy = brAnchor.read(Circle).cy + deltaY / 2;
        tlAnchor.write(Circle).cy = tlAnchor.read(Circle).cy - deltaY / 2;
    }
}

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

中心缩放 ​

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

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

我们用渐变背景来更清晰地展示这种翻转效果:

Flip a rect with gradient fill

默认关闭跨边翻转;本页开头的交互示例已开启,并用橙蓝渐变显示镜像方向:

ts
api.setAppState({ flipEnabled: true });

拖动期间应始终使用按下时保存的选框、节点变换和手柄身份。将指针转换到这个固定坐标系,以对角或对边为固定点(按住 Alt / Option 时以中心为固定点),计算带正负号的宽高。跨过固定点后负号代表镜像,不应交换手柄后提前取绝对值,否则会丢失翻转,或在下一帧让固定边漂移。恰好拖到零尺寸时使用很小的非零尺寸,下一次移动仍从原始选框计算。

应用变换后,图形的 width / height 保持为正数;rotation 和 scaleX / scaleY 一起来自同一次矩阵分解。不能把分解后的旋转与旧的缩放符号混用。路径、折线和矢量网络在更新局部几何时,还要移除已经保留在 Transform 中的旋转与翻转,避免镜像两次。

同一个镜像矩阵可以有不同的旋转和缩放分解。因此应检查图形及渐变的镜像方向、固定边和手柄位置,而不是只判断 Rotation 是否变化了 180°。Shift 保持宽高比,Alt / Option 保持中心;松手后的选框、下一次拖动和撤销/重做也应保持一致。

旋转 ​

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.

  1. 指针按下时保存 OBB、局部枢轴和世界枢轴;未自定义枢轴时使用几何中心。
  2. 在画布坐标下记录上一个指针角度,累积相邻采样点的 atan2 差值,并用 atan2(sin, cos) 归一化,避免跨过 ±π 时突变。
  3. 使用固定的枢轴和初始 OBB 求解新的原点与旋转角,随后由 Select.fitSelected 更新节点。多选旋转时,选框也使用这份初始 OBB 的旋转结果;松手后再恢复为图形的轴对齐包围框。

ECS 的 systems/select/rotate-gesture.ts 负责手势开始、采样、结束及状态清理,Select 保留输入分发、节点更新和历史记录。resize-gesture.ts 则负责缩放约束、吸附、端点拖拽和触摸偏移。

ts
// Pointer down, after converting to canvas coordinates and pixel snapping:
beginRotateGesture(api, selection, { x: canvasX, y: canvasY });

// Pointer move:
updateRotateGesture(api, selection, { x: canvasX, y: canvasY }, (obb) => {
    this.fitSelected(api, obb, selection);
});

// Pointer up; Select records the resulting document once afterwards:
finishRotateGesture(api, selection);

调整旋转中心 ​

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

Change the rotation origin

实现要点如下:

  1. 在 transformer 的 mask 上新增中心锚点(AnchorName.CENTER),并支持拖拽。
  2. 将自定义旋转中心保存为 transformer 局部坐标(rotatePivotX/Y),用 rotatePivotPinned 标记是否由用户显式设置。
  3. 拖拽中心锚点时,把画布坐标转换到 transformer 局部坐标:
ts
const { x, y } = api.canvas2Transformer({ x: canvasX, y: canvasY }, mask);
tf.rotatePivotX = x;
tf.rotatePivotY = y;
tf.rotatePivotPinned = true;
  1. 旋转时优先使用该 pivot(未设置时回退几何中心),并在求解新 OBB 原点时保持该 pivot 的世界坐标不变:
ts
const {
    obb,
    pivotWorld: [px, py],
    pivotLocal: [lx, ly],
} = gesture;
const rotation = obb.rotation + gesture.accumulated;
const c = Math.cos(rotation);
const s = Math.sin(rotation);
const newAttrs = {
    ...obb,
    x: px - lx * obb.scaleX * c + ly * obb.scaleY * s,
    y: py - lx * obb.scaleX * s - ly * obb.scaleY * c,
    rotation,
};
  1. 当选中对象集合变化时,将 pivot 重置回几何中心。

使用方向键移动图形 ​

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

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

多选对齐与等间距分布 ​

ECS API 提供 left、right、top、bottom、centerH、centerV 六种对齐方式, 依据世界坐标系中的几何包围盒计算,不包含描边厚度。操作只修改位置:旋转、翻转、 尺寸、路径数据、文本度量和 Flex 尺寸偏好保持不变,也适用于带旋转或缩放的父级。

ts
const ids = [...api.getAppState().layersSelected];
await api.edit((editor) => {
    editor.alignSelectedNodes('left', ids);
});
// 分成两次编辑,让派生几何在操作之间得到更新。
await api.edit((editor) => {
    editor.distributeSelectedNodesSpacing('horizontal', ids);
});

分布方向支持 horizontal 和 vertical,沿世界坐标轴排序后,按选区范围计算相邻 几何边界的相等间距;图形重叠时允许负间距。重复 ID、已删除、不存在或锁定的节点, 以及父变换不可逆的节点会被跳过。父子同时选中时,子节点随父级移动,不会再移动一次; 至少需要两个有效的顶层选中节点。方法返回是否发生移动,布局未变化时方法自身不记录历史。

属性面板通过 api.edit() 提交操作,保留点击时的画布和节点 ID,在写入阶段读取最新节点, 一次操作对应一步撤销。排队期间删除或锁定节点导致无变化时,会取消本次编辑,不把其他 尚未提交的修改写入历史。直接使用 API 时,也可向 edit() 传入 AbortSignal,在修改前取消。

其他图形的 Transformer ​

目前我们实现的 transformer 适合圆形、椭圆、矩形这样的图形,但对于直线、折线以及分段的曲线并不合适,后者需要更多控制点。

直线 ​

直线仅需要两个锚点,我们之前介绍的基于矩形包围盒的一系列处理都可以大幅简化。

ts
export class Transformable {
    @field.ref declare lineMask: Entity;
    @field.ref declare x1y1Anchor: Entity;
    @field.ref declare x2y2Anchor: Entity;
}
Transformer for line

折线 ​

对于折线,每个连接点都有一个对应的控制点,另外每一组相邻控制点中间再额外增加一个点,用于生成新的线段:

ts
export class Transformable {
    @field.ref declare polylineMask: Entity;
    @field.object declare controlPoints: Entity[];
    @field.object declare segmentMidpoints: Entity[];
}

与之配套的交互是,悬停在控制点上,可以通过 Delete 删除。为了兼顾通过矩形 Transformer resize 和旋转的能力,我们增加双击进入控制点编辑模式的交互。

Path ​

tldraw 提供了一个例子:cubic-bezier-shape。对于交互编辑来说,需要解决两个问题:

  1. 稳定的可编辑点:每个锚点、每个贝塞尔控制点都能被选中、拖拽,且拖拽后写回 d。
  2. 写回不丢语义:尽量用统一、可预测的命令序列,避免在编辑器里手写解析器。

因此我们需要先把 d 归一成少量命令类型(例如相对命令转换成绝对命令),再用「命令下标 + 坐标在数组里的偏移」当作 handle 的身份证,拖拽时只改数字,最后用 path2String 再序列化回字符串。

命令产生的 handlemeta 含义
M / L1 个点(顶点)coordOffset: 1 → 该点 (x,y)
Q控制点 + 终点offset 1 与 3
Ccp1、cp2、终点offset 1、3、5
A仅终点offset 6(弧本身不提供可拖拽的「贝塞尔柄」,只暴露终点)
ts
interface PathControlHandleMeta {
    commandIndex: number; // 归一化后命令数组里的下标。
    coordOffset: number; // 该 handle 对应坐标的起始下标(从 1 开始,因为 command[0] 是命令字)。
}

type HandlePoint = {
    x: number;
    y: number;
    meta: PathControlHandleMeta;
};

同样,双击可以进入控制点编辑模式。

扩展阅读 ​

Released under the MIT License.