# Versions

> Add canvas version history to your editor: keep named versions, save automatic ones every few saves, and restore a version without losing any work.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/versions
Last updated: 2026-09-28

Undo covers the last few minutes. Canvas version history keeps chosen states for days or months, such as "Sent to client". Versions are full copies of the document, kept in your storage adapter.

```tsx
"use client";

import type { VersionSummary } from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { createMemoryStorage } from "fabricjs-document-engine/storage";
import { BookmarkPlusIcon, CircleIcon, SquareIcon } from "lucide-react";
import { useFormatter, useTranslations } from "next-intl";
import { useEffect, useState } from "react";

import {
  DemoCanvas,
  DemoFrame,
  newShape,
  seedShapes,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";

const storage = createMemoryStorage();

export default function VersionsDemo() {
  const t = useTranslations("demos");
  const format = useFormatter();
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas, { storage });
  const [versions, setVersions] = useState<VersionSummary[]>([]);

  useEffect(() => {
    if (!engine) return;
    engine.canvas.add(...seedShapes());
    engine.clearHistory();
  }, [engine]);

  async function keepVersion() {
    if (!engine) return;
    await engine.save();
    await engine.createVersion(
      t("versions.name", { number: versions.length + 1 })
    );
    setVersions(await namedVersions());
  }

  async function restore(id: string) {
    if (!engine) return;
    await engine.restoreVersion(id);
    await engine.save();
    setVersions(await namedVersions());
  }

  // The engine also keeps automatic versions; this list shows the named ones.
  async function namedVersions() {
    const all = (await engine?.listVersions()) ?? [];
    return all.filter((version) => version.kind === "named");
  }

  return (
    <DemoFrame>
      <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={BookmarkPlusIcon}
          label={t("versions.keep")}
          primary
          disabled={!engine}
          className="ml-auto"
          onClick={() => void keepVersion()}
        />
      </Toolbar>

      {/* A fixed height: the list scrolls instead of growing the card. */}
      <ul className="h-[92px] overflow-y-auto rounded-lg border text-[13px]">
        {versions.length === 0 && (
          <li className="px-3 py-2 text-muted-foreground">
            {t("versions.empty")}
          </li>
        )}
        {versions.map((version) => (
          <li
            key={version.id}
            className="flex h-[30px] items-center gap-2 border-b px-3 last:border-b-0"
          >
            <span className="truncate font-medium">{version.name}</span>
            <span className="text-muted-foreground">
              {format.dateTime(new Date(version.createdAt), {
                timeStyle: "medium",
              })}
            </span>
            <button
              type="button"
              className="ml-auto shrink-0 font-medium text-[#6f00ff] hover:underline"
              onClick={() => void restore(version.id)}
            >
              {t("versions.restore")}
            </button>
          </li>
        ))}
      </ul>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

## Keep and list versions

```ts
const version = await engine.createVersion("Sent to client");
const versions = await engine.listVersions();
```

`listVersions` returns summaries, newest first: `{ id, documentId, name, kind, createdAt, revision }`. `kind` is `named` for a named version and `auto` for an automatic one.

## Restore a version

```ts
await engine.restoreVersion(version.id);
```

When you restore a version, nothing is lost:

1. The engine first keeps an automatic version named `Before restoring "..."`.
2. It then loads the old content as a new, unsaved revision of the same document.
3. The next save stores it as the newest revision, so the canvas version history stays in order.

To undo a restore, restore the automatic version.

## Automatic versions

```ts
createDocumentEngine({
  canvas,
  storage,
  versions: { autoEvery: 10, keepAuto: 20 },
});
```

This keeps an automatic version after every 10 successful saves, and only the newest 20 automatic ones. A named version is never pruned.

## Delete a version

```ts
await engine.deleteVersion(version.id);
```

## Storage support

The built-in adapters support versions. A custom adapter adds four methods: `saveVersion`, `listVersions`, `loadVersion` and `deleteVersion`. Without them, version calls fail with `VERSIONS_UNSUPPORTED`. See [custom backend](https://fabricjs-document-engine.jscrate.dev/docs/storage/custom-backend) to add canvas version history to your own API.
