fabricjs-document-engine 0.2.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.
Files changed (52) hide show
  1. package/README.md +113 -13
  2. package/dist/assets/asset-manifest.cjs +43 -0
  3. package/dist/assets/asset-manifest.d.cts +16 -0
  4. package/dist/assets/asset-manifest.d.ts +16 -0
  5. package/dist/assets/asset-manifest.js +41 -0
  6. package/dist/assets/asset-pipeline.cjs +175 -0
  7. package/dist/assets/asset-pipeline.d.cts +31 -0
  8. package/dist/assets/asset-pipeline.d.ts +31 -0
  9. package/dist/assets/asset-pipeline.js +172 -0
  10. package/dist/assets/asset-references.cjs +83 -0
  11. package/dist/assets/asset-references.js +82 -0
  12. package/dist/assets/font-check.cjs +59 -0
  13. package/dist/assets/font-check.d.cts +4 -0
  14. package/dist/assets/font-check.d.ts +4 -0
  15. package/dist/assets/font-check.js +57 -0
  16. package/dist/assets/image-check.cjs +36 -0
  17. package/dist/assets/image-check.js +33 -0
  18. package/dist/document/document-format.d.cts +2 -0
  19. package/dist/document/document-format.d.ts +2 -0
  20. package/dist/document/validate-document.cjs +4 -0
  21. package/dist/document/validate-document.js +4 -0
  22. package/dist/engine/create-document-engine.cjs +112 -10
  23. package/dist/engine/create-document-engine.d.cts +28 -0
  24. package/dist/engine/create-document-engine.d.ts +28 -0
  25. package/dist/engine/create-document-engine.js +112 -10
  26. package/dist/engine/errors.cjs +2 -0
  27. package/dist/engine/errors.d.cts +6 -1
  28. package/dist/engine/errors.d.ts +6 -1
  29. package/dist/engine/errors.js +2 -0
  30. package/dist/index.cjs +1 -1
  31. package/dist/index.d.cts +6 -1
  32. package/dist/index.d.ts +6 -1
  33. package/dist/index.js +1 -1
  34. package/dist/recovery/indexed-db-recovery.cjs +80 -0
  35. package/dist/recovery/indexed-db-recovery.d.cts +8 -0
  36. package/dist/recovery/indexed-db-recovery.d.ts +8 -0
  37. package/dist/recovery/indexed-db-recovery.js +80 -0
  38. package/dist/recovery/recovery-controller.cjs +159 -0
  39. package/dist/recovery/recovery-controller.d.cts +19 -0
  40. package/dist/recovery/recovery-controller.d.ts +19 -0
  41. package/dist/recovery/recovery-controller.js +158 -0
  42. package/dist/recovery/recovery-store.cjs +19 -0
  43. package/dist/recovery/recovery-store.d.cts +10 -0
  44. package/dist/recovery/recovery-store.d.ts +10 -0
  45. package/dist/recovery/recovery-store.js +19 -0
  46. package/dist/recovery.cjs +5 -0
  47. package/dist/recovery.d.cts +4 -0
  48. package/dist/recovery.d.ts +4 -0
  49. package/dist/recovery.js +3 -0
  50. package/dist/save/save-controller.cjs +6 -5
  51. package/dist/save/save-controller.js +6 -5
  52. package/package.json +13 -2
@@ -1,3 +1,7 @@
1
+ const require_asset_manifest = require("../assets/asset-manifest.cjs");
2
+ const require_errors = require("./errors.cjs");
3
+ const require_image_check = require("../assets/image-check.cjs");
4
+ const require_asset_pipeline = require("../assets/asset-pipeline.cjs");
1
5
  require("../document/document-format.cjs");
2
6
  const require_ids = require("../document/ids.cjs");
3
7
  const require_create_document = require("../document/create-document.cjs");
@@ -5,12 +9,16 @@ const require_validate_document = require("../document/validate-document.cjs");
5
9
  const require_walk_objects = require("../fabric/walk-objects.cjs");
6
10
  const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
7
11
  const require_object_ids = require("../fabric/object-ids.cjs");
8
- const require_errors = require("./errors.cjs");
9
12
  const require_object_registry = require("../fabric/object-registry.cjs");
10
13
  const require_create_history = require("../history/create-history.cjs");
