Fabric.js 本身没有撤销功能。用这个包,创建引擎后 Fabric.js 撤销重做就已经开启。每个用户操作是一步,你只要调用 undo() 和 redo(),就能回到上一步或下一步。
正在加载在线示例,代码在下方。
"use client";
import { bindKeyboardShortcuts } from "fabricjs-document-engine";
import {
useDocumentEngine,
useDocumentState,
} from "fabricjs-document-engine/react";
import {
CircleIcon,
PaletteIcon,
Redo2Icon,
SquareIcon,
Undo2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";
import {
COLORS,
DemoCanvas,
DemoFrame,
newShape,
seedShapes,
Status,
Toolbar,
ToolButton,
} from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";
export default function UndoRedoDemo() {
const t = useTranslations("demos");
const { elementRef, canvas } = useFabricCanvas();
const engine = useDocumentEngine(canvas);
const state = useDocumentState(engine);
const demoRef = useRef<HTMLDivElement>(null);
// Start from a small drawing, with an empty history.
useEffect(() => {
if (!engine) return;
engine.canvas.add(...seedShapes());
engine.clearHistory();
}, [engine]);
// Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z, while focus is inside this demo.
useEffect(() => {
if (!engine || !demoRef.current) return;
return bindKeyboardShortcuts(engine, { target: demoRef.current });
}, [engine]);
function recolor() {
const target = canvas?.getActiveObject() ?? canvas?.getObjects().at(-1);
if (!engine || !target) return;
const next =
COLORS[(COLORS.indexOf(String(target.fill)) + 1) % COLORS.length];
// Fabric fires no event for `set`, so record it as one labelled step.
engine.transaction("Recolor", () => target.set("fill", next));
canvas?.requestRenderAll();
}
return (
<DemoFrame ref={demoRef} tabIndex={-1}>
<DemoCanvas elementRef={elementRef} label={t("canvas")} />
<Toolbar>
<ToolButton
icon={SquareIcon}
label={t("square")}
onClick={() => canvas?.add(newShape("square"))}
/>
<ToolButton
icon={CircleIcon}
label={t("circle")}
onClick={() => canvas?.add(newShape("circle"))}
/>
<ToolButton icon={PaletteIcon} label={t("recolor")} onClick={recolor} />
<span className="ml-auto flex gap-1.5">
<ToolButton
icon={Undo2Icon}
label={t("undo")}
iconOnly
disabled={!state?.canUndo}
onClick={() => void engine?.undo()}
/>
<ToolButton
icon={Redo2Icon}
label={t("redo")}
iconOnly
disabled={!state?.canRedo}
onClick={() => void engine?.redo()}
/>
</span>
</Toolbar>
<Status tone={state?.canUndo ? "ok" : "idle"}>
{state?.canUndo
? t("undoRedo.steps", {
count: engine?.getHistory().undo.length ?? 0,
})
: t("undoRedo.empty")}
</Status>
</DemoFrame>
);
}自动记录的内容
引擎监听 Fabric.js 事件,自动记录:
- 添加和删除对象(同一时刻的多个改动合并为一步);
- 用鼠标移动、缩放和旋转(Fabric 的
object:modified); - 完成的文本编辑。
每一步都有一个标签,比如 Add rect 或 Move circle,可以显示在按钮上。
await engine.undo();
await engine.redo();
engine.canUndo();
engine.getHistory(); // { undo: ["Move circle", "Add rect"], redo: [] }调用会按顺序执行,快速按三次撤销,就会撤销三步。
代码做的改动
object.set("fill", "red") 或 canvas.bringObjectForward(object) 不会触发任何 Fabric.js 事件。把这类改动包在事务里,它们就成为带标签的一步:
engine.transaction("Arrange furniture", () => {
chair.set({ left: 120, top: 80 });
table.set("fill", "oak");
canvas.bringObjectToFront(table);
});或者先改,再调用 commit:
canvas.sendObjectBackwards(rug);
engine.commit("Send rug backwards");- 事务可以嵌套,使用最外层的标签。
- 事务可以是异步的:
await engine.transaction("Import", async () => { /* ... */ })。 - 编组和取消编组是对对象列表的改动。把移除和添加放在同一个事务里,就是一步撤销。
键盘快捷键
import { bindKeyboardShortcuts } from "fabricjs-document-engine";
const unbind = bindKeyboardShortcuts(engine);Ctrl+Z 或 Cmd+Z 撤销。Ctrl+Shift+Z、Cmd+Shift+Z 和 Ctrl+Y 重做。用户在输入框、文本域、contenteditable 元素或 Fabric.js 文本里打字时,快捷键会被忽略,原生的文本撤销照常工作。传入 { target: element } 可以只在某个元素上监听,而不是 window。
工具栏状态
engine.on("history:change", ({ canUndo, canRedo, undoLabel }) => {
undoButton.disabled = !canUndo;
redoButton.disabled = !canRedo;
undoButton.textContent = undoLabel ? `Undo ${undoLabel}` : "Undo";
});保存 id,而不是对象
撤销和重做会根据保存的状态重建改动过的对象。它们会以新的 Fabric.js 实例回来,id 不变。保存 id,再重新查找对象:
const id = shape.get("id") as string;
await engine.undo();
const current = engine.getObjectById(id);历史记录的大小
每一步只存改动过的对象,所以大图里的一步也很小。默认情况下 Fabric.js 撤销重做的历史记录保留 100 步,最多 64 MB,超出时先丢最旧的步骤:
createDocumentEngine({
canvas,
history: { limit: 50, maxBytes: 32 * 1024 * 1024 },
});撤销只覆盖本次会话里最近的编辑。想长期保留某个状态,请用版本。