fabricjs-document-engine 0.5.0 → 0.7.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 +82 -3
  2. package/dist/engine/create-document-engine.cjs +107 -4
  3. package/dist/engine/create-document-engine.d.cts +23 -0
  4. package/dist/engine/create-document-engine.d.ts +23 -0
  5. package/dist/engine/create-document-engine.js +107 -4
  6. package/dist/engine/errors.cjs +1 -0
  7. package/dist/engine/errors.d.cts +3 -1
  8. package/dist/engine/errors.d.ts +3 -1
  9. package/dist/engine/errors.js +1 -0
  10. package/dist/index.cjs +5 -0
  11. package/dist/index.d.cts +5 -2
  12. package/dist/index.d.ts +5 -2
  13. package/dist/index.js +3 -1
  14. package/dist/migrations/migrate-document.cjs +65 -0
  15. package/dist/migrations/migrate-document.d.cts +15 -0
  16. package/dist/migrations/migrate-document.d.ts +15 -0
  17. package/dist/migrations/migrate-document.js +63 -0
  18. package/dist/react/engine-context.cjs +12 -0
  19. package/dist/react/engine-context.d.cts +10 -0
  20. package/dist/react/engine-context.d.ts +10 -0
  21. package/dist/react/engine-context.js +11 -0
  22. package/dist/react/use-document-engine.cjs +23 -0
  23. package/dist/react/use-document-engine.d.cts +6 -0
  24. package/dist/react/use-document-engine.d.ts +6 -0
  25. package/dist/react/use-document-engine.js +23 -0
  26. package/dist/react/use-document-event.cjs +12 -0
  27. package/dist/react/use-document-event.d.cts +4 -0
  28. package/dist/react/use-document-event.d.ts +4 -0
  29. package/dist/react/use-document-event.js +12 -0
  30. package/dist/react/use-document-state.cjs +11 -0
  31. package/dist/react/use-document-state.d.cts +5 -0
  32. package/dist/react/use-document-state.d.ts +5 -0
  33. package/dist/react/use-document-state.js +11 -0
  34. package/dist/react.cjs +11 -0
  35. package/dist/react.d.cts +6 -0
  36. package/dist/react.d.ts +6 -0
  37. package/dist/react.js +6 -0
  38. package/dist/state/document-state-store.cjs +114 -0
  39. package/dist/state/document-state-store.d.cts +28 -0
  40. package/dist/state/document-state-store.d.ts +28 -0
  41. package/dist/state/document-state-store.js +114 -0
  42. package/dist/storage/key-value-storage.cjs +27 -1
  43. package/dist/storage/key-value-storage.d.cts +2 -1
  44. package/dist/storage/key-value-storage.d.ts +2 -1
  45. package/dist/storage/key-value-storage.js +27 -1
  46. package/dist/storage.d.cts +2 -1
  47. package/dist/storage.d.ts +2 -1
  48. package/dist/versions/document-version.cjs +20 -0
  49. package/dist/versions/document-version.d.cts +25 -0
  50. package/dist/versions/document-version.d.ts +25 -0
  51. package/dist/versions/document-version.js +17 -0
  52. package/package.json +26 -4
package/README.md CHANGED
@@ -13,7 +13,9 @@ Fabric already draws objects, handles interaction and serializes to JSON. This p
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
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.
15
15
  - **Dependable export.** PNG, JPEG, WebP, SVG and editable JSON. You choose the area, scale and background. A preflight check means an export either succeeds or tells you exactly which image or font prevents it.
16
+ - **Versions and migration.** Keep named versions, restore any of them as a new revision, and open plain Fabric JSON or documents saved by older versions of this package.
16
17
  - **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.
18
+ - **React ready, framework free.** Hooks for React, and a small state store for any other framework. Your toolbar and UI stay yours.
17
19
  - **Fabric 6 and 7.** Every release is tested against both.
18
20
 
19
21
  ## Install
@@ -39,6 +41,42 @@ localStorage.setItem(document.id, JSON.stringify(document));
39
41
  await engine.loadDocument(JSON.parse(localStorage.getItem(document.id)!));
