跳到主要内容

撤销重做命令栈

Canvas 没有 undo()。历史记录的是 文档操作,必要时才对某层位图做快照。把每次 getImageData 整张压栈,内存按帧数线性爆炸,而且和相机/DPR 缠死。

1. 命令

export interface Command {
readonly label: string;
apply(): void;
revert(): void;
}

export class History {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
private coalescing: Command | undefined;

execute(cmd: Command): void {
cmd.apply();
this.undoStack.push(cmd);
this.redoStack.length = 0;
}

/** 拖手柄期间把多次 translate 合成一条 */
coalesce(cmd: Command): void {
if (this.coalescing) {
this.coalescing.revert();
}
cmd.apply();
this.coalescing = cmd;
}

commitCoalesce(): void {
if (!this.coalescing) return;
this.undoStack.push(this.coalescing);
this.coalescing = undefined;
this.redoStack.length = 0;
}

undo(): void {
const cmd = this.undoStack.pop();
if (!cmd) return;
cmd.revert();
this.redoStack.push(cmd);
}

redo(): void {
const cmd = this.redoStack.pop();
if (!cmd) return;
cmd.apply();
this.undoStack.push(cmd);
}
}
interface Stroke {
id: string;
}

export class AddStrokeCommand implements Command {
readonly label = "stroke";
constructor(
private readonly doc: { strokes: Stroke[] },
private readonly stroke: Stroke,
) {}
apply(): void {
this.doc.strokes.push(this.stroke);
}
revert(): void {
const i = this.doc.strokes.findIndex((s) => s.id === this.stroke.id);
if (i >= 0) this.doc.strokes.splice(i, 1);
}
}

拖节点:pointerdown 记下矩阵,move 用 coalesce(new SetLocalCommand(node, next)),up 时 commitCoalesce()。这样撤销一步回到拖之前,而不是几十步。

2. 什么时候才快照像素

操作历史
加/删/改矢量节点命令
提交一笔墨迹命令(stroke 对象)
像素橡皮、涂抹、液化该层 ImageBitmap 前后各一份,或命令里存 region patch
滤镜应用到整图替换 source bitmap,命令持有 old/new 引用

patch 比整图便宜:只存脏 AABB 的 ImageData。仍要 willReadFrequently 工作表面。

上限:undo 50 步;超出丢栈底,并 oldBitmap.close()

3. 和协作

本地 undo 不能无脑改已广播的操作。OT/CRDT 下撤销是「再发一条逆操作」,不是弹本地栈。单机编辑器和多人白板不要共用一个 History 类当同步源。

4. 失败形态

症状原因
撤销一次回到空白快照的是 clear 之后的表面
拖一下 undo 50 次没 coalesce
内存涨到几百 MB全画布 ImageData × 步数 × DPR
redo 丢了execute 时没清 redo
协作对方乱跳把本地 history 当了文档

权威资料

  • 无 Canvas 专用 undo 规范。模式来自命令式编辑器;位图层参考 ImageData 管线

核对日期:2026-08-26