Fabric.js Document Engine

搜索文档

查找页面或章节

EN

崩溃、刷新、关掉标签页,未保存的内容都还在。

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

正在加载在线示例,代码在下方。

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

开启

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

启动时提供副本

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: 地址的图片刷新后就没了。副本会保存图片数据,恢复时重新生成地址。
  • 保存之后。 当一次保存覆盖了所有改动,副本会被删除。如果保存过程中标签页被关掉,副本会保留。

恢复是安全的

恢复的文档会被标记为未保存,并保留它当初基于的修订号。如果期间服务器上的版本已经更新,下一次保存会报保存冲突,而不是覆盖更新的内容。

加载时导致标签页崩溃

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

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

其他存储

createMemoryRecovery() 把副本放在内存里,适合测试。想从你自己的存储里恢复未保存的内容,实现 { get, set, delete, keys } 即可,另外可以加一个同步的 setNow,在页面关闭的那一刻使用。详见恢复 API。