40
42
  ```
41
43
 
44
+ ## React
45
+
46
+ ```tsx
47
+ import { useDocumentEngine, useDocumentState, DocumentEngineProvider, useEngine } from 'fabricjs-document-engine/react';
48
+
49
+ function Editor({ canvas }: { canvas: Canvas | null }) {
50
+ const engine = useDocumentEngine(canvas, { storage, autosave: true });
51
+ return (
52
+ <DocumentEngineProvider engine={engine}>
53
+ <YourToolbar />
54
+ </DocumentEngineProvider>
55
+ );
56
+ }
57
+
58
+ function YourToolbar() {
59
+ const engine = useEngine();
60
+ const state = useDocumentState(engine);
61
+ if (!engine || !state) return null;
62
+ return (
63
+ <>
64
+ <button disabled={!state.canUndo} onClick={() => engine.undo()}>Undo {state.undoLabel}</button>
65
+ <button disabled={!state.isDirty} onClick={() => engine.save()}>Save</button>
66
+ <span>{state.saveStatus}</span>
67
+ </>
68
+ );
69
+ }
70
+ ```
71
+
72
+ - `useDocumentEngine(canvas, options)` creates the engine once your Fabric canvas exists and destroys it on unmount. It returns `null` until then. Options are read when the engine is created.
73
+ - `useDocumentState(engine)` returns `{ documentId, isLoading, loadError, saveStatus, isDirty, isSaving, revision, lastSavedAt, saveError, canUndo, canRedo, undoLabel, redoLabel, assetWarnings }` and re-renders when any of them change.
74
+ - `useDocumentEvent(engine, 'save:error', handler)` subscribes to any event with the latest handler.
75
+ - `DocumentEngineProvider` and `useEngine()` pass the engine to deeply nested toolbars.
76
+ - The React entry is marked `'use client'` for Next.js. React is an optional peer dependency, so the core never imports it.
77
+
78
+ For other frameworks, `createDocumentStateStore(engine)` gives the same state as `{ getSnapshot, subscribe }`, which fits Svelte stores, Vue's `shallowRef` and similar tools.
79
+
42
80
  ## Saving and loading through storage
43
81
 
44
82
  The built-in adapters are the quickest way to start:
@@ -58,6 +96,8 @@ await engine.save();
58
96
 
59
97
  Both adapters also have `listDocuments()` and `deleteDocument(id)`. To build on another key-value store, use `createKeyValueStorage({ read, write, remove, keys }, prefix)`.
60
98
 
99
+ More adapters, including a REST API with revision checks and image uploads, are in [docs/storage-examples.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/storage-examples.md).
100
+
61
101
  ### Your own backend
62
102
 
63
103
  A storage adapter is two functions:
@@ -218,6 +258,33 @@ const check = await engine.preflightExport({ format: 'png' });
218
258
  if (!check.ok) showProblems(check.problems);
219
259
  ```
220
260
 
261
+ ## Versions
262
+
263
+ ```ts
264
+ const version = await engine.createVersion('Sent to client');
265
+ const versions = await engine.listVersions();
266
+ await engine.restoreVersion(version.id);
267
+ await engine.deleteVersion(version.id);
268
+ ```
269
+
270
+ - Versions are full copies of the document, kept in your storage adapter. The built-in adapters support them. A custom adapter adds four methods: `saveVersion(version)`, `listVersions(documentId)`, `loadVersion(documentId, versionId)` and `deleteVersion(documentId, versionId)`.
271
+ - `listVersions` returns summaries, newest first: `{ id, documentId, name, kind, createdAt, revision }`. `kind` is `named` or `auto`.
272
+ - **Restoring never loses work.** The engine first keeps an automatic version named `Before restoring "..."`. It then loads the old content as a new, unsaved revision of the same document. The next save stores it as the newest revision, and history stays linear. To undo a restore, restore the automatic version.
273
+ - **Automatic versions.** Use `versions: { autoEvery: 10, keepAuto: 20 }` to keep a version after every 10 successful saves. Named versions are never pruned. Only the newest `keepAuto` automatic versions are kept, 20 by default.
274
+ - Undo and redo cover recent edits in this session. Versions preserve chosen states for later.
275
+
276
+ ## Migration and importing Fabric JSON
277
+
278
+ Plain Fabric JSON, such as the output of `canvas.toJSON()` from Fabric 5, 6 or 7, opens directly:
279
+
280
+ ```ts
281
+ await engine.importFabricJson(savedJsonText, { id: 'plan-42', metadata: { source: 'old editor' } });
282
+ ```
283
+
284
+ `loadDocument` and `load(id)` also recognise plain Fabric JSON, so projects stored by an existing Fabric app open without a separate import step. A document loaded with `load(id)` keeps that id, and its next save stores it in the current format.
285
+
286
+ Every document records its `schemaVersion`. When the package format changes, older documents are upgraded step by step when they are opened. `load:success` reports `migratedFrom` when that happened. A failed step rejects with `MIGRATION_FAILED`, and `error.migrationFrom` names the version it started from. A document from a newer version of the package is refused with `UNSUPPORTED_SCHEMA` rather than being misread. `migrateDocument(value, context)` and `detectSchemaVersion(value)` are exported for tooling such as server-side batch upgrades.
287
+
221
288
  ## Recovery
222
289
 
223
290
  ```ts
@@ -342,6 +409,9 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
342
409
  | `engine.toDocument()` | Serializes the canvas into the versioned document format. |
343
410
  | `engine.loadDocument(document, { restoreCanvasSize? })` | Validates and loads a document. Resolves when the objects are on the canvas. |
344
411
  | `engine.load(id)` / `engine.save({ overwrite? })` | Reads from or writes to your storage adapter. |
412
+ | `engine.importFabricJson(json, { id?, metadata? })` | Opens plain Fabric JSON, as text or an object. |
413
+ | `engine.createVersion(name?)` / `engine.listVersions()` | Keeps a named version, or lists versions newest first. |
414
+ | `engine.restoreVersion(id)` / `engine.deleteVersion(id)` | Restores a version as a new unsaved revision, or deletes it. |
345
415
  | `engine.isDirty()` | Tells you whether there are unsaved changes. |
346
416
  | `engine.getSaveState()` | Returns `{ status, isDirty, isSaving, revision, lastSavedAt, error }`. |
347
417
  | `bindUnsavedChangesWarning(engine)` | Asks the browser to confirm before closing a page with unsaved changes. Returns an unbind function. |
@@ -367,8 +437,9 @@ Keep project data such as titles, owners and tags in `metadata` with `engine.upd
367
437
  | `engine.getHistory()` | Returns `{ undo, redo }` label lists, newest first. |
