fabricjs-document-engine 0.2.0 → 0.3.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 (36) hide show
  1. package/README.md +77 -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 +174 -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 +56 -5
  23. package/dist/engine/create-document-engine.d.cts +10 -0
  24. package/dist/engine/create-document-engine.d.ts +10 -0
  25. package/dist/engine/create-document-engine.js +56 -5
  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 +4 -1
  32. package/dist/index.d.ts +4 -1
  33. package/dist/index.js +1 -1
  34. package/dist/save/save-controller.cjs +6 -5
  35. package/dist/save/save-controller.js +6 -5
  36. package/package.json +1 -1
@@ -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,15 @@ 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";
11
14
  import { createSaveController } from "../save/save-controller.js";
12
15
  import { createEventEmitter } from "./event-emitter.js";
13
16
  //#region src/engine/create-document-engine.ts
17
+ function isReplaceableImage(object) {
18
+ const candidate = object;
19
+ return typeof candidate.getSrc === "function" && typeof candidate.setSrc === "function";
20
+ }
14
21
  function describeDocumentId(value) {
15
22
  const id = value?.id;
16
23
  return typeof id === "string" ? id : void 0;
@@ -21,6 +28,8 @@ function createDocumentEngine(options) {
21
28
  const registry = createObjectRegistry(options.customObjects);
22
29
  const events = createEventEmitter();
23
30
  const objectsById = /* @__PURE__ */ new Map();
31
+ const assetOptions = options.assets ?? {};
32
+ const uploadedUrls = /* @__PURE__ */ new Map();
24
33
  let documentInfo = createDocumentInfo(options.document);
25
34
  let activeLoad = null;
26
35
  let destroyed = false;
@@ -65,7 +74,11 @@ function createDocumentEngine(options) {
65
74
  });
66
75
  const saving = createSaveController({
67
76
  getStorage: () => requireStorage(),
68
- createDocument: () => toDocument(),
77
+ createDocument: async () => {
78
+ const prepared = await prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls);
79
+ if (prepared.warnings.length > 0) events.emit("assets:warning", { warnings: prepared.warnings });
80
+ return prepared.document;
81
+ },
69
82
  retry: options.saveRetry,
70
83
  autosave: options.autosave === true ? {} : options.autosave ?? false,
71
84
  onStateChange: (state) => events.emit("save:status", state),
@@ -93,6 +106,7 @@ function createDocumentEngine(options) {
93
106
  height: canvas.getHeight()
94
107
  },
95
108
  objects: serialized.objects,
109
+ assets: buildAssetManifest(serialized.objects),
96
110
  metadata: { ...documentInfo.metadata }
97
111
  };
98
112
  if (serialized.version !== void 0) document.fabricVersion = serialized.version;
@@ -100,8 +114,8 @@ function createDocumentEngine(options) {
100
114
  return document;
101
115
  }
102
116
  function toLoadError(error, signal) {
103
- if (isDocumentEngineError(error)) return error;
104
117
  if (signal.aborted) return new DocumentEngineError("LOAD_ABORTED", "Loading stopped because a newer load started", { cause: error });
118
+ if (isDocumentEngineError(error)) return error;
105
119
  const reason = error instanceof Error ? error.message : String(error);
106
120
  return new DocumentEngineError("LOAD_FAILED", `Fabric could not load the document: ${reason}`, { cause: error });
107
121
  }
@@ -123,7 +137,9 @@ function createDocumentEngine(options) {
123
137
  activeLoad = controller;
124
138
  events.emit("load:start", { documentId: describeDocumentId(input) });
125
139
  try {
126
- const document = checkDocument(input);
140
+ const checked = checkDocument(input);
141
+ const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
142
+ if (controller.signal.aborted) throw new Error("aborted");
127
143
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
128
144
  version: document.fabricVersion,
129
145
  background: document.canvas.background,
@@ -144,7 +160,11 @@ function createDocumentEngine(options) {
144
160
  history.reset();
145
161
  saving.startSession(document.revision ?? 0);
146
162
  canvas.requestRenderAll();
147
- events.emit("load:success", { document });
163
+ if (warnings.length > 0) events.emit("assets:warning", { warnings });
164
+ events.emit("load:success", {
165
+ document,
166
+ warnings
167
+ });
148
168
  return document;
149
169
  } catch (error) {
150
170
  const engineError = toLoadError(error, controller.signal);
@@ -186,6 +206,7 @@ function createDocumentEngine(options) {
186
206
  history.withoutRecording(() => canvas.clear());
187
207
  objectsById.clear();
188
208
  documentInfo = createDocumentInfo(newOptions);
209
+ uploadedUrls.clear();
189
210
  history.reset();
190
211
  saving.startSession(0);
191
212
  }
@@ -196,6 +217,30 @@ function createDocumentEngine(options) {
196
217
  rebuildIndex();
197
218
  return objectsById.get(id);
198
219
  }
220
+ function checkAssets() {
221
+ ensureUsable();
222
+ return inspectAssets(toDocument(), assetOptions, new AbortController().signal);
223
+ }
224
+ async function replaceImage(oldUrl, newUrl) {
225
+ ensureUsable();
226
+ const images = [];
227
+ walkObjects(canvas.getObjects(), (object) => {
228
+ if (isReplaceableImage(object) && isSameUrl(object.getSrc(), oldUrl)) images.push(object);
229
+ });
230
+ if (images.length === 0) return 0;
231
+ await history.transaction("Replace image", () => Promise.all(images.map(async (image) => {
232
+ const displayedWidth = image.width * image.scaleX;
233
+ const displayedHeight = image.height * image.scaleY;
234
+ await image.setSrc(newUrl, { crossOrigin: image.crossOrigin ?? null });
235
+ image.set({
236
+ scaleX: displayedWidth / image.width,
237
+ scaleY: displayedHeight / image.height
238
+ });
239
+ image.setCoords();
240
+ })));
241
+ canvas.requestRenderAll();
242
+ return images.length;
243
+ }
199
244
  async function travelThroughHistory(direction) {
200
245
  ensureUsable();
201
246
  try {
@@ -247,6 +292,12 @@ function createDocumentEngine(options) {
247
292
  registry.register(definition);
248
293
  },
249
294
  getObjectById,
295
+ getAssetManifest: () => toDocument().assets ?? {
296
+ images: [],
297
+ fonts: []
298
+ },
299
+ checkAssets,
300
+ replaceImage,
250
301
  transaction(label, work) {
251
302
  ensureUsable();
252
303
  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" | "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" | "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");
package/dist/index.d.cts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, SerializedFabricObject } from "./document/document-format.cjs";
2
+ import { AssetManifest, FontAsset, ImageAsset } from "./assets/asset-manifest.cjs";
3
+ import { FontLoader } from "./assets/font-check.cjs";
4
+ import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadRequest } from "./assets/asset-pipeline.cjs";
2
5
  import { NewDocumentOptions } from "./document/create-document.cjs";
3
6
  import { CustomObjectDefinition } from "./fabric/object-registry.cjs";
4
7
  import { HistoryOptions, HistoryState } from "./history/create-history.cjs";
@@ -12,4 +15,4 @@ import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOption
12
15
  import { validateDocument } from "./document/validate-document.cjs";
13
16
  import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.cjs";
14
17
  import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.cjs";
15
- export { 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 HistoryOptions, type HistoryState, 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, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
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 };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, SerializedFabricObject } from "./document/document-format.js";
2
+ import { AssetManifest, FontAsset, ImageAsset } from "./assets/asset-manifest.js";
3
+ import { FontLoader } from "./assets/font-check.js";
4
+ import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadRequest } from "./assets/asset-pipeline.js";
2
5
  import { NewDocumentOptions } from "./document/create-document.js";
3
6
  import { CustomObjectDefinition } from "./fabric/object-registry.js";
4
7
  import { HistoryOptions, HistoryState } from "./history/create-history.js";
@@ -12,4 +15,4 @@ import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOption
12
15
  import { validateDocument } from "./document/validate-document.js";
13
16
  import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
14
17
  import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
15
- export { 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 HistoryOptions, type HistoryState, 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, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
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 };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { DocumentEngineError, createConflictError, isDocumentEngineError } from "./engine/errors.js";
1
2
  import { CURRENT_SCHEMA_VERSION } from "./document/document-format.js";
2
3
  import { validateDocument } from "./document/validate-document.js";
3
- import { DocumentEngineError, createConflictError, isDocumentEngineError } from "./engine/errors.js";
4
4
  import { createDocumentEngine } from "./engine/create-document-engine.js";
5
5
  import { bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
6
6
  import { bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
@@ -68,16 +68,17 @@ function createSaveController(options) {
68
68
  const sessionAtStart = session;
69
69
  const { signal } = sessionAbort;
70
70
  const versionAtSnapshot = contentVersion;
71
- const document = {
72
- ...options.createDocument(),
73
- revision: revision + 1
74
- };
75
71
  const expectedRevision = saveOptions.overwrite === true ? null : revision;
72
+ const creating = options.createDocument();
76
73
  saving = true;
77
74
  lastError = void 0;
78
75
  publish();
79
- options.onStart(document);
80
76
  try {
77
+ const document = {
78
+ ...await creating,
79
+ revision: revision + 1
80
+ };
81
+ options.onStart(document);
81
82
  const result = await sendWithRetries(storage, document, expectedRevision, signal);
82
83
  if (sessionAtStart !== session) return document;
83
84
  revision = result?.revision ?? document.revision;
@@ -68,16 +68,17 @@ function createSaveController(options) {
68
68
  const sessionAtStart = session;
69
69
  const { signal } = sessionAbort;
70
70
  const versionAtSnapshot = contentVersion;
71
- const document = {
72
- ...options.createDocument(),
73
- revision: revision + 1
74
- };
75
71
  const expectedRevision = saveOptions.overwrite === true ? null : revision;
72
+ const creating = options.createDocument();
76
73
  saving = true;
77
74
  lastError = void 0;
78
75
  publish();
79
- options.onStart(document);
80
76
  try {
77
+ const document = {
78
+ ...await creating,
79
+ revision: revision + 1
80
+ };
81
+ options.onStart(document);
81
82
  const result = await sendWithRetries(storage, document, expectedRevision, signal);
82
83
  if (sessionAtStart !== session) return document;
83
84
  revision = result?.revision ?? document.revision;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabricjs-document-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",