自动保存要等用户停下来,保存也可能失败。恢复功能补上了这个空档:引擎会在本地保留一份未保存内容的副本,所以崩溃、刷新或关掉标签页之后,你都能恢复未保存的内容。
正在加载在线示例,代码在下方。
"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。