fabricjs-document-engine 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
11
11
  - **Safe saving.** It tracks unsaved changes and can autosave. Only one save runs at a time, so a slow older save can never overwrite newer work. Revision checks catch another tab or device saving the same document, and failed saves are retried with backoff.
12
12
  - **Your storage.** Plug in any backend with two functions, or use the built-in memory and localStorage adapters. No hosted service is needed.
13
13
  - **Assets and fonts.** Documents record the images and fonts they need. When a document is opened, every image and font is checked first. You get the exact list of what is missing, can offer replacements, and tab-only images are uploaded when you save.
14
+ - **Recovery.** Unsaved work is copied to IndexedDB while the user edits, and again at the moment the tab is closed or refreshed. After a crash or refresh you can offer to restore it, including images that only existed in the old tab.
14
15
  - **Reliable undo and redo.** One user action is one undo step. Transactions group several code changes into one labelled step, and ids survive undo and redo.
15
16
  - **Fabric 6 and 7.** Every release is tested against both.
16
17
 
@@ -175,6 +176,34 @@ await engine.replaceImage('/old-logo.png', '/new-logo.png');
175
176
 
176
177
  `replaceImage` swaps every image that uses a URL. Each image keeps its size on the page, and the change is one undo step. `engine.getAssetManifest()` returns the manifest for the current canvas.
177
178
 
179
+ ## Recovery
180
+
181
+ ```ts
182
+ import { createIndexedDbRecovery } from 'fabricjs-document-engine/recovery';
183
+
184
+ const engine = createDocumentEngine({
185
+ canvas,
186
+ storage,
187
+ recovery: { store: createIndexedDbRecovery(), interval: 2000 },
188
+ });
189
+
190
+ const [latest] = await engine.getRecoverableDocuments();
191
+ if (latest && confirm(`Restore unsaved work from ${new Date(latest.savedAt).toLocaleString()}?`)) {
192
+ await engine.restoreRecovery(latest.documentId);
193
+ } else if (latest) {
194
+ await engine.discardRecovery(latest.documentId);
195
+ }
196
+ ```
197
+
198
+ - **Checkpoints.** While there are unsaved changes, a copy is written at most once every `interval` ms (default 2000). Nothing is written while the document is saved.
199
+ - **Closing or refreshing.** Browsers do not let IndexedDB finish writing while a page unloads. So when the tab is hidden or closed, the engine also writes an immediate copy to localStorage. The newest copy wins when you read it back.
200
+ - **Tab-only images.** Images with `blob:` URLs vanish on refresh. Checkpoints keep the image data, and restoring creates fresh URLs for it.
201
+ - **After a save.** When a save covers every change, the copy is removed. If the tab closes during a save, the copy stays, so work is never lost between the edit and the server.
202
+ - **Restoring.** The restored document is marked as unsaved and keeps the revision it was based on. If the server moved on meanwhile, the next save reports `SAVE_CONFLICT` instead of overwriting newer work.
203
+ - **Interrupted loads.** A marker is kept while a document loads. If the tab crashes during loading, `engine.getInterruptedLoad()` returns `{ documentId, startedAt }` on the next start, so you can skip or discard that document instead of crashing again.
204
+ - `engine.flushRecovery()` writes a copy right now. `engine.getRecovery(id?)` reads one.
205
+ - `createMemoryRecovery()` keeps copies in memory, which is useful for tests. To use your own storage, implement `{ get, set, delete, keys }`, plus an optional synchronous `setNow` for the moment the page closes.
206
+
178
207
  ## Custom objects
179
208
 
180
209
  ```ts
@@ -282,6 +311,10 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
282
311
  | `engine.getAssetManifest()` | Lists the images and fonts used on the canvas. |
283
312
  | `engine.checkAssets()` | Resolves to `{ manifest, missingImages, unavailableFonts, warnings }` for the current canvas. |
284
313
  | `engine.replaceImage(oldUrl, newUrl)` | Replaces every image with that URL as one undo step. Resolves to the number of images replaced. |
314
+ | `engine.getRecoverableDocuments()` | Lists recovery copies, newest first. |
315
+ | `engine.restoreRecovery(id?)` / `engine.discardRecovery(id?)` | Loads or deletes a recovery copy. The default is the current document. |
316
+ | `engine.getRecovery(id?)` / `engine.flushRecovery()` | Reads a copy, or writes one now. |
317
+ | `engine.getInterruptedLoad()` | Returns the load that was running when the tab last crashed, if any. |
285
318
  | `engine.transaction(label, work)` | Runs `work` and records everything it changed as one undo step. Returns what `work` returns. |
286
319
  | `engine.commit(label?)` | Records changes made since the last step. Returns `false` when nothing changed. |
287
320
  | `engine.undo()` / `engine.redo()` | Resolves to `true` when a step was applied. Calls run one after another. |
@@ -289,7 +322,7 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
289
322
  | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
290
323
  | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
291
324
  | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
292
- | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `history:change` or `history:error`. Returns an unsubscribe function. |
325
+ | `engine.on(event, handler)` | Listens to `load:start`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `recovery:checkpoint`, `recovery:restored`, `recovery:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
293
326
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
294
327
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
295
328
 
@@ -312,6 +345,9 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
312
345
  | `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
313
346
  | `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
314
347
  | `HISTORY_FAILED` | Undo or redo could not rebuild an object, for example because an image is gone. The step is kept and the canvas is unchanged. |
348
+ | `RECOVERY_MISSING` | A recovery method was called without `recovery: { store }`. |
349
+ | `RECOVERY_NOT_FOUND` | There is no recovery copy for that document. |
350
+ | `RECOVERY_FAILED` | Writing a recovery copy failed, for example because storage is full. It is delivered as a `recovery:error` event and never interrupts editing. |
315
351
  | `STORAGE_MISSING` | `load` or `save` was called without a storage adapter. |
316
352
  | `INVALID_CUSTOM_OBJECT` | A registered class has no static `type`, or it does not extend a Fabric class. |
317
353
  | `ENGINE_DESTROYED` | The engine was used after `destroy()`. |
@@ -326,7 +362,7 @@ A failed load never clears or half-fills your canvas.
326
362
  | 2 | Undo and redo with transactions | 0.1.0 |
327
363
  | 3 | Safe saving: dirty state, autosave, stale-response protection | 0.2.0 |
328
364
  | 4 | Assets and fonts | 0.3.0 |
329
- | 5 | Recovery after a refresh or crash | |
365
+ | 5 | Recovery after a refresh or crash | 0.4.0 |
330
366
  | 6 | PNG, JPEG, SVG and JSON export with preflight checks | |
331
367
  | 7 | Named versions and schema migrations | |
332
368
  | 8 | React adapter and examples | |
@@ -172,3 +172,4 @@ async function prepareAssetsForSave(document, options, uploadedUrls) {
172
172
  exports.inspectAssets = inspectAssets;
173
173
  exports.prepareAssetsForLoad = prepareAssetsForLoad;
174
174
  exports.prepareAssetsForSave = prepareAssetsForSave;
175
+ exports.rewriteUrls = rewriteUrls;
@@ -169,4 +169,4 @@ async function prepareAssetsForSave(document, options, uploadedUrls) {
169
169
  };
170
170
  }