14
+ const require_recovery_controller = require("../recovery/recovery-controller.cjs");
11
15
  const require_save_controller = require("../save/save-controller.cjs");
12
16
  const require_event_emitter = require("./event-emitter.cjs");
13
17
  //#region src/engine/create-document-engine.ts
18
+ function isReplaceableImage(object) {
19
+ const candidate = object;
20
+ return typeof candidate.getSrc === "function" && typeof candidate.setSrc === "function";
21
+ }
14
22
  function describeDocumentId(value) {
15
23
  const id = value?.id;
16
24
  return typeof id === "string" ? id : void 0;
@@ -21,6 +29,8 @@ function createDocumentEngine(options) {
21
29
  const registry = require_object_registry.createObjectRegistry(options.customObjects);
22
30
  const events = require_event_emitter.createEventEmitter();
23
31
  const objectsById = /* @__PURE__ */ new Map();
32
+ const assetOptions = options.assets ?? {};
33
+ const uploadedUrls = /* @__PURE__ */ new Map();
24
34
  let documentInfo = require_create_document.createDocumentInfo(options.document);
25
35
  let activeLoad = null;
26
36
  let destroyed = false;
@@ -61,19 +71,43 @@ function createDocumentEngine(options) {
61
71
  return require_fabric_adapter.serializeCanvas(canvas, registry.propertiesToInclude()).objects;
62
72
  },
63
73
  onChange: (state) => events.emit("history:change", state),
64
- onContentChange: () => saving.noteContentChange()
74
+ onContentChange: () => noteContentChange()
65
75
  });
66
76
  const saving = require_save_controller.createSaveController({
67
77
  getStorage: () => requireStorage(),
68
- createDocument: () => toDocument(),
78
+ createDocument: async () => {
79
+ const prepared = await require_asset_pipeline.prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls);
80
+ if (prepared.warnings.length > 0) events.emit("assets:warning", { warnings: prepared.warnings });
81
+ return prepared.document;
82
+ },
69
83
  retry: options.saveRetry,
70
84
  autosave: options.autosave === true ? {} : options.autosave ?? false,
71
85
  onStateChange: (state) => events.emit("save:status", state),
72
86
  onStart: (document) => events.emit("save:start", { document }),
73
- 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
+ },
74
91
  onError: (error) => events.emit("save:error", { error }),
75
92
  onRetry: (event) => events.emit("save:retry", event)
76
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
+ }
77
111
  function toDocument() {
78
112
  ensureUsable();
79
113
  rebuildIndex();
@@ -93,6 +127,7 @@ function createDocumentEngine(options) {
93
127
  height: canvas.getHeight()
94
128
  },
95
129
  objects: serialized.objects,
130
+ assets: require_asset_manifest.buildAssetManifest(serialized.objects),
96
131
  metadata: { ...documentInfo.metadata }
97
132
  };
98
133
  if (serialized.version !== void 0) document.fabricVersion = serialized.version;
@@ -100,8 +135,8 @@ function createDocumentEngine(options) {
100
135
  return document;
101
136
  }
102
137
  function toLoadError(error, signal) {
103
- if (require_errors.isDocumentEngineError(error)) return error;
104
138
  if (signal.aborted) return new require_errors.DocumentEngineError("LOAD_ABORTED", "Loading stopped because a newer load started", { cause: error });
139
+ if (require_errors.isDocumentEngineError(error)) return error;
105
140
  const reason = error instanceof Error ? error.message : String(error);
106
141
  return new require_errors.DocumentEngineError("LOAD_FAILED", `Fabric could not load the document: ${reason}`, { cause: error });
107
142
  }