368
438
  | `engine.clearHistory()` | Forgets all steps. Loading a document or starting a new one also does this. |
369
439
  | `bindKeyboardShortcuts(engine, { target? })` | Adds the undo and redo shortcuts. Returns an unbind function. |
370
- | `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`, `export:success`, `export:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
440
+ | `engine.on(event, handler)` | Listens to `load:start`, `document:change`, `load:success`, `load:error`, `save:start`, `save:success`, `save:error`, `save:retry`, `save:status`, `assets:warning`, `recovery:checkpoint`, `recovery:restored`, `recovery:error`, `export:success`, `export:error`, `version:created`, `version:restored`, `version:error`, `history:change` or `history:error`. Returns an unsubscribe function. |
371
441
  | `engine.destroy()` | Stops listening to the canvas and cancels a running load. |
442
+ | `createDocumentStateStore(engine)` | Framework-free `{ getSnapshot, subscribe, destroy }` state for toolbars. |
372
443
  | `validateDocument(value)` | Returns a list of issues with the exact path of each problem. |
373
444
 
374
445
  ## Errors
@@ -390,6 +461,10 @@ Every failure is a `DocumentEngineError` with a `code` you can switch on:
390
461
  | `SAVE_CANCELLED` | A queued save was dropped because another document was opened. |
391
462
  | `DOCUMENT_NOT_FOUND` | The built-in adapters have no document with that id. |
392
463
  | `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. |
464
+ | `MIGRATION_FAILED` | An older document could not be upgraded. `error.migrationFrom` is the schema version it started from. |
465
+ | `VERSIONS_UNSUPPORTED` | The storage adapter has no version methods. |
466
+ | `VERSION_NOT_FOUND` | There is no version with that id. |
467
+ | `VERSION_FAILED` | An automatic version could not be kept. It is delivered as a `version:error` event. |
393
468
  | `EXPORT_BLOCKED` | The preflight found problems. `error.problems` lists each one with the objects involved. |
394
469
  | `INVALID_EXPORT_OPTIONS` | The format, scale, quality, area or padding is not valid, or the area is empty. |
395
470
  | `EXPORT_ABORTED` | The export was cancelled with its `signal`. |
@@ -413,11 +488,15 @@ A failed load never clears or half-fills your canvas.
413
488
  | 4 | Assets and fonts | 0.3.0 |
414
489
  | 5 | Recovery after a refresh or crash | 0.4.0 |
415
490
  | 6 | PNG, JPEG, SVG and JSON export with preflight checks | 0.5.0 |
416
- | 7 | Named versions and schema migrations | |
417
- | 8 | React adapter and examples | |
491
+ | 7 | Named versions and schema migrations | 0.6.0 |
492
+ | 8 | React adapter and examples | 0.7.0 |
418
493
  | 9 | Hardening and benchmarks | |
419
494
  | 10 | Stable API | 1.0.0 |
420
495
 