171
171
  //#endregion
172
- export { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave };
172
+ export { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave, rewriteUrls };
@@ -11,6 +11,7 @@ const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
11
11
  const require_object_ids = require("../fabric/object-ids.cjs");
12
12
  const require_object_registry = require("../fabric/object-registry.cjs");
13
13
  const require_create_history = require("../history/create-history.cjs");
14
+ const require_recovery_controller = require("../recovery/recovery-controller.cjs");
14
15
  const require_save_controller = require("../save/save-controller.cjs");
15
16
  const require_event_emitter = require("./event-emitter.cjs");
16
17
  //#region src/engine/create-document-engine.ts
@@ -70,7 +71,7 @@ function createDocumentEngine(options) {
70
71
  return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
71
72
  },
72
73
  onChange: (state) => events.emit("history:change", state),
73
- onContentChange: () => saving.noteContentChange()
74
+ onContentChange: () => noteContentChange()
74
75
  });
75
76
  const saving = require_save_controller.createSaveController({
76
77
  getStorage: () => requireStorage(),
@@ -83,10 +84,30 @@ function createDocumentEngine(options) {
83
84
  autosave: options.autosave === true ? {} : options.autosave ?? false,
84
85
  onStateChange: (state) => events.emit("save:status", state),
85
86
  onStart: (document) => events.emit("save:start", { document }),
86
- onSuccess: (document) => events.emit("save:success", { document }),
87
+ onSuccess: (document) => {
88
+ if (recovery && !saving.state().isDirty) recovery.remove(document.id);
89
+ events.emit("save:success", { document });
90
+ },
87
91
  onError: (error) => events.emit("save:error", { error }),
88
92
  onRetry: (event) => events.emit("save:retry", event)
89
93
  });
94
+ const recovery = options.recovery ? require_recovery_controller.createRecoveryController({
95
+ ...options.recovery,
96
+ createDocument: () => toDocument(),
97
+ shouldWrite: () => !destroyed && saving.state().isDirty,
98
+ onCheckpoint: (record) => events.emit("recovery:checkpoint", {
99
+ documentId: record.documentId,
100
+ savedAt: record.savedAt
101
+ }),
102
+ onError: (error) => {
103
+ const reason = error instanceof Error ? error.message : String(error);
104
+ events.emit("recovery:error", { error: new require_errors.DocumentEngineError("RECOVERY_FAILED", `Could not write the recovery copy: ${reason}`, { cause: error }) });
105
+ }
106
+ }) : void 0;
107
+ function noteContentChange() {
108
+ saving.noteContentChange();
109
+ recovery?.schedule();
110
+ }
90
111
  function toDocument() {
91
112
  ensureUsable();
92
113
  rebuildIndex();
@@ -135,8 +156,11 @@ function createDocumentEngine(options) {
135
156
  activeLoad?.abort();
136
157
  const controller = new AbortController();
137
158
  activeLoad = controller;
138
- events.emit("load:start", { documentId: describeDocumentId(input) });
159
+ const documentId = describeDocumentId(input);
160
+ events.emit("load:start", { documentId });
139
161
  try {
162
+ await recovery?.markLoadStarted(documentId);
163
+ if (controller.signal.aborted) throw new Error("aborted");
140
164
  const checked = checkDocument(input);
141
165
  const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal);
142
166
  if (controller.signal.aborted) throw new Error("aborted");
@@ -158,6 +182,7 @@ function createDocumentEngine(options) {
158
182
  };
159
183
  rebuildIndex();
160
184
  history.reset();
185
+ recovery?.cancel();
161
186
  saving.startSession(document.revision ?? 0);
162
187
  canvas.requestRenderAll();
163
188
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
@@ -171,7 +196,10 @@ function createDocumentEngine(options) {
171
196
  events.emit("load:error", { error: engineError });
172
197
  throw engineError;
173
198
  } finally {
174
- if (activeLoad === controller) activeLoad = null;
199
+ if (activeLoad === controller) {
200
+ activeLoad = null;
201
+ await recovery?.markLoadFinished();
202
+ }
175
203
  }
176
204
  }
177
205
  function requireStorage() {
@@ -208,6 +236,7 @@ function createDocumentEngine(options) {
208
236
  documentInfo = require_create_document.createDocumentInfo(newOptions);
209
237
  uploadedUrls.clear();
210
238
  history.reset();
239
+ recovery?.cancel();
211
240
  saving.startSession(0);
212
241
  }
213
242
  function getObjectById(id) {
@@ -241,6 +270,21 @@ function createDocumentEngine(options) {
241
270
  canvas.requestRenderAll();
242
271
  return images.length;
243
272
  }
273
+ function requireRecovery() {
274
+ ensureUsable();
275
+ if (!recovery) throw new require_errors.DocumentEngineError("RECOVERY_MISSING", "Pass recovery: { store } to createDocumentEngine to use recovery");
276
+ return recovery;
277
+ }
278
+ async function restoreRecovery(documentId, loadOptions) {
279
+ const source = requireRecovery();
280
+ const id = documentId ?? documentInfo.id;
281
+ const record = await source.read(id);
282
+ if (!record) throw new require_errors.DocumentEngineError("RECOVERY_NOT_FOUND", `There is no recovery copy for document "${id}"`);
283
+ const loaded = await loadDocument(await require_recovery_controller.restoreRecordedFiles(record), loadOptions);
284
+ noteContentChange();
285
+ events.emit("recovery:restored", { document: loaded });
286
+ return loaded;
287
+ }
244
288
  async function travelThroughHistory(direction) {
245
289
  ensureUsable();
246
290
  try {
@@ -258,6 +302,7 @@ function createDocumentEngine(options) {
258
302
  activeLoad?.abort();
259
303
  history.destroy();
260
304
  saving.destroy();
305
+ recovery?.destroy();
261
306
  canvas.off("object:added", handleObjectAdded);
262
307
  canvas.off("object:removed", handleObjectRemoved);
263
308
  objectsById.clear();
@@ -278,7 +323,7 @@ function createDocumentEngine(options) {
278
323
  ...changes
279
324
  }
280
325
  };
281
- saving.noteContentChange();
326
+ noteContentChange();
282
327
  },
283
328
  newDocument,
284
329
  toDocument,
@@ -298,6 +343,12 @@ function createDocumentEngine(options) {
298
343
  },
299
344
  checkAssets,
300
345
  replaceImage,
346
+ flushRecovery: async () => requireRecovery().flush(),
347
+ getRecoverableDocuments: async () => requireRecovery().list(),
348
+ getRecovery: async (documentId) => requireRecovery().read(documentId ?? documentInfo.id),
349
+ restoreRecovery,
350
+ discardRecovery: async (documentId) => requireRecovery().remove(documentId ?? documentInfo.id),
351
+ getInterruptedLoad: async () => requireRecovery().interruptedLoad(),
301
352
  transaction(label, work) {
302
353
  ensureUsable();
303
354
  return history.transaction(label, work);
@@ -4,6 +4,7 @@ import { AssetOptions, AssetReport, AssetWarning } from "../assets/asset-pipelin
4
4
  import { NewDocumentOptions } from "../document/create-document.cjs";
5
5
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
6
6
  import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
7
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.cjs";
7
8
  import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
8
9
  import { RetryOptions } from "../save/retry.cjs";
9
10
  import { DocumentEngineError } from "./errors.cjs";
@@ -21,6 +22,7 @@ export interface DocumentEngineOptions {
21
22
  autosave?: boolean | AutosaveOptions;
22
23
  saveRetry?: RetryOptions;
23
24
  assets?: AssetOptions;
25
+ recovery?: RecoveryOptions;
24
26
  }
25
27
  export interface LoadOptions {
26
28
  restoreCanvasSize?: boolean;
@@ -54,6 +56,16 @@ export interface DocumentEngineEvents {
54
56
  "history:error": {
55
57
  error: DocumentEngineError;
56
58
  };
59
+ "recovery:checkpoint": {
60
+ documentId: string;
61
+ savedAt: string;
62
+ };
63
+ "recovery:restored": {
64
+ document: FabricDocument;
65
+ };
66
+ "recovery:error": {
67
+ error: DocumentEngineError;
68
+ };
57
69
  }
58
70
  export interface DocumentEngine {
59
71
  readonly canvas: StaticCanvas;
@@ -82,6 +94,12 @@ export interface DocumentEngine {
82
94
  getAssetManifest(): AssetManifest;
83
95
  checkAssets(): Promise<AssetReport>;
84
96
  replaceImage(oldUrl: string, newUrl: string): Promise<number>;
97
+ flushRecovery(): Promise<void>;
98
+ getRecoverableDocuments(): Promise<RecoveryRecord[]>;
99
+ getRecovery(documentId?: string): Promise<RecoveryRecord | undefined>;
100
+ restoreRecovery(documentId?: string, options?: LoadOptions): Promise<FabricDocument>;
101
+ discardRecovery(documentId?: string): Promise<void>;
102
+ getInterruptedLoad(): Promise<InterruptedLoad | undefined>;
85
103
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
86
104
  destroy(): void;
87
105
  }
@@ -4,6 +4,7 @@ import { AssetOptions, AssetReport, AssetWarning } from "../assets/asset-pipelin
4
4
  import { NewDocumentOptions } from "../document/create-document.js";
5
5
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
6
6
  import { HistoryOptions, HistoryState } from "../history/create-history.js";
7
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.js";
7
8
  import { AutosaveOptions } from "../save/autosave-scheduler.js";
8
9
  import { RetryOptions } from "../save/retry.js";
9
10
  import { DocumentEngineError } from "./errors.js";
@@ -21,6 +22,7 @@ export interface DocumentEngineOptions {
21
22
  autosave?: boolean | AutosaveOptions;
22
23
  saveRetry?: RetryOptions;
23
24
  assets?: AssetOptions;
25
+ recovery?: RecoveryOptions;
24
26
  }
25
27
  export interface LoadOptions {
26
28
  restoreCanvasSize?: boolean;
@@ -54,6 +56,16 @@ export interface DocumentEngineEvents {
54
56
  "history:error": {
55
57
  error: DocumentEngineError;
56
58
  };
59
+ "recovery:checkpoint": {
60
+ documentId: string;
61
+ savedAt: string;
62
+ };
63
+ "recovery:restored": {
64
+ document: FabricDocument;
65
+ };
66
+ "recovery:error": {
67
+ error: DocumentEngineError;
68
+ };
57
69
  }
58
70
  export interface DocumentEngine {
59
71
  readonly canvas: StaticCanvas;
@@ -82,6 +94,12 @@ export interface DocumentEngine {
82
94
  getAssetManifest(): AssetManifest;
83
95
  checkAssets(): Promise<AssetReport>;
84
96
  replaceImage(oldUrl: string, newUrl: string): Promise<number>;
97
+ flushRecovery(): Promise<void>;
98
+ getRecoverableDocuments(): Promise<RecoveryRecord[]>;
99
+ getRecovery(documentId?: string): Promise<RecoveryRecord | undefined>;
100
+ restoreRecovery(documentId?: string, options?: LoadOptions): Promise<FabricDocument>;
101
+ discardRecovery(documentId?: string): Promise<void>;
102
+ getInterruptedLoad(): Promise<InterruptedLoad | undefined>;
85
103
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
86
104
  destroy(): void;
87
105
  }
@@ -11,6 +11,7 @@ import { loadIntoCanvas, serializeCanvas } from "../fabric/fabric-adapter.js";
11
11
  import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
12
12
  import { createObjectRegistry } from "../fabric/object-registry.js";
13
13
  import { createHistory } from "../history/create-history.js";
14
+ import { createRecoveryController, restoreRecordedFiles } from "../recovery/recovery-controller.js";
14
15
  import { createSaveController } from "../save/save-controller.js";
15
16
  import { createEventEmitter } from "./event-emitter.js";
16
17
  //#region src/engine/create-document-engine.ts
@@ -70,7 +71,7 @@ function createDocumentEngine(options) {
70
71
  return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
71
72
  },
72
73
  onChange: (state) => events.emit("history:change", state),
73
- onContentChange: () => saving.noteContentChange()
74
+ onContentChange: () => noteContentChange()
74
75
  });
75
76
  const saving = createSaveController({
76
77
  getStorage: () => requireStorage(),
@@ -83,10 +84,30 @@ function createDocumentEngine(options) {
83
84
  autosave: options.autosave === true ? {} : options.autosave ?? false,
84
85
  onStateChange: (state) => events.emit("save:status", state),
85
86
  onStart: (document) => events.emit("save:start", { document }),
86
- onSuccess: (document) => events.emit("save:success", { document }),
87
+ onSuccess: (document) => {
88
+ if (recovery && !saving.state().isDirty) recovery.remove(document.id);
89
+ events.emit("save:success", { document });
90
+ },
87
91
  onError: (error) => events.emit("save:error", { error }),
88
92
  onRetry: (event) => events.emit("save:retry", event)
89
93
  });
94
+ const recovery = options.recovery ? createRecoveryController({
95
+ ...options.recovery,
96
+ createDocument: () => toDocument(),
97
+ shouldWrite: () => !destroyed && saving.state().isDirty,
98
+ onCheckpoint: (record) => events.emit("recovery:checkpoint", {
99
+ documentId: record.documentId,
100
+ savedAt: record.savedAt
101
+ }),
102
+ onError: (error) => {
103
+ const reason = error instanceof Error ? error.message : String(error);
104
+ events.emit("recovery:error", { error: new DocumentEngineError("RECOVERY_FAILED", `Could not write the recovery copy: ${reason}`, { cause: error }) });
105
+ }
106
+ }) : void 0;
107
+ function noteContentChange() {
108
+ saving.noteContentChange();
109
+ recovery?.schedule();
110
+ }
90
111
  function toDocument() {
91
112
  ensureUsable();
92
113
  rebuildIndex();
@@ -135,8 +156,11 @@ function createDocumentEngine(options) {
135
156
  activeLoad?.abort();
136
157
  const controller = new AbortController();
137
158
  activeLoad = controller;
138
- events.emit("load:start", { documentId: describeDocumentId(input) });
159
+ const documentId = describeDocumentId(input);
160
+ events.emit("load:start", { documentId });
139
161
  try {
162
+ await recovery?.markLoadStarted(documentId);
163
+ if (controller.signal.aborted) throw new Error("aborted");
140
164
  const checked = checkDocument(input);
141
165
  const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
142
166
  if (controller.signal.aborted) throw new Error("aborted");
@@ -158,6 +182,7 @@ function createDocumentEngine(options) {
158
182
  };
159
183
  rebuildIndex();
160
184
  history.reset();
185
+ recovery?.cancel();
161
186
  saving.startSession(document.revision ?? 0);
162
187
  canvas.requestRenderAll();
163
188
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
@@ -171,7 +196,10 @@ function createDocumentEngine(options) {
171
196
  events.emit("load:error", { error: engineError });
172
197
  throw engineError;
173
198
  } finally {
174
- if (activeLoad === controller) activeLoad = null;
199
+ if (activeLoad === controller) {
200
+ activeLoad = null;
201
+ await recovery?.markLoadFinished();
202
+ }
175
203
  }
176
204
  }
177
205
  function requireStorage() {
@@ -208,6 +236,7 @@ function createDocumentEngine(options) {
208
236
  documentInfo = createDocumentInfo(newOptions);
209
237
  uploadedUrls.clear();
210
238
  history.reset();
239
+ recovery?.cancel();
211
240
  saving.startSession(0);
212
241
  }
213
242
  function getObjectById(id) {
@@ -241,6 +270,21 @@ function createDocumentEngine(options) {
241
270
  canvas.requestRenderAll();
242
271
  return images.length;
243
272
  }
273
+ function requireRecovery() {
274
+ ensureUsable();
275
+ if (!recovery) throw new DocumentEngineError("RECOVERY_MISSING", "Pass recovery: { store } to createDocumentEngine to use recovery");
276
+ return recovery;
277
+ }
278
+ async function restoreRecovery(documentId, loadOptions) {
279
+ const source = requireRecovery();
280
+ const id = documentId ?? documentInfo.id;
281
+ const record = await source.read(id);
282
+ if (!record) throw new DocumentEngineError("RECOVERY_NOT_FOUND", `There is no recovery copy for document "${id}"`);
283
+ const loaded = await loadDocument(await restoreRecordedFiles(record), loadOptions);
284
+ noteContentChange();
285
+ events.emit("recovery:restored", { document: loaded });
286
+ return loaded;
287
+ }
244
288
  async function travelThroughHistory(direction) {
245
289
  ensureUsable();
246
290
  try {
@@ -258,6 +302,7 @@ function createDocumentEngine(options) {
258
302
  activeLoad?.abort();
259
303
  history.destroy();
260
304
  saving.destroy();
305
+ recovery?.destroy();
261
306
  canvas.off("object:added", handleObjectAdded);
262
307
  canvas.off("object:removed", handleObjectRemoved);
263
308
  objectsById.clear();
@@ -278,7 +323,7 @@ function createDocumentEngine(options) {
278
323
  ...changes
279
324
  }
280
325
  };
281
- saving.noteContentChange();
326
+ noteContentChange();
282
327
  },
283
328
  newDocument,
284
329
  toDocument,
@@ -298,6 +343,12 @@ function createDocumentEngine(options) {
298
343
  },
299
344
  checkAssets,
300
345
  replaceImage,
346
+ flushRecovery: async () => requireRecovery().flush(),
347
+ getRecoverableDocuments: async () => requireRecovery().list(),
348
+ getRecovery: async (documentId) => requireRecovery().read(documentId ?? documentInfo.id),
349
+ restoreRecovery,
350
+ discardRecovery: async (documentId) => requireRecovery().remove(documentId ?? documentInfo.id),
351
+ getInterruptedLoad: async () => requireRecovery().interruptedLoad(),
301
352
  transaction(label, work) {
302
353
  ensureUsable();
303
354
  return history.transaction(label, work);
@@ -1,6 +1,6 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
2
2
  //#region src/engine/errors.d.ts
3
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "ENGINE_DESTROYED";
3
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "ENGINE_DESTROYED";
4
4
  export interface DocumentIssue {
5
5
  code: DocumentErrorCode;
6
6
  path: string;
@@ -1,6 +1,6 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.js";
2
2
  //#region src/engine/errors.d.ts
3
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "ENGINE_DESTROYED";
3
+ export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_FAILED" | "MISSING_ASSETS" | "MISSING_FONTS" | "ASSET_UPLOAD_FAILED" | "STORAGE_MISSING" | "SAVE_FAILED" | "SAVE_CONFLICT" | "SAVE_CANCELLED" | "DOCUMENT_NOT_FOUND" | "HISTORY_FAILED" | "RECOVERY_MISSING" | "RECOVERY_NOT_FOUND" | "RECOVERY_FAILED" | "ENGINE_DESTROYED";
4
4
  export interface DocumentIssue {
5
5
  code: DocumentErrorCode;
6
6
  path: string;
package/dist/index.d.cts CHANGED
@@ -5,6 +5,8 @@ import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadReques
5
5
  import { NewDocumentOptions } from "./document/create-document.cjs";
6
6
  import { CustomObjectDefinition } from "./fabric/object-registry.cjs";
7
7
  import { HistoryOptions, HistoryState } from "./history/create-history.cjs";
8
+ import { RecoveryStore } from "./recovery/recovery-store.cjs";
9
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "./recovery/recovery-controller.cjs";
8
10
  import { AutosaveOptions } from "./save/autosave-scheduler.cjs";
9
11
  import { RetryOptions } from "./save/retry.cjs";
10
12
  import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, DocumentIssue, createConflictError, isDocumentEngineError } from "./engine/errors.cjs";
@@ -15,4 +17,4 @@ import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOption
15
17
  import { validateDocument } from "./document/validate-document.cjs";
16
18
  import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.cjs";
17
19
  import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.cjs";
18
- export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
20
+ export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadReques
5
5
  import { NewDocumentOptions } from "./document/create-document.js";
6
6
  import { CustomObjectDefinition } from "./fabric/object-registry.js";
7
7
  import { HistoryOptions, HistoryState } from "./history/create-history.js";
8
+ import { RecoveryStore } from "./recovery/recovery-store.js";
9
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "./recovery/recovery-controller.js";
8
10
  import { AutosaveOptions } from "./save/autosave-scheduler.js";
9
11
  import { RetryOptions } from "./save/retry.js";
10
12
  import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, DocumentIssue, createConflictError, isDocumentEngineError } from "./engine/errors.js";
@@ -15,4 +17,4 @@ import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOption
15
17
  import { validateDocument } from "./document/validate-document.js";
16
18
  import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
17
19
  import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
18
- export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
20
+ export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type InterruptedLoad, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
@@ -0,0 +1,80 @@
1
+ //#region src/recovery/indexed-db-recovery.ts
2
+ function requestToPromise(request) {
3
+ return new Promise((resolve, reject) => {
4
+ request.onsuccess = () => resolve(request.result);
5
+ request.onerror = () => reject(request.error);
6
+ });
7
+ }
8
+ function browserStorage() {
9
+ try {
10
+ return globalThis.localStorage;
11
+ } catch {
12
+ return;
13
+ }
14
+ }
15
+ function createIndexedDbRecovery(options = {}) {
16
+ const databaseName = options.databaseName ?? "fabricjs-document-engine";
17
+ const storeName = options.storeName ?? "recovery";
18
+ let opening;
19
+ let database;
20
+ const emergencyPrefix = `${databaseName}/${storeName}:`;
21
+ const emergencyStorage = browserStorage();
22
+ function readEmergency(key) {
23
+ const json = emergencyStorage?.getItem(`${emergencyPrefix}${key}`);
24
+ return json === null || json === void 0 ? void 0 : JSON.parse(json);
25
+ }
26
+ function emergencyKeys() {
27
+ if (!emergencyStorage) return [];
28
+ return Array.from({ length: emergencyStorage.length }, (_, index) => emergencyStorage.key(index) ?? "").filter((key) => key.startsWith(emergencyPrefix)).map((key) => key.slice(emergencyPrefix.length));
29
+ }
30
+ function openDatabase() {
31
+ opening ?? (opening = new Promise((resolve, reject) => {
32
+ const request = indexedDB.open(databaseName, 1);
33
+ request.onupgradeneeded = () => {
34
+ if (!request.result.objectStoreNames.contains(storeName)) request.result.createObjectStore(storeName);
35
+ };
36
+ request.onsuccess = () => {
37
+ database = request.result;
38
+ resolve(request.result);
39
+ };
40
+ request.onerror = () => {
41
+ opening = void 0;
42
+ reject(request.error);
43
+ };
44
+ }));
45
+ return opening;
46
+ }
47
+ async function run(mode, work) {
48
+ const transaction = (database ?? await openDatabase()).transaction(storeName, mode);
49
+ const result = requestToPromise(work(transaction.objectStore(storeName)));
50
+ await new Promise((resolve, reject) => {
51
+ transaction.oncomplete = () => resolve();
52
+ transaction.onerror = () => reject(transaction.error);
53
+ transaction.onabort = () => reject(transaction.error);
54
+ });
55
+ return result;
56
+ }
57
+ return {
58
+ get: async (key) => await run("readonly", (store) => store.get(key)) ?? readEmergency(key),
59
+ set: async (key, value) => {
60
+ await run("readwrite", (store) => store.put(value, key));
61
+ },
62
+ setNow(key, value) {
63
+ try {
64
+ emergencyStorage?.setItem(`${emergencyPrefix}${key}`, JSON.stringify(value));
65
+ } catch {
66
+ return;
67
+ }
68
+ },
69
+ delete: async (key) => {
70
+ emergencyStorage?.removeItem(`${emergencyPrefix}${key}`);
71
+ await run("readwrite", (store) => store.delete(key));
72
+ },
73
+ keys: async () => {
74
+ const stored = (await run("readonly", (store) => store.getAllKeys())).map(String);
75
+ return [.../* @__PURE__ */ new Set([...stored, ...emergencyKeys()])];
76
+ }
77
+ };
78
+ }
79
+ //#endregion
80
+ exports.createIndexedDbRecovery = createIndexedDbRecovery;
@@ -0,0 +1,8 @@
1
+ import { RecoveryStore } from "./recovery-store.cjs";
2
+ //#region src/recovery/indexed-db-recovery.d.ts
3
+ export interface IndexedDbRecoveryOptions {
4
+ databaseName?: string;
5
+ storeName?: string;
6
+ }
7
+ export declare function createIndexedDbRecovery(options?: IndexedDbRecoveryOptions): RecoveryStore;
8
+ //#endregion
@@ -0,0 +1,8 @@
1
+ import { RecoveryStore } from "./recovery-store.js";
2
+ //#region src/recovery/indexed-db-recovery.d.ts
3
+ export interface IndexedDbRecoveryOptions {
4
+ databaseName?: string;
5
+ storeName?: string;
6
+ }
7
+ export declare function createIndexedDbRecovery(options?: IndexedDbRecoveryOptions): RecoveryStore;
8
+ //#endregion
@@ -0,0 +1,80 @@
1
+ //#region src/recovery/indexed-db-recovery.ts
2
+ function requestToPromise(request) {
3
+ return new Promise((resolve, reject) => {
4
+ request.onsuccess = () => resolve(request.result);
5
+ request.onerror = () => reject(request.error);
6
+ });
7
+ }
8
+ function browserStorage() {
9
+ try {
10
+ return globalThis.localStorage;
11
+ } catch {
12
+ return;
13
+ }
14
+ }
15
+ function createIndexedDbRecovery(options = {}) {
16
+ const databaseName = options.databaseName ?? "fabricjs-document-engine";
17
+ const storeName = options.storeName ?? "recovery";
18
+ let opening;
19
+ let database;
20
+ const emergencyPrefix = `${databaseName}/${storeName}:`;
21
+ const emergencyStorage = browserStorage();
22
+ function readEmergency(key) {
23
+ const json = emergencyStorage?.getItem(`${emergencyPrefix}${key}`);
24
+ return json === null || json === void 0 ? void 0 : JSON.parse(json);
25
+ }
26
+ function emergencyKeys() {
27
+ if (!emergencyStorage) return [];
28
+ return Array.from({ length: emergencyStorage.length }, (_, index) => emergencyStorage.key(index) ?? "").filter((key) => key.startsWith(emergencyPrefix)).map((key) => key.slice(emergencyPrefix.length));
29
+ }
30
+ function openDatabase() {
31
+ opening ?? (opening = new Promise((resolve, reject) => {
32
+ const request = indexedDB.open(databaseName, 1);
33
+ request.onupgradeneeded = () => {
34
+ if (!request.result.objectStoreNames.contains(storeName)) request.result.createObjectStore(storeName);
35
+ };
36
+ request.onsuccess = () => {
37
+ database = request.result;
38
+ resolve(request.result);
39
+ };
40
+ request.onerror = () => {
41
+ opening = void 0;
42
+ reject(request.error);
43
+ };
44
+ }));
45
+ return opening;
46
+ }
47
+ async function run(mode, work) {
48
+ const transaction = (database ?? await openDatabase()).transaction(storeName, mode);
49
+ const result = requestToPromise(work(transaction.objectStore(storeName)));
50
+ await new Promise((resolve, reject) => {
51
+ transaction.oncomplete = () => resolve();
52
+ transaction.onerror = () => reject(transaction.error);
53
+ transaction.onabort = () => reject(transaction.error);
54
+ });
55
+ return result;
56
+ }
57
+ return {
58
+ get: async (key) => await run("readonly", (store) => store.get(key)) ?? readEmergency(key),
59
+ set: async (key, value) => {
60
+ await run("readwrite", (store) => store.put(value, key));
61
+ },
62
+ setNow(key, value) {
63
+ try {
64
+ emergencyStorage?.setItem(`${emergencyPrefix}${key}`, JSON.stringify(value));
65
+ } catch {
66
+ return;
67
+ }
68
+ },
69
+ delete: async (key) => {
70
+ emergencyStorage?.removeItem(`${emergencyPrefix}${key}`);
71
+ await run("readwrite", (store) => store.delete(key));
72
+ },
73
+ keys: async () => {
74
+ const stored = (await run("readonly", (store) => store.getAllKeys())).map(String);
75
+ return [.../* @__PURE__ */ new Set([...stored, ...emergencyKeys()])];
76
+ }
77
+ };
78
+ }
79
+ //#endregion
80
+ export { createIndexedDbRecovery };
@@ -0,0 +1,159 @@
1
+ const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
2
+ //#region src/recovery/recovery-controller.ts
3
+ const documentKeyPrefix = "document:";
4
+ const unloadKeyPrefix = "unload:";
5
+ const loadingKey = "loading";
6
+ function documentKey(documentId) {
7
+ return `${documentKeyPrefix}${documentId}`;
8
+ }
9
+ function unloadKey(documentId) {
10
+ return `${unloadKeyPrefix}${documentId}`;
11
+ }
12
+ function newerRecord(checkpoint, unloadCopy) {
13
+ if (!unloadCopy) return checkpoint;
14
+ if (!checkpoint || unloadCopy.savedAt > checkpoint.savedAt) return {
15
+ ...unloadCopy,
16
+ files: {
17
+ ...checkpoint?.files,
18
+ ...unloadCopy.files
19
+ }
20
+ };
21
+ return checkpoint;
22
+ }
23
+ function isRecoveryRecord(value) {
24
+ const record = value;
25
+ return typeof record?.documentId === "string" && typeof record.document === "object" && record.document !== null;
26
+ }
27
+ async function restoreRecordedFiles(record) {
28
+ const document = JSON.parse(JSON.stringify(record.document));
29
+ const recreatedUrls = /* @__PURE__ */ new Map();
30
+ await require_asset_pipeline.rewriteUrls(document, async (url) => {
31
+ const blob = record.files[url];
32
+ if (!blob) return url;
33
+ const recreated = recreatedUrls.get(url) ?? URL.createObjectURL(blob);
34
+ recreatedUrls.set(url, recreated);
35
+ return recreated;
36
+ });
37
+ return document;
38
+ }
39
+ function createRecoveryController(options) {
40
+ const { store } = options;
41
+ const interval = options.interval ?? 2e3;
42
+ const capturedFiles = /* @__PURE__ */ new Map();
43
+ const readyFiles = /* @__PURE__ */ new Map();
44
+ let timer;
45
+ let queue = Promise.resolve();
46
+ let checkpointWaiting;
47
+ function enqueue(task) {
48
+ const run = queue.then(task).catch(options.onError);
49
+ queue = run;
50
+ return run;
51
+ }
52
+ async function captureTabOnlyFiles(document) {
53
+ const files = {};
54
+ await require_asset_pipeline.rewriteUrls(document, async (url) => {
55
+ if (!url.startsWith("blob:")) return url;
56
+ let capturing = capturedFiles.get(url);
57
+ if (!capturing) {
58
+ capturing = fetch(url).then((response) => response.blob());
59
+ capturedFiles.set(url, capturing);
60
+ capturing.then((blob) => readyFiles.set(url, blob), () => void 0);
61
+ capturing.catch(() => capturedFiles.delete(url));
62
+ }
63
+ files[url] = await capturing;
64
+ return url;
65
+ });
66
+ return files;
67
+ }
68
+ function createRecord(document, files) {
69
+ return {
70
+ documentId: document.id,
71
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
72
+ baseRevision: document.revision ?? 0,
73
+ document,
74
+ files
75
+ };
76
+ }
77
+ async function writeCheckpoint() {
78
+ checkpointWaiting = void 0;
79
+ if (!options.shouldWrite()) return;
80
+ const document = options.createDocument();
81
+ const record = createRecord(document, await captureTabOnlyFiles(document));
82
+ await store.set(documentKey(document.id), record);
83
+ options.onCheckpoint(record);
84
+ }
85
+ function writeCheckpointBeforeThePageCloses() {
86
+ cancel();
87
+ if (!options.shouldWrite()) return;
88
+ const document = options.createDocument();
89
+ if (store.setNow) {
90
+ store.setNow(unloadKey(document.id), createRecord(document, {}));
91
+ return;
92
+ }
93
+ const record = createRecord(document, Object.fromEntries(readyFiles));
94
+ store.set(documentKey(document.id), record).then(() => options.onCheckpoint(record), options.onError);
95
+ }
96
+ async function read(documentId) {
97
+ await queue;
98
+ const [checkpoint, unloadCopy] = await Promise.all([store.get(documentKey(documentId)), store.get(unloadKey(documentId))]);
99
+ return newerRecord(isRecoveryRecord(checkpoint) ? checkpoint : void 0, isRecoveryRecord(unloadCopy) ? unloadCopy : void 0);
100
+ }
101
+ function cancel() {
102
+ if (timer !== void 0) clearTimeout(timer);
103
+ timer = void 0;
104
+ }
105
+ function flush() {
106
+ cancel();
107
+ checkpointWaiting ?? (checkpointWaiting = enqueue(writeCheckpoint));
108
+ return checkpointWaiting;
109
+ }
110
+ const handlePageHidden = () => writeCheckpointBeforeThePageCloses();
111
+ const handleVisibilityChange = () => {
112
+ if (document.visibilityState === "hidden") writeCheckpointBeforeThePageCloses();
113
+ };
114
+ const pageEvents = typeof window === "undefined" ? void 0 : window;
115
+ pageEvents?.addEventListener("pagehide", handlePageHidden);
116
+ pageEvents?.document.addEventListener("visibilitychange", handleVisibilityChange);
117
+ return {
118
+ schedule() {
119
+ if (timer !== void 0) return;
120
+ timer = setTimeout(() => {
121
+ timer = void 0;
122
+ flush();
123
+ }, interval);
124
+ },
125
+ flush,
126
+ cancel,
127
+ remove: (documentId) => enqueue(async () => {
128
+ await store.delete(unloadKey(documentId));
129
+ await store.delete(documentKey(documentId));
130
+ }),
131
+ read,
132
+ async list() {
133
+ await queue;
134
+ const documentIds = /* @__PURE__ */ new Set();
135
+ for (const key of await store.keys()) {
136
+ if (key.startsWith(documentKeyPrefix)) documentIds.add(key.slice(9));
137
+ if (key.startsWith(unloadKeyPrefix)) documentIds.add(key.slice(7));
138
+ }
139
+ return (await Promise.all([...documentIds].map(read))).filter((record) => record !== void 0).sort((first, second) => second.savedAt.localeCompare(first.savedAt));
140
+ },
141
+ markLoadStarted: (documentId) => store.set(loadingKey, {
142
+ documentId,
143
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
144
+ }).catch(options.onError),
145
+ markLoadFinished: () => store.delete(loadingKey).catch(options.onError),
146
+ async interruptedLoad() {
147
+ const marker = await store.get(loadingKey);
148
+ return marker && typeof marker.startedAt === "string" ? marker : void 0;
149
+ },
150
+ destroy() {
151
+ cancel();
152
+ pageEvents?.removeEventListener("pagehide", handlePageHidden);
153
+ pageEvents?.document.removeEventListener("visibilitychange", handleVisibilityChange);
154
+ }
155
+ };
156
+ }
157
+ //#endregion
158
+ exports.createRecoveryController = createRecoveryController;
159
+ exports.restoreRecordedFiles = restoreRecordedFiles;
@@ -0,0 +1,19 @@
1
+ import { FabricDocument } from "../document/document-format.cjs";
2
+ import { RecoveryStore } from "./recovery-store.cjs";
3
+ //#region src/recovery/recovery-controller.d.ts
4
+ export interface RecoveryOptions {
5
+ store: RecoveryStore;
6
+ interval?: number;
7
+ }
8
+ export interface RecoveryRecord {
9
+ documentId: string;
10
+ savedAt: string;
11
+ baseRevision: number;
12
+ document: FabricDocument;
13
+ files: Record<string, Blob>;
14
+ }
15
+ export interface InterruptedLoad {
16
+ documentId: string | undefined;
17
+ startedAt: string;
18
+ }
19
+ //#endregion
@@ -0,0 +1,19 @@
1
+ import { FabricDocument } from "../document/document-format.js";
2
+ import { RecoveryStore } from "./recovery-store.js";
3
+ //#region src/recovery/recovery-controller.d.ts
4
+ export interface RecoveryOptions {
5
+ store: RecoveryStore;
6
+ interval?: number;
7
+ }
8
+ export interface RecoveryRecord {
9
+ documentId: string;
10
+ savedAt: string;
11
+ baseRevision: number;
12
+ document: FabricDocument;
13
+ files: Record<string, Blob>;
14
+ }
15
+ export interface InterruptedLoad {
16
+ documentId: string | undefined;
17
+ startedAt: string;
18
+ }
19
+ //#endregion
@@ -0,0 +1,158 @@
1
+ import { rewriteUrls } from "../assets/asset-pipeline.js";
2
+ //#region src/recovery/recovery-controller.ts
3
+ const documentKeyPrefix = "document:";
4
+ const unloadKeyPrefix = "unload:";
5
+ const loadingKey = "loading";
6
+ function documentKey(documentId) {
7
+ return `${documentKeyPrefix}${documentId}`;
8
+ }
9
+ function unloadKey(documentId) {
10
+ return `${unloadKeyPrefix}${documentId}`;
11
+ }
12
+ function newerRecord(checkpoint, unloadCopy) {
13
+ if (!unloadCopy) return checkpoint;
14
+ if (!checkpoint || unloadCopy.savedAt > checkpoint.savedAt) return {
15
+ ...unloadCopy,
16
+ files: {
17
+ ...checkpoint?.files,
18
+ ...unloadCopy.files
19
+ }
20
+ };
21
+ return checkpoint;
22
+ }
23
+ function isRecoveryRecord(value) {
24
+ const record = value;
25
+ return typeof record?.documentId === "string" && typeof record.document === "object" && record.document !== null;
26
+ }
27
+ async function restoreRecordedFiles(record) {
28
+ const document = JSON.parse(JSON.stringify(record.document));
29
+ const recreatedUrls = /* @__PURE__ */ new Map();
30
+ await rewriteUrls(document, async (url) => {
31
+ const blob = record.files[url];
32
+ if (!blob) return url;
33
+ const recreated = recreatedUrls.get(url) ?? URL.createObjectURL(blob);
34
+ recreatedUrls.set(url, recreated);
35
+ return recreated;
36
+ });
37
+ return document;
38
+ }
39
+ function createRecoveryController(options) {
40
+ const { store } = options;
41
+ const interval = options.interval ?? 2e3;
42
+ const capturedFiles = /* @__PURE__ */ new Map();
43
+ const readyFiles = /* @__PURE__ */ new Map();
44
+ let timer;
45
+ let queue = Promise.resolve();
46
+ let checkpointWaiting;
47
+ function enqueue(task) {
48
+ const run = queue.then(task).catch(options.onError);
49
+ queue = run;
50
+ return run;
51
+ }
52
+ async function captureTabOnlyFiles(document) {
53
+ const files = {};
54
+ await rewriteUrls(document, async (url) => {
55
+ if (!url.startsWith("blob:")) return url;
56
+ let capturing = capturedFiles.get(url);
57
+ if (!capturing) {
58
+ capturing = fetch(url).then((response) => response.blob());
59
+ capturedFiles.set(url, capturing);
60
+ capturing.then((blob) => readyFiles.set(url, blob), () => void 0);
61
+ capturing.catch(() => capturedFiles.delete(url));
62
+ }
63
+ files[url] = await capturing;
64
+ return url;
65
+ });
66
+ return files;
67
+ }
68
+ function createRecord(document, files) {
69
+ return {
70
+ documentId: document.id,
71
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
72
+ baseRevision: document.revision ?? 0,
73
+ document,
74
+ files
75
+ };
76
+ }
77
+ async function writeCheckpoint() {
78
+ checkpointWaiting = void 0;
79
+ if (!options.shouldWrite()) return;
80
+ const document = options.createDocument();
81
+ const record = createRecord(document, await captureTabOnlyFiles(document));
82
+ await store.set(documentKey(document.id), record);
83
+ options.onCheckpoint(record);
84
+ }
85
+ function writeCheckpointBeforeThePageCloses() {
86
+ cancel();
87
+ if (!options.shouldWrite()) return;
88
+ const document = options.createDocument();
89
+ if (store.setNow) {
90
+ store.setNow(unloadKey(document.id), createRecord(document, {}));
91
+ return;
92
+ }
93
+ const record = createRecord(document, Object.fromEntries(readyFiles));
94
+ store.set(documentKey(document.id), record).then(() => options.onCheckpoint(record), options.onError);
95
+ }
96
+ async function read(documentId) {
97
+ await queue;
98
+ const [checkpoint, unloadCopy] = await Promise.all([store.get(documentKey(documentId)), store.get(unloadKey(documentId))]);
99
+ return newerRecord(isRecoveryRecord(checkpoint) ? checkpoint : void 0, isRecoveryRecord(unloadCopy) ? unloadCopy : void 0);
100
+ }
101
+ function cancel() {
102
+ if (timer !== void 0) clearTimeout(timer);
103
+ timer = void 0;
104
+ }
105
+ function flush() {
106
+ cancel();
107
+ checkpointWaiting ?? (checkpointWaiting = enqueue(writeCheckpoint));
108
+ return checkpointWaiting;
109
+ }
110
+ const handlePageHidden = () => writeCheckpointBeforeThePageCloses();
111
+ const handleVisibilityChange = () => {
112
+ if (document.visibilityState === "hidden") writeCheckpointBeforeThePageCloses();
113
+ };
114
+ const pageEvents = typeof window === "undefined" ? void 0 : window;
115
+ pageEvents?.addEventListener("pagehide", handlePageHidden);
116
+ pageEvents?.document.addEventListener("visibilitychange", handleVisibilityChange);
117
+ return {
118
+ schedule() {
119
+ if (timer !== void 0) return;
120
+ timer = setTimeout(() => {
121
+ timer = void 0;
122
+ flush();
123
+ }, interval);
124
+ },
125
+ flush,
126
+ cancel,
127
+ remove: (documentId) => enqueue(async () => {
128
+ await store.delete(unloadKey(documentId));
129
+ await store.delete(documentKey(documentId));
130
+ }),
131
+ read,
132
+ async list() {
133
+ await queue;
134
+ const documentIds = /* @__PURE__ */ new Set();
135
+ for (const key of await store.keys()) {
136
+ if (key.startsWith(documentKeyPrefix)) documentIds.add(key.slice(9));
137
+ if (key.startsWith(unloadKeyPrefix)) documentIds.add(key.slice(7));
138
+ }
139
+ return (await Promise.all([...documentIds].map(read))).filter((record) => record !== void 0).sort((first, second) => second.savedAt.localeCompare(first.savedAt));
140
+ },
141
+ markLoadStarted: (documentId) => store.set(loadingKey, {
142
+ documentId,
143
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
144
+ }).catch(options.onError),
145
+ markLoadFinished: () => store.delete(loadingKey).catch(options.onError),
146
+ async interruptedLoad() {
147
+ const marker = await store.get(loadingKey);
148
+ return marker && typeof marker.startedAt === "string" ? marker : void 0;
149
+ },
150
+ destroy() {
151
+ cancel();
152
+ pageEvents?.removeEventListener("pagehide", handlePageHidden);
153
+ pageEvents?.document.removeEventListener("visibilitychange", handleVisibilityChange);
154
+ }
155
+ };
156
+ }
157
+ //#endregion
158
+ export { createRecoveryController, restoreRecordedFiles };
@@ -0,0 +1,19 @@
1
+ //#region src/recovery/recovery-store.ts
2
+ function createMemoryRecovery() {
3
+ const values = /* @__PURE__ */ new Map();
4
+ return {
5
+ get: async (key) => values.get(key),
6
+ set: async (key, value) => {
7
+ values.set(key, value);
8
+ },
9
+ setNow: (key, value) => {
10
+ values.set(key, value);
11
+ },
12
+ delete: async (key) => {
13
+ values.delete(key);
14
+ },
15
+ keys: async () => [...values.keys()]
16
+ };
17
+ }
18
+ //#endregion
19
+ exports.createMemoryRecovery = createMemoryRecovery;
@@ -0,0 +1,10 @@
1
+ //#region src/recovery/recovery-store.d.ts
2
+ export interface RecoveryStore {
3
+ get(key: string): Promise<unknown>;
4
+ set(key: string, value: unknown): Promise<void>;
5
+ setNow?(key: string, value: unknown): void;
6
+ delete(key: string): Promise<void>;
7
+ keys(): Promise<string[]>;
8
+ }
9
+ export declare function createMemoryRecovery(): RecoveryStore;
10
+ //#endregion
@@ -0,0 +1,10 @@
1
+ //#region src/recovery/recovery-store.d.ts
2
+ export interface RecoveryStore {
3
+ get(key: string): Promise<unknown>;
4
+ set(key: string, value: unknown): Promise<void>;
5
+ setNow?(key: string, value: unknown): void;
6
+ delete(key: string): Promise<void>;
7
+ keys(): Promise<string[]>;
8
+ }
9
+ export declare function createMemoryRecovery(): RecoveryStore;
10
+ //#endregion
@@ -0,0 +1,19 @@
1
+ //#region src/recovery/recovery-store.ts
2
+ function createMemoryRecovery() {
3
+ const values = /* @__PURE__ */ new Map();
4
+ return {
5
+ get: async (key) => values.get(key),
6
+ set: async (key, value) => {
7
+ values.set(key, value);
8
+ },
9
+ setNow: (key, value) => {
10
+ values.set(key, value);
11
+ },
12
+ delete: async (key) => {
13
+ values.delete(key);
14
+ },
15
+ keys: async () => [...values.keys()]
16
+ };
17
+ }
18
+ //#endregion
19
+ export { createMemoryRecovery };
@@ -0,0 +1,5 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_indexed_db_recovery = require("./recovery/indexed-db-recovery.cjs");
3
+ const require_recovery_store = require("./recovery/recovery-store.cjs");
4
+ exports.createIndexedDbRecovery = require_indexed_db_recovery.createIndexedDbRecovery;
5
+ exports.createMemoryRecovery = require_recovery_store.createMemoryRecovery;
@@ -0,0 +1,4 @@
1
+ import { RecoveryStore, createMemoryRecovery } from "./recovery/recovery-store.cjs";
2
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "./recovery/recovery-controller.cjs";
3
+ import { IndexedDbRecoveryOptions, createIndexedDbRecovery } from "./recovery/indexed-db-recovery.cjs";
4
+ export { type IndexedDbRecoveryOptions, type InterruptedLoad, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, createIndexedDbRecovery, createMemoryRecovery };
@@ -0,0 +1,4 @@
1
+ import { RecoveryStore, createMemoryRecovery } from "./recovery/recovery-store.js";
2
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "./recovery/recovery-controller.js";
3
+ import { IndexedDbRecoveryOptions, createIndexedDbRecovery } from "./recovery/indexed-db-recovery.js";
4
+ export { type IndexedDbRecoveryOptions, type InterruptedLoad, type RecoveryOptions, type RecoveryRecord, type RecoveryStore, createIndexedDbRecovery, createMemoryRecovery };
@@ -0,0 +1,3 @@
1
+ import { createIndexedDbRecovery } from "./recovery/indexed-db-recovery.js";
2
+ import { createMemoryRecovery } from "./recovery/recovery-store.js";
3
+ export { createIndexedDbRecovery, createMemoryRecovery };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabricjs-document-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Turn an existing Fabric.js canvas into a dependable editable document: stable object ids, versioned save and load, undo and redo, safe autosave with conflict protection, custom objects and clear errors.",
5
5
  "keywords": [
6
6
  "fabric",
@@ -18,7 +18,8 @@
18
18
  "autosave",
19
19
  "editor",
20
20
  "typescript",
21
- "storage"
21
+ "storage",
22
+ "recovery"
22
23
  ],
23
24
  "license": "MIT",
24
25
  "author": "Sohail Khan (https://me.jscrate.dev)",
@@ -53,6 +54,16 @@
53
54
  "default": "./dist/storage.cjs"
54
55
  }
55
56
  },
57
+ "./recovery": {
58
+ "import": {
59
+ "types": "./dist/recovery.d.ts",
60
+ "default": "./dist/recovery.js"
61
+ },
62
+ "require": {
63
+ "types": "./dist/recovery.d.cts",
64
+ "default": "./dist/recovery.cjs"
65
+ }
66
+ },
56
67
  "./package.json": "./package.json"
57
68
  },
58
69
  "scripts": {