@@ -121,9 +156,14 @@ function createDocumentEngine(options) {
121
156
  activeLoad?.abort();
122
157
  const controller = new AbortController();
123
158
  activeLoad = controller;
124
- events.emit("load:start", { documentId: describeDocumentId(input) });
159
+ const documentId = describeDocumentId(input);
160
+ events.emit("load:start", { documentId });
125
161
  try {
126
- const document = checkDocument(input);
162
+ await recovery?.markLoadStarted(documentId);
163
+ if (controller.signal.aborted) throw new Error("aborted");
164
+ const checked = checkDocument(input);
165
+ const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal);
166
+ if (controller.signal.aborted) throw new Error("aborted");
127
167
  await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
128
168
  version: document.fabricVersion,
129
169
  background: document.canvas.background,
@@ -142,16 +182,24 @@ function createDocumentEngine(options) {
142
182
  };
143
183
  rebuildIndex();
144
184
  history.reset();
185
+ recovery?.cancel();
145
186
  saving.startSession(document.revision ?? 0);
146
187
  canvas.requestRenderAll();
147
- events.emit("load:success", { document });
188
+ if (warnings.length > 0) events.emit("assets:warning", { warnings });
189
+ events.emit("load:success", {
190
+ document,
191
+ warnings
192
+ });
148
193
  return document;
149
194
  } catch (error) {
150
195
  const engineError = toLoadError(error, controller.signal);
151
196
  events.emit("load:error", { error: engineError });
152
197
  throw engineError;
153
198
  } finally {
154
- if (activeLoad === controller) activeLoad = null;
199
+ if (activeLoad === controller) {
200
+ activeLoad = null;
201
+ await recovery?.markLoadFinished();
202
+ }
155
203
  }
156
204
  }
