# 恢复

> 崩溃或刷新后恢复未保存的内容。用户编辑时引擎会把改动复制到 IndexedDB，下次打开时交还给用户。

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

自动保存要等用户停下来，保存也可能失败。恢复功能补上了这个空档：引擎会在本地保留一份未保存内容的副本，所以崩溃、刷新或关掉标签页之后，你都能恢复未保存的内容。

```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)}
    />
  );
}
```

```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>
  );
}
```

## 开启

```ts
import { createIndexedDbRecovery } from "fabricjs-document-engine/recovery";

const engine = createDocumentEngine({
  canvas,
  storage,
  recovery: { store: createIndexedDbRecovery(), interval: 2000 },
});
```

## 启动时提供副本

```ts
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);
}
```

## 副本怎么写入

- **编辑时。** 有未保存的改动时，每隔最多 `interval` 毫秒向 IndexedDB 写一次副本。文档已保存时不会写。
- **关闭或刷新时。** 页面卸载时浏览器不会等 IndexedDB 写完。所以标签页被隐藏或关闭时，引擎还会立刻向 localStorage 写一份副本，读取时以最新的为准。
- **只在当前标签页的图片。** `blob:` 地址的图片刷新后就没了。副本会保存图片数据，恢复时重新生成地址。
- **保存之后。** 当一次保存覆盖了所有改动，副本会被删除。如果保存过程中标签页被关掉，副本会保留。

## 恢复是安全的

恢复的文档会被标记为未保存，并保留它当初基于的修订号。如果期间服务器上的版本已经更新，下一次保存会报[保存冲突](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/save-conflicts)，而不是覆盖更新的内容。

## 加载时导致标签页崩溃

如果某份文档在加载时让标签页崩溃，下次启动可能又会崩溃。引擎在每次加载时都会留一个标记：

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

## 其他存储

`createMemoryRecovery()` 把副本放在内存里，适合测试。想从你自己的存储里恢复未保存的内容，实现 `{ get, set, delete, keys }` 即可，另外可以加一个同步的 `setNow`，在页面关闭的那一刻使用。详见[恢复 API](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/recovery-api)。
