> For the complete documentation index, see [llms.txt](https://408550179s-organization.gitbook.io/blog/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://408550179s-organization.gitbook.io/blog/hua-bu-bian-ji-qi-zhong-de-che-xiao-zhong-zuo-he-jian-tie-ban-chu-li.md).

# 画布编辑器中的撤销重做和剪贴板处理

画布编辑器一旦进入复杂场景，撤销重做就不能只保存整份 JSON。因为画布元素多了之后，全量快照会越来越大，性能和内存都会出问题。

比较稳的方式是：

* 给每个元素注入稳定 UUID。
* 每次操作后计算差异。
* 历史栈里存差异，而不是全量 JSON。
* 撤销/重做时反向应用差异。
* 输入法、拖拽、内部文本编辑需要单独处理。

## 历史项结构

一个差异项可以设计成这样：

```ts
interface DiffItem {
  uuid: string;
  type: "update" | "add" | "remove";
  oldProps?: Record<string, any>;
  newProps?: Record<string, any>;
  data?: any;
  parentId?: string;
  index?: number;
}
```

含义：

* `update`：保存变更前后的属性。
* `add`：保存新增元素完整数据和插入位置。
* `remove`：保存被删除元素完整数据和原位置。

这样撤销时可以反向执行：

```
update -> 用 oldProps 还原
add    -> 删除这个 uuid
remove -> 按 parentId/index 放回去
```

## 防抖保存

拖拽、输入、缩放这类操作会连续触发事件，不能每一帧都入栈。

```ts
this.debouncedSave = debounce(() => {
  const state = this.getCurrentState();

  if (state !== false) {
    this.pushDiff(state);
  }
}, 200);
```

防抖时间不宜太长，否则用户操作结束后撤销不及时；也不能太短，否则历史栈会很碎。

## 输入法要特殊处理

中文输入时，`input` 事件会在拼音组合过程中不断触发。如果这个时候保存历史，会出现一次输入被拆成很多步的问题。

```ts
private isIMEComposing = false;

private onCompositionStart = () => {
  this.isIMEComposing = true;
};

private onCompositionEnd = () => {
  this.isIMEComposing = false;

  const active = this.canvas?.getActiveObject?.();
  if ((active?.tag === "Text" || active?.tag === "HTMLText") && this.app.editor.innerEditing) {
    setTimeout(() => this.snapshotState(), 0);
  }
};

private onEditableInput = (event: InputEvent): void => {
  const target = event.target as HTMLElement | null;
  if (!target) return;

  const isTextEditor = target.id === "textInnerEditor" || target.isContentEditable;
  if (!isTextEditor) return;
  if (!this.app.editor.innerEditing) return;
  if (this.isIMEComposing) return;

  this.snapshotState();
};
```

这段处理可以让中文输入确认后再保存一次历史。

## 拦截撤销快捷键

如果用户正在输入框里按 `Cmd/Ctrl + Z`，浏览器默认会撤销输入框内容。画布编辑器里通常希望统一走编辑器自己的撤销逻辑。

```ts
private onKeyEvent = (event: KeyboardEvent): void => {
  const isMod = event.metaKey || event.ctrlKey;
  const key = (event.key || "").toLowerCase();

  const isUndo = isMod && key === "z" && !event.shiftKey;
  const isRedo = isMod && (key === "y" || (key === "z" && event.shiftKey));

  const target = event.target as HTMLElement | null;
  const isEditable =
    !!target &&
    (target.tagName === "INPUT" ||
      target.tagName === "TEXTAREA" ||
      target.isContentEditable);

  if (isEditable && (isUndo || isRedo)) {
    event.preventDefault();
    event.stopPropagation();

    if (isUndo) this.undo();
    else this.redo();
  }
};
```

## 剪贴板复制

画布元素复制时，不建议只复制当前对象的引用，要复制它的 JSON 数据。

```ts
private async copy() {
  const activeObject = this.canvas.getActiveObject();
  if (!activeObject) return;

  const cloneObj = activeObject.clone();
  const group = MEditorHelper.group([cloneObj], activeObject);
  const json = JSON.stringify(group.toJSON());

  await this.clipboard.writeText(json);
}
```

多选时可以把多个元素先组成一个 group，再写入剪贴板。

## 粘贴图片

设计器里用户经常会直接复制图片，然后在画布里粘贴。浏览器剪贴板里拿到的是 `Blob`，需要判断类型：

```ts
private paste() {
  this.clipboard.readBlob().then(async (blobs) => {
    if (!blobs) return;

    for (const blob of blobs) {
      if (blob.type.startsWith("image/")) {
        await this.handleImageBlob(blob);
      } else if (blob.type === "text/plain") {
        await this.handleTextPlainBlob(blob);
      } else {
        await this.handleJsonBlob(blob);
      }
    }
  });
}
```

图片粘贴时可以先转成 File，再走统一上传逻辑：

```ts
const file = new File([blob], "pasted-image.png", { type: blob.type });
```

## 粘贴位置

如果支持“粘贴到鼠标位置”，需要在 pointer move 时记录鼠标坐标：

```ts
canvas.app.tree.on(PointerEvent.MOVE, (event) => {
  this.pointer = new Point(event.x, event.y);
});
```

粘贴时再把全局点转换为当前画布内部坐标：

```ts
const { x, y } = this.pointer;
const pastePoint = this.canvas.contentFrame.getInnerPoint({ x, y });
```

## 总结

画布编辑器的撤销重做和剪贴板不是两个孤立功能，它们都依赖同一件事：元素数据必须可序列化、可定位、可恢复。

建议一开始就做好：

* 元素 UUID。
* 差异历史。
* 输入法状态。
* 快捷键拦截。
* JSON 剪贴板。
* 图片 Blob 粘贴。

这些基础能力做好之后，后面做多人协同、自动保存、版本恢复也会更容易。