157
205
  function requireStorage() {
@@ -186,7 +234,9 @@ function createDocumentEngine(options) {
186
234
  history.withoutRecording(() => canvas.clear());
187
235
  objectsById.clear();
188
236
  documentInfo = require_create_document.createDocumentInfo(newOptions);
237
+ uploadedUrls.clear();
189
238
  history.reset();
239
+ recovery?.cancel();
190
240
  saving.startSession(0);
191
241
  }
192
242
  function getObjectById(id) {
@@ -196,6 +246,45 @@ function createDocumentEngine(options) {
196
246
  rebuildIndex();
197
247
  return objectsById.get(id);
198
248
  }
249
+ function checkAssets() {
250
+ ensureUsable();
251
+ return require_asset_pipeline.inspectAssets(toDocument(), assetOptions, new AbortController().signal);
252
+ }
253
+ async function replaceImage(oldUrl, newUrl) {
254
+ ensureUsable();
255
+ const images = [];
256
+ require_walk_objects.walkObjects(canvas.getObjects(), (object) => {
257
+ if (isReplaceableImage(object) && require_image_check.isSameUrl(object.getSrc(), oldUrl)) images.push(object);
258
+ });
259
+ if (images.length === 0) return 0;
260
+ await history.transaction("Replace image", () => Promise.all(images.map(async (image) => {
261
+ const displayedWidth = image.width * image.scaleX;
262
+ const displayedHeight = image.height * image.scaleY;
263
+ await image.setSrc(newUrl, { crossOrigin: image.crossOrigin ?? null });
264
+ image.set({
265
+ scaleX: displayedWidth / image.width,
266
+ scaleY: displayedHeight / image.height
267
+ });
268
+ image.setCoords();
269
+ })));
270
+ canvas.requestRenderAll();
271
+ return images.length;
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
+ }
199
288
  async function travelThroughHistory(direction) {
200
289
  ensureUsable();
201
290
  try {
@@ -213,6 +302,7 @@ function createDocumentEngine(options) {
213
302
  activeLoad?.abort();
214
303
  history.destroy();
215
304
  saving.destroy();
305
+ recovery?.destroy();
216
306
  canvas.off("object:added", handleObjectAdded);
217
307
  canvas.off("object:removed", handleObjectRemoved);
218
308
  objectsById.clear();
@@ -233,7 +323,7 @@ function createDocumentEngine(options) {
233
323
  ...changes
234
324
  }
235
325
  };
236
- saving.noteContentChange();
326
+ noteContentChange();
237
327
  },
238
328
  newDocument,
239
329
  toDocument,
@@ -247,6 +337,18 @@ function createDocumentEngine(options) {
247
337
  registry.register(definition);
248
338
  },
249
339
  getObjectById,
340
+ getAssetManifest: () => toDocument().assets ?? {
341
+ images: [],
342
+ fonts: []
343
+ },
344
+ checkAssets,
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(),
250
352
  transaction(label, work) {
251
353
  ensureUsable();
252
354
  return history.transaction(label, work);
@@ -1,7 +1,10 @@
1
1
  import { DocumentInfo, FabricDocument } from "../document/document-format.cjs";
2
+ import { AssetManifest } from "../assets/asset-manifest.cjs";
3
+ import { AssetOptions, AssetReport, AssetWarning } from "../assets/asset-pipeline.cjs";
2
4
  import { NewDocumentOptions } from "../document/create-document.cjs";
3
5
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
4
6
  import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
7
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.cjs";
5
8
  import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
6
9
  import { RetryOptions } from "../save/retry.cjs";
7
10
  import { DocumentEngineError } from "./errors.cjs";
@@ -18,6 +21,8 @@ export interface DocumentEngineOptions {
18
21
  history?: HistoryOptions;
19
22
  autosave?: boolean | AutosaveOptions;
20
23
  saveRetry?: RetryOptions;
24
+ assets?: AssetOptions;
25
+ recovery?: RecoveryOptions;
21
26
  }
22
27
  export interface LoadOptions {
23
28
  restoreCanvasSize?: boolean;
@@ -28,6 +33,10 @@ export interface DocumentEngineEvents {
28
33
  };
29
34
  "load:success": {
30
35
  document: FabricDocument;
36
+ warnings: AssetWarning[];
37
+ };
38
+ "assets:warning": {
39
+ warnings: AssetWarning[];
31
40
  };
32
41
  "load:error": {
33
42
  error: DocumentEngineError;
@@ -47,6 +56,16 @@ export interface DocumentEngineEvents {
47
56
  "history:error": {
48
57
  error: DocumentEngineError;
49
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
+ };
50
69
  }
51
70
  export interface DocumentEngine {
52
71
  readonly canvas: StaticCanvas;
@@ -72,6 +91,15 @@ export interface DocumentEngine {
72
91
  };
73
92
  clearHistory(): void;
74
93
  getObjectById(id: string): FabricObject | undefined;
94
+ getAssetManifest(): AssetManifest;
95
+ checkAssets(): Promise<AssetReport>;
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>;
75
103
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
76
104
  destroy(): void;
77
105
  }
@@ -1,7 +1,10 @@
1
1
  import { DocumentInfo, FabricDocument } from "../document/document-format.js";
2
+ import { AssetManifest } from "../assets/asset-manifest.js";
3
+ import { AssetOptions, AssetReport, AssetWarning } from "../assets/asset-pipeline.js";
2
4
  import { NewDocumentOptions } from "../document/create-document.js";
3
5
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
4
6
  import { HistoryOptions, HistoryState } from "../history/create-history.js";
7
+ import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.js";
5
8
  import { AutosaveOptions } from "../save/autosave-scheduler.js";
6
9
  import { RetryOptions } from "../save/retry.js";
7
10
  import { DocumentEngineError } from "./errors.js";
@@ -18,6 +21,8 @@ export interface DocumentEngineOptions {
18
21
  history?: HistoryOptions;
19
22
  autosave?: boolean | AutosaveOptions;
20
23
  saveRetry?: RetryOptions;
24
+ assets?: AssetOptions;
25
+ recovery?: RecoveryOptions;
21
26
  }
22
27
  export interface LoadOptions {
23
28
  restoreCanvasSize?: boolean;
@@ -28,6 +33,10 @@ export interface DocumentEngineEvents {
28
33
  };
29
34
  "load:success": {
30
35
  document: FabricDocument;
36
+ warnings: AssetWarning[];
37
+ };
38
+ "assets:warning": {
39
+ warnings: AssetWarning[];
31
40
  };
32
41
  "load:error": {
33
42
  error: DocumentEngineError;
@@ -47,6 +56,16 @@ export interface DocumentEngineEvents {
47
56
  "history:error": {
48
57
  error: DocumentEngineError;
49
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
+ };
50
69
  }
51
70
  export interface DocumentEngine {
52
71
  readonly canvas: StaticCanvas;
@@ -72,6 +91,15 @@ export interface DocumentEngine {
72
91
  };
73
92
  clearHistory(): void;
74
93
  getObjectById(id: string): FabricObject | undefined;
94
+ getAssetManifest(): AssetManifest;
95
+ checkAssets(): Promise<AssetReport>;
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>;
75
103
  on<Name extends keyof DocumentEngineEvents>(name: Name, handler: (payload: DocumentEngineEvents[Name]) => void): Unsubscribe;
76
104
  destroy(): void;
77
105
  }
@@ -1,3 +1,7 @@
1
+ import { buildAssetManifest } from "../assets/asset-manifest.js";
2
+ import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
3
+ import { isSameUrl } from "../assets/image-check.js";
4
+ import { inspectAssets, prepareAssetsForLoad, prepareAssetsForSave } from "../assets/asset-pipeline.js";
1
5
  import "../document/document-format.js";
2
6
  import { createId } from "../document/ids.js";
3
7
  import { createDocumentInfo } from "../document/create-document.js";
@@ -5,12 +9,16 @@ import { validateDocument } from "../document/validate-document.js";
5
9
  import { collectSerializedTypes, walkObjects } from "../fabric/walk-objects.js";
6
10
  import { loadIntoCanvas, serializeCanvas } from "../fabric/fabric-adapter.js";
7
11
  import { readObjectId, writeObjectId } from "../fabric/object-ids.js";
8
- import { DocumentEngineError, isDocumentEngineError } from "./errors.js";
9
12
  import { createObjectRegistry } from "../fabric/object-registry.js";
10
13
  import { createHistory } from "../history/create-history.js";
14
+ import { createRecoveryController, restoreRecordedFiles } from "../recovery/recovery-controller.js";
11
15
  import { createSaveController } from "../save/save-controller.js";
12
16
  import { createEventEmitter } from "./event-emitter.js";
13
17
  //#region src/engine/create-document-engine.ts
18
+ function isReplaceableImage(object) {
19
+ const candidate = object;
20
+ return typeof candidate.getSrc === "function" && typeof candidate.setSrc === "function";
21
+ }
14
22
  function describeDocumentId(value) {
15
23
  const id = value?.id;
16
24
  return typeof id === "string" ? id : void 0;
@@ -21,6 +29,8 @@ function createDocumentEngine(options) {
21
29
  const registry = createObjectRegistry(options.customObjects);
22
30
  const events = createEventEmitter();
23
31
  const objectsById = /* @__PURE__ */ new Map();
32
+ const assetOptions = options.assets ?? {};
33
+ const uploadedUrls = /* @__PURE__ */ new Map();
24
34
  let documentInfo = createDocumentInfo(options.document);
25
35
  let activeLoad = null;
26
36
  let destroyed = false;
@@ -61,19 +71,43 @@ function createDocumentEngine(options) {
61
71
  return serializeCanvas(canvas, registry.propertiesToInclude()).objects;
62
72
  },
63
73
  onChange: (state) => events.emit("history:change", state),
64
- onContentChange: () => saving.noteContentChange()
74
+ onContentChange: () => noteContentChange()
65
75
  });
66
76
  const saving = createSaveController({
67
77
  getStorage: () => requireStorage(),
68
- createDocument: () => toDocument(),
78
+ createDocument: async () => {
79
+ const prepared = await prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls);
80
+ if (prepared.warnings.length > 0) events.emit("assets:warning", { warnings: prepared.warnings });
81
+ return prepared.document;
82
+ },
69
83
  retry: options.saveRetry,
70
84
  autosave: options.autosave === true ? {} : options.autosave ?? false,
71
85
  onStateChange: (state) => events.emit("save:status", state),
72
86
  onStart: (document) => events.emit("save:start", { document }),
73
- 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
+ },
74
91
  onError: (error) => events.emit("save:error", { error }),
75
92
  onRetry: (event) => events.emit("save:retry", event)
76
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
+ }
77
111
  function toDocument() {
78
112
  ensureUsable();
79
113
  rebuildIndex();
@@ -93,6 +127,7 @@ function createDocumentEngine(options) {
93
127
  height: canvas.getHeight()
94
128
  },
95
129
  objects: serialized.objects,
130
+ assets: buildAssetManifest(serialized.objects),
96
131
  metadata: { ...documentInfo.metadata }
97
132
  };
98
133
  if (serialized.version !== void 0) document.fabricVersion = serialized.version;
@@ -100,8 +135,8 @@ function createDocumentEngine(options) {
100
135
  return document;
101
136
  }
102
137
  function toLoadError(error, signal) {
103
- if (isDocumentEngineError(error)) return error;
104
138
  if (signal.aborted) return new DocumentEngineError("LOAD_ABORTED", "Loading stopped because a newer load started", { cause: error });
139
+ if (isDocumentEngineError(error)) return error;
105
140
  const reason = error instanceof Error ? error.message : String(error);
106
141
  return new DocumentEngineError("LOAD_FAILED", `Fabric could not load the document: ${reason}`, { cause: error });
107
142
  }
@@ -121,9 +156,14 @@ function createDocumentEngine(options) {
121
156
  activeLoad?.abort();
122
157
  const controller = new AbortController();
123
158
  activeLoad = controller;
124
- events.emit("load:start", { documentId: describeDocumentId(input) });
159
+ const documentId = describeDocumentId(input);
160
+ events.emit("load:start", { documentId });
125
161
  try {
126
- const document = checkDocument(input);
162
+ await recovery?.markLoadStarted(documentId);
163
+ if (controller.signal.aborted) throw new Error("aborted");
164
+ const checked = checkDocument(input);
165
+ const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
166
+ if (controller.signal.aborted) throw new Error("aborted");
127
167
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
128
168
  version: document.fabricVersion,
129
169
  background: document.canvas.background,
@@ -142,16 +182,24 @@ function createDocumentEngine(options) {
142
182
  };
143
183
  rebuildIndex();
144
184
  history.reset();
185
+ recovery?.cancel();
145
186
  saving.startSession(document.revision ?? 0);
146
187
  canvas.requestRenderAll();
147
- events.emit("load:success", { document });
188
+ if (warnings.length > 0) events.emit("assets:warning", { warnings });
189
+ events.emit("load:success", {
190
+ document,
191
+ warnings
192
+ });
148
193
  return document;
149
194
  } catch (error) {
150
195
  const engineError = toLoadError(error, controller.signal);
151
196
  events.emit("load:error", { error: engineError });
152
197
  throw engineError;
153
198
  } finally {
154
- if (activeLoad === controller) activeLoad = null;
199
+ if (activeLoad === controller) {
200
+ activeLoad = null;
201
+ await recovery?.markLoadFinished();
202
+ }
155
203
  }
156
204
  }
157
205
  function requireStorage() {
@@ -186,7 +234,9 @@ function createDocumentEngine(options) {
186
234
  history.withoutRecording(() => canvas.clear());
187
235
  objectsById.clear();
188
236
  documentInfo = createDocumentInfo(newOptions);
237
+ uploadedUrls.clear();
189
238
  history.reset();
239
+ recovery?.cancel();
190
240
  saving.startSession(0);
191
241
  }
192
242
  function getObjectById(id) {
@@ -196,6 +246,45 @@ function createDocumentEngine(options) {
196
246
  rebuildIndex();
197
247
  return objectsById.get(id);
198
248
  }
249
+ function checkAssets() {
250
+ ensureUsable();
251
+ return inspectAssets(toDocument(), assetOptions, new AbortController().signal);
252
+ }
253
+ async function replaceImage(oldUrl, newUrl) {
254
+ ensureUsable();
255
+ const images = [];
256
+ walkObjects(canvas.getObjects(), (object) => {
257
+ if (isReplaceableImage(object) && isSameUrl(object.getSrc(), oldUrl)) images.push(object);
258
+ });
259
+ if (images.length === 0) return 0;
260
+ await history.transaction("Replace image", () => Promise.all(images.map(async (image) => {
261
+ const displayedWidth = image.width * image.scaleX;
262
+ const displayedHeight = image.height * image.scaleY;
263
+ await image.setSrc(newUrl, { crossOrigin: image.crossOrigin ?? null });
264
+ image.set({
265
+ scaleX: displayedWidth / image.width,
266
+ scaleY: displayedHeight / image.height
267
+ });
268
+ image.setCoords();
269
+ })));
270
+ canvas.requestRenderAll();
271
+ return images.length;
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
+ }
199
288
  async function travelThroughHistory(direction) {
200
289
  ensureUsable();
201
290
  try {
@@ -213,6 +302,7 @@ function createDocumentEngine(options) {
213
302
  activeLoad?.abort();
214
303
  history.destroy();
215
304
  saving.destroy();
305
+ recovery?.destroy();
216
306
  canvas.off("object:added", handleObjectAdded);
217
307
  canvas.off("object:removed", handleObjectRemoved);
218
308
  objectsById.clear();
@@ -233,7 +323,7 @@ function createDocumentEngine(options) {
233
323
  ...changes
234
324
  }
235
325
  };
236
- saving.noteContentChange();
326
+ noteContentChange();
237
327
  },
238
328
  newDocument,
239
329
  toDocument,
@@ -247,6 +337,18 @@ function createDocumentEngine(options) {
247
337
  registry.register(definition);
248
338
  },
249
339
  getObjectById,
340
+ getAssetManifest: () => toDocument().assets ?? {
341
+ images: [],
342
+ fonts: []
343
+ },
344
+ checkAssets,
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(),
250
352
  transaction(label, work) {
251
353
  ensureUsable();
252
354
  return history.transaction(label, work);
@@ -6,6 +6,8 @@ var DocumentEngineError = class extends Error {
6
6
  this.code = code;
7
7
  this.issues = details.issues ?? [];
8
8
  this.unknownTypes = details.unknownTypes ?? [];
9
+ this.missingAssets = details.missingAssets ?? [];
10
+ this.missingFonts = details.missingFonts ?? [];
9
11
  this.cause = details.cause;
10
12
  this.retryable = details.retryable ?? false;
11
13
  }
@@ -1,5 +1,6 @@
1
+ import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
1
2
  //#region src/engine/errors.d.ts
2
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_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";
3
4
  export interface DocumentIssue {
4
5
  code: DocumentErrorCode;
5
6
  path: string;
@@ -8,6 +9,8 @@ export interface DocumentIssue {
8
9
  export interface DocumentEngineErrorDetails {
9
10
  issues?: DocumentIssue[];
10
11
  unknownTypes?: string[];
12
+ missingAssets?: ImageAsset[];
13
+ missingFonts?: FontAsset[];
11
14
  cause?: unknown;
12
15
  retryable?: boolean;
13
16
  }
@@ -15,6 +18,8 @@ export declare class DocumentEngineError extends Error {
15
18
  readonly code: DocumentErrorCode;
16
19
  readonly issues: DocumentIssue[];
17
20
  readonly unknownTypes: string[];
21
+ readonly missingAssets: ImageAsset[];
22
+ readonly missingFonts: FontAsset[];
18
23
  readonly cause: unknown;
19
24
  readonly retryable: boolean;
20
25
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);
@@ -1,5 +1,6 @@
1
+ import { FontAsset, ImageAsset } from "../assets/asset-manifest.js";
1
2
  //#region src/engine/errors.d.ts
2
- export type DocumentErrorCode = "INVALID_DOCUMENT" | "UNSUPPORTED_SCHEMA" | "UNKNOWN_OBJECT_TYPE" | "INVALID_CUSTOM_OBJECT" | "LOAD_ABORTED" | "LOAD_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";
3
4
  export interface DocumentIssue {
4
5
  code: DocumentErrorCode;
5
6
  path: string;
@@ -8,6 +9,8 @@ export interface DocumentIssue {
8
9
  export interface DocumentEngineErrorDetails {
9
10
  issues?: DocumentIssue[];
10
11
  unknownTypes?: string[];
12
+ missingAssets?: ImageAsset[];
13
+ missingFonts?: FontAsset[];
11
14
  cause?: unknown;
12
15
  retryable?: boolean;
13
16
  }
@@ -15,6 +18,8 @@ export declare class DocumentEngineError extends Error {
15
18
  readonly code: DocumentErrorCode;
16
19
  readonly issues: DocumentIssue[];
17
20
  readonly unknownTypes: string[];
21
+ readonly missingAssets: ImageAsset[];
22
+ readonly missingFonts: FontAsset[];
18
23
  readonly cause: unknown;
19
24
  readonly retryable: boolean;
20
25
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);
@@ -6,6 +6,8 @@ var DocumentEngineError = class extends Error {
6
6
  this.code = code;
7
7
  this.issues = details.issues ?? [];
8
8
  this.unknownTypes = details.unknownTypes ?? [];
9
+ this.missingAssets = details.missingAssets ?? [];
10
+ this.missingFonts = details.missingFonts ?? [];
9
11
  this.cause = details.cause;
10
12
  this.retryable = details.retryable ?? false;
11
13
  }
package/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_errors = require("./engine/errors.cjs");
2
3
  const require_document_format = require("./document/document-format.cjs");
3
4
  const require_validate_document = require("./document/validate-document.cjs");
4
- const require_errors = require("./engine/errors.cjs");
5
5
  const require_create_document_engine = require("./engine/create-document-engine.cjs");
6
6
  const require_keyboard_shortcuts = require("./history/keyboard-shortcuts.cjs");
7
7
  const require_unsaved_changes_warning = require("./save/unsaved-changes-warning.cjs");