Fabric.js has no undo built in. With this package, Fabric.js undo redo is on as soon as you create the engine. Every user action becomes one step, and you call undo() and redo().
Loading the live demo. Its code is below.
"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>
);
}What is recorded for you
The engine listens to Fabric.js events and records:
- adding and deleting objects (several changes in the same tick become one step),
- moving, resizing and rotating with the pointer (Fabric's
object:modified), - finished text editing.
Each step gets a label, such as Add rect or Move circle, that you can show on the button.
await engine.undo();
await engine.redo();
engine.canUndo();
engine.getHistory(); // { undo: ["Move circle", "Add rect"], redo: [] }Calls run in order, so pressing undo three times fast undoes three steps.
Changes your code makes
Fabric.js fires no event for object.set("fill", "red") or canvas.bringObjectForward(object). Wrap such changes in a transaction, so they become one labelled step:
engine.transaction("Arrange furniture", () => {
chair.set({ left: 120, top: 80 });
table.set("fill", "oak");
canvas.bringObjectToFront(table);
});Or make the change first and call commit:
canvas.sendObjectBackwards(rug);
engine.commit("Send rug backwards");- Transactions can be nested. The outermost label is used.
- They can be async:
await engine.transaction("Import", async () => { /* ... */ }). - Grouping and ungrouping are changes to the object list. Do the remove and the add in one transaction to get one undo step.
Keyboard shortcuts
import { bindKeyboardShortcuts } from "fabricjs-document-engine";
const unbind = bindKeyboardShortcuts(engine);Ctrl+Z or Cmd+Z undoes. Ctrl+Shift+Z, Cmd+Shift+Z and Ctrl+Y redo. The shortcuts are ignored while the user types in an input, a textarea, a contenteditable element or Fabric.js text, so native text undo keeps working there. Pass { target: element } to listen on one element instead of window.
Toolbar state
engine.on("history:change", ({ canUndo, canRedo, undoLabel }) => {
undoButton.disabled = !canUndo;
redoButton.disabled = !canRedo;
undoButton.textContent = undoLabel ? `Undo ${undoLabel}` : "Undo";
});Keep ids, not objects
Undo and redo rebuild the changed objects from their saved state. They come back as new Fabric.js instances with the same ids. Keep the id, and look the object up again:
const id = shape.get("id") as string;
await engine.undo();
const current = engine.getObjectById(id);Fabric.js history size
Each step stores only the objects that changed, so a step stays small in large drawings. By default the Fabric.js history keeps 100 steps and at most 64 MB. The oldest steps are dropped first:
createDocumentEngine({
canvas,
history: { limit: 50, maxBytes: 32 * 1024 * 1024 },
});Fabric.js undo redo covers the recent edits of this session. To keep states for later, use versions.