496
+ ## Troubleshooting
497
+
498
+ Common problems and fixes are in [docs/troubleshooting.md](https://github.com/re-sohail/fabricjs-document-engine/blob/main/docs/troubleshooting.md).
499
+
421
500
  ## License
422
501
 
423
502
  MIT
@@ -15,7 +15,9 @@ const require_validate_document = require("../document/validate-document.cjs");
15
15
  const require_fabric_adapter = require("../fabric/fabric-adapter.cjs");
16
16
  const require_object_registry = require("../fabric/object-registry.cjs");
17
17
  const require_create_history = require("../history/create-history.cjs");
18
+ const require_migrate_document = require("../migrations/migrate-document.cjs");
18
19
  const require_recovery_controller = require("../recovery/recovery-controller.cjs");
20
+ const require_document_version = require("../versions/document-version.cjs");
19
21
  const require_save_controller = require("../save/save-controller.cjs");
20
22
  const require_event_emitter = require("./event-emitter.cjs");
21
23
  //#region src/engine/create-document-engine.ts
@@ -91,6 +93,7 @@ function createDocumentEngine(options) {
91
93
  onSuccess: (document) => {
92
94
  if (recovery && !saving.state().isDirty) recovery.remove(document.id);
93
95
  events.emit("save:success", { document });
96
+ createAutomaticVersionAfterSave(document);
94
97
  },
95
98
  onError: (error) => events.emit("save:error", { error }),
96
99
  onRetry: (event) => events.emit("save:retry", event)
@@ -155,7 +158,10 @@ function createDocumentEngine(options) {
155
158
  if (unknownTypes.length > 0) throw new require_errors.DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The document uses object types that are not registered: ${unknownTypes.join(", ")}. Register them with customObjects or engine.registerObject before loading.`, { unknownTypes });
156
159
  return document;
157
160
  }
158
- async function loadDocument(input, loadOptions = {}) {
161
+ function loadDocument(input, loadOptions = {}) {
162
+ return loadMigratedDocument(input, loadOptions, {});
163
+ }
164
+ async function loadMigratedDocument(input, loadOptions, importDetails) {
159
165
  ensureUsable();
160
166
  activeLoad?.abort();
161
167
  const controller = new AbortController();
@@ -165,7 +171,12 @@ function createDocumentEngine(options) {
165
171
  try {
166
172
  await recovery?.markLoadStarted(documentId);
167
173
  if (controller.signal.aborted) throw new Error("aborted");
168
- const checked = checkDocument(input);
174
+ const { document: migrated, migratedFrom } = require_migrate_document.migrateDocument(input, {
175
+ canvasWidth: canvas.getWidth(),
176
+ canvasHeight: canvas.getHeight(),
177
+ ...importDetails
178
+ });
179
+ const checked = checkDocument(migrated);
169
180
  const { document, warnings } = await require_asset_pipeline.prepareAssetsForLoad(checked, assetOptions, controller.signal);
170
181
  if (controller.signal.aborted) throw new Error("aborted");
171
182
  await history.withoutRecording(() => require_fabric_adapter.loadIntoCanvas(canvas, {
@@ -190,9 +201,11 @@ function createDocumentEngine(options) {
190
201
  saving.startSession(document.revision ?? 0);
191
202
  canvas.requestRenderAll();
192
203
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
204
+ events.emit("document:change", { documentId: document.id });
193
205
  events.emit("load:success", {
194
206
  document,
195
- warnings
207
+ warnings,
208
+ migratedFrom
196
209
  });
197
210
  return document;
198
211
  } catch (error) {
@@ -206,6 +219,90 @@ function createDocumentEngine(options) {
206
219
  }
207
220
  }
208
221
  }
222
+ function importFabricJson(json, importOptions = {}) {
223
+ let parsed = json;
224
+ if (typeof json === "string") try {
225
+ parsed = JSON.parse(json);
226
+ } catch (error) {
227
+ const reason = error instanceof Error ? error.message : String(error);
228
+ return Promise.reject(new require_errors.DocumentEngineError("INVALID_DOCUMENT", `The text is not valid JSON: ${reason}`, { cause: error }));
229
+ }
230
+ const { id, metadata, ...loadOptions } = importOptions;
231
+ return loadMigratedDocument(parsed, loadOptions, {
232
+ id,
233
+ metadata
234
+ });
235
+ }
236
+ let savesSinceAutomaticVersion = 0;
237
+ let lastVersionTime = 0;
238
+ function nextVersionTimestamp() {
239
+ lastVersionTime = Math.max(Date.now(), lastVersionTime + 1);
240
+ return new Date(lastVersionTime).toISOString();
241
+ }
242
+ function requireVersions() {
243
+ ensureUsable();
244
+ if (!require_document_version.supportsVersions(storage)) throw new require_errors.DocumentEngineError("VERSIONS_UNSUPPORTED", "The storage adapter needs saveVersion, listVersions, loadVersion and deleteVersion to keep versions");
245
+ return storage;
246
+ }
247
+ async function pruneAutomaticVersions(versionStorage, documentId) {
248
+ const keepAuto = options.versions?.keepAuto ?? 20;
249
+ const existing = await versionStorage.listVersions(documentId);
250
+ await Promise.all(require_document_version.versionsToPrune(existing, keepAuto).map((version) => versionStorage.deleteVersion(documentId, version.id)));
251
+ }
252
+ async function storeVersion(name, kind, document) {
253
+ const versionStorage = requireVersions();
254
+ const content = document ?? (await require_asset_pipeline.prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls)).document;
255
+ const version = {
256
+ id: require_ids.createId(),
257
+ documentId: content.id,
258
+ name,
259
+ kind,
260
+ createdAt: nextVersionTimestamp(),
261
+ revision: content.revision ?? saving.state().revision,
262
+ document: content
263
+ };
264
+ await versionStorage.saveVersion(version);
265
+ await pruneAutomaticVersions(versionStorage, content.id);
266
+ const summary = require_document_version.summarize(version);
267
+ events.emit("version:created", summary);
268
+ return summary;
269
+ }
270
+ function reportVersionError(error) {
271
+ const reason = error instanceof Error ? error.message : String(error);
272
+ const engineError = require_errors.isDocumentEngineError(error) ? error : new require_errors.DocumentEngineError("VERSION_FAILED", `Could not keep an automatic version: ${reason}`, { cause: error });
273
+ events.emit("version:error", { error: engineError });
274
+ }
275
+ function createAutomaticVersionAfterSave(document) {
276
+ const every = options.versions?.autoEvery ?? 0;
277
+ if (every <= 0 || !require_document_version.supportsVersions(storage)) return;
278
+ savesSinceAutomaticVersion += 1;
279
+ if (savesSinceAutomaticVersion < every) return;
280
+ savesSinceAutomaticVersion = 0;
281
+ storeVersion(`Autosave ${(/* @__PURE__ */ new Date()).toLocaleString()}`, "auto", document).catch(reportVersionError);
282
+ }
283
+ async function restoreVersion(versionId) {
284
+ const versionStorage = requireVersions();
285
+ const current = documentInfo;
286
+ const version = await versionStorage.loadVersion(current.id, versionId);
287
+ await storeVersion(`Before restoring "${version.name}"`, "auto");
288
+ const { document: migrated } = require_migrate_document.migrateDocument(version.document, {
289
+ canvasWidth: canvas.getWidth(),
290
+ canvasHeight: canvas.getHeight()
291
+ });
292
+ const baseRevision = saving.state().revision;
293
+ const loaded = await loadDocument({
294
+ ...migrated,
295
+ id: current.id,
296
+ createdAt: current.createdAt,
297
+ revision: baseRevision
298
+ });
299
+ noteContentChange();
300
+ events.emit("version:restored", {
301
+ version: require_document_version.summarize(version),
302
+ document: loaded
303
+ });
304
+ return loaded;
305
+ }
209
306
  function requireStorage() {
210
307
  if (!storage) throw new require_errors.DocumentEngineError("STORAGE_MISSING", "Pass a storage adapter to createDocumentEngine to use load and save");
211
308
  return storage;
@@ -222,7 +319,7 @@ function createDocumentEngine(options) {
222
319
  events.emit("load:error", { error: engineError });
223
320
  throw engineError;
224
321
  }
225
- return loadDocument(stored, loadOptions);
322
+ return loadMigratedDocument(stored, loadOptions ?? {}, { id: documentId });
226
323
  }
227
324
  function save(saveOptions) {
228
325
  try {
@@ -242,6 +339,7 @@ function createDocumentEngine(options) {
242
339
  history.reset();
243
340
  recovery?.cancel();
244
341
  saving.startSession(0);
342
+ events.emit("document:change", { documentId: documentInfo.id });
245
343
  }
246
344
  function getObjectById(id) {
247
345
  ensureUsable();
@@ -419,6 +517,11 @@ function createDocumentEngine(options) {
419
517
  toDocument,
420
518
  loadDocument,
421
519
  load,
520
+ importFabricJson,
521
+ createVersion: (name) => storeVersion(name ?? `Version ${(/* @__PURE__ */ new Date()).toLocaleString()}`, "named"),
522
+ listVersions: async (documentId) => requireVersions().listVersions(documentId ?? documentInfo.id),
523
+ restoreVersion,
524
+ deleteVersion: async (versionId) => requireVersions().deleteVersion(documentInfo.id, versionId),
422
525
  save,
423
526
  isDirty: () => saving.state().isDirty,
424
527
  getSaveState: () => saving.state(),
@@ -6,6 +6,7 @@ import { ExportPreflight } from "../export/preflight-export.cjs";
6
6
  import { NewDocumentOptions } from "../document/create-document.cjs";
7
7
  import { CustomObjectDefinition } from "../fabric/object-registry.cjs";
8
8
  import { HistoryOptions, HistoryState } from "../history/create-history.cjs";
9
+ import { VersionOptions, VersionSummary } from "../versions/document-version.cjs";
9
10
  import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.cjs";
10
11
  import { AutosaveOptions } from "../save/autosave-scheduler.cjs";
11
12
  import { RetryOptions } from "../save/retry.cjs";
@@ -25,10 +26,15 @@ export interface DocumentEngineOptions {
25
26
  saveRetry?: RetryOptions;
26
27
  assets?: AssetOptions;
27
28
  recovery?: RecoveryOptions;
29
+ versions?: VersionOptions;
28
30
  }
29
31
  export interface LoadOptions {
30
32
  restoreCanvasSize?: boolean;
31
33
  }
34
+ export interface ImportOptions extends LoadOptions {
35
+ id?: string;
36
+ metadata?: Record<string, unknown>;
37
+ }
32
38
  export interface ExportResult {
33
39
  format: ExportFormat;
34
40
  mimeType: string;
@@ -42,9 +48,13 @@ export interface DocumentEngineEvents {
42
48
  "load:start": {
43
49
  documentId: string | undefined;
44
50
  };
51
+ "document:change": {
52
+ documentId: string;
53
+ };
45
54
  "load:success": {
46
55
  document: FabricDocument;
47
56
  warnings: AssetWarning[];
57
+ migratedFrom: number | undefined;
48
58
  };
49
59
  "assets:warning": {
50
60
  warnings: AssetWarning[];
@@ -86,6 +96,14 @@ export interface DocumentEngineEvents {
86
96
  "export:error": {
87
97
  error: DocumentEngineError;
88
98
  };
99
+ "version:created": VersionSummary;
100
+ "version:restored": {
101
+ version: VersionSummary;
102
+ document: FabricDocument;
103
+ };
104
+ "version:error": {
105
+ error: DocumentEngineError;
106
+ };
89
107
  }
90
108
  export interface DocumentEngine {
91
109
  readonly canvas: StaticCanvas;
@@ -95,6 +113,11 @@ export interface DocumentEngine {
95
113
  toDocument(): FabricDocument;
96
114
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
97
115
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
116
+ importFabricJson(json: string | Record<string, unknown>, options?: ImportOptions): Promise<FabricDocument>;
117
+ createVersion(name?: string): Promise<VersionSummary>;
118
+ listVersions(documentId?: string): Promise<VersionSummary[]>;
119
+ restoreVersion(versionId: string): Promise<FabricDocument>;
120
+ deleteVersion(versionId: string): Promise<void>;
98
121
  save(options?: SaveOptions): Promise<FabricDocument>;
99
122
  isDirty(): boolean;
100
123
  getSaveState(): SaveState;
@@ -6,6 +6,7 @@ import { ExportPreflight } from "../export/preflight-export.js";
6
6
  import { NewDocumentOptions } from "../document/create-document.js";
7
7
  import { CustomObjectDefinition } from "../fabric/object-registry.js";
8
8
  import { HistoryOptions, HistoryState } from "../history/create-history.js";
9
+ import { VersionOptions, VersionSummary } from "../versions/document-version.js";
9
10
  import { InterruptedLoad, RecoveryOptions, RecoveryRecord } from "../recovery/recovery-controller.js";
10
11
  import { AutosaveOptions } from "../save/autosave-scheduler.js";
11
12
  import { RetryOptions } from "../save/retry.js";
@@ -25,10 +26,15 @@ export interface DocumentEngineOptions {
25
26
  saveRetry?: RetryOptions;
26
27
  assets?: AssetOptions;
27
28
  recovery?: RecoveryOptions;
29
+ versions?: VersionOptions;
28
30
  }
29
31
  export interface LoadOptions {
30
32
  restoreCanvasSize?: boolean;
31
33
  }
34
+ export interface ImportOptions extends LoadOptions {
35
+ id?: string;
36
+ metadata?: Record<string, unknown>;
37
+ }
32
38
  export interface ExportResult {
33
39
  format: ExportFormat;
34
40
  mimeType: string;
@@ -42,9 +48,13 @@ export interface DocumentEngineEvents {
42
48
  "load:start": {
43
49
  documentId: string | undefined;
44
50
  };
51
+ "document:change": {
52
+ documentId: string;
53
+ };
45
54
  "load:success": {
46
55
  document: FabricDocument;
47
56
  warnings: AssetWarning[];
57
+ migratedFrom: number | undefined;
48
58
  };
49
59
  "assets:warning": {
50
60
  warnings: AssetWarning[];
@@ -86,6 +96,14 @@ export interface DocumentEngineEvents {
86
96
  "export:error": {
87
97
  error: DocumentEngineError;
88
98
  };
99
+ "version:created": VersionSummary;
100
+ "version:restored": {
101
+ version: VersionSummary;
102
+ document: FabricDocument;
103
+ };
104
+ "version:error": {
105
+ error: DocumentEngineError;
106
+ };
89
107
  }
90
108
  export interface DocumentEngine {
91
109
  readonly canvas: StaticCanvas;
@@ -95,6 +113,11 @@ export interface DocumentEngine {
95
113
  toDocument(): FabricDocument;
96
114
  loadDocument(document: unknown, options?: LoadOptions): Promise<FabricDocument>;
97
115
  load(documentId: string, options?: LoadOptions): Promise<FabricDocument>;
116
+ importFabricJson(json: string | Record<string, unknown>, options?: ImportOptions): Promise<FabricDocument>;
117
+ createVersion(name?: string): Promise<VersionSummary>;
118
+ listVersions(documentId?: string): Promise<VersionSummary[]>;
119
+ restoreVersion(versionId: string): Promise<FabricDocument>;
120
+ deleteVersion(versionId: string): Promise<void>;
98
121
  save(options?: SaveOptions): Promise<FabricDocument>;
99
122
  isDirty(): boolean;
100
123
  getSaveState(): SaveState;
@@ -15,7 +15,9 @@ import { validateDocument } from "../document/validate-document.js";
15
15
  import { loadIntoCanvas, serializeCanvas } from "../fabric/fabric-adapter.js";
16
16
  import { createObjectRegistry } from "../fabric/object-registry.js";
17
17
  import { createHistory } from "../history/create-history.js";
18
+ import { migrateDocument } from "../migrations/migrate-document.js";
18
19
  import { createRecoveryController, restoreRecordedFiles } from "../recovery/recovery-controller.js";
20
+ import { summarize, supportsVersions, versionsToPrune } from "../versions/document-version.js";
19
21
  import { createSaveController } from "../save/save-controller.js";
20
22
  import { createEventEmitter } from "./event-emitter.js";
21
23
  //#region src/engine/create-document-engine.ts
@@ -91,6 +93,7 @@ function createDocumentEngine(options) {
91
93
  onSuccess: (document) => {
92
94
  if (recovery && !saving.state().isDirty) recovery.remove(document.id);
93
95
  events.emit("save:success", { document });
96
+ createAutomaticVersionAfterSave(document);
94
97
  },
95
98
  onError: (error) => events.emit("save:error", { error }),
96
99
  onRetry: (event) => events.emit("save:retry", event)
@@ -155,7 +158,10 @@ function createDocumentEngine(options) {
155
158
  if (unknownTypes.length > 0) throw new DocumentEngineError("UNKNOWN_OBJECT_TYPE", `The document uses object types that are not registered: ${unknownTypes.join(", ")}. Register them with customObjects or engine.registerObject before loading.`, { unknownTypes });
156
159
  return document;
157
160
  }
158
- async function loadDocument(input, loadOptions = {}) {
161
+ function loadDocument(input, loadOptions = {}) {
162
+ return loadMigratedDocument(input, loadOptions, {});
163
+ }
164
+ async function loadMigratedDocument(input, loadOptions, importDetails) {
159
165
  ensureUsable();
160
166
  activeLoad?.abort();
161
167
  const controller = new AbortController();
@@ -165,7 +171,12 @@ function createDocumentEngine(options) {
165
171
  try {
166
172
  await recovery?.markLoadStarted(documentId);
167
173
  if (controller.signal.aborted) throw new Error("aborted");
168
- const checked = checkDocument(input);
174
+ const { document: migrated, migratedFrom } = migrateDocument(input, {
175
+ canvasWidth: canvas.getWidth(),
176
+ canvasHeight: canvas.getHeight(),
177
+ ...importDetails
178
+ });
179
+ const checked = checkDocument(migrated);
169
180
  const { document, warnings } = await prepareAssetsForLoad(checked, assetOptions, controller.signal);
170
181
  if (controller.signal.aborted) throw new Error("aborted");
171
182
  await history.withoutRecording(() => loadIntoCanvas(canvas, {
@@ -190,9 +201,11 @@ function createDocumentEngine(options) {
190
201
  saving.startSession(document.revision ?? 0);
191
202
  canvas.requestRenderAll();
192
203
  if (warnings.length > 0) events.emit("assets:warning", { warnings });
204
+ events.emit("document:change", { documentId: document.id });
193
205
  events.emit("load:success", {
194
206
  document,
195
- warnings
207
+ warnings,
208
+ migratedFrom
196
209
  });
197
210
  return document;
198
211
  } catch (error) {
@@ -206,6 +219,90 @@ function createDocumentEngine(options) {
206
219
  }
207
220
  }
208
221
  }
222
+ function importFabricJson(json, importOptions = {}) {
223
+ let parsed = json;
224
+ if (typeof json === "string") try {
225
+ parsed = JSON.parse(json);
226
+ } catch (error) {
227
+ const reason = error instanceof Error ? error.message : String(error);
228
+ return Promise.reject(new DocumentEngineError("INVALID_DOCUMENT", `The text is not valid JSON: ${reason}`, { cause: error }));
229
+ }
230
+ const { id, metadata, ...loadOptions } = importOptions;
231
+ return loadMigratedDocument(parsed, loadOptions, {
232
+ id,
233
+ metadata
234
+ });
235
+ }
236
+ let savesSinceAutomaticVersion = 0;
237
+ let lastVersionTime = 0;
238
+ function nextVersionTimestamp() {
239
+ lastVersionTime = Math.max(Date.now(), lastVersionTime + 1);
240
+ return new Date(lastVersionTime).toISOString();
241
+ }
242
+ function requireVersions() {
243
+ ensureUsable();
244
+ if (!supportsVersions(storage)) throw new DocumentEngineError("VERSIONS_UNSUPPORTED", "The storage adapter needs saveVersion, listVersions, loadVersion and deleteVersion to keep versions");
245
+ return storage;
246
+ }
247
+ async function pruneAutomaticVersions(versionStorage, documentId) {
248
+ const keepAuto = options.versions?.keepAuto ?? 20;
249
+ const existing = await versionStorage.listVersions(documentId);
250
+ await Promise.all(versionsToPrune(existing, keepAuto).map((version) => versionStorage.deleteVersion(documentId, version.id)));
251
+ }
252
+ async function storeVersion(name, kind, document) {
253
+ const versionStorage = requireVersions();
254
+ const content = document ?? (await prepareAssetsForSave(toDocument(), assetOptions, uploadedUrls)).document;
255
+ const version = {
256
+ id: createId(),
257
+ documentId: content.id,
258
+ name,
259
+ kind,
260
+ createdAt: nextVersionTimestamp(),
261
+ revision: content.revision ?? saving.state().revision,
262
+ document: content
263
+ };
264
+ await versionStorage.saveVersion(version);
265
+ await pruneAutomaticVersions(versionStorage, content.id);
266
+ const summary = summarize(version);
267
+ events.emit("version:created", summary);
268
+ return summary;
269
+ }
270
+ function reportVersionError(error) {
271
+ const reason = error instanceof Error ? error.message : String(error);
272
+ const engineError = isDocumentEngineError(error) ? error : new DocumentEngineError("VERSION_FAILED", `Could not keep an automatic version: ${reason}`, { cause: error });
273
+ events.emit("version:error", { error: engineError });
274
+ }
275
+ function createAutomaticVersionAfterSave(document) {
276
+ const every = options.versions?.autoEvery ?? 0;
277
+ if (every <= 0 || !supportsVersions(storage)) return;
278
+ savesSinceAutomaticVersion += 1;
279
+ if (savesSinceAutomaticVersion < every) return;
280
+ savesSinceAutomaticVersion = 0;
281
+ storeVersion(`Autosave ${(/* @__PURE__ */ new Date()).toLocaleString()}`, "auto", document).catch(reportVersionError);
282
+ }
283
+ async function restoreVersion(versionId) {
284
+ const versionStorage = requireVersions();
285
+ const current = documentInfo;
286
+ const version = await versionStorage.loadVersion(current.id, versionId);
287
+ await storeVersion(`Before restoring "${version.name}"`, "auto");
288
+ const { document: migrated } = migrateDocument(version.document, {
289
+ canvasWidth: canvas.getWidth(),
290
+ canvasHeight: canvas.getHeight()
291
+ });
292
+ const baseRevision = saving.state().revision;
293
+ const loaded = await loadDocument({
294
+ ...migrated,
295
+ id: current.id,
296
+ createdAt: current.createdAt,
297
+ revision: baseRevision
298
+ });
299
+ noteContentChange();
300
+ events.emit("version:restored", {
301
+ version: summarize(version),
302
+ document: loaded
303
+ });
304
+ return loaded;
305
+ }
209
306
  function requireStorage() {
210
307
  if (!storage) throw new DocumentEngineError("STORAGE_MISSING", "Pass a storage adapter to createDocumentEngine to use load and save");
211
308
  return storage;
@@ -222,7 +319,7 @@ function createDocumentEngine(options) {
222
319
  events.emit("load:error", { error: engineError });
223
320
  throw engineError;
224
321
  }
225
- return loadDocument(stored, loadOptions);
322
+ return loadMigratedDocument(stored, loadOptions ?? {}, { id: documentId });
226
323
  }
227
324
  function save(saveOptions) {
228
325
  try {
@@ -242,6 +339,7 @@ function createDocumentEngine(options) {
242
339
  history.reset();
243
340
  recovery?.cancel();
244
341
  saving.startSession(0);
342
+ events.emit("document:change", { documentId: documentInfo.id });
245
343
  }
246
344
  function getObjectById(id) {
247
345
  ensureUsable();
@@ -419,6 +517,11 @@ function createDocumentEngine(options) {
419
517
  toDocument,
420
518
  loadDocument,
421
519
  load,
520
+ importFabricJson,
521
+ createVersion: (name) => storeVersion(name ?? `Version ${(/* @__PURE__ */ new Date()).toLocaleString()}`, "named"),
522
+ listVersions: async (documentId) => requireVersions().listVersions(documentId ?? documentInfo.id),
523
+ restoreVersion,
524
+ deleteVersion: async (versionId) => requireVersions().deleteVersion(documentInfo.id, versionId),
422
525
  save,
423
526
  isDirty: () => saving.state().isDirty,
424
527
  getSaveState: () => saving.state(),
@@ -9,6 +9,7 @@ var DocumentEngineError = class extends Error {
9
9
  this.missingAssets = details.missingAssets ?? [];
10
10
  this.missingFonts = details.missingFonts ?? [];
11
11
  this.problems = details.problems ?? [];
12
+ this.migrationFrom = details.migrationFrom;
12
13
  this.cause = details.cause;
13
14
  this.retryable = details.retryable ?? false;
14
15
  }
@@ -1,7 +1,7 @@
1
1
  import { FontAsset, ImageAsset } from "../assets/asset-manifest.cjs";
2
2
  import { ExportProblem } from "../export/preflight-export.cjs";
3
3
  //#region src/engine/errors.d.ts
4
- 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" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
4
+ 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" | "MIGRATION_FAILED" | "VERSIONS_UNSUPPORTED" | "VERSION_NOT_FOUND" | "VERSION_FAILED" | "INVALID_EXPORT_OPTIONS" | "EXPORT_BLOCKED" | "EXPORT_FAILED" | "EXPORT_ABORTED" | "ENGINE_DESTROYED";
5
5
  export interface DocumentIssue {
6
6
  code: DocumentErrorCode;
7
7
  path: string;
@@ -13,6 +13,7 @@ export interface DocumentEngineErrorDetails {
13
13
  missingAssets?: ImageAsset[];
14
14
  missingFonts?: FontAsset[];
15
15
  problems?: ExportProblem[];
16
+ migrationFrom?: number;
16
17
  cause?: unknown;
17
18
  retryable?: boolean;
18
19
  }
@@ -23,6 +24,7 @@ export declare class DocumentEngineError extends Error {
23
24
  readonly missingAssets: ImageAsset[];
24
25
  readonly missingFonts: FontAsset[];
25
26
  readonly problems: ExportProblem[];
27
+ readonly migrationFrom: number | undefined;
26
28
  readonly cause: unknown;
27
29
  readonly retryable: boolean;
28
30
  constructor(code: DocumentErrorCode, message: string, details?: DocumentEngineErrorDetails);