Fabric.js Document Engine

Search documentation

Find a page or section

中文

Unsaved work survives crashes, refreshes and closed tabs.

Autosave waits for a pause, and a save can fail. Recovery covers the gap: the engine keeps a local copy of unsaved work, so you can recover unsaved canvas work after a crash, a refresh or a closed tab.

Loading the live demo. Its code is below.

recovery-demo.tsx
"use client";
 
import type { RecoveryRecord } from "fabricjs-document-engine";
import {
  useDocumentEngine,
  useDocumentState,
} from "fabricjs-document-engine/react";
import { createMemoryRecovery } from "fabricjs-document-engine/recovery";
import { createMemoryStorage } from "fabricjs-document-engine/storage";
import { CircleIcon, SquareIcon, ZapIcon } from "lucide-react";
import { useFormatter, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
 
import {
  DemoCanvas,
  DemoFrame,
  newShape,
  seedShapes,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";
 
// In your app, use `createIndexedDbRecovery()` so copies survive a reload.
// Memory keeps this demo self-contained.
const storage = createMemoryStorage();
const recovery = { store: createMemoryRecovery(), interval: 500 };
 
function Editor({ fresh, onCrash }: { fresh: boolean; onCrash: () => void }) {
  const t = useTranslations("demos");
  const format = useFormatter();
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas, {
    storage,
    recovery,
    document: { id: "recovery-demo" },
  });
  const state = useDocumentState(engine);
  const [copy, setCopy] = useState<RecoveryRecord>();
 
  // The first session starts with a drawing that was never saved. After a
  // crash the editor opens empty and looks for the work it lost.
  useEffect(() => {
    if (!engine) return;
    if (fresh) engine.canvas.add(...seedShapes());
    engine.getRecoverableDocuments().then(([latest]) => setCopy(latest));
  }, [engine, fresh]);
 
  async function restore() {
    await engine?.restoreRecovery(copy?.documentId);
    setCopy(undefined);
  }
 
  async function discard() {
    await engine?.discardRecovery(copy?.documentId);
    setCopy(undefined);
  }
 
  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")}>
        {copy && (
          <div
            role="alert"
            className="absolute inset-x-2 bottom-2 flex items-center gap-2 rounded-md border bg-background/95 p-1.5 pl-3 text-[13px] shadow-sm backdrop-blur"
          >
            <span className="truncate">
              {t("recovery.found", {
                time: format.dateTime(new Date(copy.savedAt), {
                  timeStyle: "medium",
                }),
              })}
            </span>
            <button
              type="button"
              className="ml-auto h-7 shrink-0 rounded-md px-2 font-medium hover:bg-muted"
              onClick={() => void discard()}
            >
              {t("recovery.discard")}
            </button>
            <button
              type="button"
              className="h-7 shrink-0 rounded-md bg-foreground px-2.5 font-medium text-background hover:bg-foreground/85"
              onClick={() => void restore()}
            >
              {t("recovery.restore")}
            </button>
          </div>
        )}
      </DemoCanvas>
 
      <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={ZapIcon}
          label={t("recovery.crash")}
          className="ml-auto"
          disabled={!state?.isDirty}
          onClick={onCrash}
        />
      </Toolbar>
 
      <Status tone={copy ? "error" : state?.isDirty ? "busy" : "ok"}>
        {copy
          ? t("recovery.crashed")
          : state?.isDirty
            ? t("recovery.unsaved")
            : t("recovery.saved")}
      </Status>
    </DemoFrame>
  );
}
 
export default function RecoveryDemo() {
  // A new key throws the editor away without saving, like a closed tab.
  const [session, setSession] = useState(0);
 
  return (
    <Editor
      key={session}
      fresh={session === 0}
      onCrash={() => setSession((n) => n + 1)}
    />
  );
}

Turn it on

import { createIndexedDbRecovery } from "fabricjs-document-engine/recovery";
 
const engine = createDocumentEngine({
  canvas,
  storage,
  recovery: { store: createIndexedDbRecovery(), interval: 2000 },
});

Offer the copy on start

const [latest] = await engine.getRecoverableDocuments();
 
if (
  latest &&
  confirm(
    `Restore unsaved work from ${new Date(latest.savedAt).toLocaleString()}?`
  )
) {
  await engine.restoreRecovery(latest.documentId);
} else if (latest) {
  await engine.discardRecovery(latest.documentId);
}

How copies are written

  • While editing. With unsaved changes, a copy is written to IndexedDB at most once every interval milliseconds. Nothing is written while the document is saved.
  • On close or refresh. Browsers do not let IndexedDB finish while a page unloads. So when the tab is hidden or closed, the engine also writes an instant copy to localStorage. The newest copy wins.
  • Tab-only images. Images with blob: URLs vanish on refresh. Copies keep the image data, and restoring creates new URLs for it.
  • After a save. When a save covers every change, the copy is removed. If the tab closes during a save, the copy stays.

Restoring is safe

The restored document is marked as unsaved and keeps the revision it was based on. If the server moved on in the meantime, the next save reports a save conflict instead of overwriting newer work.

A load that crashes the tab

If a document crashes the tab while it loads, the next start could crash again. The engine keeps a marker during each load:

const interrupted = await engine.getInterruptedLoad();
if (interrupted) {
  // Ask before opening interrupted.documentId again.
}

Other stores

createMemoryRecovery() keeps copies in memory, which is useful in tests. To recover unsaved canvas work from your own storage, implement { get, set, delete, keys }, plus an optional synchronous setNow for the moment the page closes. See the recovery API.