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 +38 -2
- package/dist/assets/asset-pipeline.cjs +1 -0
- package/dist/assets/asset-pipeline.js +1 -1
- package/dist/engine/create-document-engine.cjs +56 -5
- package/dist/engine/create-document-engine.d.cts +18 -0
- package/dist/engine/create-document-engine.d.ts +18 -0
- package/dist/engine/create-document-engine.js +56 -5
- package/dist/engine/errors.d.cts +1 -1
- package/dist/engine/errors.d.ts +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/recovery/indexed-db-recovery.cjs +80 -0
- package/dist/recovery/indexed-db-recovery.d.cts +8 -0
- package/dist/recovery/indexed-db-recovery.d.ts +8 -0
- package/dist/recovery/indexed-db-recovery.js +80 -0
- package/dist/recovery/recovery-controller.cjs +159 -0
- package/dist/recovery/recovery-controller.d.cts +19 -0
- package/dist/recovery/recovery-controller.d.ts +19 -0
- package/dist/recovery/recovery-controller.js +158 -0
- package/dist/recovery/recovery-store.cjs +19 -0
- package/dist/recovery/recovery-store.d.cts +10 -0
- package/dist/recovery/recovery-store.d.ts +10 -0
- package/dist/recovery/recovery-store.js +19 -0
- package/dist/recovery.cjs +5 -0
- package/dist/recovery.d.cts +4 -0
- package/dist/recovery.d.ts +4 -0
- package/dist/recovery.js +3 -0
- package/package.json +13 -2
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;
|
|
@@ -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: () =>
|
|
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) =>
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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: () =>
|
|
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) =>
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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);
|
package/dist/engine/errors.d.cts
CHANGED
|
@@ -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;
|
package/dist/engine/errors.d.ts
CHANGED
|
@@ -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 };
|
package/dist/recovery.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fabricjs-document-engine",
|
|
3
|
-
"version": "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": {
|